С ростом количества компонентов в программной системе, обычно растёт и количество людей принимающих участие в её разработке. Как следствие, для сохранения темпов разработки и простоты сопровождения, подходы к организации 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 мы используем уже несколько лет. Поэтому рассказать обо всех сценариях его использования в рамках одной обзорной статьи не представляется возможным. По этой же причине я не стал перегружать статью и техническими деталями. Она и так получилась довольно объемная. Задавайте вопросы, и я постараюсь на них ответить прямо в комментариях. Если какая-то тема будет особенно интересна, можно будет раскрыть её более подробно в отдельной статье.

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


  1. Tiendil
    26.02.2019 13:48

    Спасибо. Очень интересно.

    Есть много вопросов:

    — Как организовано управление правами доступа сервисов к функциональности друг-друга?
    — «Шлюз для доступа к платформенной функциональности» — под этим имеется в виду внешнее апи или внутреннее?
    — Расскажите подробнее про «массовые операции», не совсем понятно что это и как работает.
    — Генерация пользовательских интерфейсов предполагает описание в контрактах сложных ограничений на данные? Или эти интерфейсы — просто формочка для ввода данных для передачи в апи?


    1. ii000314 Автор
      26.02.2019 14:51
      +1

      Спасибо. Постараюсь дать как можно более развёрнутые ответы

      1. За минимальную единицу функциональности в Contract API мы принимаем Контракт, независимо от того какой из платформенных компонентов обслуживает этот контракт. В контексте аутентификации/авторизации Контракт является action_item'ом, на использование которого можно выдать права. По задумке каждый платформенный компонент имеет свой уникальный identity и список ассоциированных с ним action_item'ов. Всё это хранится в специализированном платформенном компоненте, отвечающем за идентификацию, аутентификацию, авторизацию и аудит (i3a). В результате серверная библиотека, принимая запрос на обработку Контракта, первым делом аутентифицирует клиента, получая его identity. Затем авторизует в контексте используемого action_item'а. После этого кэширует результат на некоторое время. Для того что бы при следующем запросе этого клиента не ходить в сторонний сервис. Другими словами вся логика скрыта внутри библиотеки и программисту не нужно об этом заботится.
      2. Это внутренний API который мы рекомендуем использовать тем платформенным компонентам, которые не хотят или не могут использовать наши библиотеки. Либо тем, чьей кодовой базой платформа не владеет. Последнее сделано скорее из соображений безопасности, т.к. брокер сообщений в центре Contract API — это наш «хрустальный сосуд», от работоспособности которого зависит функционирование платформы. А наличие специализированного сервиса между клиентом и Contract API позволяет, при необходимости, фильтровать запросы и не дать клиентам перегрузить запросами брокер сообщений. Это, в случае чего, даст нам дополнительное время на реагирование.
      3. Временами есть необходимость вызывать привычный API в массовом порядке. Например, создать 1000 аккаунтов, или выставить 10000 банов. Раньше под каждый такой случай писались специализированные API, или даже целые сервисы. Но благодаря тому что контракт имеет строго определённую структуру, мы смогли написать сервис который целиком отвечает за исполнение массовой операции и генерацию отчётов, при этом, не зная о том какую конкретную операцию он выполняет. Ближайшая аналогия которая сейчас приходит в голову — это Invoker из шаблона проектирования «команда». Сам Контракт, в данном случае его структура, выступает той самой обёрткой Command из того же шаблона проектирования.\
      4. Для того что бы отобразить контракт на UI необходимо зарегистрировать виджет в специализированном компоненте. По умолчанию виджет выглядит как формочка для ввода данных. Однако пользователь имеет возможность управлять как отображением данных, так и самими данными (например задать параметры по умолчанию или, теоретически, подгрузить часть данных, для передачи в API, снаружи). После этого виджет можно встроить в необходимое количество мест. Изменив виджет, он соответственно изменится одновременно во всех админках.

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


      1. Tiendil
        26.02.2019 15:13

        Промахнулся с ответом: habr.com/ru/company/wargaming/blog/441708/#comment_19808562


  1. Tiendil
    26.02.2019 15:12

    надеюсь мне удалось ответить на заданные вопрос

    Вполне.

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


    1. ii000314 Автор
      26.02.2019 15:33

      На уровне ядра платформы мы наделяем правами компонент-админку, а то, как распределить эти права между своими пользователями — админка решает сама. То есть Contract API может проверить, что админка использует функциональность платформы, к которой у неё действительно есть доступ, не более.


  1. Tiendil
    26.02.2019 17:15

    Искали open source аналоги (если не софт, то хоть стандарты)? Если нашли то какие и почему не использовали их?


    1. ii000314 Автор
      26.02.2019 18:24

      Насколько я помню, мы в первую очередь смотрели на RAML/Swagger/OpenAPI + ServiceDiscovery, но в итоге решили не привязывать своё решение к REST. К тому же мы хотели построить Event Driven систему, т.к. на неё хорошо ложатся некоторые из наших процессов.

      Наиболее близким по духу стандартом, как мне кажется, является SOA. Однако SOAP показался тяжеловатым. Одним из основных требований предъявляемых Contract API было обеспечение высокой производительности.

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

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