Украинские события опять разделили нашу историю на периоды «До» и «После». IT все сегодняшние пертурбации коснулось нисколько не меньше, чем другие отрасли. И если в тучные годы компании могли себе позволить некоторые послабления, то сейчас проблемы оплаты, разрыв устоявшихся связей, снижение платежеспособности заказчиков и прочие последствия вынуждают их задуматься над оптимизацией расходов на разработку.

Оптимизировать расходы можно по-разному. Например, уволить дорогих разработчиков и набрать джунов, просто сократить штат или понизить зарплаты. Лично мне ближе подход увеличения скорости и качества разработки за счет применения более совершенных методик и стека.

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

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

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

Нет смысла подробно описывать проекты, да и предлагаемые архитектурные подходы не оптимизировались специально для них. Эти подходы я оттачивал на протяжении многих лет на разных проектах, включая ETL-системы и Web-бэкенды. Также я уже 2 года преподаю их в компании Otus на курсе «Backend-разработка на Kotlin», внося улучшения на каждом потоке студентов. И, если боевые металлургические проекты обсуждать здесь мне не позволяет NDA, то учебные проекты доступны в открытом виде на github и мы вполне сможем изучить их в этой статье. Давайте откроем проект маркетплейса учебной группы мая 2021 года и далее будем обсуждать именно его: ссылка.

Но предварительно несколько слов об общих особенностях подхода в целом и проекта в частности. Первое. Проект написан на Kotlin и, я вам скажу, значительная доля наших темпов была обеспечена именно этим языком. Ни Java, ни JS/TS, ни Python, по моему опыту, таких темпов не обеспечивает. Но все те же подходы мы также вполне успешно применяли и при разработке на дугих языках, включая Python.

Второе. Откуда вообще возникают задержки при разработке? Вроде «ты ж программист» — взял и сделал. Да, все легко делается, пока проект мелкий и простой. Но по мере роста проекта количество сущностей в нем начинает расти бешеными темпами и они начинают конфликтовать между собой, вызывая:

  1. Баги. Невозможно заранее предусмотреть все варианты развития логики. Значительная часть логики вскрывается уже на продуктовой площадке и выливается в баг-репорты.

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

  3. Переделки. Не всегда выявленные нюансы могут ограничиться только доделками. Нередко приходится переделывать часть программы.

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

Очевидно, давно уже всем известно, как бороться с такими напастями:

  1. Более тщательной проработкой проекта. Порой тщательной настолько, что проект становится водопадом, в котором планирование занимает чуть ли не столько же времени, сколько сама разработка. А потом оказывается, что все равно что-то не предусмотрели, после чего ТЗ вновь долго согласовывается и так же долго внедряется. Не очень способствует оптимизации.

  2. Гибкими подходами. Мы признаемся в том, что не всемогущи и ограничиваем тщательность начальной проработки. При этом планируем рефакторинг в несколько итераций. Это отличный подход на бумаге, но на деле тут есть один нюанс: ваш проект должен предусматривать подобные рефакторинги, а для этого у него должна быть гибкая архитектура, чего в большинстве проектов, сделанных на Spring-MVC (да и на множестве других фреймворках) не наблюдается.

Итак, как же нам достичь гибкой архитектуры? Тут ничего нового нет. Все эти избитые вопросы с собеседований типа SOLID, GRASP, Банда четырех, чистая архитектура, DDD и прочее — это как раз про это.

Взгляните на проект маркетплейса. Что первое бросается в глаза — это большое количество модулей, т.е. модульная архитектура. Каждый модуль появляется для того, чтобы максимально изолировать какую-то функциональность. Когда она изолирована, мы всегда с легкостью можем локализовать источник проблем и всегда легко сможем корректировать (исправлять баги, доделывать, переделывать) именно эту функциональсть, не затрагивая другие части программы. На всех собеседованиях спрашивают про DI, все знают, что Spring на этом и построен, но почему-то в реальных проектах я не часто вижу модульную архитектуру.

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

Аналогично, серия модулей repo обслуживает хранение. Сейчас Oracle объявила санкции российским потребителям и у многих компаний встает вопрос: как заменять базы данных этой компании на что-то другое. И ORM-библиотеки не всегда могут помочь в этом вопросе, потому как диалекты SQL могут вносить серьезные изменения в производительность и пр. нюансы, так и переход может выполняться не только на SQL-базы, но и на NoSQL или NewSQL. Когда у вас хранение выделено в отдельный модуль, то потребуется разработать только один новый модуль, подключить его в модуле фреймворка и все. Другие модули затронуты не будут.

Отдельно хочется упомянуть модули ok-marketplace-mp-transport-mp и ok-marketplace-be-service-openapi. В этих модулях находятся разные версии API для одного и того же микросервиса. Как видите, даже API мы выносим в отдельные модули. И это дает возможность нам не только легко менять спецификацию без существенных переделок, но и поддерживать одновременно несколько версий API в одном микросервисе. Например, для бесшовного апгрейда отдельных микросервисов системы.

Для того, чтобы изменение API не влияло на остальные компоненты, данные из транспортных моделей перекладываются во внутренние модели, размещенные в модуле ok-marketplace-be-common. Внутренние модели используются большинством остальных модулей микросервиса и собраны в контекст (о нем чуть далее). Они используются исключительно внутри этой программы, нигде не публикуются, а значит их изменение никак не отражается на внешних интеграциях или хранении. Именно поэтому они формируются так, как удобно нам. Например, там могут быть избыточные поля, они могут быть мутабельными, время нам удобно хранить не как ISO 8601 или Long-таймстэмпом, а как Instant.

Перекладка данных из транспортных моделей во внутренние производится с помощью модулей-маперов. Почему они тоже выделены в отдельные модули, а не объединены с транспортными моделями? Пример такой. У нас был реализован WebRTC-интерфейс, состоящий из трех компонентов: (1) сигнального сервера, (2) сервиса-продюсера видеопотока и (3) фронтенда-потребителя видеопотока. Все эти компоненты использовали те же самые транспортные модели, но собственные внутренние. Поэтому маперы не могут включаться внутрь модуля транспортных моделей и точно так же не могут включаться в фреймворк.

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

В проекте можно увидеть модуль ok-marketplace-be-logics. Он занимает особую роль. В нем происходит выполнение бизнес-логики. Бизнес-логика не может зависеть от конкретных реализаций хранения или форматов данных, она просто описывает то, что обычно рисуется на PBM-диаграмах, т.е. операции обработки полученных данных и вычисление результата. Именно поэтому этот модуль зависит только от ok-marketplace-be-common (плюс небольшие библиотеки-помощники типа валидаторов). В модуле бизнес-логики мы не интересуемся, в какую базу будут сохранены данные и как эта база устроена. В нем мы просто указываем, что такие-то данные необходимо сохранить.

Строится модуль бизнес-логики на базе классического шаблона Chain of Responsibilities (CoR, Цепочка обязанностей), который описан был еще в книге Банды четырех в далеком 1994 году, т.е. за год до появления Java и JavaScript. К сожалению, редко видел людей, которые им пользуются, хотя то, что он используется в Spring, знает больше людей :)

Шаблон представляет из себя классический конвейер Форда, в котором одинаково устроенные функции-обработчики последовательно выполняют работу над объектом класса-контекста (да, это его я упомянул выше). Работа с контекстом происходит следующим образом:

  1. Он создается при каждом вызове контроллера в фреймворке.

  2. Полученные в запросе данные десериализуются в транспортные модели, мапятся во внутренние модели, которые и складываются в контекст.

  3. Подготовленный контекст пропускается через цепочку всех обработчиков, вычисляя результат.

  4. Результат отправляется как ответ микросервиса.

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

Многие текущие изменения и бизнес-логике мы в команде выполняли в течении пары часов, максимум пары дней (в случае глобальных изменений). В нашей команде ни разу не прозвучала фраза, которую я встречал в других командах: «В ТЗ/архитектуру/постановку это требование не было заложено, поэтому на переделку требуется несколько месяцев».

Шаблон CoR в проекте реализован в проекте в виде библиотеки в модуле ok-marketplace-mp-common-cor. В боевых проектах мы используем его Open Source реализацию из git-проекта. Особенность библиотеки в том, что она оптимизирована для высокой читаемости логики человеком. Даже через месяц в проекте не просто становится разобраться, ведь все забывается. А благодаря библиотеке вся бизнес-логика приложения наглядно представлена в одном файле. И да, если в начале проекта число обработчиков редко превышает десяток, то в типичном боевом микросервисе их в итоге накапливается десятки и даже сотни.

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

Так вот, для связи микросервисов мы используем OpenAPI. Сейчас он стал уже довольно распространен, но все еще не так широко, как хотелось бы. OpenAPI спецификацию может написать даже аналитик, не без согласования с тимлидом, конечно. Далее из этой спецификации генерируются транспортные модели — те самые модули ok-marketplace-be-transport-openapi и ok-marketplace-mp-transport-mp. Зачем это делается? Наш CI настроен таким образом, что сразу после изменения транспортных моделей происходит пересборка всего проекта. Поскольку Котлин относится к языкам со строгой типизацией, микросервисы, изменение транспортных моделей в которых не учтены, при пересборке падают. Такие падения позволяют нам контролировать согласованность кода в проекте и это гораздо лучше, чем если о несогласованности мы узнаем уже только в проде.

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

Обо мне: Сергей Окатов, 

руководитель отдела разработки, архитектор компании Datana;

автор и руководитель курса “Backend разработка на Kotlin” в компании Otus.


Сегодня вечером в Otus состоится demo-занятие «Тестирование в микросервисной архитектуре», на которое приглашаем всех желающих. На занятии расскажем про различные типы тестов и инструментов, используемых в тестировании, а также поговорим о том, как микросервисная архитектура изменила подходы к тестированию. Регистрация здесь.

Комментарии (14)


  1. syusifov
    23.03.2022 17:24
    +5

    Без самих проектов все это блабла.

    Возможно некто ваши 5 проектов реализовал бы один и за полгода.


  1. Cibercerg2121
    24.03.2022 07:35

    А вы без аналитика эти проекты делали?


    1. svok Автор
      24.03.2022 07:37

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


    1. svok Автор
      24.03.2022 08:23
      +1

      Дошло. Вы, вероятно, думаете, что аналитик каким-то чудом может спрогнозировать все нюансы бизнес-логики и благодаря его работе переделки не потребуются. Это не так по вполне объективным причинам.

      1. Заказчик.

        1. Далеко не всегда представляет как оно должно выглядеть и что все-таки требуется. И, даже если вы вытрясете из него конкретное решение и закрепите в договоре, далеко не всегда в итоге окажется это решение верным и тем, что ему нужно. Формально пункт договора выполнен, реально - нет.

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

      2. Аналитики.

        1. Аналитик - это не эксперт предметной области. Это две разные должности. Так что не стоит от него ждать чудес владения предметной областью.

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

        3. Даже программист доводит программу до рабочего состояния с 3-5-й итерации. А у аналитика нет никаких средств тестирования его документации. Все ошибки аналитики выплывают только на других стадиях ввода в эксплуатацию.

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


  1. SabMakc
    24.03.2022 09:50

    Строится модуль бизнес-логики на базе классического шаблона Chain of Responsibilities (CoR, Цепочка обязанностей), который описан был еще в книге Банды четырех в далеком 1994 году, т.е. за год до появления Java и JavaScript. К сожалению, редко видел людей, которые им пользуются, хотя то, что он используется в Spring, знает больше людей :)

    Цепочка обязанностей - хороший паттерн, "красивый", спору нет.

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


    1. svok Автор
      24.03.2022 10:13

      Тут целый ряд нюансов

      1. Сам по себе шаблон вряд ли может быть медленный. Скорее медленной может быть конкретная реализация. Т.е. я бы в первую очередь пооптимизировал библиотеку, если такая проблема возникла.

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

      3. У нас на этом шаблоне работает видеообработка на Python со скоростью 30 кадров в секунду. За все время работы с ним я ни разу не сталкивался с проблемами производительности.

      4. Очевидно, что, чем сложнее бизнес-логика, тем больше нагрузка на ЦПУ. И вряд ли имеет существенное значение в каком виде эта логика реализована. Она все равно будет жрать ресурсы.

      5. Часто помогает алгоритмическая оптимизация бизнес-логики. Например, когда вместо 500 if мы реализуем машину состояний, которая отрабатывает за наносекунды.


      1. SabMakc
        24.03.2022 10:31

        А причем тут сам шаблон? К шаблону тут вопросов нет, как и к реализации. Нет к вопросов и к работе обработчиков в данном паттерне - они работают не лучше и не хуже, чем при других подходах и реализациях (при прочих равных).

        Я именно про анализ времени выполнения кода - сложно оценить время работы каждого отдельного обработчика.

        Тут не только то, что шаблон углубляет дерево вызова, что само по себе усложняет анализ. Но и про то, что профайлеры имеют определенную погрешность в силу своей работы (периодический анализ стека вызова каждого потока*), что увеличивает погрешность с ростом глубины вызова и с уменьшением времени выполнения каждого отдельного вызова.

        * Да, существуют и альтернативные подходы - но они сильно режут скорость выполнения кода и могут вносить дополнительные искажения в получаемые результаты.


        1. svok Автор
          24.03.2022 10:52

          1. Мне не очень понятно как углубляется дерево вызовов. Шаблон оперирует последовательным вызовом обработчиков, ухода у глубину практически нет. У нас максимум три уровня глубины обработчиков было.

          2. Я правильно понял, что у вас какие-то сложности с Java-профайлерами возникли? Может можно выбрать другой? В крайнем случае оценить скорость работы обработчиков можно просто добавлением функции измерения в саму библиотеку.


          1. SabMakc
            24.03.2022 11:37

            Эм... А мы точно про цепочку обязанностей говорим? Потому как суть паттерна - каждый обработчик обрабатывает запрос сам и, в случае необходимости, вызывает последующие обработчики (имея возможность вмешательства в параметры вызова и возвращаемый результат).

            Вот на этапе "вызывает последующие обработчики" и увеличивается глубина вызова. Чем больше обработчиков - тем больше глубина вызова.


            1. svok Автор
              24.03.2022 11:42

              Мы говорим о цепочке, но реализация у нас другая. Обработчик ничего не вызывает, он просто делает свою работу. А вот процессор управляет вызовом обработчиков. И это вполне эффективно.


              1. SabMakc
                24.03.2022 11:56

                Вот в итоге подобный подход применил и я - последовательный вызов обработчиков из списка ) И профайлер сразу стал отлично показывать куда и сколько времени уходит )

                Но последовательный вызов обработчиков из списка - это уже не "цепочка обязанностей" )

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

                А список обработчиков такой гибкости не дает...


            1. svok Автор
              24.03.2022 11:54

              Глянул, да в Википедии Java-пример сделан как у вас, а Python-пример - как у нас.

              https://ru.wikipedia.org/wiki/Цепочка_обязанностей


              1. SabMakc
                24.03.2022 11:59

                Мне тут понравилось начало статьи - очень наглядно показывает в чем разница между цепочкой обязанностей и списком обработчиков: http://cpp-reference.ru/patterns/behavioral-patterns/chain-of-responsibility/


              1. SabMakc
                24.03.2022 12:17

                К слову, в английской версии статьи, на сколько я могу судить, корректный Python-пример: https://en.wikipedia.org/wiki/Chain-of-responsibility_pattern

                C#/PHP примеры тоже корректны, на сколько я могу судить... А вот Java-пример - что-то не туда ушли, с функциональным подходом... В русскоязычной же статье Java-пример корректный.