И снова здравствуйте! Меня по-прежнему зовут Александр Попов, и я всё ещё TechLead команды разработки маркетплейса 05.ru.
Я рад представить вам продолжение статьи о распределённой архитектуре нашего продукта. Предыдущая статья вышла около года назад, и за это время мы практически полностью перешли на новую архитектуру. Естественно, по пути столкнулись с проблемами, но в то же время получили неплохие бонусы. Вот об этом я и хотел бы рассказать.
Перед прочтением рекомендую ознакомиться с первой частью моего рассказа о новой архитектуре нашего продукта. Потому что велика вероятность встретить непонятные термины и описания незнакомых процессов.
It's alive! It's alive!
Если говорить совсем коротко, то архитектура работает и неплохо показывает себя как в эксплуатации, так и в процессе разработки. Чёткое разделение на сервисы и фасады сильно помогает в понимании логики работы всего приложения. Фасады на Temporal работают стабильно и, вопреки ожиданиям, не падают. Написанный Service Template снимает с разработчиков огромное количество когнитивной нагрузки. Кодогенерация позволяет значительно ускорить разработку новых сервисов.
К сожалению, продукт всё ещё сохранил некоторые черты того самого монстра Виктора Франкенштейна, так как на данный момент осталось около 10% кода, который не соответствует общей архитектуре. Но это не потому, что старый код прекрасно работает, а потому что не хватает времени на устранение техдолга. Торжественно обещаю, что к выходу следующей статьи от легаси и старой архитектуры ничего не останется!
Ну а теперь предлагаю подробнее ознакомиться с тем, как мы проходили этот долгий, сложный и тернистый путь внедрения Temporal и Service Template в наш продукт. Вас ждут невероятно крутые и удобные решения, а также непредвиденные сложности и откровенные фейлы. Надеюсь, что наша история поможет вам избежать хотя бы части ошибок, если решитесь повторить подобный путь.
Service Template — must have для разработки распределённой системы
В целом, мысль из заголовка не нова и не гениальна. Но замечу, что решение вложиться в разработку качественного Service Template было одним из лучших на начальном этапе.
Мы уже имели некоторое подобие Service Template ещё до внедрения новой архитектуры. Но раньше он представлял собой, скорее, просто сборник хелперов и базовых классов, без чёткой структуры и логического разделения на модули.
На разработку нового Service Template меня вдохновила книга Р. Мартина «Чистая архитектура». И в этот раз я подошёл к процессу создания более основательно, уже чётко понимая, какую функциональность нужно выносить в общую библиотеку, как её разделять на слои и как выстраивать связи.
Сейчас Service Template представляет собой монолитную библиотеку. Но в дальнейшем планируется разделить его на отдельные модули, которые можно подключать к сервисам и фасадам по мере необходимости.
Чтобы вам легче было понять, какую нагрузку снимает Service Template с разработчика, вот ряд проблем, которые он решает:
чёткая структура кода;
кодогенерация;
управляемое кеширование ответов;
автоматический CRUD на каждую сущность;
трассировка всех запросов;
сбор метрик;
работа с шиной событий;
аутентификация запросов и проброс авторизации в сообщения шины;
переводимые на разные языки поля сущностей;
работа с оркестратором;
тестирование сервисов и фасадов.
Цель написать новый сервис за несколько часов, конечно, ещё не достигнута, потому что не уделили достаточно времени и сил разработке полноценной кодогенерации. Причина проста: новых сервисов появляется не так много, чаще дорабатываем существующие. Но точно могу сказать, что идея написания генератора не заброшена, а всего лишь отложена на полку в ожидании подходящего момента, чтобы представить её бизнесу.
В целом, за прошедший год не возникало ситуаций, в которых Service Template мешал бы разработке или накладывал ограничения на разрабатываемый код. Поэтому ещё раз повторю: это было одним из лучших решений при разработке продукта.
Temporal — волшебная таблетка с побочными эффектами
Как я писал в предыдущей статье, в качестве оркестратора мы выбрали продукт под названием Temporal. И с задачей оркестрации он справляется прекрасно: легко масштабируется, очень отказоустойчив, у него обширная функциональность для реализации саг, ретраев, многопоточности и всего остального. Но, как говорится, есть нюансы. И вот о них я постараюсь рассказать максимально подробно.
Первое, о чём довольно мало упоминается в руководствах по Temporal, это проблема версионности workflow. Коротко о том, в чём она заключается.
Temporal позволяет реализовывать долгоживущие workflow. Например, весь процесс работы с заказом, от момента его создания до выдачи клиенту. Это может занимать день, неделю, даже месяц. За это время неизбежно будут внесены изменения в код workflow. И беда в том, что если это изменение в той части workflow, которая уже пройдена, то при следующем обращении к нему возникнет ошибка недетерминированности процесса. Другими словами, Temporal не может восстановить состояние workflow, на котором он остановился в прошлый раз.
На эту тему я нашёл неплохой видеоурок. В нём очень подробно объясняются причины возникновения проблемы и как Temporal позволяет её решить с использованием механизма хранения версий.
Но вот беда: я наткнулся на этот ролик совсем не в официальной документации Temporal. К счастью, мы обратили внимание на проблему версионности ещё до того, как у нас начали падать workflow на продакшене. Но всё равно очень поздно.
Другая не слишком освещённая, но крайне важная составляющая качественной разработки workflow — это их тестирование. Она описана в официальной документации, но и тут не всё так просто.
Для тестирования workflow должен использоваться какой-нибудь сервер Temporal, на котором и будут запускаться тестовые workflow. В документации предлагается использовать легковесный сервер, специально предназначенный для этого. У него даже есть удобная возможность перемотки времени. Правда, нигде не говорится, что этот сервер не поддерживает часть функциональности Temporal. Например, работу с дочерними workflow. Эту информацию я смог узнать только напрямую от разработчиков Temporal SDK.
В итоге, вместо тестового сервера мы стали запускать тесты на полноценном сервере Temporal, но в отдельном пространстве имён. Таким образом мы смогли протестировать всю функциональность наших workflow. Правда, лишившись удобной функции перемотки времени.
Ещё одна довольно серьёзная проблема, с которой мы столкнулись, — это проброс всех ошибок от самых глубоких глубин workflow до конечного клиента. Поскольку весь обмен данными в Temporal проходит сериализацию и десериализацию, проброс обычных исключений не работает привычным образом. Каждая ошибка оборачивается в специальный класс. Причём классы отличаются для ошибок workflow, activity и дочерних workflow. И, что особенно неприятно, — довольно слабо документирована официально.
Были и другие, более мелкие неприятности при разработке качественных workflow. Но их решение уже не занимало столько времени и не вызывало потенциально опасных ситуаций.
Подводя итог: я всё ещё считаю Temporal оптимальным выбором в качестве оркестратора. Но качественная разработка на нём связана со множеством неочевидных и неожиданных проблем. А также хотелось бы иметь более качественную и продуманную документацию по работе с SDK.
От теории к практике
Я уверен, что читать абстрактные рассуждения про наши успехи и неудачи было невероятно захватывающе. Но давайте перейдём к практическим примерам.
Начну с модерации отзыва, описанной в предыдущей статье.
Вот так процесс должен был выглядеть по задумке: весь сценарий описан в одном workflow от начала и до конца. На практике получилось немного иначе. Обновление среднего рейтинга товарных предложений решили реализовать через события шины. И на это есть ряд причин:

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

А уже фасады других доменов слушают нужные им события и обрабатывают, как им необходимо.
В целом, подобными диаграммами описываются и все остальные сценарии, как тривиальные синхронные, так и сложные, растянутые по времени и с несколькими участниками (например, процесс работы над заказом).
Из описанного выше мы можем сделать следующие выводы:
В теории, нет разницы между теорией и практикой. На практике — есть. Инженерная пословица
Предложенная год назад архитектура вполне неплохо работает и оправдывает ожидания.
Новая архитектура оказалась довольно гибкой к изменениям. В качестве примера подобной гибкости дальше мы рассмотрим совмещённый сервис с фасадом.
Новая архитектура неплохо документируется и отвечает на ряд однотипных вопросов при разработке каждой фичи: где описывать сценарий, где прослушивать события, где хранить информацию, в каком месте валидировать данные и т. д.
Ой! Как неожиданно и приятнаааа!
Описанными выше преимуществами новая архитектура не ограничивается. Хочу привести в пример ещё одну задачу, которую довольно элегантно получилось решить благодаря Temporal и гибкости нашей архитектуры.
Речь про сервис, который отвечает за доступ к файлам, а также за обработку и кеширование изображений. Упрощённо схема работы клиента с файлами через сервис выглядит следующим образом:

Наверное, вы уже обратили внимание, что в схеме не указан отдельно фасад с оркестратором и сервис с данными. Но тут нет ошибки, в данном случае решили объединить фасад и сервис в одно приложение, потому что функциональность Storage довольно сильно обособлена от остального бэкенда. В ней нет и не будет никаких процессов, затрагивающих другие сервисы. И вообще, есть даже планы вынести весь сервис Storage в платформу. О структуре платформы и подходе PaaS у нас есть отдельная статья.
И поэтому разделять функциональность сервиса на два отдельных приложения только ради соответствия архитектуре продуктовых сервисов не было никакого смысла. А в случае разделения мы бы получили кучу накладных расходов на сетевые вызовы между приложениями.
К счастью, никаких технических ограничений встроить Temporal в сервис нет. И в результате мы получили довольно шустрый и в то же время гибкий к доработкам сервис.
Но есть ещё одна фича, которая реализована в этом сервисе. Про неё также упоминается в нескольких обучающих видео по Temporal. Речь про использование разных языков при разработке одного приложения. То есть мы можем описать сценарии оркестратора (workflow) на привычном стеке, а activity — на наиболее подходящих языках. В нашем примере весь сервис и workflow написаны на PHP. Но вы, наверное, в курсе, что обрабатывать изображения или видео на PHP — идея так себе. Поэтому всё, что нам нужно сделать — это написать activity, в которых происходит работа с изображениями, на более подходящем языке: Go или C#. За неимением квалифицированных разработчиков на .NET мы выбрали Go.
И самое приятное во всей этой ситуации, что мы можем не просто переключить используемую activity на лету, но даже использовать параллельно оба варианта реализации.
Ты туда не ходи, ты сюда ходи. А то снег в башка попадёт — совсем мёртвый будешь
Под конец хотел поделиться ещё одним крайне сомнительным решением, которое мы приняли на волне эйфории от использования Temporal. Оно не связано напрямую с архитектурой бэка, но влияет на весь продукт в целом.
Речь пойдёт про фронт-приложение маркетплейса. Оно целиком написано на фреймворке Nuxt. А в качестве агрегатора данных от бэкенда выбрали Temporal. И общение с бэкендом происходит не через HTTP-запросы напрямую с клиентов, а через activity Temporal.
То есть, при запросах POST/PUT/DELETE мы получаем примерно такую схему взаимодействия компонентов системы:

Конечно, у такого решения есть преимущества:
максимально подробные и удобные логи всего запроса;
использование фронт-приложением всех преимуществ Temporal и отсутствие зоопарка технологий;
удобная и однотипная работа с ошибками на бэке и фронте.
Но получилось так, что мы забиваем гвозди микроскопом. Сам Temporal подобные нагрузки выдержит, тут всё в порядке. Но уже по схеме видно основную проблему — большие накладные расходы на сетевые запросы. А хранение логов фронта и бэка в одном месте тоже создаёт определённые неудобства.
Сейчас становится всё более очевидно, что для агрегации данных вполне можно воспользоваться и менее замороченным инструментом. К сожалению, поменять решение «на лету» мы уже не можем. Нужно довольно много времени на поиск альтернативы и её внедрение в работающий продукт.
Скоро всё развалится (но это не точно)
Надеюсь, вам было интересно хоть немного погрузиться во все перипетии нашего нелёгкого и не всегда позитивного процесса разработки и внедрения архитектуры, придуманной (не утверждаю, что уникальной) больше года назад. Конечно, в одной статье сложно рассказать обо всех сложностях и успехах, с которыми мы сталкивались. Но я постарался припомнить и осветить самое полезное и интересное. Возможно, наша история поможет кому-то избежать лишнего года боли, граблей и бессонных ночей.
Конечно же, жду ваши комментарии с критикой и обещаниями, что у нас скоро всё развалится под нагрузкой. А также с предложениями гораздо более гибких и удобных решений. Встретимся ещё раз через с год с новыми историями об успехах и неудачах.