#13: Любимый паттерн: Builder, Concurrency, Actors
Любимый паттерн Александра — Builder: закрытый конструктор, immutable-объекты, валидации в сеттерах, именованные параметры, которых нет в Java, тестовые фикстуры — и как он пишет билдеры руками, без Lombok (а Copilot их отлично генерирует). Во второй части по мотивам «Программиста-прагматика» — конкурентность: чем она отличается от параллелизма, почему shared state is hard, как устроена модель акторов и почему её современное воплощение — микросервисы с Kafka-«мейлбоксом» и Kubernetes-«супервайзером». Полезняшки — плейлист про устройство баз данных и фичи новой Java.
Главное
- Builder — один из самых полезных паттернов в современной Java: закрытый конструктор плюс билдер-компаньон дают immutable-объекты, корректные по построению.
- Билдер привносит в Java именованные параметры: вместо конструктора из одиннадцати null'ов — читаемые сеттеры, понятные и на ревью без подсветки IDE.
- Валидации живут в сеттерах билдера: каждая проверка рядом со своим полем, тестируется отдельно — и если объект существует, он гарантированно провалидирован.
- Билдер — не для всего: сервисы и контроллеры создаёт DI-фреймворк, а стихия билдера — дата-классы (Person, Configuration, Address) и тестовые фикстуры, достраиваемые в каждом тесте.
- «Лишний код» — слабый контраргумент: написание билдера заставляет заранее продумать дефолты и инварианты объекта, а Copilot генерирует такой код почти без ошибок.
- Конкурентность — выполнение кода «как будто параллельно», параллелизм — реально параллельно; любой поход по сети, в базу или файловую систему — кандидат на асинхронность.
- Shared state is hard: гонки данных, дедлоки, а мьютекс — устная договорённость, которую компилятор не проверяет; предпочитайте узкие примитивы (атомики с CAS, CountDownLatch) широким блокировкам.
- Модель акторов изящна и скейлится (Erlang), но её современное воплощение — микросервисы: Kafka как мейлбокс, Kubernetes как супервайзер; используйте там, где польза очевидна, а начинайте со здравого монолита.
Ссылки
Расшифровка
[00:20] Здарова! Меня зовут Саша Пахомов, и я инженер, который любит своё дело. Это тринадцатый выпуск подкаста «Тысяча фичей». Сегодня я расскажу про мой любимый паттерн и как я адаптировал его под свой стиль разработки, а потом немного поговорим о конкурентности, модели акторов и микросервисах. Поехали!
[00:47] Помните, в одном из прошлых выпусков я рассказывал, как пишу тесты — с комментариями given, when, then? У меня есть ещё одна такая особенность, «стилёк» программирования: думаю, коллеги, видя такой код, сразу думают «это написал Саша». Я про паттерн Builder. Моё личное мнение: это один из самых полезных паттернов, которые вообще можно вычитать из книжек и интернетов, — даже применительно к современной Java. Для начала разберёмся, о чём речь. Builder — паттерн (или просто подход), при котором мы закрываем конструктор объекта: делаем его приватным или package-private, но точно не публичным — создать объект через конструктор нельзя. Допустим, есть объект Person. Конструктора, который мог бы вызвать сторонний пользователь кода, у него нет — зато есть статический метод, по классике называемый builder(), который возвращает инстанс PersonBuilder, определённого, как правило, в том же файле (это два очень сильно связанных объекта). У PersonBuilder есть методы, которыми мы сетим поля: имя, фамилия, возраст, email, адрес — всё-всё. А в конце вызываем build() — и получаем инстанс самого Person или любого его наследника: Person вообще может быть интерфейсом, и что там возвращает билдер, нас волновать не должно. Получили объект — пользуемся, как любым другим.
[02:48] Почему я считаю это удобным и красивым подходом? Первое: сам Person — immutable, и я всегда пишу такие объекты неизменяемыми. У них есть методы, возвращающие значения полей, есть методы, которые могут что-то посчитать, — но изменения внутреннего стейта не происходит. Если нужно что-то поменять — случился день рождения, возраст надо проинкрементировать, — я напишу ещё один статический метод, не builder(), а, скажем, fromPerson(): он принимает Person, сетит в билдер все поля из переданного объекта и возвращает билдер. Дальше мы переопределяем только интересующие поля, делаем build() — и получаем новый immutable-объект на основе старого. Такой подход я применяю почти везде — кроме мест, где это действительно неуместно; но, по мне, уместность оправдывается почти всегда. Итак, первый плюс адекватного билдера — immutable-объекты. Второй — полный контроль над процессом создания объекта. Конструктор в Java — механизм несовершенный: да, он принимает аргументы и может выполнять почти любой код («почти» — потому что конструктор ещё не полноценный метод, и доступа к некоторым полям там нет, нужно быть аккуратным). Как правило, логику в конструкторе стараются не писать — максимум простые проверки на not null. А в билдере у нас полный контроль: все дефолтные значения определяем в билдере, любую валидацию применяем на любом этапе конструирования. Сетим возраст — и в этом же методе, прежде чем вернуть билдер, валидируем: возраст не отрицательный и в пределах разумного. Прямо здесь — и видно, что вот это валидация возраста: она не размазана по конструктору среди других проверок, а лежит в методе, в котором мы возраст и сетим. Логично и понятно.
[05:48] Следующий плюс: таким подходом мы приносим в язык фичу, которой в нём нет, — именованные параметры. Бывают сложносоставные объекты — Person, адрес: вложенные объекты, много полей. И весь этот клин-код, который говорит «не больше трёх-четырёх параметров метода», просто идёт нахрен — мы живём в реальном мире, и у Person, блин, бывает много полей. Здоровый конструктор из 11–15 параметров, семь строк подряд — попробуй угадай, какая строка за что. Тут явно не хватает именованных параметров: в некоторых языках они есть, в Java — нет. IDEA старается как может и подсвечивает имена параметров: вызовешь конструктор из одиннадцати null’ов — поймёшь, какой null к какому полю относится. Но это только в IDEA: на ревью кода этого не видно, да и полагаться на редактор в таких вещах я бы не стал. А с билдером всё видно: вызвали пустой билдер и сразу build() — значит, всё дефолты; сетим строку — сразу видно, в какое из семи строковых полей: name или surname. Буквально именованные параметры. Дальше — можно разделить конструирование объекта на несколько этапов. Не обязательно в одном месте собирать «по нитке» каждый параметр со всех сервисов и тащить вереницу параметров через все методы: создаём билдер, метод, который умеет посчитать возраст, сетит возраст и передаёт билдер дальше; следующий достаёт паспортные данные и сетит их — и так по цепочке процесс создания сложного объекта распределяется по коду, а в конце получаем готовый Person. Никакой вереницы параметров.
[08:16] Ещё плюсы. Легко создавать тестовые фикстуры — «прибилженные» объекты, достраиваемые в каждом тесте. В сетап-методе JUnit 5 (beforeEach или beforeAll) конструируем часть Person, одинаковую во всех тестах: адрес, имя, фамилию, номер паспорта — то, что мы сейчас не тестируем. А в каждом тесте достраиваем именно ту часть, которую проверяем: в тесте про отрицательный возраст — age(-1), в следующем — age(255). Тесты становятся чище и читаемее, код — лаконичнее. И последний плюс: легко тестировать саму логику билдера. Валидация в каждом методе тестируется по отдельности. Была бы валидация в конструкторе — там был бы список из дофигища ифов, и чтобы проверить каждый, пришлось бы вызывать конструктор то с первым параметром null, то со вторым, то с третьим — представляете, сколько тестов и этот «съезжающий сверху вниз null»? Выглядит странно. А с билдером — сетим null в одно поле и проверяем, что прилетел IllegalArgumentException. Что тестируем, то и сетим, не таща за собой остальные параметры.
[10:33] Теперь — как я их пишу. Я, в общем-то, даже не читал в интернетах, как их писать: где-то увидел в коде, начал сам — так и пишу. Во-первых, никакого Lombok, как вы могли догадаться, — честный Java-код в том же классе (или интерфейсе), который определяет мой дата-класс. Делаю класс Person: все поля private final — абсолютно все; закрытый конструктор, принимающий все поля; геттеры — причём без префикса get. Я уже не помню, сколько времени, когда пишу код с нуля, не использую get/set-префиксы: на мой взгляд, эта штука умерла, и современная Java выглядит вполне лаконичным читаемым кодом без тонн boilerplate (кто-то скажет «билдер — это boilerplate», но я не согласен). В IDEA даже есть настройка генерировать record-style геттеры — без get. Дальше в том же классе — статический метод builder() и тут же статический класс PersonBuilder: в нём поля уже не final, просто приватные, и прямо в определении полей просетчены дефолтные значения — дефолты должны быть. Затем record-style сеттеры и метод build(), который вызывает конструктор Person, сетит все поля и возвращает построенный immutable-объект. Внутри сеттеров билдера я всегда делаю адекватные параметру валидации — задаю границы допустимых значений, которые вообще могут попасть внутрь Person. Так я гарантирую: если у меня есть Person — он по-любому сделан через билдер, а билдер по-любому всё провалидировал. Явные контракты: не нужно нигде делать дополнительные валидации — если объект существует, он immutable и сконфигурирован правильно, с дефолтами и проверками. И максимум валидаций — именно в сеттерах, не в build(): так легче тестировать, понятнее, и код чище.
[13:38] Стоит отметить: билдер нужен не всегда. Не каждый мой класс сопровождается компаньоном-билдером. Сервисы, контроллеры, репозитории, парсеры — здесь билдеры я, как правило, не использую: если есть DI, конструирование объектов — его работа. Очевидные классы с пустым конструктором, утилитарные — тот же парсер, которому дал строку и получил результат, — тоже без билдера. А вот, например, ParserConfiguration — дата-объект с настройками парсера (символ переноса строки и прочее), который передаётся парсеру на вход, — его я сделаю с билдером: для дата-класса именованные параметры и контролируемое создание — отличная фича. В остальном — it depends, как обычно. Чаще всего билдеры у меня для дата-классов — Person, Configuration, Address: всё, что ходит между сервисами и что мы очень часто создаём в тестах, потому что это входные и выходные данные программ. В остальных местах не упарываюсь — не хочу, чтобы вы подумали, что я фанатик: всегда должен быть разумный подход.
[15:29] Когда я обсуждаю билдеры с коллегами — на ревью, например, — всегда возникает один контраргумент: «это лишний код». Зачем писать одно и то же — public static class и так далее, — если есть стандартные механизмы создания объектов, а хочешь именованные параметры — ну, не знаю, бери Kotlin? Я это даже контраргументом не воспринимаю. Я не вижу никакой проблемы написать код билдера — я вижу в этом преимущество: когда я его пишу, я явно и осознанно думаю, в каком состоянии объект должен быть сконструирован, какие у него дефолты. Я пропускаю процесс создания объекта через голову — и в этот момент рождается много валидаций и проверок, которые не родились бы, ляпни я просто конструктор: я бы о них не подумал, и потом где-нибудь в стороннем сервисе при age = -1 у нас бы что-то упало — я бы, конечно, написал тест и пофиксил, но с билдером я думаю об этом заранее. Это плюс. А если код писать «очень сложно» (не знаю кому, но вдруг) — используйте Copilot: для генерации билдеров он подходит офигенно. Я один-два раза написал свои билдеры, определил сигнатуру объекта — и дальше просто жму Enter: он предлагает и статический метод, и класс. В таком коде Copilot ошибается очень редко — я, конечно, перепроверяю, но с задачей «вот дата-класс — напиши под него билдер» он справляется замечательно. Так что и аргумент «много писать» — не аргумент. (Про Copilot, может, отдельно сделаю выпуск — штука неоднозначная.) Не знаю, что о билдерах думаете вы — используете или нет, пишите. А мы пойдём дальше.
[18:14] Если это не первый выпуск «Тысячи фичей», который вы слушаете, вы знаете: я читаю «Программиста-прагматика» и делюсь интересными мыслями. Недавно читал главу про конкурентность и параллелизм. Сначала думал — всем всё понятно, делиться нечем; но некоторыми мыслями всё же поделюсь: может, не новыми, но поразмышлять на фоне — почему нет. Для начала: что такое конкурентность и почему конкурентность не равна параллелизму (сорян, если знаете, но проговорим, чтобы быть на одной стороне). Конкурентность — выполнение двух или более частей кода так, как будто они выполняются параллельно. Ключевое слово — «как будто»: скорее всего, реально параллельно они не выполняются. Параллелизм — то же самое, но части кода действительно выполняются параллельно: банальный пример — инструкции на двух разных ядрах процессора, которые молотят циферки одновременно, как два танцора. А пример конкурентности — потоки в Java: они выполняются конкурентно, не обязательно параллельно; все привычные примитивы и модель многопоточности Java — именно конкурентные. Дальше, говоря «параллельный код», я буду иметь в виду конкурентность — это не оговорка, просто для ясности.
[19:58] Зачем нам параллельный код, если можно всё написать в одном потоке? Авторы приводят пример с готовкой коктейлей: если бармен делает всё по очереди — приготовит за одно время; а если посидит и нарисует activity-диаграмму процесса, то поймёт, что многие части можно распараллелить. Заморозка льда и подготовка сырья — независимы; подготовка шейкера, засыпка льда, смешивание ингредиентов — тоже можно делать не по очереди. Настоящая точка синхронизации, где всё должно быть готово, — момент шейка и подачи: эти два шага не могут жить без всех предыдущих, а вот предыдущие могут выполняться параллельно. Так авторы предлагают думать и о своём коде и сервисах. Конкретно: любой поход куда-то — по сети, в базу данных, в файловую систему — потенциальное место для распараллеливания или асинхронного вызова. Не ждать ответа от веб-сервера, а послать запрос и повесить коллбэк — какой код исполнить, когда придёт ответ, — а текущий поток не блокировать ожиданием. Стандартный подход; думаю, плюс-минус все современные веб-приложения в этой парадигме и пишутся.
[21:55] Но у конкурентного кода есть и проблемы — он не всегда красивый, асинхронный и эффективный. Со всеми проблемами мы сталкивались: основное — гонки данных. Если shared state используется несколькими потоками, возникают аномалии. Например: написал if (шариков в корзине > 0) — кладу шарик и думаешь, что там теперь один шарик. А другой поток тоже посмотрел этот if и тоже положил. Оба думают, что положили по одному, значит, там один, — а их два. Несоблюдённый инвариант кода: читаешь — не видишь, что такое может произойти, а в конкурентной среде это происходит везде. Как бороться? Примитивы синхронизации: семафоры, мониторы, мьютексы — названий куча, в разных языках по-разному, но по факту это простой примитив, который везде работает одинаково. Это «штучка», которой может владеть только один (или больше, если сконфигурировано) поток: мьютексом владеет один. Прежде чем сделать вычисление, проверку, чтение или запись, берём мьютекс — объектик, который говорит: «я отвечаю за эту область данных, без меня её читать нельзя». Но важно: это устная договорённость на уровне контракта — а не на уровне компилятора. Другой программист, который придёт почитать эти «залоченные» данные, может просто не взять мьютекс — не знал, например, — и все те же проблемы вернутся. Это так не работает. Есть подходы на уровне баз данных: атомарные изменения, транзакционность — система сама решает вопросы взятия и отпускания блокировок, а пользователи работают с ресурсом так, будто пользователь в системе один: они «забывают» про конкурентность, а издержки берёт на себя система. В коде транзакционность сделать получается не всегда — чаще используем обычные блокировки. В Java самое простое — synchronized; можно атомики, atomic variables. Атомики — мои любимые: они поддерживают семантику CAS, и блокирования в коде меньше. Атомик, CountDownLatch, Condition — всегда более узкоспециализированный примитив, чем общая блокировка (а блокировка — это всегда тяжело): значит, блокирования в теории меньше. Поэтому я всегда предпочитаю атомики, Condition и защёлки synchronized-коду и блокировкам. Вывод авторов про всю эту часть: shared state is hard. Эта модель взаимодействия с памятью тяжёлая в любом случае: дедлоки, лайвлоки, всё сложно отладить и поймать — сколько флэки-тестов рождается из-за конкурентных ошибок! И в конце они приводят шутку: «Доктор, у меня болит вот здесь, когда я трогаю». — «Ну, тогда не трогай». Если сложно — давайте не будем это использовать.
[26:12] А что использовать вместо привычных семафоров? Авторы предлагают актерную модель — как альтернативу конкурентной модели взаимодействия с памятью. Если кто не знает, объясню на пальцах — мы говорим не про конкретную реализацию, а про модель. Модель акторов — это модель, в которой существуют акторы: сущности, умеющие обрабатывать сообщения. Сообщения приходят им в мейлбокс — условно, у каждого актора есть очередь сообщений на обработку. Обработав сообщение, актор может послать сообщение дальше — любому актору, главное знать его адрес (адрес можно взять из сообщения: там есть поле sender, и можно ответить отправителю, пометив отправителем себя) — или не делать ничего. Больше про внешний мир акторы не знают ничего: мейлбокс, возможность отправить сообщение, обработка. Это базовая идея; бывают ещё супервайзеры, которые стоят над акторами, но это уже доработки — в классической модели больше ничего особо и нет. Я работал с моделью акторов во фреймворке Akka — он написан на Scala, а я использовал его из Java. Кто использовал Scala- или Kotlin-код из Java, понимает: экспириенс не лучший (это не как Java-код из Kotlin — в обратную сторону тяжело). Поэтому воспоминания у меня спутанные и, я бы сказал, негативные. Но сама модель была понятна — она ровно такая, как я объяснил, простая. Челлендж в другом: в этой модели нужно суметь написать код. Модель жёсткая, за её рамки выходить нельзя: появилась глобальная переменная, на которую смотрят все акторы, — всё сразу ломается (да и возможности такой особо нет — а если есть, лучше не надо). Когда я писал код с акторами, мне было сложно — не понять, как они работают, а написать нужный мне код в этой модели: я придумывал императивный код в голове и потом перекладывал его на акторы — очень тяжело. Сейчас, думаю, было бы проще: я бы дизайнил под модель сразу решение, а не код. Ещё хорошее в модели — и это я полностью поддерживаю: акторы настолько ничего не знают о внешнем мире, что не знают даже, где находится актор, которому они шлют сообщение. Это может быть другой сервер, другой дата-центр — или актор в той же JVM. Всё, что есть, — месседжи. Поэтому модель легко скейлить — в теории: держали всё на одной машине, не хватило ресурсов — разделили пополам на две, а код вообще не поменялся: на уровне фреймворка сконфигурировали адреса акторов — и работает. Это следствие самой модели — её не дизайнили специально под это, просто модель подходит. Но, по чесноку, в реальной жизни кода «прямо акторы-акторы» я видел немного. Есть отдельные системы: язык Erlang, на котором пишут очень отказоустойчивые, живучие системы, — там модель акторов заложена в сам язык, и за счёт неё проблем с конкурентностью практически нет, а система получается живучей: акторы могут умирать, их рестартуют — и всё опять работает, это независимые части программы. Кроме Erlang — Akka на Scala, но особого распространения это, как будто, не получило.
[30:56] Большинство современного софта пишется не на модели акторов, а уходит в сторону микросервисов и взаимодействия через месседж-брокеры — тоже асинхронного. И, по сути, там тоже есть мейлбокс — условная Kafka, есть акторы — наши микросервисы, а супервайзер — Kubernetes. Модель заскейлилась, стала сложнее и неявнее — но дело её живёт; авторы это упоминают: микросервисы можно воспринимать как модель акторов — до какого-то предела. И вот за что мне нравится эта книжка и почему я до сих пор её читаю: авторы всегда показывают негативную сторону использования любой технологии. Они позиционируют себя — и я себя так же — как инженеры, которые решают насущные проблемы, конструируют, строят системы. Они не топят за конкретные технологии и языки, потому что это всё инструменты. Та самая фраза «язык — это инструмент» — я действительно так считаю: я строю систему, и какой язык подходит, такой и выберу. У каждого выбора есть обратная сторона. Если писать все решения в парадигме современных микросервисов с месседж-брокерами и всем асинхронным — мы решаем проблемы скейлинга и отказоустойчивости, это правда. Но вместе с этим приходит другая сторона: нужно понимать, что происходит в системе — в большой системе, не в одном микросервисе, — когда что-то пошло не так. Начинаются trace ID, прокидываемые через запросы, приходит система мониторинга — целая наука, как это сделать грамотно. Дальше формат взаимодействия сервисов: можно сказать «гоняем джейсончики», но обычно развитие приводит к тому, что JSON накладен, нужно что-то легковеснее, появляются генераторы API и сгенерированные клиенты — всё обрастает и становится громоздким. Хотя, с другой стороны, можно было написать здравый монолит — и он бы, наверное, работал: не всегда мы пишем системы, которые в чёрную пятницу должны скейлиться ×100 и обратно. Пример красивый, но в большинстве случаев нужен нормальный сервис, который скейлится до какого-то размера, — а потом мы всё равно упрёмся в базу. В заключение авторы говорят: используйте акторы и микросервисы в тех частях большой системы, где это действительно необходимо — где польза от модели видна, очевидна и огромна, — но не превращайте в актерную модель всю систему: проблем может оказаться больше, чем пользы. Я с этим согласен — стандартно: пишите сначала более-менее адекватный монолит, а потом распилите то, что нужно распилить; что останется — то останется. Если я буду писать что-то своё с нуля, я начну просто с сервисов, а о микросервисах задумаюсь тогда, когда это станет необходимо.
[34:54] Такой вот выпуск получился. С меня, как обычно, полезняшка — сегодня их две. Первая — плейлист на YouTube про то, как устроены базы данных: если вы уже прочитали книжку с кабанчиком и книгу Алекса Петрова, которыми я делился в предыдущих выпусках, и хотите копнуть глубже — этот плейлист для вас. Вторая — список фичей, которые войдут в новую версию Java, выходящую на этой неделе, а также фичи, которые планируются в следующую LTS-версию в сентябре. Не забывайте писать отзывы и предлагать темы в Telegram-канале «Тысячи фичей», а также делиться подкастом с другими. Давайте прокачивать себя и людей вокруг. Ну а на этом всё. Услышимся!