Создаём единую инфраструктуру для параллельной разработки мобильных игр

Она помогла нам переиспользовать игровые механики в непохожих проектах и увеличила скорость разработки на 25%.

Создаём единую инфраструктуру для параллельной разработки мобильных игр

Всем привет! Меня зовут Владимир Федин, я лид серверного отдела Green Grey.Рано или поздно каждая игровая студия, которая одновременно разрабатывает несколько проектов, сталкивается с проблемой поддержки и развития нескольких бэкендов.

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

Поэтому мы в Green Grey решили создать инструментарий, который позволил бы переиспользовать одинаковые компоненты в разных проектах. Под катом — история о том, как мы разрабатывали универсальное серверное ядро, какие шишки набили и от каких подходов отказались.

Что такое серверное ядро?

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

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

Часто такой базой становится первый проект студии. Нам в руки попало сразу четыре проекта на этапе формирования требований.

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

Вот какие задачи решаем с помощью серверного ядра:

  • Авторизация пользователя — связывание данных с уникальными идентификаторами, предоставляемыми социальными сетями.
  • Обработка покупок — получение чеков платёжных систем от клиента и верификация их на стороне платёжной системы.
  • Обеспечение межсерверного взаимодействия — стандартные механизмы коммуникации между микросервисами, реализованные на уровне фреймворка.
  • Архитектурные подходы и паттерны для написания бизнес-логики — работа с данными игрока, работа с базой, структура взаимодействия логических сущностей внутри игры. Ядро предлагает библиотеку архитектурных решений, на которые можно опираться при выработке своих.
  • Архитектура для автотестов — налаженный процесс, который можно в готовом виде использовать на всех проектах, и набор примеров автотестов, которые клиент может использовать как эталон для своих проверок.
  • Пайплайны для CI/CD — это процесс организации обновлений кода в репозитории. Однажды налаженный на сервисах ядра он распространяется по остальным проектам по единой схеме.
  • Devops-инфраструктура — единое ядро и схема коммуникации микросервисов позволяет выстроить общую для всех проектов схему масштабирования в зависимости от нагрузки в облаке.

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

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

Создаём единую инфраструктуру для параллельной разработки мобильных игр

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

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

Добавление механик или инструментов в ядро

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

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

Возникает вопрос — как добавить эту механику в ядро так, чтобы не ломать архитектуру и совместимость с другими командами? Пришлось повозиться, но в итоге мы нашли решение.

В примере выше самое простое, что приходит на ум, — добавить в API конкретные запросы по просьбе клиентов. Например, чтобы начислить валюту — запрос «начислитьВалюту», а чтобы добавить сущность — запрос «добавитьСущность».

Например:

@MessagePattern(AddBusinessAssetDto.CommandName) async addBusinessAsset(@Payload() body: AddBusinessAssetDto) { const player = await this.getPlayer(body); … const { businessAssetId } = body.data; const businessAsset = await this.service.addBusinessAsset( player, businessAssetId, ); const updates = await player.save(); return buildSuccessAnswer(businessAsset, { updates }); }

В API появляются запросы:

  • add-business-asset-cheat
  • add-currency-cheat,
  • add-item-cheat

и так далее.

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

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

После того как чит-запросы на двух проектах начали отнимать ощутимое время разработки, мы решили пересмотреть подход. Сделали универсальный DSL (Domain-specific language) для описания трансформаций данных игрока. С помощью DSL разработчик со стороны клиента может пользоваться одним серверным запросом для любых изменений — и им больше не нужно добавлять новые или модифицировать существующие запросы для читов:

apply-modifications

… "modifications": [ { "type":"businessAsset", "action":"add", "params":{ "id": "ba_0001", "capture": true, "level": 5, } }, { "type":"currency", "action":"add", "params":{ "id": "cryptoCoin" "amount": 3000 } } ]

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

… @MessagePattern(ApplyModificationsDto.CommandName) @UseInterceptors(PlayerModelInterceptor(false)) @UseGuards(DevelopmentGuard) async modificatePlayer( @Payload() data: ApplyModificationsDto, @Ctx() { player }: ClientContext, ) { const result = await this.modifiactionService.execute( player, data.data.modifications, ); const updates = await player.save(); return buildSuccessAnswer(result, { updates }); } public async execute(player: Player, modifications: TModification[]) { const invalidModifications = await this.validateModifications( modifications, ); if (invalidModifications.length) { Logger.warn( `Bad modifications. Validation result: ${JSON.stringify( invalidModifications )}`, this.constructor.name, ); return { success: false, invalidModifications }; } for (const modification of modifications) { await this.modificationExecutorsProvider .getExecutor(modification.type) .execute(player, modification); } return { success: true } }…

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

Передача обновлений ядра другим клиентам

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

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

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

Обновление ядра — двунаправленная история. Часть изменений проектов попадает в ядро, необходимые изменения ядра распределяются по остальным проектам. Это происходит через Git Merge и иногда болезненный процесс ручного слияния. Мы пока не нашли варианты, как сделать это автоматизировано.

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

Единая функциональность при различных требованиях

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

У каждой из команд своё видение, как должна быть устроена их архитектура, и у нас нет желания навязывать собственное. И мы готовы делать уступки клиентам, потому что игра — их основной продукт. Наша история — инфраструктура. Мы стараемся предлагать командам механики, которые им подходят.

Одним из таких случаев было проектирование механики отправки обновлений пользователей. Первый вариант — использование JSON Patch в качестве списка изменений в данных клиентах — был разработан совместно с клиентской командой, которая предпочитала UniRX и реактивщину. Но не на всех проектах клиентские разработчики оценили подход, и нам пришлось расширить функциональность ядра.

А не ограничивает ли ядро клиентских разработчиков детским выбором определённых инструментов? Был случай, когда нашим клиентам не подошёл предложенный нами способ доставки обновлений ядра.

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

  • В первом случае — это список того, что изменилось в данных клиента в формате JSON Patch.
  • Второй вариант представляет перечень изменений корневых сущностей. Мы пишем, что поменялось, и передаём объект клиенту целиком. Тут сохраняется совместимость с JSON Patch, но клиенту нет необходимости самому разбирать каждую строку, он может загружать все данные объекта.
  • И в третьем случае — это JSON-структура. Сама модель, но содержащая только изменённые поля. Здесь клиенту уже нет необходимости поддерживать формат JSON Patch.

Каждый вариант по-своему хорош и используется разными командами.

Для наглядности пример первого варианта апдейта:

[ { "op": "replace", "path": "/kicks/random/seed", "value": 2926611012 }, { "op": "add", "path": "/dailyRewards/slots/0", "value": { "id": 2, "status": 0 } }, { "op": "replace", "path": "/dailyRewards/slots/1/status", "value": 0 }, { "op": "replace", "path": "/dailyRewards/resetTime", "value": 1643362211 }, { "op": "remove", "path": "/foo" } ]

Во втором варианте передача производится в том же формате JSON Patch за одним изменением — будут передаваться заранее определённые сущности целиком и всегда в контексте op = "replace" или "add". "remove" будет передаваться как "replace" cо значением null.

Модифицированный под реализацию апдейт из примера выше:

[ { "op": "replace", "path": "/kicks/random", "value": { "seed": 2926611012 } }, { "op": "add", "path": "/dailyRewards/slots/0", "value": { "id": 2, "status": 0 } }, { "op": "replace", "path": "/dailyRewards/slots/1", "value": { "id": 1, "status": 0 } }, { "op": "replace", "path": "/dailyRewards/resetTime", "value": 1643362211 }, { "op": "replace", "path": "/foo", "value": null } ]

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

Модифицированный под реализацию апдейт из примера выше:

{ "kicks": { "random": { "seed": 2926611012 } }, "dailyRewards": { "slots": { "0": { "id": 2, "status": 0 }, "1": { "id": 1, "status": 0 } }, "resetTime": 1643362211 }, "artefacts": { "map": { "02": null } }, "foo": null }

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

Чтобы они быстрее разобрались с нюансами, мы улучшили процесс онбординга.

Как мы проводим онбординг новых разработчиков

Наши проекты работают на Node.js, а пишем мы на TypeScript. В качестве фреймворка мы используем Nest.js. Для коммуникации сервисов использовался RabbitMQ, и недавно мы переехали на NATS, основная база данных MongoDB. В качестве основного облака используем AWS. Мы старались выбирать инструменты максимально близкие к SoTA серверной разработки.

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

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

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

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

Что ещё почитать?

Для тех, кто хочет глубже разбираться в теме или планирует участвовать в разработке подобных систем, можем порекомендовать почитать:

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

«System Design Interview» — цель книги — подготовка к вопросам по проектированию систем. Но она хороша разбором большого количества типовых архитектурных кейсов и их решений с описанием плюсов и минусов каждого.

«Чистая Архитектура» Роберта Мартина — одна из классических работ Мартина. Даёт отличное понимание фундаментальных правил создания программной архитектуры, независимо от того, какой код и для чего вы пишите. Ясное изложение сочетается с глубиной мысли исключительно опытного программиста. Радует общая системность при минимуме деталей, не научит вас программировать, но если вы уже умеете, может дать много инсайтов относительно тех систем, которые вы уже встречали.

2626
1 комментарий

Сделали универсальный DSL (Domain-specific language) для описания трансформаций данных игрока.

Я может не до конца в специфику въехал, но тут разве не подошёл бы идеально GraphQL?