[Перевод] Деконструкция монолита: Максимально производительный подход к проектированию программ

Как и почему компания Shopify перешла от монолитной архитектуры к модульно-монолитной.


image


У компании Shopify одна из крупнейших баз кода на Ruby on Rails. Над ней трудились более десяти лет свыше тысячи разработчиков. Она включает множество разнообразных функций, например, выставление счетов продавцам, управление сторонними приложениями, обновление информации о товарах, обработка доставки и так далее.

Изначально система была построена как монолит, то есть все эти разные функциональные возможности были встроены в одну кодовую базу без каких-либо разграничений между ними. В течение многих лет эта архитектура работала нормально, но в конце концов мы достигли точки, когда недостатки монолита перевесили преимущества. Нам предстояло сделать выбор, как действовать дальше.

В последние годы популярность микросервисов возросла, и их стали называть универсальным решением всех проблем, возникающих при использовании монолитов. Однако наш собственный коллективный опыт подсказывал нам, что универсального решения не существует, а микросервисы привносят свои собственные проблемы. Мы решили превратить Shopify в модульный монолит, то есть сохранить весь код в одной кодовой базе, но обеспечить определение и соблюдение границ между различными компонентами.

Любая архитектура программного обеспечения имеет свой набор плюсов и минусов. В зависимости от того, на какой стадии развития находится приложение, для него будет иметь смысл использовать разные решения. Переход от монолита к модульному монолиту был для нас следующим логическим шагом.

Монолитная архитектура

Согласно Википедии, монолит — это программная система, в которой функционально различимые аспекты переплетены между собой, а не содержат архитектурно отдельные компоненты. В случае Shopify это означало, что код, обрабатывающий расчет стоимости доставки, жил рядом с кодом, обрабатывающим оформление заказа, и мало что мешало им вызывать друг друга. Со временем это привело к чрезмерно сильной связанности кода, обрабатывающего разные бизнес-процессы.

Преимущества монолитных систем

Монолитную архитектуру реализовать проще всего. Если не применять никакой архитектуры, то в результате, скорее всего, получится монолит. Это особенно верно для фреймворка Ruby on Rails, который располагает к созданию монолитов в силу глобальной доступности всего кода на уровне приложения. Монолитная архитектура может завести приложение очень далеко, поскольку она проста в разработке и на начальном этапе позволяет командам прогрессировать очень быстро, и поэтому раньше выкатить свой продукт клиентам.

Если держать всю кодовую базу в одном месте и развертывание приложения тоже в одном месте, то сразу возникает множество преимуществ. Вам потребуется поддерживать только один репозиторий, и вы сможете легко искать и находить всю функциональность в одной папке. Также будет достаточно поддерживать только один конвейер тестирования и развертывания, что, в зависимости от сложности вашего приложения, может избавить вас от многих издержек. Создание, настройка и поддержка этих конвейеров может быть дорогостоящей задачей, поскольку требуется целенаправленно обеспечивать их согласованность. Поскольку весь код развёрнут в одном приложении, все данные могут храниться в одной общей базе. Если понадобится извлечь тот или иной фрагмент данных достаточно сделать простой запрос к базе данных.

Поскольку монолиты развёртываются в одном месте, управлять нужно только одним комплектом инфраструктуры. Большинство Ruby-приложений поставляются вместе с базой данных, веб-сервером, возможностью выполнения фоновых заданий, а также, зачастую, с другими компонентами инфраструктуры, такими как Redis, Kafka, Elasticsearch и пр. Каждый дополнительный инфраструктурный блок означает, что вам придётся дольше работать в роли DevOps, а не в роли архитектора. Дополнительная инфраструктура означает увеличение количества возможных точек отказа. В таком случае портится отказоустойчивость и безопасность ваших приложений.

Одно из самых весомых преимуществ монолитной архитектуры перед несколькими отдельными сервисами заключается в том, что вы можете обращаться к различным компонентам напрямую, а не через API веб-сервисов. Это означает, что вам не нужно беспокоиться об управлении версиями API и обратной совместимости, а также о потенциальных задержках в работе.

Недостатки монолитных систем

Однако если приложение достигает определенного масштаба или команда, создающая его, достигает определенного масштаба, оно в конечном итоге перерастает монолитную архитектуру. Это произошло в Shopify в 2016 году и проявилось в постоянно растущей сложности создания и тестирования новых функций. В частности, пара моментов послужила для нас тревожным сигналом.

Приложение было очень хрупким, а новый код приводил к неожиданным последствиям. Вполне безобидное на первый взгляд изменение могло вызвать каскад разнородныз сбоев в тестировании. Например, если код, рассчитывающий тарифы на доставку, вызывал код, рассчитывающий налоговые ставки, то внесение изменений в способ расчета налоговых ставок могло повлиять на результат расчета тарифов на доставку, но при этом могло быть неочевидно, почему. Это происходило из-за сильной связанности и отсутствия границ, что также привело к тому, что тесты было сложно писать, и они очень медленно выполнялись в ходе непрерывной интеграции.

Разработка в Shopify требовала учитывать обширный контекст для внесения, казалось бы, простых изменений. Когда новые сотрудники Shopify входили в команду и знакомились с кодовой базой, им требовалось усвоить огромный объём информации, прежде чем приступать к работе. Например, новому разработчику, присоединившемуся к команде доставки, достаточно было понять реализацию бизнес-логики доставки. Однако в реальности такой новичок также должен был понимать, как создаются заказы, как мы обрабатываем платежи и многое другое, поскольку все было тесно связано между собой. Это слишком большой объем информации, которую приходится держать в голове только для того, чтобы отправить свою первую функцию. В сложных монолитных приложениях кривая обучения отличается большой крутизной.

Все проблемы, с которыми мы столкнулись, непосредственно проистекали из отсутствия границ между различными функциональными зонами в нашем коде. Было ясно, что требуется ослабить связь между различными областями, но вопрос заключался в том, как это сделать.

Микросервисная архитектура

Одно из решений, которое сейчас пользуется большой популярностью в отрасли, — микросервисы. Микросервисная архитектура — это подход к разработке приложений, при котором большое приложение строится как набор небольших сервисов, развёртываемых независимо друг от друга. Хотя микросервисы могли бы решить проблемы, с которыми мы столкнулись, они привнесли бы целый набор других проблем.

Нам пришлось бы поддерживать несколько различных конвейеров тестирования и развёртывания, брать на себя инфраструктурные издержки для каждого сервиса и при этом не всегда иметь доступ к нужным данным в нужный момент. Поскольку каждый сервис развёртывается независимо от других, взаимодействие между сервисами означает пересечение сети, что увеличивает задержки и снижает надежность при каждом обращении. Кроме того, крупные операции рефакторинга, затрагивающие несколько сервисов, могут быть утомительными, так как требуется вносить изменения во все зависимые сервисы и механизмы координации развертывания.

Модульные монолиты

Нам нужно было решение, которое повышало бы модульность без увеличения количества развёртываемых единиц, позволяя получить преимущества монолитов и микросервисов без их недостатков.

image


Монолит против микросервисов от Саймона Брауна.


Модульный монолит — это система, в которой весь код работает как единое приложение, а между различными доменами существуют строго определённые границы.

Реализация модульного монолита в Shopify: Компонентизация

Когда стало ясно, что мы переросли монолитную структуру, и она сказывается на производительности, всем разработчикам, работающим в нашей основной системе, был разослан опрос, чтобы выявить основные болевые точки. Мы знали, что у нас есть проблема, но при разработке решения мы хотели опираться на данные, чтобы оно действительно решало проблему, а не просто было анекдотичным.

По результатам этого опроса было принято решение разделить нашу кодовую базу. В начале 2017 года была собрана небольшая, но сильная команда для решения этой задачи. Изначально проект назывался «Break-Core-Up-Into-Multiple-Pieces» (Разбиение ядра на множество компонентов), а со временем мы переименовали этот процесс в «компонентизацию».

Организация кода

Первым вопросом, который мы взялись решать, была организация кода. На данный момент наш код был устроен как типичное Rails-приложение: по программным концепциям (модели, представления, контроллеры). Цель состояла в том, чтобы переорганизовать его по концепциям реального мира (например, заказы, доставка, инвентаризация и выставление счетов), чтобы облегчить поиск кода, поиск людей, которые понимают код, и самостоятельное восприятие отдельных частей. Каждый компонент будет структурирован как собственное мини-приложение на рельсах, с целью в конечном итоге обозначить их как ruby-модули. Мы надеялись, что новая организация позволит выделить области, которые были излишне связаны между собой.

image


Реалистичная реорганизация: до и после.


Составление первоначального списка компонентов потребовало провести немало исследований и задействовать заинтересованные стороны из разных отделов компании. Мы сделали это, перечислив все классы Ruby (всего около 6000) в обширной электронной таблице и вручную отметив, к какому компоненту он относится. Несмотря на то, что в ходе этого процесса код не менялся, работа всё равно затрагивала всю кодовую базу и потенциально была очень рискованной, если бы мы что-то сделали неправильно. Мы добились этого одним большим пул-реквестом, построенным с помощью автоматизированных скриптов.

Поскольку внесенные изменения сводились к перемещению файлов, возможные сбои могли возникнуть из-за того, что наш код «не знал», где найти определения объектов, что привело бы к ошибкам во время выполнения. Наша кодовая база хорошо протестирована, поэтому, запустив наши тесты локально и в CI без сбоев, а также прогнав как можно больше функциональности локально и в стейджинговой среде, мы смогли убедиться, что ничего не было упущено. Мы решили сделать все это в одном PR, чтобы как можно меньше беспокоить разработчиков. К сожалению, в результате этого изменения мы потеряли большую часть истории Git в Github, когда перемещение файлов неправильно учитывалось как удаление и создание, а не как переименование. Мы по-прежнему можем отследить происхождение с помощью опции git -follow, которая отслеживает историю перемещений файлов, однако Github не понимает, что это за перемещение.

Изолирование зависимостей

Следующим шагом стала изоляция зависимостей путем отделения предметных областей бизнеса друг от друга. Каждый компонент определил чистый специализированный интерфейс с границами домена, выраженными через общедоступный API. Каждый компонент получил исключительное право владения связанными с ним данными.

Хотя команда не смогла внедрить такой порядок во всей кодовой базе Shopify, поскольку для этого требовались эксперты из каждой предметной области, команда всё-таки определила шаблоны и предоставила инструменты для выполнения этой задачи.

Мы разработали инструмент под названием Wedge, который отслеживает прогресс каждого компонента на пути к обеспечению изоляции. Он выявляет любые нарушения границ домена (когда доступ к другому компоненту осуществляется не через его публично определенный API), а также связь данных между компонентами. Для этого мы написали инструмент, который подключается к точкам трассировки Ruby во время CI, чтобы получить полный граф вызовов. Затем мы сортируем вызывающих и вызываемых по компонентам, выбирая только те вызовы, которые пересекают границы компонентов, и отправляем их в Wedge. Вместе с этими вызовами мы отправляем некоторые дополнительные данные из анализа кода, такие как ассоциации ActiveRecord и наследование. Затем Wedge определяет, какие из этих межкомпонентных вещей (вызовы, ассоциации, наследование) являются нормальными, а какие — нарушающими. В целом:

  • Межкомпонентные ассоциации всегда нарушают компонентность
  • Вызовы возможны только к тем вещам, которые явно являются общедоступными


Затем Wedge подсчитывает общий балл, а также перечисляет нарушения по каждому компоненту.

image


Shopify’s Wedge отслеживает прогресс в достижении каждой цели компонента.


В качестве следующего шага мы построим график динамики оценок с течением времени и отобразим значимые различия, чтобы люди могли видеть, почему и когда оценка изменилась.

Обеспечение соблюдения границ

В перспективе мы хотели бы сделать еще один шаг вперед и обеспечить соблюдение этих границ программно. В этой статье Дэна Мангса приводится подробный пример того, как одна команда разработчиков приложения добилась соблюдения границ. Хотя мы все еще изучаем подход, который мы хотим использовать, в общих чертах мы планируем, чтобы каждый компонент загружал только те компоненты, от которых он явно зависит. Это приведет к ошибкам во время выполнения, если он попытается обратиться к коду компонента, зависимость от которого не была объявлена. Мы также можем вызывать ошибки времени выполнения или отказы тестов, когда к компонентам обращаются не через их публичный API.

Мы также хотим распутать граф зависимостей домена, устранив случайные и кольцевые зависимости. Достижение полной изоляции — это постоянная задача, но в нее вкладываются все разработчики Shopify, и мы уже видим некоторые из ожидаемых преимуществ. Например, у нас был устаревший налоговый движок, который уже не соответствовал потребностям наших продавцов. До того, как были предприняты усилия, описанные в этом посте, замена старой системы на новую была бы практически невыполнимой задачей. Однако, поскольку мы приложили столько усилий для изоляции зависимостей, мы смогли заменить наш налоговый движок на совершенно новую систему расчета налогов.

В заключение следует сказать, что отсутствие всякой архитектуры часто является лучшей архитектурой на ранних этапах работы над системой. Это не значит, что не нужно внедрять хорошие практики работы с программным обеспечением, но не стоит тратить недели и месяцы на попытки спроектировать сложную систему, которую вы еще не знаете. Гипотеза об устойчивости архитектуры Мартина Фаулера отлично иллюстрирует эту идею, объясняя, что на ранних стадиях большинства приложений вы можете двигаться очень быстро, не прибегая к проектированию. Вполне целесообразно искать компромисс между качеством дизайна и временем выхода на рынок. Как только скорость добавления функций и возможностей начинает замедляться, наступает время инвестировать в хороший дизайн.

Лучшие этапы для рефакторинга и перестройки — как можно более поздние, поскольку в процессе разработки вы постоянно узнаете все больше о своей системе и бизнес-области. Проектирование сложной системы микросервисов до того, как подробно исследована предметная область — рискованный шаг, на котором спотыкаются очень многие. По словам Мартина Фаулера, «почти во всех случаях, когда я слышал о системе, которая была построена как микросервисная с нуля, это заканчивалось серьезными проблемами… Не стоит начинать новый проект с микросервисов, даже если вы уверены, что ваше приложение будет достаточно большим, чтобы оправдать такой подход».

Хорошая архитектура программного обеспечения — это задача на постоянное развитие, и от того, в каком масштабе вы работаете, зависит, какое именно решение подойдёт для вашего приложения. Монолиты, модульные монолиты и сервис-ориентированная архитектура развиваются по эволюционной шкале по мере роста сложности вашего приложения. Каждая архитектура подходит для команды/приложения своего размера, и переход от уровня к уровню будет даваться очень болезненно. Когда вы начнете натыкаться на многие из болевых точек, описанных в этой статье, вы понимаете, что переросли текущее решение, и пора переходить к следующему.

P.S. Обращаем ваше внимание на то, что у нас на сайте проходит распродажа.

Habrahabr.ru прочитано 786 раз