Когда-то давно я уже поднимал тему применения Scala в игровом сервере. Тогда это был совсем простой пример использующий только Scala. С тех времен много воды утекло. Scala и Akka развиваются, но статей по ним что-то не прибавляется. А тема очень интересна. В общем хочется продолжить цикл статей про сервер на Scala. В этой статье будет описана общая архитектура решения. А так же что дает использование Scala и Akka. Примеры кода.
Так в чем же суть? Что такого особенного в использовании этой связки?
Дискляймер.
Тек кто шарит в теме, могут найти неточности и упрощения в описании. Так и было задумано. Я хотел показать общие моменты для тех, кто не знает что это и как это можно использовать. Но тем не менее, всем добро пожаловать в каменты. Надеюсь на конструктивное обсуждение и жаркие холивары) Ибо частенько, в холиварах, всплывают моменты о которых обычно не задумываешься, но которые играют существенную роль.
Что такое Akka, и почему это хорошо
Если не вдаваться в подробности, то разработка на акторах исходит из философии, что все круг акторы. Так же как и ООП исходит из философии, что все круг объекты. Принципиальные отличия же состоят в том, что акторы выполняются параллельно. В то время как ООП код выполняется последовательно и для параллельного исполнения надо делать дополнительные и далеко не всегда простые действия. А так же акторы взаимодействуют между собой не через вызовы методов у объектов, как в ООП, а через отправку сообщений. В акторе есть очередь этих сообщений (mailbox). Сообщения обрабатываются строго по очереди.
Акторы в Akka, описываются как легковесные потоки (green threads). Создание такого актора почти ничего не стоит, их можно создать миллионы. Создатели декларируют, что на 1Gb памяти можно создать порядка 2.5млн акторов. И на одной машине можно достичь скорости обмена порядка 50млн сообщ/сек.
Ну и что с того? Спросите вы. В чем профит от всего этого?
А профит в общем-то очевидный. Код получается слабосвязанным, актору не нужна прямая ссылка другой актор, чтобы отправить ему сообщение. В модели акторов, отсутствует разделяемое состояние. Сообщения приходящие в акторы, обрабатываются последовательно. Получается, что актор не зависит ни от кого. Данные в нем не надо синхронизировать с другими акторами, а код, в отдельно взятом акторе, выполняется «в одном потоке». Ну а как известно, писать однопоточный код гораздо проще чем многопоточный. Но так как у нас акторы выполняются параллельно, то в итоге вся система работает параллельно, равномерно утилизируя все доступное железо. В целом надежность системы получается выше.
Есть мнение (и я его разделяю), что вот как раз акторы и есть самая правильная реализация ООП. Ибо в жизни, например, если мне надо взять молоток, а я до него дотянуться не могу, то я не управляю непосредственно рукой соседа который мне подает молоток. Я говорю ему (по сути посылаю устное сообщение) «подай молоток». Он его принимает, обрабатывает и подает молоток.
Конечно это очень простое описание сложной системы. У Akka очень много возможностей. Те же очереди в акторе могут быть реализованы разными способами. Например иметь ограничение по размеру, или сохраняться в БД, или иметь определенную сортировку. Да и никто собственно не мешает реализовать свою очередь с шахматами и поэтессами. Что еще особенного? Акторы могут исполняться на разных механизмах JVM. Например на пуле потоков или на Fork Join пуле (по умолчанию используется именно он). Можно управлять потоками, выделяя для какого-то актора отдельный поток или даже пул потоков. Акторы могут работать как в пределах одной машины так и по сети. Есть кластер «из коробки».
Чтобы послать актору сообщение, надо знать его адрес или иметь ссылку в виде ActorRef. Адресная система имеет древовидную систему. Например «akka://sessions/id12345». Это адрес актора отвечающего за обработку сообщений в сессии игрока id12345.
Ему можно послать сообщение
context.actorSelection(«akka://sessions/id12345») ! Msg
Или послать сообщение всем подключенным игрокам
context.actorSelection(«akka://sessions/*») ! Msg
В общем чтобы было понятнее, приведу простой пример. Сразу скажу, пример высосан из пальца, просто чтобы показать варианты. Допустим надо игрокам рассылать периодически сообщения на почту. Что может быть проще? Делаем класс какой нибудь, в нем метод принимающий адрес и сообщение. Все в общем-то банально. Но тут игра стала популярной и количество писем стало расти. Распишу как это может выглядеть в Akka.
Вы создаете актор, который принимает сообщение в виде класса с 2 полями (в Scala это будет простой case class)
Email(String address, String msg)
В обработчике описываете отправку сообщения
def receive = {
case evt: Email(address, msg) ⇒ sendEmail(address, msg)
}
Все в общем-то. Теперь этот актор будет получать свой кусочек ресурсов железа и отправлять почту.
Тут приходит толпа народа, и система стала тормозить с отправкой почты. Заходим в конфиг и выделяем этому актору отдельный поток, чтобы он меньше зависел от другой нагрузки.
Толпа еще растет. Заходим в конфиг и выделяем актору пул потоков.
Толпа еще растет. Переносим актор на отдельный комп, он начинает потреблять все железо этого компа.
Толпа еще растет. Выделяем несколько машин, создаем кластер и теперь у нас отправкой почты занимается целый кластер.
При всем при этом, код нашего актора не меняется, мы все это настраиваем через конфиг.
Все приложение даже не в курсе, что актор куда-то переехал и по сути является уже кластером, ему также на адрес «/mailSender» отправляют сообщения.
Каждый может представить, сколько телодвижений надо будет сделать, чтобы реализовать такую систему в классическом варианте на ООП и потоках. Понятно, что пример притянут за уши и трещит по швам. Но если не занудствовать, то вполне можно представить какой-то свой личный опыт в таком ракурсе.
Но где-же сервер ?
С акторами примерно разобрались. Попробуем с проектировать игровой сервер с применением этой модели. Так как у нас теперь все является акторами, то мы описываем через акторы в первую очередь основные элементы сервера, без учета особенностей конкретной игры.
На схеме показано как может выглядеть сервер в этом случае.
Front Actor – Актор отвечающий за общение с клиентами. Их подключение, отключение и контролирует сессии. Является супервизором для актора S
S – Пользовательские сессии. Собственно в данном случает, это открытые сокет соединения. Актор отвечает непосредственно за передачу и прием сообщений от клиента. И является дочерним по отношению к FrontActor.
Location Actor – Актор отвечающий за обработку какой-то области в игровом мире. Например часть карты или комнату.
Еще можно создать актор для работы с БД, но мы его пока рассматривать не будем. Работа с БД обычна и описывать там особо нечего.
Вот собственно и весь сервер. Что же мы получили?
У нас есть актор, который отвечает за сетевое взаимодействие. В Акка встроено высокоэффективное сетевое ядро, из коробки поддерживающее TCP и UDP. Поэтому для создания фронта, надо сделать очень мало телодвижений. Наш актор принимает коннект от клиента, создает для него сессию и в дальнейшем, вся отправка и прием сообщений идет через него.
Фронт актор выглядит примерно так:
class AkkaTCP( address: String, port: Int) extends Actor
{
val log = Logging(context.system, this)
override def preStart() {
log.info( "Starting tcp net server" )
import context.system
val opts = List(SO.KeepAlive(on = true),SO.TcpNoDelay(on = true))
IO(Tcp) ! Bind(self, new InetSocketAddress(address, port), options = opts )
}
def receive = {
case b @ Bound(localAddress) ⇒
// do some logging or setup ...
case CommandFailed(_: Bind) ⇒ context stop self
case c @ Connected(remote, local) ⇒
log.info( "New incoming tcp connection on server" )
val framer = new LengthFieldFrame( 8192, ByteOrder.BIG_ENDIAN, 4, false )
val init = TcpPipelineHandler.withLogger(log, framer >> new TcpReadWriteAdapter )
val connection = sender
val sessact = Props( new Session( idCounter, connection, init, remote, local ) )
val sess = context.actorOf( sessact , remote.toString )
val pipeline = context.actorOf(TcpPipelineHandler.props( init, connection, sess))
connection ! Register(pipeline)
}
}
Сессия выглядит примерно так:
// ----- Класс-сообщение реализующий команду отправки на клиент
case class Send( data: Array[Byte] )
// -----
class Session( val id: Long, connect: ActorRef,
init: Init[WithinActorContext, ByteString, ByteString],
remote: InetSocketAddress,
local: InetSocketAddress ) extends Actor
{
val log = Logging(context.system, this)
// ----- actor -----
override def preStart() {
// initialization code
log.info( "Session start: {}", toString )
}
override def receive = {
case init.Event(data) ⇒ receiveData(data) // Обрабатываем получение сообщения
case Send(cmd, data) ⇒ sendData(cmd, data) // Обрабатываем отправку сообщения
case _: Tcp.ConnectionClosed ⇒ Closed()
case _ => log.info( "unknown message" )
}
override def postStop() {
// clean up resources
log.info( "Session stop: {}", toString )
}
// ----- actions -----
def receiveData( data: ByteString ) {
...
// Распаковываем сообщение, отправляем по назначению
}
def sendData( cmd: Int, data: Array[Byte] ) {
val msg: ByteString = ByteString( ... ) // Упаковываем сообщение
connect ! Write( msg ) // отправляем
}
def Closed(){
context stop self
}
// ----- override -----
override def toString =
"{ Id: %d, Type:TCP, Connected: %s, IP: %s:%s }".format ( id, connected, clientIpAddress, clientPort )
}
В результате, нам не надо думать, сколько потоков выделить для приема отправки сообщений, как они будут синхронизироваться и т. д. Код очень простой.
Так же у нас есть актор отвечающий за локации (или комнаты).
Он более сложный, так как он будет обрабатывать команды, для расчета игровой ситуации. В самом простейшем случае это можно сделать внутри него же. Но лучше выделить отдельный актор для расчета игровой механики. Если это пошаговая игра, то дополнительно делать ничего не надо, просто получать команду и делать расчет. Если же это какая-то реалтайм игра, то в нем уже надо будет реализовывать игровой цикл, который каждые N мс будет собирать пришедшие команды, делать расчет и готовить реплики результатов для отправки их игрокам из этой локации. Под каждый такой актор можно выделить свой поток.
В реальном проекте конечно же надо будет усложнять схему. Добавлять актор супервизор, который будет рулить комнатами. Создавать их когда надо, и удалять за ненадобностью. В зависимости от сложности игровой механики, можно усложнить сам механизм расчета, например выделив под него отдельный сервер.
Вот как может выглядеть такой актор:
class Location( name: String ) extends Actor
{
val log = Logging(context.system, this)
// ----- actor -----
override def preStart() {
log.info( "Room start: {}", name )
}
override def receive = {
case evt: Event ⇒ handleEvent( evt )
case cmd: Command ⇒ handleCommand( cmd )
case _ => log.info( "unknown message" )
}
override def postStop() {
// clean up resources
log.info( "Room stop: {}", name )
}
// ----- handles -----
def handleEvent( evt: Event ) = {
}
def handleCommand( cmd: Command ) = {
// Пример отправки реплики отправителю команды
cmd.sender ! Send( "gamedata".getBytes )
}
}
Command – Это сообщение от клиента, какая-то команда. Например Выстрел, движение, активация заклинания.
Event – это внутреннее событие сервера. Например создание моба или переключение времени дня.
Если есть интерес, можно продолжить и разобрать код какого-то рабочего варианта. Или же про Akka подробнее, с примерами.
Автор: solver