WG Contract API: zoo of services

в 9:19, , рубрики: api, distributed systems, Gamedev, systems design, wargaming, Анализ и проектирование систем, Блог компании Wargaming, микросервисы, проектирование систем, разработка игр, распределенные системы

WG Contract API: zoo of services - 1

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

Если хотите познакомиться поближе с тем как команда Wargaming Platform справляется со сложностью системы из более чем сотни взаимодействующих друг с другом web-сервисов, то добро пожаловать под кат.

Всем привет! Меня зовут Валентин и я инженер на “Платформе” в компании Wargaming. Для тех, кто не знает что такое платформа и чем она занимается, я оставлю тут ссылку на недавнюю публикацию одного из моих коллег — max_posedon

На данный момент я работаю в компании уже более пяти лет и частично застал период активного роста World of Tanks. Чтобы раскрыть проблематику, поднимаемую в данной статье, мне необходимо начать с краткого экскурса в историю Wargaming Platform.

Немного истории

Рост популярности “танков” оказался лавинообразным, и как это обычно бывает в таких случаях, инфраструктура вокруг игры стала стремительно развиваться. В результате игра очень быстро обросла различными web-сервисами, и на момент моего присоединения к команде их счет уже шел на десятки (сейчас, к слову, более 100 платформенных компонентов работают и приносят пользу компании).

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

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

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

Знакомьтесь, Contract API

Внутри платформы мы называем его Contract API. По своей сути это интеграционный фреймворк, представленный комплектом документации и клиентскими библиотеками под каждую технологию из нашего стека (Erlang/Elixir, Java/Scala, Python). Разрабатывается он, в первую очередь, для того чтобы упростить интеграцию платформенных компонентов друг с другом. Во вторую, чтобы помочь нам решить ряд следующих проблем:

  • стилистические различия программных интерфейсов
  • наличие прямых межкомпонентные зависимостей
  • поддержание документации в актуальном состоянии
  • интроспекция и отладка сквозной функциональности

Итак, обо всем по порядку.

Стилистические различия программных интерфейсов

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

  • Отсутствие строгого стандарта того, как должен выглядеть API. Свод рекомендаций должного эффекта часто не имеет, API всё равно получается разный. Особенно если разработка ведется командами из разных офисов компании. У каждой команды сложились свои привычки и практики. В совокупности такие API часто не выглядят как части единого целого.
  • Отсутствие единого справочника с именами и форматами бизнес-специфичных сущностей. Как правило, не получается взять сущность из результата работы одного API и передать её в API другого сервиса. Для этого нужны трансформации.
  • Отсутствие системы обязательного централизованного ревью для API. Всегда жмут сроки и нет времени на то, чтобы собирать аппрувы и, тем более, вносить изменения в API, который на поверку часто оказывается уже наполовину протестированным.

Первое, что мы сделали при проектировании Contract API, это заявили, что отныне API принадлежит платформе, а не отдельно взятому компоненту. Это привело к тому, что разработка новой функциональности начинается с пулл-реквеста в централизованное хранилище API. В данный момент в качестве хранилища мы используем GIT репозиторий. Для удобства мы разделили весь API на отдельные бизнес-функции, формализовали структуру этой функции и назвали её Контракт.

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

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

Наличие прямых межкомпонентных зависимостей

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

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

Перед нами встал выбор из нескольких вариантов. Из них мы особенно тщательно рассматривали:

  • Реализацию протоколов обнаружения сервисов (service discovery) на каждом из компонентов.
  • Использование посредника (mediator), который бы перенаправлял клиентские запросы в правильный компонент платформы.
  • Использование брокера сообщений (message broker) в качестве шины для обмена сообщениями.

В результате некоторых раздумий и экспериментов выбор пал на брокер сообщений, несмотря на то что он виделся нам потенциальной единой точкой отказа и увеличивал накладные расходы на эксплуатацию платформы. Немаловажную роль в выборе сыграл факт того, что в платформе на тот момент уже имелась экспертиза по работе с RabbitMQ. А сам брокер хорошо масштабировался и имел встроенные механизмы обеспечения отказоустойчивости. В качестве бонуса мы получили возможность реализовать “под капотом” архитектуру, управляемую событиями (event-driven architecture или EDA). Что впоследствии открыло перед нами более широкие возможности межсервисного взаимодействия, по сравнению с взаимодействием типа “точка-точка”.

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

Поддержание документации в актуальном состоянии

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

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

Интроспекция и отладка сквозной функциональности

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

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

Что еще мы разработали поверх Contract API

Помимо основного своего назначения и решения вышеизложенных проблем, Contract API позволил нам реализовать ряд полезных сервисов.

Шлюз для доступа к платформенной функциональности

Стандартизация API в виде контрактов позволила нам разработать единую точку доступа к платформенной функциональности через HTTP. Причем при появлении новой функциональности (контрактов) у нас нет необходимости как-либо модифицировать эту точку доступа. Она совместима наперед со всеми будущими контрактами. Это позволяет работать с платформой как с единым продуктом используя привычный многим HTTP интерфейс.

Сервис массовых операций

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

Единая обработка платформенных ошибок

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

Автоматическая генерация пользовательских интерфейсов

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

Протоколирование платформенных взаимодействий

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

Основное назначение Contract API

Но всё же основное назначение Contract API — снижать издержки на интеграцию платформенных компонентов.

Разработчики абстрагированы от транспортного уровня библиотеками, которые мы разработали под каждый из наших технологических стеков. Это дает нам некоторое поле для маневра на тот случай, если придётся менять брокер сообщений или вовсе переходить на взаимодействие типа “точка-точка”. Внешний интерфейс библиотеки сохранится без изменений.

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

Пример вызова контракта с использованием Python

from platform_client import Client

client = Client(contracts_path=CONTRACTS_PATH, url=AMQP_URL, app_id='client')
client.call("ban-management.create-ban.v1", {
  "wgid": 1234567890,
  "reason": "Fraudulent activity",
  "title": "ru.wot",
  "component": "game",
  "bantype": "access_denied",
  "author_id": "v_nikonovich",
  "expires_at": "2038-01-19 03:14:07Z"
})

{
  u'ban_id': 31415926,
  u'wgid': 1234567890,
  u'title': u'ru.wot',
  u'component': u'game',
  u'reason': u'Fraudulent activity',
  u'bantype': u'access_denied',
  u'status': u"active",
  u'started_at': u"2019-02-15T15:15:15Z",
  u'expires_at': u"2038-01-19 03:14:07Z"
}

Этот же вызов контракта, но с использованием Elixir

:platform_client.call("ban-management.create-ban.v1", %{
  "wgid" => 1234567890,
  "reason" => "Fraudulent activity",
  "title" => "ru.wot",
  "component" => "game",
  "bantype" => "access_denied",
  "author_id" => "v_nikonovich",
  "expires_at" => "2038-01-19 03:14:07Z"
})

{:ok, %{
  "ban_id" => 31415926,
  "wgid" => 1234567890,
  "title" => "ru.wot",
  "conponent" => "game",
  "reason" => "Fraudulent activity",
  "bantype" => "access_denied",
  "status" => "active",
  "started_at" => "2019-02-15T15:15:15Z",
  "expires_at" => "2038-01-19 03:14:07Z"
}}

На месте контракта “ban-management.create-ban.v1” может быть любая другая платформенная функциональность, например: “account-management.rename-account.v1” или “notification-center.create-sms-notification.v1”. И вся она будет доступна через эту единую точку интеграции с платформой.

Обзор будет неполным, если не продемонстрировать Contract API с точки зрения серверного разработчика. Рассмотрим ситуацию, в которой разработчику нужно реализовать обработчик для всё того же контракта “ban-management.create-ban.v1”.

from platform_server import BlockingServer, handler

class CustomServer(BlockingServer):
  @handler('ban-management.create-ban.v1')
  def handle_create_ban(self, params, context):
    response = do_some_usefull_job(params)
    return response

d = CustomServer(app_id="server", amqp_url=AMQP_URL, contracts_path=CONTRACTS_PATH)
d.serve()

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

Благодаря тому что под капотом Contract API реализован на основе событий, мы получаем возможность выйти за рамки сценария Запрос/Ответ и реализовать более широкий спектр межсервисных взаимодействий.

Например:

  • сделать запрос и забыть (не дожидаясь ответа)
  • сделать запросы одновременно к нескольким контрактам (даже без использования event loop)
  • сделать запрос и получить ответы сразу от нескольких обработчиков (если это предусмотрено сценарием интеграции)
  • зарегистрировать обработчик ответа (срабатывает, если обработчик контракта отчитался о завершении, принимает на вход результат работы обработчика контракта, то есть его ответ)

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

Вместо заключения

Contract API мы используем уже несколько лет. Поэтому рассказать обо всех сценариях его использования в рамках одной обзорной статьи не представляется возможным. По этой же причине я не стал перегружать статью и техническими деталями. Она и так получилась довольно объемная. Задавайте вопросы, и я постараюсь на них ответить прямо в комментариях. Если какая-то тема будет особенно интересна, можно будет раскрыть её более подробно в отдельной статье.

Автор: Валентин

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js