Создание игрового сервера на базе Azure

В процессе реализации одной игры для Windows RT потребовалось сделать мультиплеер на 2 игрока. При этом необходима была поддержка кроссплатформенной игры между WinRT и Windows phone 7.5. Сервисов предоставляющих такую возможность обнаружено не было, поэтому я решил написать свой простой сервер, который бы просто пересылал сообщения от одного клиента другому в реальном времени. Так как у меня есть только аккаунт Azure, реализацию было решено делать под него. При этом Azure обеспечивает легкое масштабирование, отличную консоль управления (новый интерфейс) и много сервисов облегчающих разработку. Ну и главная для меня особенность разработки под Azure: возможность разработки на C# и Visual Studio 2012.

Итак будем делать игровой сервер, обеспечивающий игру 2-х человек друг против друга. Сервер построен по технологии WCF с дуплексным каналом в Worker Role. Сервер обеспечивает игру в реальном времени между различными ОС. 

Создание Worker Role


Создаем новый проект в Visual studio 2012:


Выбираем рабочую роль:



В свойствах рабочей роли создадим две конечные точки:



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

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

Теперь пропишем код запуска нашей службы WCF в файле WorkerRole.cs.

private void StartGameService(int retries) { if (retries == 0) { RoleEnvironment.RequestRecycle(); return; } Trace.TraceInformation("Starting game service host..."); _serviceHost = new ServiceHost(typeof(GameService)); _serviceHost.Faulted += (sender, e) => { Trace.TraceError("Host fault occured. Aborting and restarting the host. Retry count: {0}", retries); _serviceHost.Abort(); StartGameService(--retries); }; var binding = new NetTcpBinding(SecurityMode.None); RoleInstanceEndpoint externalEndPoint = RoleEnvironment.CurrentRoleInstance. InstanceEndpoints["GameServer"]; RoleInstanceEndpoint mexpEndPoint = RoleEnvironment.CurrentRoleInstance. InstanceEndpoints["mexport"]; var metadatabehavior = new ServiceMetadataBehavior(); _serviceHost.Description.Behaviors.Add(metadatabehavior); Binding mexBinding = MetadataExchangeBindings. CreateMexTcpBinding(); string mexendpointurl = string.Format ("net.tcp://{0}/GameServerMetadata", mexpEndPoint.IPEndpoint); _serviceHost.AddServiceEndpoint(typeof(IMetadataExchange), mexBinding, mexendpointurl); _serviceHost.AddServiceEndpoint( typeof(IGameService), binding, string.Format("net.tcp://{0}/GameServer", externalEndPoint.IPEndpoint)); try { _serviceHost.Open(); Trace.TraceInformation( "Game service host started successfully."); } catch (TimeoutException timeoutException) { Trace.TraceError( "The service operation timed out. {0}", timeoutException.Message); } catch (CommunicationException communicationException) { Trace.TraceError( "Could not start game service host. {0}", communicationException.Message); } }

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

В методе Run() пропишем запуск нашего сервиса:

public override void Run() { // Это образец реализации рабочего процесса. Замените его собственной логикой. Trace.WriteLine("GameServerWorkRole entry point called", "Information"); StartGameService(3); while (true) { Thread.Sleep(10000); Trace.WriteLine("Working", "Information"); } }

Служба сервера


Весь код приводить не буду, т.к. объемен для статьи. (Скачать исходный код можно здесь) Приведу только участки кода, описывающие концепцию.

Имеются два менеджера: менеджер пользователей и менеджер игр, которые хранят информацию об текущих сессиях пользователей и текущих сессиях игр соответственно. Данные хранятся в статичных словарях, где ключом является уникальный идентификатор пользователя/игры. Также менеджеры реализуют потоко-безопасный доступ к словарю.

Так как сервер разрабатывается для игр под мобильные платформы, то необходимо учитывать, что связь с сервером будет постоянно пропадать и сессия рваться. Для решения этой проблемы мы будем с периодичностью в 30 секунд вызывать метод сервера Register() на клиенте.

Метод Register в серверном коде:

public ClientInformation Register(string uid, string userName) { // retrieve session information string roleId = RoleEnvironment. CurrentRoleInstance.Id; string sessionId = uid; var callback = OperationContext.Current. GetCallbackChannel<IClientNotification>(); var game = GameManager. GetCurrentGamesForPlayer(sessionId); if (game != null && !game.IsActive) { game.IsActive = true; NotifyConnectedClientsGame(game); } SessionInformation session; if (SessionManager.CreateOrUpdateSession (sessionId, userName, roleId, callback, out session)) { // ensure that the session is killed when channel is closed OperationContext.Current.Channel.Closed += (sender, e) => { session.IsActive = false; game = GameManager.GetCurrentGamesForPlayer(sessionId); if (game != null && game.IsActive) { game.IsActive = false; NotifyConnectedClientsGame(game); } Trace.TraceInformation("Session '{0}' by user '{1}' has been closed in role '{2}'.", sessionId, userName, roleId); }; Trace.TraceInformation("Session '{0}' by user '{1}' has been opened in role '{2}'.", sessionId, userName, roleId); } return new ClientInformation { SessionId = sessionId, UserName = userName, RoleId = roleId }; }

Изначально планировалось получать sessionId из OperationContext.Current, но т.к. связь постоянно рвется, каждый вызов клиента после обрыва будет создавать новую сессию. Нам же необходимо не создавать новую сессию, а обновлять существующую. Для этого в метод Register передается уникальный идентификатор пользователя, получаемый на клиенте функцией Guid.NewGuid().ToString().

При обрыве связи, игра и сессия игрока помечаются как неактивные. Это необходимо потому, что неизвестно, вернется пользователь в игру или нет. При вызове Register мы получаем текущую игру и помечаем ее как активную (т.е. подразумевается, что был обрыв связи и игрок вернулся в игру).

Каждые 60 секунд на сервере вызывается обработчик отключения неактивных сессий и игр:

imer timer; TimeSpan TimeForDelete; public GameService() { TimeForDelete = new TimeSpan(0, 0, 60); timer = new Timer(timerCallback,null,60000,60000); } private void timerCallback(object state) { var sesions = SessionManager.GetNotAcitiveSessions(); var sesionForDelete = sesions.Where(x => DateTime.Now.Subtract(x.LastSyncTime) > TimeForDelete).Select(x=>x.SessionId); foreach (var sessionId in sesionForDelete) { SessionManager.RemoveSession(sessionId); DeleteGame(sessionId); } }

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

Метод MakeTurn() рассылает сообщения всем пользователям текущей игры:

public void MakeTurn(string uid, string type, string data) { var game = GameManager. GetCurrentGamesForPlayer(uid); if (game != null) { foreach (var player in game.Players) { if (player.SessionId != uid) { var playerSession = SessionManager.GetSession(player.SessionId); if (playerSession.Callback != null) { try { playerSession.Callback. DeliverGameMessage(type, data); } catch { } } } } } }

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

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

public void DeleteGame(string uid) { var deletingGame = GameManager.GetGame(uid); if (deletingGame != null) { GameManager.RemoveGame(uid); NotifyConnectedClientsGame(deletingGame); } else { deletingGame = GameManager. GetCurrentGamesForPlayer(uid); if (deletingGame != null) { try { deletingGame.Players.RemoveAt(1); NotifyConnectedClientsGame(deletingGame); } catch { } } } }

Локальная отладка Worker Role


Для локальной отладки необходимо запустить Visual Studio от имени администратора и запустить проект. При этом запустится среда отладки Microsoft Azure. (Для открытия окна эмулятора необходимо в трее щелкнуть по значку эмулятора)



В данном окне вы можете видеть консоль сообщений каждого экземпляра.
Для создания тестового клиента запустите новый экземпляр Visual Studio и создайте проект.
В проекте добавьте ссылку на службу:



В поле адреса пишем url нашей конечной точки с метаданными. (в моем случае локальный IP оказался 127.255.0.1). Прописываем пространство имен для формируемого кода жмем кнопку “ОК”.

Разворачивание Worker Role в облаке


Настроим Azure через портал. Рабочие роли создаются и управляются в меню “Cloud services”:



Жмем “Create a cloud service”, выбираем регион с ближайшим дата-центром и придумываем url для обращения к нашей роли:



Жмем “Create cloud service” внизу формы и через несколько секунд, наш сервис будет создан. 

Теперь необходимо подготовить пакет нашей роли в Visual Studio. Для этого вызываем контекстное меню наше роли и выбираем “Пакет”:



Откроется новое окно, в котором можно указать конфигурацию службы (в нашем случае конфигурации одинаковые).



Жмем “Упаковать” и через несколько секунд откроется папка в проводнике с двумя файлами *.cspkg и *.cscfg.

Возвращаемся на портал, заходим в наш “сервис” и жмем “upload a new production deployment”:



Откроется окно, в котором необходимо выбрать файлы нашего пакета:



Галочку “Deploy even if one or more roles contain a single instance” необходимо ставить если в вашей роли всего один экземпляр (как в нашем случае).

Через некоторое время роль развернется и настроится.

Сейчас мы можем добавить службу на клиенте, указав с троке адреса net.tcp://mytestgameserver.cloudapp.net:8001/GameServerMetadata.

ВАЖНО. Роль крутится в цикле и следовательно использует процессорное время облака. Если вы не используете службу, то отключите роль, что бы не платить за использование процессорного времени.

Создание клиента под Windows RT


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

Измените url в файле Reference.cs на:
net.tcp://mytestgameserver.cloudapp.net:3030/GameServer
т.к. после автогенерации кода встает не верный IP.

Для тестирования взаимодействия двух клиентов необходимо:
1. Развернуть одно приложение на “локальном компьютере”
2. Запустить второе приложение в “Имитатор”.

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

Итог

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

Автор статьи: Игорь Кулик.

​Благодарим автора статьи Игоря Кулика за создание материала