Описание прототипа моего игрового мультиплеерного сервера

Привет, Хабр. Рад представить свою первую статью: описание прототипа игрового мультиплеерного сервера.

Исходный код (под лицензией Apache 2.0)

Содержание:

  • Архитектура обработки входящих запросов
  • Краткое описание прочих моментов
    • Модули и взаимодействия основных классов
    • Разные виды тестов
    • Кэширование при работе с БД

Архитектура обработки входящих действий от пользователя


Входной точкой является websocket-контроллер, принимающий всевозможные реквесты от пользователей: начиная от логина и заявок на игру до игровых ходов и написания сообщений в чат. Этот контроллер обслуживает thread-pool (около 20 потоков).

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

Поэтому архитектуру я постарался спроектировать также и в соответствии с этим требованием (см. картинку):

image

Запросы на аутентификацию (№1 на картинке)


Входящие запросы на аутентификацию помещаются в очередь в специальный thread-pool. После этого поток сразу отпускается и снова готов принимать действия пользователя.

Сама же обработка аутентификации будет проходить уже в асинхронном режиме. Она включает:

— аутентификацию в Facebook'е;
— создание либо обновление данных о пользователе в БД;
— отсылку информации на клиент в случае успешного логина.

Размер этого thread-pool можно регулировать уже в зависимости от нагруженности сервера.

Запросы на игру (№2 на картинке)


При запросе игрока создать игровую заявку — решением в лоб было бы сразу вызывать функционал (функцию/метод), который бы матчил игроков. А в целях потокобезопасности добавить какой-либо вариант блокирования общего ресурса (в нашем случае это список заявок на игру). То есть берём лок, затем матчим игроков, затем создаём мужду ними игру и рассылаем нотификации на клиенты. Если эти операции занимают относительно длительное время, то все остальные потоки, ожидающие снятия лока, будут простаивать впустую. Как следствие — сервер может потенциально не принимать входящих игровых действий, которые должны поступать в Game Loop немедленно.

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

В новом варианте запросы на игру добавляются в Concurrent Map, где key — это пользователь, а value — заявка на игру.
Всё — после этого входящий поток сразу отпускается.
Потоки даже не всегда будут блочить друг друга (при записи в неё), так как ConcurrentMap лочит не всю мапу, а сегмент.

Раз в n секунд вызывается ровно 1 поток для обработки входящих игровых заявок (Matching players). Он спокойно и обрабатывает эту мапу. Тем самым обеспечивается потокобезопасность без блокировок и быстрое отпускание входящих потоков.

Это решение обладает ещё одним плюсом — оно позволяет «набиться» заявкам и потом сматчить их более подходящим образом. Логика и реализация стали проще.

Обработка игровых действий (№ 3 на картинке)


Здесь несколько сложнее:

1) Входящие игровые действия (ходы) просто складываются в специальную очередь (для каждой игры предназначена своя очередь). Эта очередь просто хранит совершённые игроками действия. После этого поток традиционно отпускается.

2) Как водится, у нас есть GameLoop (игровой цикл). Он обходит список всех игр. Далее для каждой игры он достаёт связанную с ней очередь. И уже из этой очереди достаёт сделанные игроками ходы. Затем последовательно обрабатывает каждое действие. Всё просто.

В принципе, также возможно распараллелить обработку разных игр по пулу потоков. Это возможно, так как игры никак не связаны друг с другом. Этот функционал также можно сделать неблокирующим: достаточно, например, использовать неблокирующие локи из библиотеки java.util.concurrent. Либо, если у нас распределенное решение, — использовать Hazelcast как распределенный кэш с возможностью лочить ключи в шареной мапе… Однако данный функционал не требуется, так как обработка игровых действий итак слишком быстрая. Поэтому GameLoop выполняется в одном потоке.

3) Есть еще один момент — если игра изменилась, то на клиенты нужно отправить уведомления, а также, при необходимости, обновить данные в БД. Это также сделано в асинхронном режиме (№ 4 на картинке), чтобы не тормозить GameLoop.

Резюмируя

Архитектура спроектирована так, чтобы:

— Реквесты с игровыми действиями обладали максимальным приоритетом перед другими типами запросов (например, перед логином, или заявкой на игру).

Чтобы не было такого, что пришло 100 запросов на аутентификацию и «забили» thread-pool (обслуживающий пользовательские запросы). При этом поступающие игровые действия вставали бы в очередь, и все игры разом «притормаживали» бы на несколько секунд.

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

Такой подход поддерживает вертикальное масштабирование и увеличение количества потоков. Статья в тему, см. раздел «Проблемы масштабируемости»

Краткое описание прочих моментов


Одной из малоприоритетных задач было сделать слабое связывание между модулем сервера и модулем игры. Иными словами, чтобы можно было легко «выдернуть» саму игру и прицепить её к Desktop-UI. Либо поместить другую игру настольного типа к многопользовательскому серверу.

image

Это достигается за счёт разграничения зон ответственности. Проект разбит на три модуля: сервер, игровое API и реализация игры.

Вся игровая логика «зашита» в модуле игры. Сервер просто переадресует через code API (Java-код) принимаемые игровые действия в контроллер игры. Ответы от игры приходят либо сразу же, либо отложено — через подписку (шаблон Subscriber).

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

Имеются как юнит, так и интеграционные тесты.

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

Например, это могут быть разные варианты дисконнектов: допустим, если пользователь приконнектился с нового устройства, то старый коннекшн нужно закрыть. Или, например, это проверка дисконнекта, если пользователь был неактивен 15 минут (чтобы не ждать так долго — многие параметры вынесены в переменные среды и «замокированы» на несколько миллисекунд для быстрого прогона тестов).

Также есть проверка чата: что разные пользователи видят сообщения друг друга.
Есть проверки игровых запросов и создание игры.

Под вышеперечисленные кейзы хорошо подошли интеграционные тесты, поднимающие сервер и IoC-контекст (замоканы внешние системы).

Также используются и юнит-тесты. Например, там, где не нужно поднимать контекст; или там, где надо проверить множество вариаций входящих параметров.

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

В целом при выборе типа и методов написания тестов мне понравился этот доклад: «Hexlet — Тестирование и TDD».

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

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

Не все моменты раскрыты в статье. В частности про управление коннекшенами и дисконнекты (например, в случае открытия сессии нового устройства). В игре также есть система рейтинга и топ-100 игроков на главной таблице. Есть не только мультиплеер, но и игра с ботами. Заложены точки расширения функционала по разным аспектам как сервера так и игры.

Игра написана на Java. С активным использованием Spring Framework, который из коробки даёт работу с Websocket'ами (Spring WebSocket), интеграционные тесты (Spring Boot Test) и кучу других плюшек (DI, например).

Горизонтальное масштабирование для вебсокетов сделать не так просто. Поэтому в целях скорости для прототипа было решено его не делать.

Пара смешных моментов


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

Также имеется бесплатный Postgre с лимитом 10к строк. Из-за этого приходится периодически запускать удаление неактуальных аккаунтов.
Share post

Similar posts

AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 12

    +10
    Фотки схем это конечно круто… Ещё и рукописный текст. Ничего не разобрать же?
    Можно было б для статьи и нарисовать такие простенькие схемы.
      0
      Почерк более чем разборчивый, процентов на 90
        –1
        Согласен
          0
          fixed
          0
          Я не совсем понимаю, что там такого на серваке может долго выполняться, чтоб понадобился ConcurrentMap. У нас серверный игровой инстанс не на Java, но сотни пользователей держит без проблем на 1 потоке (Unity3d headless build). Все тяжёлые запросы вроде запросов к БД асинхронны.
            0
            Не совсем вас понял.

            У меня есть контроллеры, принимающие реквесты от пользователей. Их обслуживает thread-pool (около 20 потоков). Соответственно между ними возникает конкуренция за шареные ресурсы.
            Так же есть еще поток GameLoop. Он тоже дополнительно обращается к шареным ресурсам. Следовательно, надо еще и его учитывать с позиций потокобезопасности.

            Ну и в статье я описал как решаю следующие проблемы:
            — Реквесты с игровыми действиями обладают максимальным приоритетом перед другими типами запросов (например, логин, заявка на игру).
            Чтобы не было такого, что пришло 100 запросов на аутентификацию и «забили» thread-pool (обслуживающий пользовательские запросы). При этом поступающие игровые действия вставали бы в очередь, и все игры разом “притормозили” бы на несколько секунд.
            — Неблокирующая многопоточность.
            Чтобы не было проблем при вертикальном масштабировании и увеличении количества обслуживающих потоков.

            Неблокирующие коллекции позволяют записывать данные без «глюков» при одновременных запросах разными потоками. А также решают ряд других проблем, например, безопасное чтение третьим конкурирующим потоком.
            Мне понравилась эта статья в тему потокобезопасных коллекций: www.ibm.com/developerworks/ru/library/j-jtp07233/index.html

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

            У меня встречный вопрос: у вас, как я понял, GameLoop также запущен в одном потоке. Но контроллеры, вероятно, обслуживаются пулом потоков. Каким образом происходит синхронизация между контроллерами и игровым циклом?
              +1
              Реквесты с игровыми действиями обладают максимальным приоритетом перед другими типами запросов (например, логин, заявка на игру).
              Чтобы не было такого, что пришло 100 запросов на аутентификацию и «забили» thread-pool (обслуживающий пользовательские запросы). При этом поступающие игровые действия вставали бы в очередь, и все игры разом “притормозили” бы на несколько секунд.

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

              У нас подход другой. Мы прикидываем, сколько игроков тянет инстанс (а он однопоточный), а потом просто по числу ядер запускает инстансы.
                0
                Клиенты у вас коннектятся по (веб)сокетам? Если да, то как происходит горизонтальное масштабирование игровых серверов? Или же, как планировалось у меня, — сервера никак не связаны друг с другом (игроки с разных серверов не могут играть друг с другом и не видят друг друга)?

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

                А микросервисную тоже не стал рассматривать по той же причине. Плюс к тому же непонятно как это хостить на бесплатном аккаунте. Плюс время разработки увеличивается. А у меня не было ни бюджета, ни ресурсов, ни времени.
                  0
                  К Api/Auth серверам через http. Эти сервисы за балансером стоят, так что я их могу сколько угодно насоздавать.

                  Api выдаёт нужный инстанс по Round-robin'у, клиент к нему потом по udp коннектится. Инстансов можно тоже сколько угодно создавать. Единственное проблемное место пока только это матч мейкер. Он в одном экземпляре.

                  Находят друг друга на сервере все сервисы через consul.io
                    0
                    Спасибо
            0
            Резюмируя

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


            Резюмируя: я такими формулировками в институте писал в работах для галочки. Куча воды, а потом ХОБА и прекрасный результат. Преподы делали вид что поняли, я делал вид что написал что-то дельное, никто не терял лица друг перед другом и мы расходились по сторонам. Примерно также и тут.
              0
              Спасибо за комментарий.

              Исправил этот абзац. Я его действительно написал не особо думая. Также подправил первую часть статьи.

              Сейчас всё понятно написано или остались ещё непонятные абзацы?

            Only users with full accounts can post comments. Log in, please.