С момента написания предыдущей статьи я находился под пристальным вниманием. Попытка опубликовать материалы на англоязычных платформах обернулась фиаско — в первые же минуты легионы последователей тайного братства обрушились с критикой:
— Нет никакой организации! — вопили они.
Подозреваю, что слежка велась через мой телеграм-канал.
Тем не менее я жив, а значит, пора поведать об архитектурной подлости неимоверных масштабов. Вы узнаете, как ведется борьба с крупными корпорациями изнутри и снаружи, как умы разработчиков заражают деструктивными идеями в обертке сакральных истин.
Тактика работы с архитектурой
Чего бы нам хотелось получить от архитектуры проекта глобально? Предельно упрощая, можно выделить 2 требования:
Хотим соблюдать SLA (время ответа сервиса, RPS, минимальный latency, uptime и т.п.)
Хотим, чтобы фичи быстро релизились (Time to market, TTM).
Остальные хотелки — производные. Например, хочется, чтобы сложность кодовой базы росла как можно медленнее. Но зачем? Чтобы сократить TTM.
Хочется, чтобы решения масштабировались, но зачем? Чтобы SLA соблюдать, когда аудитория сервиса продолжит расти.
Хочется, чтобы нас не могли «задедосить», но зачем? Чтобы соблюдать SLA (Uptime guarantee).
Из предыдущей статьи про кодовую базу мы уже видели, как адепты анархии влияют на оба пункта. Внедрение проблем с производительностью влияет на SLA, а когнитивное истощение и ловушки в коде — на TTM.
Архитектурные решения позволяют точно так же влиять на оба требования, только масштабы выше и последствия страшнее.
Бойтесь идолов!
Хочу раскрыть жуткую тайну, тщательно оберегаемую оккультным братством десятилетиями. Правду о том, как корпорации по всему миру уничтожаются чужими руками.
И́дол (греч. εἴδωλον, др.-рус. идолъ; также, кумир, фантом, истукан) — материальный предмет, который служит объектом религиозного поклонения и магических действий. — Wikipedia.
Мало кто знает, но апологеты техно-анархии насаждают свою религию и идолопоклонство, чтобы продвигать идеи в массы, минуя «рацио».
Сложно убедить человека вредить кодовой базе, наворачивая килограммы абстракций или разбивая функцию из 5 строк на 5 функций. Но если сказать, что так велел господь или что без этого не пойдет дождь — происходит чудо. Разработчик начинает верить, в том числе в то, что вера укрепит его техническое решение:
Иди, вера твоя спасла тебя. И он тотчас прозрел и очистил себя и код свой. — Потерянные стихи из Техно-Евангелия братства технического угнетения.
Уловка в том, что принципы можно трактовать достаточно широко. Двоякое толкование и одновременно безрассудное слепое поклонение принципам приводят
к спорам на code review со ссылками на авторитеты;
к потери времени на чтение очередной статьи о том, как действительно нужно трактовать эти принципы;
к отвлечению от написания простого работающего кода;
к ограничению восприятия (делай так, а не иначе, независимо от контекста).
Что же это за принципы, о которых идет речь? Вы готовы узреть ложь в одеянии правды?
Это принципы, сформулированные изначально под аббревиатурой IDOLS!
I.D.O.L.S!
I.D.O.L.S!
Но это слово известно и под другим именем
Бойтесь этого слова, ведь оно суть S.O.L.I.D!
Позволю себе сослаться на так удачно вышедшую статью «Перестаньте молиться на принципы S.O.L.I.D». Согласен со всем, кроме примеров Liskov Substitution и Interface Segregation — там, на мой взгляд, критикуемый код с интерфейсами выглядит лучше.
В статье не было примера необязательности Dependency Inversion Principle, приведу свой. Возьмём сущность ProductsDataSource
. Вместо того, чтобы выделять интерфейсProductsDataSource
и реализацию SomeProductsDataSourceImpl
, можно оставить ProductsDataSource
единственной реализацией.
Было:
class ProductsDataSource()
fun someCodeOne(productsSource ProductsDataSource) {
// ...
}
// ...
fun someCodeN(productsSource ProductsDataSource) {
// ...
}
Если понадобится другая реализация, просто заведите интерфейс ProductsDataSource
, а единственную реализацию переименуйте в SomeProductsDataSourceImpl
— никакие изменения в других местах не нужны, всё теперь завязано на интерфейс.
class ProductsDataSourceImpl()
interface ProductsDataSource {}
// остальное не меняется
Хотелось бы добавить и про Opened/Closed. Боб в своих лекциях критикует switch
внутри функции и приводит преимущества решения через расширение классов. Проблема тут в том, что такой подход работает только при добавлении новых классов. Если же постоянно добавляются новые функции, нам придется изменять уже написанные классы, нарушая этот же самый O/P. Проблема известна с 1975 под именем expression problem.
S.O.L.I.D. — это всего лишь набор случайных принципов, применение которых более или менее актуально в зависимости от контекста задачи и языка программирования.
Злоупотребление DRY
Можно оправдать любое злодеяние, если подобрать нужные слова. Одно из таких слов — DRY (Don't Repeat Yourself).
Если кто-то усомнился в адекватности автора, попробуйте, не читая дальше, ответить на вопрос: зачем избегать дублирования? Назовите одну действительно важную причину.
Дело не в желании печатать меньше. Страшно забыть про все места, когда общая логика изменится.
Принцип DRY
впервые был сформулирован в 1999 (в книге «The Pragmatic Programmer»), когда разработчики не имели возможности открыть GitHub, посмотреть Issue, PR и все измененные файлы. Не было умных редакторов кода, которые с LLM или другой реализацией паттерн-матчинга позволили бы пробежаться по коду и сделать автозамену. Не было решений, вроде sealed class
в Kotlin и Java с исчерпывающим when
и switch
(с 17 версии).
Архитектурные террористы, игнорируя развитие инструментов, до сих пор пропагандируют злоупотребление DRY
в ожидании предсказуемых негативных последствий.
Первая проблема DRY
— код становится менее читаем. Чем больше переходов нужно, чтобы добраться до логики сквозь дебри абстракций, тем сложнее. В этом плане можно смотреть на DRY
как на меру нарушения другого принципа — LoC
(Locality of Behaviour), облегчающего восприятие кода.
Вторая проблема DRY
— возможность внести ошибку при изменении общего кода, когда только в подмножестве нужны изменения. Допустим, была функция, возвращающая сущность User
из базы, а затем ей добавили колонку deleted
. В большинстве мест нужно отфильтровать по признаку deleted
, но не везде, а программист, меняющий код, может об этом не подумать и добавить изменение только в общий код. Как результат — неявные баги, которые могут обнаружиться на проде.
Третья проблема DRY
— связанность всего со всем. Как в старом анекдоте про ООП: захотели банан — получили обезьяну, держащую этот банан, и джунгли в придачу. И проблема не только в отсутствии возможности в будущем безболезненно вынести часть кода в отдельный сервис. Связный код сложнее тестировать и сложнее рефакторить, что напрямую влияет на TTM. Если начать говорить об этом с техно-анархистом, можно услышать про high cohesion, low coupling. Делается это для того, чтобы уйти от необходимости обсуждения проблем по существу в софистику, ведь low coupling может быть определен субъективно, а в реальных приложения и вовсе неосуществим:
High coupling must be unavoidable, statistically speaking, apparently contradicting standard ideas about software structure. - research paper, Can We Avoid High Coupling?
Четвертая проблема DRY
проявляется в крупных проектах, если общий код (какой-нибудь набор common_utils
) между двумя сервисами или приложениями выносится в отдельный репозиторий. Теперь, чтобы зарелизить задачу, нужно сперва зарелизить библиотеку с общим кодом. Будет два независимых пулреквеста. Разные релизные ветки будут зависеть от разных релизов библиотеки. Придется думать об обратной совместимости. Как результат, TTM возрастет на порядок.
Не призываю отказываться от DRY
, призываю не злоупотреблять. DRY
хорош, но лучшее — враг хорошего. Low coupling — тоже хорошо, но 0 coupling — это бесполезный код, который можно удалить.
Решения, убивающие скорость получения обратной связи
The quicker I get the feedback, the happier I am. — Martin Fowler
У Мартина Фаулера наберётся не один десяток высказываний про важность петли обратной связи. В том числе про то, что не всё можно спроектировать сразу, и что существует «степень понимания, которая достигается только выполнением программы, когда можно увидеть, что действительно работает»
Безусловно, есть задачи, которые на языках со статической типизацией выполняются «с первого раза» — написал, запустил, работает. Это как правило какая-то стандартная рутина.
Всегда найдется очень умный человек или адепт технического угнетения, который скажет, что частые запуски ему не нужны, ведь можно прочитать документацию и сразу всё написать как надо. Но я наблюдал, как разработчики часами не могли решить задачу, пренебрегая скоростью обратной связи.
Выглядит это так:
Разработчик делает ПР (потому что локально не воспроизводится).
Ждет CI 45 минут и получает очередную ошибку.
Думает над новым решением и Возвращается к 1.
За день так можно сделать около 8-10 попыток. Если же потратить полчаса (полдня) и добиться воспроизведения проблемы локально, можно сделать те же 10 попыток за 10 минут.
Уничтожители корпораций — опытные разработчики, поэтому легко могут найти способы замедления петли обратной связи на проекте, чтобы не попасть под подозрение в саботаже. Например, выбрать библиотеку для DI (вроде Dagger 2), использующую кодогенерацию. На больших проектах каждый git pull будет сопровождаться чистой сборкой.
Особенно легко внедрить Dagger в мобильной разработке, где можно давить на важность быстрого старта приложения для обоснования DI с кодогенерацией. И не важно, что для большинства девайсов это вопрос 0.45-20ms (замеры из прошлого, уверен, на новых моделях еще быстрее).
Есть проекты вроде dagger-reflect, чтобы в compile time была кодогенерация, а во время разработки — reflection, но техно-террористы каким-то образом отвлекли Джейка Вортона от проекта.
Другой отличный пример — упомянутый в предыдущей статье AspectJ. Тут и кодовая база будет обрастать неявно описанной логикой, и компиляция замедлится.
В Android-разрабтке феноменально хорошо себя зарекомендовал robolectric — как инструмент для выключения TDD. Тесты будут запускаться так долго, что никто их запускать не будет.
В каждом стеке есть свои приемы увеличения петли обратной связи, описание каждого выходит за рамки статьи.
Монорепозитории и гравитация
Как мы уже проговорили выше, для счастья и продуктивности разработчика критически важна скорость получения «фидбека». Соответственно, витязи технического упадка хотят организовать монорепозиторий так, чтобы обратную связь разработчик получал как можно медленнее. Давайте сразу введем термин «гравитации репозитория» — то, насколько время решения задач замедляется в результате плохой организации проекта.
Пример из опыта. Я работал в российском «FAANG» в 2021, когда лид мобильной разработки решил прошить «гредловую» нить сквозь все gradle-репозитории. Последние уже были частью «монорепы», но оставались свободными — то есть имелась возможность открыть в редакторе кода только нужную папку и работать с ней, независимо от остальных.
Я жил счастливо и буквально парил над землей, ведь гравитация не ощущалась — сборка проекта занимался 2-5 секунд, clean build — секунд 20-30. Добавление новой зависимости и синхронизация системы сборки — секунд 10-15.
Вернувшись из отпуска, я узнал, что репозиторий переехал на «multi project builds». Под тяжестью гравитации вся команда оказалась на лопатках — время сборки проекта увеличилось в 5-10 раз. До меня дошли слухи, что кто-то (возможно, тот самый лид) потерял 3 часа из-за того, что версии библиотек у двух репозиториев не совпали. Чтобы такое не повторилось, было принято решение, из-за которого по 3 часа в день теперь терял каждый из 50+ человек .
Дополнительной мотивацией лида могло быть желание контролировать стек. Однако по результату ущерба, нанесенного внедренным решением, я предпологаю вмешательство организации техно-анархистов.
Также не могу не добавить, что бывший коллега, ревьюивший статью, матерился только в этой части текста: «я это *** *** по 40 минут синкал ***» — написал потерпевший.
Переход на микросервисы без нужды
Ибо истинно говорю вам: если вы будете иметь веру с горчичное зерно и скажете монолиту сему: «перейди на микросервисы», и он перейдёт; и ничего не будет невозможного для вас; — Потерянные стихи из Техно-Евангелия братства технического угнетения.
Братство архитекторного терриризма в очередной раз воспользовалось мистическими практиками, чтобы насадить веру, что микросервисы — лекарство от всех болезней. И это работает. На профессиональных конференциях уже не принято говорить, зачем:
«Как продать микросервисы: пошаговый план для разработчика» (Москва, Highload++ 2024).
Давайте сразу разделим понятия микросервисы и сервисы (service-based architecture). Сервисы — это разбитый по доменным областям монолит (например, сервис доставки). Микросервисы — уже разбитые по функциям сервисы (например, микросервис нотификации о доставке). За подробностями можно обратиться к архитектору Марку Ричардсу Microservices vs. Service Based Architecture.
Не утверждаю, что переход на микросервисы — это всегда плохо. Существуют успешные примеры, но ведь и сломанные часы иногда показывают правильное время, но не следует забывать, что за микросервисы мы платим огромную цену:
Увеличение сложности системы: будет сложнее деплоить, мониторить, поддерживать и тестировать.
Увеличение накладных расходов: каждому микросервису нужна своя база данных, кеш, аналитика, репозиторий конфигов, и т.д.
Увеличение Latency: вместо вызова функции будет поход по сети.
Увеличение стоимости разработки: понадобится больше усилий от дефопсов, больше суеты и ресурсов для организации e2e тестирования, больше паттернов для реализации бизнес-логики (следующий пункт).
Микросервисы усложняют поддержание целостности данных: вместо одной транзакции в базу — SAGA, TCC, XA, Workflow, Outbox pattern, и т.п.
Как результат вышеперечисленного — микросервисы будут стоит бизнесу на порядок больше.
Критикуя микросервисную архитектуру, я не отказываюсь от гибридных подходов, развивающихся из Modulith-архитектуры, когда мы разделяем код по модулям по доменным областям. Подобная архитектура, не имея перечисленных выше проблем, позволит получить некоторые плюсы микросервисов, вроде параллельной работы над разными модулями и быстрой сборки проекта. Ничего не помешает с течением времени, при необходимости, выделить модуль в сервис или микросервис. Кроме того, до выделения микросервисов у нас будет больше времени, чтобы «нащупать» границы.
Адепта же оккультного братства можно отличить по слепому желанию все переписать на микросервисы или сразу начать с микросервисов.
Запрещаем рефакторинг обилием Unit-тестов
Тесты в умелых руках — смертельное оружие.
Авторитеты (Майк Кон, книга «Succeeding with Agile») говорят, что правильная пирамида тестирования — это 70% unit-тестов, 20% интеграционных и 10% UI. Возможно, Майк один из вдохновителей данной статьи, но что-то подобное пропагандировал и Мартин Фаулер, которого я выше цитировал. Ох, неловко вышло.
В любом случае, кажется, что Мартин осознал свои ошибки и попытался спрятаться за размытостью формулировок.
Проблема в том, что Unit-тесты имеют очень низкий КПД (в сравнении с интеграционными тестами), при этом значительно усложняют рефакторинг.
Представьте, у вас есть http-endpoint (далее «ручка») /link
.
Ручку можно рассмотреть как черный ящик, на вход которому идут две вещи:
явно: запрос пользователя;
неявно: текущий стейт системы (базы данных, кешей).
А на выходе:
явно: ответ пользователю;
неявно: текущий стейт системы (базы данных, кеши, записи в очереди).
Внутри ручки /link
могут использоваться 50 функций. Какие-то из них могут содержать сложную логику, и на них можно написать unit-тесты. Но я уверен, что большая частью функций не так уж и сложны.
Для демонстрации идеи давайте посмотрим на крайноси.
Имея только интеграционный тест, разработчик может со спокойной душой переписать 50 фукнций в 30, избавиться от лишних запросов, переписать другие для оптимизации. Менять код ничего не мешает, а корректность кода будет проверена.
Но представьте, если на все имеющиеся 50 функций написаны unit-тесты. Рефакторинг будет невозможен. Когда unit-тестами покрыто всё, придется переписывать еще и каждый тест. Уже звучит как муторная рутина. Удаление теста — всегда подозрительно на ревью, и к этому с большой вероятностью придерутся.
Кроме того, 50 unit-тестов не всегда могут заменить один интеграционный тест из-за эмерджентности: сумма частей не равна целому. Даже если все 50 тестов гарантируют правильность 50-ти функций, нужно все равно протестировать работу этих функций друг с другом в рамках ручки.
Каким же должно быть соотношение тестов? На мой взгляд, ни один адекватный программист не будет давать такие соотношения, надо смотреть на контекст. Вполне можно жить без unit-тестов вообще.
Интеграционные тесты дают свободу, юнит-тесты — душат. — Неизвестный мученик, павший во время рефакторинга.
На велосипеде по зоопарку
Прежде чем двигаться дальше, замечу, что Clojure — мой любимый ЯП. В качестве неопровержимого доказательства привожу этот пост.
Clojure — язык свободы, в том числе свободы для саботажа, поэтому многие ярые сторонники техно-анархизма приходят за идеями и примерами решений, чтобы потом насаждать практики в другие проекты. Пишут книги, распространяя идеи на ЯП вроде Java, Go, C#, где они не применимы.
Мне довелось поработать на Clojure в австралийском стартапе, и признаюсь, что после этого проекта я наконец-то понял, чем хорош Go.
Вот что приносило боль на работе:
Злоупотребление примитивами теории категорий.
Каждая интеграция с партнерами написана в своем стиле, с разными подходами к валидации, документации, описанию запросов, тестированию.
Каждая задача решалась множеством способов. Например, несколько библиотек для переменных окружения; несколько реализаций для валидации; несколько решений для запросов к базе (макросы, dsl на ними, honeysql, просто строки).
Десятки способов работы с concurrency. Прибавьте к concurrency-примитивам Java примитивы Clojure,
core async
и библиотеку, имитирующую эрленговский otplike.Набор огромных макросов, дающий близкую к GraphQL функциональность, с неявными ограничениями в неявных местах.
Редактор кода с LSP не поддерживал подсветку синтаксиса макросов на проекте. Потратив два дня, так и не смог завести «кастомную» логику статического анализа для каждого макроса через популярную clj-kondo.
Никакой унификации стиля и организации кода. Где-то строчки по 400 символов в длину, где-то код тестов написан прямо в файлах с бизнес-логикой (даже не в
comment
-форме).
Архитектурный терроризм осуществлялся написанием велосипедов и добавлением новых технологий и подходов для решения уже решенных на проекте задач.
Выбор языка программирования
В предыдущем разделе про зоопарк и велосипеды мы посмотрели, как поборники архитектурного саботажа воспользовались свободой на проекте и сумели навести беспорядок. Теперь давайте сконцентрируемся на языке.
Как бы я ни любил Clojure, не хочу позволять коллегам отнимать моё время. Когда каждый день находишь новый способ реализации цикла или разбираешься с кодом, содержащим monoid
и reducer
, которые оказываются простым фильтром или циклом — это начинает надоедать.
Вот забавная картинка, которая не подразумевалась, как забавная картинка:
Это только типы, есть еще мультиметоды и интерфейсы: defprotocol
, gen-interface
. Такое обилие возможностей отвлекает от решения реальных задач. Например, я хочу добавить валидацию через Spec и пишу ее как для map
или defrecord
. Но если кто-то уже описал типы через deftype
для оптимизации (или gen-class
, proxy
, reify
для совместимости с java), придется это отдельно поддерживать.
Всё описанное выше является примером проблемы «проклятья лиспа». Хотите верьте, хотите нет, но оккультное братство наложило проклятье на целое семейство языков.
The power of Lisp is its own worst enemy. — Rudolf Winestock.
С Kotlin, например, возможностей меньше, а значит, и саботаж устраивать сложнее, но ситуация не идеальная. В предыдущей статье уже обсуждалась «маскировка проблем производительности через циклы», но на Kotlin можно десятком способов реализовать и другие идеи, не только циклы.
Java кажется более предпочтительным вариантом, так как менее экспрессивна, чем Kotlin, но огромный минус языка в том, что у адептов обсуждаемой организации было очень много времени, чтобы пристреляться. Сделано всё, чтобы проекты собирались долго (аннотации и кодогенерация) и реальная логика терялась в тумане абстракций (аннотации, OOP, SOLID, DRY):
Пример из статьи «Annotations nightmare»:
@XmlElementWrapper(name="orders")
@XmlJavaTypeAdapter(OrderJaxbAdapter.class)
@XmlElements({
@XmlElement(name="order_2",type=Order2.class),
@XmlElement(name="old_order",type=OldOrder.class)
})
@JsonIgnore
@JsonProperty
@NotNull
@ManyToMany
@Fetch(FetchMode.SUBSELECT)
@JoinTable(
name = "customer_order",
joinColumns = {
@JoinColumn(name = "customer_id", referencedColumnName = "id")
},
inverseJoinColumns = {
@JoinColumn(name = "order_id", referencedColumnName = "id")
}
)
private List orders;
Согласен, пример старенький. Тут еще не хватает аннотаций AspectJ и Dagger, так бы получилось добавить еще 4 штуки.
Подробное обсуждение языков программирования выходит за рамки статьи, оставлю только последнюю идею, прежде чем двигаться дальше. Существует книга «Java Concurrency in Practice», но нет книги «Go Concurrency in Practice».
В Go всегда пишешь type
, когда нужно объявить тип, for
— когда нужен цикл. Что замечательно, компилятор просто не будет работать, если используется нестандартное форматирование. А это значит, что куча бесполезной мишуры пропадет, так и не появившись:
Настройки, которые нужно устанавливать, приходя на новый проект;
Утилиты вроде
ktfmt
,js-beautify
и тому подобные;Споры о том, какое форматирование использовать на проекте;
«Кожаный» линтинг на code review: «поставь/убери, пожалуйста, запятую/пробел».
Подход с решением каждой задачи одним способом приводит к тому, что любая кодовая база становится читабельной сразу. Нет риска нарваться на DSL, макросы или аннотации, с магией которых придётся разбираться и терять время, добираясь до действительно значимого кода.
Поэтому в большой команде, где будут джуны, мидлы и техно-анархисты, я бы хотел писать на Go. Язык защитит от коллег во многих аспектах. Компиляция будет быстрой даже в монорепозитории. Тесты можно запускать, компилируя только нужный файл (и зависимости, если они изменились).
Если проект мой личный, всё ещё хотелось бы писать на Clojure, используя REPL (моментальный фидбек) и отличную абстракцию над sequence. По моему опыту* на Clojure намного быстрее получаешь готовое решение.
* Опыт
Больше всего работал на Kotlin и Clojure, но хотя бы более трех месяцев (по 2+ часа в день) писал на следующих языках: Scheme, Clojure, Fennel (Lua), Dart, Go, JavaScript, TypeScript, Python.
Назвал бы еще Bash и Groovy, но использовал их скорее бессознательно.
На практике главное, чтобы язык был адекватен задаче. Если задача — написать Launcher для Android, то ни Go, ни Clojure не подойдут, а Kotlin будет отличным выбором. Game development уровня AAA — C++. Что-то возле ML — Python.
Распознать адепта уничтожения корпораций можно в момент, когда для мобильного приложения предлагается Scala или OCaml.
Заключительные слова
Выше приведен далеко не полный список подходов, принципов и приемов, способных повлиять негативно на архитектуру проекта. Организация, чья цель — добиться упадка корпораций, не стоит на месте и развивается точно так же, как и вся индустрия.
Допускаю false positive: что-то из перечисленного выше могло попасть в моё поле зрения исключительно из-за неудачного опыта применения технологии. Прошу не обижаться и приходить дискутировать в комментарии.