Го в Go! Как команда PHP взялась писать микросервисы

    Всем привет! Меня зовут Алексей Скоробогатый, я системный архитектор в Lamoda. В феврале 2019 года я выступал на Go Meetup еще на позиции тимлида команды Core. Сегодня хочу представить расшифровку своего доклада, который вы также можете посмотреть.


    Наша команда называется Core неспроста: в зону ответственности входит все, что связано с заказами в e-commerce платформе. Команда образовалась из PHP-разработчиков и специалистов по нашему order processing, который на тот момент представлял собой единый монолит. Мы занимались и продолжаем заниматься декомпозицией его на микросервисы.


    image


    Оформление заказа в нашей системе состоит из связанных компонентов: есть блок доставки и корзина, блоки скидок и оплаты, — и в самом конце есть кнопка, которая отправляет заказ собираться на склад. Именно в этот момент начинается работа системы order processing, где все данные заказа будут провалидированы, а информация агрегирована.


    image


    Внутри всего этого — сложная многокритериальная логика. Блоки взаимодействуют между собой и влияют друг на друга. Непрерывные и постоянные изменения от бизнеса еще увеличивают сложность критериев. Кроме того, у нас есть разные платформы, через которые клиенты могут создавать заказы: сайт, приложения, колл-центр, В2В-платформа. А также жесткие критерии SLA/MTTI/MTTR (метрики регистрации и решения инцидента). Все это требует от сервиса высокой гибкости и устойчивости.


    Архитектурное наследие


    Как я уже говорил, на момент образования нашей команды система order processing представляла собой монолит – почти 100 тысяч строк кода, в которых описывалась непосредственно бизнес-логика. Основная часть была написана в 2011 году, с использованием классической многослойной MVC-архитектуры. В основе был РНР (фреймворк ZF1), который постепенно оброс адаптерами и symfony-компонентами для взаимодействия с различными сервисами. За время существования у системы было более 50 контрибьюторов, и хотя нам удалось сохранить единый стиль написания кода, это тоже наложило свои ограничения. Плюс ко всему возникло большое количество смешанных контекстов — по разным причинам в систему были имплементированы некоторые механизмы, не связанные непосредственно с обработкой заказов. Все это привело к тому, что на настоящий момент мы имеем MySQL базу данных размером более 1 терабайта.


    Схематично изначальную архитектуру можно представить так:


    image


    Заказ, конечно, находился на каждом из слоев — но помимо заказа были и другие контексты. Мы начали с того, что определили bounded context именно заказа и назвали его Customer Order, так как помимо самого заказа, там есть те самые блоки, которые я упомянул в начале: доставка, оплата и прочее. Внутри монолита всем этим было сложно управлять: любые изменения влекли к увеличению зависимостей, код доставлялся на прод очень долго, всё время увеличивалась вероятность ошибок и отказа системы. А мы ведь говорим про создание заказа, основную метрику интернет-магазина — если заказы не создаются, то остальное уже не так важно. Отказ системы вызывает немедленное падение продаж.


    Поэтому мы решили вынести контекст Customer Order из системы Order Processing в отдельный микросервис, который назвали Order Management.


    image


    Требования и инструментарий


    После определения контекста, который решили вынести из монолита в первую очередь, мы сформировали требования к нашему будущему сервису:


    • Производительность
    • Консистентность данных
    • Устойчивость
    • Предсказуемость
    • Прозрачность
    • Инкрементальность изменений

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


    В итоге мы пришли к определенной структуре, которую используем во всех новых микросервисах:


    Bounded Context. Каждый новый микросервис, начиная с Order Management, мы создаем на основе бизнес-требований. Должны существовать конкретные объяснения, какую часть системы и почему требуется вынести в отдельный микросервис.


    Существующая инфраструктура и инструментарий. Мы не первая команда в Lamoda, которая начала внедрять Go, до нас были первопроходцы — непосредственно Go-шная команда, которая подготовила инфраструктуру и инструментарий:


    1. Gogi (swagger) — генератор спецификации по swagger.
    2. Gonkey (testing) — для функциональных тестов.
    3. Мы используем Json-rpc и генерим обвязку client/server по swagger. Также все это деплоим в Kubernetes, собираем метрики в Prometheus, для трейсинга используем ELK/Jaeger – все это входит в обвязку, которую создает Gogi для каждого нового микросервиса по спецификации.

    Примерно так выглядит наш новый микросервис Order Management:


    image


    На входе у нас есть данные, мы их агрегируем, валидируем, взаимодействуем со сторонними сервисами, принимаем решения и передаем результаты дальше в Order Processing — тот самый монолит, который большой, неустойчивый и требовательный к ресурсам. Это тоже нужно учитывать при построении микросервиса.


    Сдвиг парадигмы


    Выбрав Go, мы сразу получили несколько преимуществ:


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

    Язык Go ограничивает воображение разработчика. Это стало камнем преткновения для нашей команды, привыкшей к РНР, когда мы перешли к разработке на Go. Мы столкнулись с настоящим парадигменным сдвигом. Нам пришлось пройти через несколько стадий и понять некоторые вещи:


    1. В Go тяжело строить абстракции.
    2. Go, можно сказать, Object-based, но не Object-oriented язык, так как там нет прямого наследования и некоторых других вещей.
    3. Go способствует писать явно, а не скрывать объекты за абстракциями.
    4. Go имеет Pipelining. Это вдохновило нас на построение цепочек обработчиков для работы с данными.

    В итоге мы пришли к пониманию, что Go – это процедурный язык программирования.
    image


    Data first


    Я думал, как визуализировать проблему, с которой мы столкнулись, и наткнулся на эту картинку:


    image


    Здесь изображен “объектно-ориентированный” взгляд на мир, где мы строим абстракции и закрываем за ними объекты. Например, тут не просто дверь, а Indoor Session Initialiser. Не зрачок, а Visitor Monitor Interface — и так далее.


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


    Рассуждая таким образом, мы поставили на первое место данные, и получили такой Pipelining в сервисе:


    image


    Изначально мы определяем модель данных, которые поступают в конвейер обработчиков. Данные являются изменяемыми, причем изменения могут происходить как последовательно, так и конкурентно (concurrency). С помощью этого мы выигрываем в скорости.


    Назад в будущее


    Неожиданно, разрабатывая микросервисы, мы пришли к модели программирования 70-х годов. После 70-х возникли большие enterprise-монолиты, где появилось объектно-ориентированное программирование, функциональное программирование – большие абстракции, которые позволяли удерживать код в этих монолитах. В микросервисах нам все это не нужно, и мы можем использовать отличную модель CSP (communicating sequential processes), идею которой выдвинул как раз в 70-х Чарльз Хор.


    Также мы используем Sequence/Selection/Interation — парадигму структурного программирования, согласно которой весь код программы можно составить из соответствующих управляющих конструкций.


    Ну и процедурное программирование, которое в 70-х годах было мейнстримом :)


    Структура проекта


    image


    Как я уже говорил, на первое место мы поставили данные. Кроме того, построение проекта “от инфраструктуры” мы заменили на бизнес-ориентированное. Чтобы разработчик, заходя в код проекта, сразу видел, чем занимается сервис — это и есть та самая прозрачность, которую мы определили как одно из основных требований к структуре наших микросервисов.


    В результате мы имеем плоскую архитектуру: небольшой слой API плюс модели данных. А вся логика (которая ограничена у нас контекстом бизнес требования от микросервиса), хранится в процессорах (обработчиках).


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


    Чтобы контролировать гранулярность и не плодить микросервисы необдуманно, логику, которая не относится непосредственно к этому контексту, но близко связана с данным микросервисом, мы заключаем в слое services. А потом, если есть бизнес-потребность, мы выносим ее в отдельный микросервис — и далее с помощью rpc-вызова обращаемся к нему.


    image


    Таким образом, для внутреннего API в процессорах у сервиса взаимодействие никак не меняется.


    Устойчивость


    Мы решили не брать заранее какие-то сторонние библиотеки, так как данные, с которыми мы работаем, достаточно чувствительные. Поэтому мы немного повелосипедили :) Например, сами реализовали некоторые классические механизмы — для Idempotency, Queue-worker, Fault Tolerance, Compensating transactions. Наш следующий шаг — постараться это переиспользовать. Завернуть в библиотеки, может быть side-car контейнеры в Pod'ах Kubernetes. Но уже сейчас мы можем эти паттерны применять.


    Мы реализуем в своих системах паттерн, который называется graceful degradation: сервис должен продолжать работать, независимо от внешних вызовов, в которых мы агрегируем информацию. На примере создания заказа: если запрос попал в сервис, мы в любом случае заказ создадим. Даже если упадет соседний сервис, отвечающий за какую-то часть той информации, которую мы должны сагрегировать или провалидировать. Более того – мы не потеряем заказ, даже если мы не сможем в краткосрочном отказе процессинга заказа, куда мы должны передать. Это тоже один из критериев, по которым мы принимаем решение, выносить ли логику в отдельный сервис. Если сервис не может обеспечить свою работу при недоступности следующих сервисов в сети, то либо нужно его перепроектировать, либо подумать о том, стоит ли его вообще выносить из монолита.


    Го в Go!


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


    Одной из основных наших задач было повышение устойчивости сервиса. Конечно, Go не дает повышения устойчивости просто “из коробки”. Но, по моему ощущению, в экосистеме Go оказалось проще создать весь необходимый Reliability kit даже своими руками, не прибегая к сторонним библиотекам.


    Другой важной задачей было увеличить гибкость системы. И тут я однозначно могу сказать, что скорость внесения требуемых бизнесом изменений сильно выросла. Благодаря архитектуре новых микросервисов разработчик остается один на один с бизнес-фичей, ему не нужно думать о том, чтобы строить клиенты, слать мониторинги, пробрасывать трейсинги, и настраивать логирование. Мы оставляем для разработчика именно прослойку написания бизнес-логики, позволяя ему не задумываться обо всей инфраструктурной обвязке.


    Собираемся ли мы полностью переписать все на Go и отказаться от РНР?


    Нет, так как мы идем от бизнес-потребностей, и есть некоторые контексты, в которых РНР очень хорошо ложится — там не нужна такая скорость и весь Go-шный набор инструментов. Вся автоматизация операций по доставке заказов и управлению фотостудией сделана на PHP. Но, например, в e-commerce платформе в customer side мы почти все переписываем на Go, так как там это оправданно.

    Lamoda
    82,23
    Russian Fashion Tech
    Поделиться публикацией

    Комментарии 15

      0
      В Go тяжело строить абстракции.

      Go способствует писать явно, а не скрывать объекты за абстракциями.

      Не понял почему, в Go же есть интерфейсы.
        0
        Мне вообще сложно представить как так писать код без абстракций :) Довольно странное заявление автора.
          0
          Речь про количество уровней абстракции, которые используются. Мысль была в том, что далеко не все шаблоны проектирования, используемые в PHP, у нас получилось применить в Go. Что и логично. Многое пришлось переосмыслить. В этом и был наш сдвиг парадигмы.
            0
            Вы, наверное, имеете ввиду, что в Go нет наследования и это мешает применять множество паттернов, которые основанны как раз на наследовании?
            Тут у Go имхо сдвиг как раз к более гибкому подходу, основанному на композиции вместо наследования.
          0
          несколько людей переходящих на го говорили, что на го очень тяжело поддерживать всякие бизнес процеесы. допустим какую-нить систему скидок в зависимости от разных товаров или же шедулллер рассылок писем…

          вы какой именно функуионал оставляете на php и не планируете переписывать на го?
            0
            Да, во многих местах у нас остаётся php и python. Как раз там, где есть развесистая бизнес-логика и её сложное конфигурирование. Чаще всего. Но точно могу сказать (на примере сервиса из статьи) что и на Go можно описать сложную логику, которую потом без боли можно поддерживать и развивать.
            0
            Мы используем Json-rpc и генерим обвязку client/server
            Что используете для транспорта и «service discovery»?
              0
              Json-rpc поверх http. Текстовый протокол для общения между микросервисами это наше осознанное решение. Пока необходимость перехода на бинарный не стоит.

              Когда у нас был Nomad, то был Consul как service discovery. Но после пары инцидентов с Nomad, мы спешно от него отказались и перешли полностью в K8s. В нём сейчас внутри ходим по dns, а для взаимодействия снаружи используется ingress-nginx.

              Уже в следующем году собираемся рассматривать построения service-mesh и там же будем выбирать какой service discovery взять. Необходимость чувствуется уже сейчас.
                0

                Envoy, посмотрите, он для этих целей как раз и создавался.

              0
              ему не нужно думать о том, чтобы строить клиенты, слать мониторинги, пробрасывать трейсинги, и настраивать логирование

              Судя по всему, кто-то это сделал для него заранее, да?


              Интересно, а есть в мире хоть один пхпешник, который в наше время самостоятельно перешёл хотя бы частично на Go и остался доволен? Я вот не доволен, как раз потому что нужно строить клиенты, слать мониторинги, пробрасывать трейсинги, и настраивать логирование. Собственно на логировании уже затнкулся: требование в любых логах указывать requestId и как не кручу всё как-то криво выглядит. Или где-то на уровень доступа в базу пробрасывать чуть ли не полностью http-запрос, начиная с main. Или пробрасывать наверх ошибки, а наверху разгребать регулярками тип ошибки, причём это только для логов ошибок, для дебаг и прочего инфо не работает, то есть пробрасывать параметром requestId на самые низкие уровни… Ну или глобальные переменные какие-то.

                +1
                Да, у нас свой кодогенератор, который по шаблону строит клиент и сервер со всеми обвязками. Выше я отвечал про количество уровней абстракций. И проблема, с которой мы тоже столкнулись именно в этом. Как написать код, который можно без боли читать и поддерживать. Считаю что в какой-то мере нам это удалось.

                Про трейсинг. Это ведь не что-то специфичное для Go, а скорее необходимость в мире распределённых систем.

                P.S. Я тот phpшник, который перешёл на Go и остался доволен :)
                  0

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

                0
                Не теряете ли вы гибкость масштабирования при использовании бизнес-ориентированного подхода формирования сервисов? Бывает ли такое, что какой-то сервис начинает «пухнуть» от бизнес логики в нем? Какие вы использовали решения данных проблем, если они у вас возникали?
                  +1
                  Я попробую в паре абзацах рассказать как мы подходим к дизайну данных и сервисов. Надеюсь отвечу этим на вопросы.

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

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

                  В итоге, это может быть и микросервис и модульный монолит. Главное, оставлять возможность гибко управлять изменениями и взаимодействовать через описанные контексты.
                    +1

                    А можете порекомендовать почитать что-то о технических практиках DDD в Go? Может мои проблемы из-за того, что я упорно пытаюсь натянуть практики характерные для Java/Hibernate или PHP/Doctrine на Go/Gorm там где этому упорно мешает Go?

                Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                Самое читаемое