Всегда приятно начинать новый проект. Простые классы, четкие границы, ясная архитектура — все логично и красиво. Новая функциональность добавляется легко и быстро.
Идет время, проект развивается, поступают новые требования. Но приходит день, когда вы обнаруживаете в коде что-то плохое. Кто-то срезал угол и сделал небольшой костыль. Бывает, что вы сами делаете что-то на скорую руку, честно вставляя в код “todo” — просто потому, что эта функциональность нужна для ближайшего релиза, а времени сделать все правильно нет. “Это технический долг, который мы обязательно исправим после очередного релиза, но сейчас надо выдать версию” — произносим мы при этом.
Нет ничего более постоянного, чем временное — очень справедливые слова. Чаще всего бывает, что дальше технический долг будет только нарастать. Так же, как обслуживание долга в банке требует выплаты процентов, наличие технического долга забирает свой процент. Мы платим за это увеличивающейся сложностью внесения изменений, нарастающей неочевидностью и нелогичностью модели, изчезающим энтузиазмом команды.
В определенный момент становится понятно, что история повторилась и у нас в руках очередной “большой ком грязи”. Что же делать и можно ли этого избежать?
В настоящее время многие видят ответ в микросервисной архитектуре. В самом деле, наличие четких физических границ, например, не позволит срезать углы так, как это было бы сделано в случае монолита.
Но у микросервисного подхода есть своя цена, проистекающая из распределённого характера такой системы. Там, где раньше все происходило в одном процессе, теперь межсерверное взаимодействие, с передачей данных по сети, сопровождающейся сериализацией/десериализацией данных. Там, где раньше была транзакционая целостность, теперь событийная консистентность. Вместо синхронных вызовов, с понятным результатом, теперь асинхронное обращение к нескольким узлам, каждое из которых может вернуться с ошибкой или отвалиться по таймауту… И много других, неочевидных на первый взгляд, но непременных спутников распределённых систем. В конце концов, начиная новый проект нам может быть сложно правильно разделить будущую систему на микросервисы, просто потому, что мы не знаем как будет развиваться система. А пытаться продумать архитектуру наперед может оказаться малопродуктивным занятием.
В целом, при разработке с нуля нового проекта на основе микросервисов, не покидает ощущение стрельбы из пушки по воробьям. Система еще не выглядит такой сложной, чтобы применять деление на подсистемы.
Очевидно, в развитии системы возникает момент, когда цена микросервисов окупается за счёт уменьшения издержек от снижения производительности команды при усложнении системы. Мартин Фаулер (Martin Fowler) хорошо проиллюстрировал этой в своей заметке Microservice Premium:
Также хорошо видно, что для проектов малой и средней сложности монолитный подход более выигрышен в плане производительности команды. А ведь это как раз тот этап, когда зачастую определяется, пойдёт ли проект в большую жизнь. Например, в случае стартапа или proof-of-concept проекта. Тратить на этом этапе ресурсы, забирая их у разработки фич может привести к тому, что эта большая жизнь и не придет. А если проект не выстрелит, то ресурсы просто будут выброшены.
Вот если бы можно было развиваться по кривой монолита на низкой и средней сложности проекта, а потом перескочить на кривую микросервисов и продолжить развивать систему большой сложности уже на ней!..
К сожалению, такого подхода ещё не изобрели. Но зато, есть другой — который вполне может претендовать на звание “золотой середины”. Речь ниже пойдёт о модульной организации и связанных с этим преимуществах.
Если попробовать сравнить модульный и микросервисный подходы, то можно выделить следующие характеристики:
Характеристика | Модули | Мсервисы |
Декомпозиция и строгие границы | + | + |
Возможность наличия собственной БД | + | + |
Простота рефакторинга и перенос границ | + | — |
Отсутствие накладных расходов на networking | + | — |
Отсутствие накладных расходов на инфраструктуру | + | — |
Типизация и проверка компилятором передаваемых данных | + | — |
Проверка компонентных зависимостей на этапе компиляции | + | — |
Поддержание схемы взаимодействия компонентов в явном виде | + | — |
Синхронное взаимодействие | + | — |
Возможность выполнения транзакций между компонентами | + | — |
Использование разных языков для разных компонентов | — | + |
Независимое развертывание / раздельные процессы (~отказоустойчивость) | — | + |
Возможность независимого масштабирования компонентов | — | + |
Различный жизненный цикл для разных компонентов | — | + |
Итак, мы хотим разделить нашу системы на части, определив интерфейсы на границах, которые будут обеспечивать контракты взаимодействующих компонентов. В принципе, мы могли бы попробовать ввести соответствующее разделение на пакеты (java packages). Но быстро станет ясно, что это не поможет в соблюдении границ, так как достаточный уровень инкапсуляции при этом не обеспечивается. Всегда можно обратится к любому public классу в обход интерфейса. А с помощью reflection API можно получать доступ даже к private методам/полям. Это приводит к тому, что части приложения взаимодействуют уже не на основе контрактов, а на основе знания о внутреннем устройстве друг друга. При этом любое изменение, даже не меняющее контракт компонента, будет ломать все зависящие от него компоненты.
Как же обеспечить строгую инкапсуляцию? С одной стороны, для Java платформы уже давно существует индустриальный стандарт компонентной архитектуры приложения OSGi, который решает эту задачу и даже позволяет динамическую загрузку/выгрузку компонентов без перезапуска JVM. На его основе реализован ряд систем. Однако, за долгие 17 лет существования он не пошёл в массы, возможно по причине своей сложности, которая не позволяет кардинально снизить издержки по сравнению с теми же микросервисами.
Хорошая новость в том, что в рамках грядущей Java 9 запланирована реализация модульной системы на уровне JVM. Это, с одной стороны, позволит модуляризовать саму Java-платформу, а с другой — предоставит конструкцию, обеспечивающую строгое соблюдение границ модулей как при компиляции, так и во время исполнения.
В целом, при правильном применении этого подхода, можно будет говорить о модулях как о микросервисах без HTTP и внутри JVM. Для такой системы, видимо, будет справедлива картинка:
При этом, ничто не мешает на поздних этапах, когда границы модулей уже прошли проверку реальностью, производить их выделение в микросервисы. Таким образом, можно получить плюсы обоих подходов — быстрый старт монолита с линейным ростом сложности микросервисной системы.