Pull to refresh

Игровой сервер на Scala + Akka: Разбор примера

Reading time 7 min
Views 27K


В прошлый раз я описал в общих чертах использование Akka для игрового сервера.
Сейчас разберем простой, но тем не менее рабочий пример сервера.

Дискляймер


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

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

Архитектура


У нас стоит задача сделать многопользовательскую игру, ну пусть это будут танчики (ага, свежая идея).
В исходниках, на гитхабе, будут и сервер и клиент. Но здесь мы рассмотрим только сервер.

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

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

Итак, давайте сначала нарисуем, что мы вообще делаем.

Это наш меганавороченный клиент. Фул 3D, между прочим:



А это сервер:



Стрелочками обозначены потоки сообщений между акторами.
1. TCP service – сервис отвечающий за подключение клиентов. У нас вариант с TCP.
2. Session – актор игровой сессии. Отвечает за обмен сообщениями с клиентом.
3. Task service – сервис для выполнения общих задач.
4. Auth service – сервис выполняющий аутентификацию игрока.
5. GM service – сервис игровой механики. Отвечает за управление комнатами и общими игровыми действиями…
6. Room – это акторы, выполняющие роль комнат, в которых и происходит игра.
7. Storage – сервис для работы с хранилищем данных. БД SQL или еще что либо.

Распишем подробнее, что же у нас получилось.
Я буду приводить здесь только кусочки кода. Весь код выложен на гитхаб.

TCP service

Akka, на данный момент, в стандартной поставке поддерживает TCP и UDP подключения. В экспериментальной ветке есть и WebSocket. Мы будем использовать TCP. Сетевой стек в Akka, берет свои корни у Spray и работает более эффективно, чем например Netty. Хотя у Netty, при этом, больше функционала.

Итак, клиент подключается к TCP service. Ему создается актор connection, отвечающий непосредственно за соединение. После установления соединения, мы создаем актор Session, который отвечает за игровую сессию и через connection обменивается сообщениями с клиентом.

При работе с TCP есть некоторые нюансы. TCP это постоянное соединение. И не всегда система может точно сказать, клиент все еще подключен или уже нет.

Поэтому для проверки клиента используют так называемый Heartbeat. Сервер пингует периодически клиент, пустым пакетом, чтобы понять, есть ли еще подключение.
Для этого в Session заводится шедуллер, который в нашем случае пингует клиент каждые 10 сек.

scheduler = context.system.scheduler.schedule(10.seconds, 10.seconds, self, Heartbeat)

Далее, как только связь с клиентом установлена, он посылает серверу команду на аутентификацию. Ее принимает актор Session.
Session перенаправляет сообщение к Task service. Что бы тот разобрался, что это вообще за сообщение и что с ним делать. Оно выглядит так:

case class CommandTask(session: ActorRef, comm: PacketMSG)

Да, забыл пояснить. В качестве транспорта используем Protobuf. Так что здесь PacketMSG это протобафовский объект, наше сообщение от клиента. Session это ссылка на актор сессии игрока.

Task service

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

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

  def handlePacket(task: CommandTask) = {
    task.comm.getCmd match {
      case Cmd.Auth.code => authService ! Authenticate(task.session, task.comm)
      case Cmd.Join.code => gameService ! JoinGame(task.session)
      case Cmd.Move.code => gameService ! PlayerMove(task.session, task.comm)
      case _ => log.info("Crazy message")
    }
  }

Auth service

Сервис, отвечающий за аутентификацию. У нас он очень примитивный. Умеет только ходить в БД за пользователем:

  override def receive = {
    case task: Authenticate  => handleAuth(task)
    case task: SomePlayer => handleAuthenticated(task)
    case task: AuthenticatedFailed => handleFailed(task)

    case _ => log.info("unknown message")
  }

Если он получает Authenticate, значит, надо сходить в БД проверить:

case class GetPlayerByName(session: ActorRef, comm: PacketMSG)

Если получает SomePlayer, значит, аутентификация успешна и можно сообщить эту радостную новость всем заинтересованным. Игроку и GM.

    task.session ! Send(Cmd.AuthResp, login.build().toByteArray)
    gameService ! task

А если AuthenticatedFailed, значит, не нашли игрока, и эту печальную новость тоже надо сообщить всем заинтересованным. В данном случае только игроку. Кстати, в реальном сервере такие попытки можно считать и наказывать настырных:

task.session ! Send(Cmd.AuthErr, Array[Byte]())

Собственно, никто не мешает навернуть его по полной, обеспечив самые разные варианты аутентификации.

Storage

Работа с БД в Akka это отдельная тема. Т.к. внутри работа происходит на обычных потоках. То создав много «долгоиграющих» задач акторам, можно подвесить всю систему. Акторы должны быть легковесными. У нас в качестве «БД» используется обычный список. Поэтому ничего не будет тормозить. Но реальная БД заблокирует поток надолго. Поэтому в реальном проекте актору, работающему с БД, выделяется отдельный поток, или пул потоков, чтобы он не тормозил всю систему.

Если игрок нашелся, то в актор Session отправляется сообщение с успешной аутентификацией, который уже упакует его и отправит клиенту

Ну вот мы и зашли в игру.
Далее клиент, посылает сообщение «Начинаем игру». Task service перенаправит сообщение GM service, он создаст комнату и поместит в нее игрока.

Затем оповестит клиента о начале игры.
Но у нас реализация простая, поэтому сразу после подключения первого игрока, сервер автоматом создаст комнату и поместит в нее игрока. Таким образом все подключившиеся, будут в одной комнате.

GM service

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

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

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

Далее я опишу что с этим можно сделать.

А пока мы запустили комнату и в нее добавили игрока. Игра реалтаймовая, поэтому нам надо оповещать игроков об изменения состояния игры регулярно. Ну, допустим, каждые 100мс. Хотя, конечно, это время индивидуально для игр.

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

Для этого заводим шедуллер, который каждые 100мс будет присылать нам «Тик».
Событие, означающее, что пришло время пересчитать игровую ситуацию.

scheduler = context.system.scheduler.schedule(100.millisecond, 100.millisecond, self, Tick)

Получается, что каждая комната сама себе будет периодически говорить «пора пересчитать игру».
Результаты пересчета отсылаются всем подключенным игрокам в комнате.
В нашем случае учитываются только перемещения игроков.

    players.keySet.map(p => getPoint(move, p))
    players.values.map(s => s ! Send(Cmd.Move, move.build().toByteArray))

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

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

Ну в общем, так и есть. Система сообщений пишется на коленке за пару часов. Подключаем Netty и вперед, заре навстречу. Выделяем каждый сервис в поток, и обмениваемся сообщениями через коллекции.
Зачем использовать какую-то сложную Akka?

А что вообще нам может предложить Akka в данном случае?..
Ну про то, что у нас весь код очень простой, однопоточный. И про другие удобства Akka, я уже писал. Не буду повторяться.

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

Ну вот, например, комнаты, которые могут (и будут в большинстве случаев) нагружать CPU работой. В обычном варианте, если нам понадобится разделить комнаты по потокам, нам придется об этом думать. Думать и писать код для этого разделения.

Что же мы имеем сейчас?

Комната, это простой класс, актор. Он очень простой. Весь код выполняется в общем пуле потоков. На тесте с парой игроков, у себя локально, это будет мало ощущаться.

Но вот нам надо потестить сервер уже например с 50 игроками. Мы решаем выделить каждую комнату в свой поток. Для этого надо просто указать, что этот актор использует отдельный поток, а не общий пул. Это все, мы не думали о синхронизации, не думали о общих данных. Более того, в Akka есть кластер «из коробки». Это значит, что вынести комнаты на отдельные машины в сети не будет большой проблемой. Код самой комнаты не изменится вообще. Это будет тот же самый актор, только работать он будет на отдельной машине.

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

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

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

Если тема интересна, можно будет сделать продолжение с рефакторингом, приблизив код к более реальному продакшену.

Весь код, включая сервер и клиент, выложен на GitHub.
Only registered users can participate in poll. Log in, please.
Продолжение
36.99% Рефакторинг сервера 64
63.01% Лучше просто про Akka. Диспечеры, роутеры, мейлбоксы и т.д. 109
173 users voted. 54 users abstained.
Tags:
Hubs:
+19
Comments 53
Comments Comments 53

Articles