Система трекинга загрузки игрового клиента. Часть 2
В этой статье мы поговорим о сервисе GeoIP, который определяет геоданные по IP-адресу запроса, веб-сокетах, реализации polling сервера, AngularJS, Highcharts и проведем краткий анализ системы трекинга загрузки игрового клиента.
Система трекинга загрузки игрового клиента. Часть 1.
Cервис GeoIP
Частью проекта была разработка сервиса по определению геоданных. Он должен был определять геоданные по IP-адресу запроса, а также находить геоинформацию по координатам. Подобных онлайн-сервисов доступно множество, но нам нужен был локально работающий и контролируемый сервис, который может обрабатывать большое количество одновременных и среднесуточных запросов. В результате была приобретена база DB-IP и написан свой сервис. База данного сервиса содержит порядка 7 миллионов диапазонов адресов (как IPv4, так и IPv6) и обновляется ежемесячно. База распространяется в формате CSV.
Уже используемая в нашем проекте MongoDB отлично подошла для хранения и ежемесячного обновления базы IP-адресов. Было принято решение распределить диапазоны IP-адресов по разным коллекциям. Как результат – поиск происходит не из всех данных, а только из конкретной коллекции, что в совокупности с индексированием дало хорошую скорость поиска: 2–8 миллисекунды.
Кроме того, необходимо было находить геоданные по географическим координатам. В этот раз MongoDB порадовала возможностью легко и удобно работать с координатами и географическими объектами. Для хранения координат был использован объект GeoJson2DGeographicCoordinates и индекс GeoSpatialSpherical. База умеет находить ближайшие данные по координатам, для чего достаточно задать исходные координаты и расстояние для поиска в мерах.
Пример поиска:
var queryList = new List<IMongoQuery>
{
Query.Near("Coordinates", lng, lat, distanceInMeters, true)
… другие критерии поиска
};
MongoCursor<HighChartRegion> result = GeoDataCollection .Find(Query.And(queryList))
Веб-сокеты (WebSockets), реализация polling сервера
Поскольку система проектировалась для корпоративного использования, не было необходимости поддерживать все веб-браузеры. Это позволило без проблем использовать технологию WebSocket. Сама технология не новая, но широкого применения пока не получила, к тому же поддерживается не всеми браузерами и готовых решений для .net не так уж и много. Из наиболее популярных – Supersocket, Fleck, XSockets, SignalR, и у каждого есть свои достоинства и недостатки. Как вариант можно использовать WebSocket-класс от Microsoft для написания своего сокет-сервера.
При выборе следует обратить внимание на возможность работы под IIS-процессом, а также на поддержку безопасного соединения (WSS). Например, SignalR требует IIS8, а XSockets – платное enterprise-решение.
Идея использования состоит в том, что вместо периодических запросов на сервер, соответственно инициализации новых и новых запросов, устанавливается соединение и происходит обмен данными между клиентом и сервером. Кроме того, использование веб- сокетов позволяет решить проблему, когда несколько клиентов часто запрашивают одни и те же данные, тем самым нагружая сервер при сложных вычислениях.
В нашем случае мы использовали Fleck сокет-сервер, на базе которого написали обертку – PollerWebSocketServer. PollerWebSocketServer умеет обрабатывать входящие запросы, а также регистрировать подписчиков и рассылать push-уведомления.
Для взаимодействия клиента с сервером был разработан свой протокол общения, чтобы клиент мог запрашивать различные данные, подписываться на получение данных или отписываться. WebSocketMessage-класс обработки запроса с клиента выглядит так:
public class WebSocketMessage : RequestFilters, IWebSocketMessage
{
[Json("id")]
public string Id { get; set; }
[JsonSkip]
public Guid SocketId { get; set; }
[Json("command")]
public WebSocketCommand Command { get; set; }
[Json("type")]
public WebSocketCommandType CommandType { get; set; }
[JsonSkip]
public string Hash
{
get
{
return GetHash();
}
}
Hash создается на основании запрашиваемых данных (фильтров) и используется для отслеживания уникальных запросов. Группе таких подписчиков рассылаются единожды сгенерированные данные.
В ответе на такой запрос возвращается ID и команда запроса, время следующего push-уведомления и данные.
Инициализация PollerWebSocketServer выглядит следующим образом:
var wss = new WebSocketServer(Settings.SocketPort, Settings.Certificate != null);
wss.SupportedSubProtocols = new[] { "map", "stats"};
wss.ServerSettings = SettingsProvider.WebSockerServerSettings;
if (SettingsProvider.Certificate != null)
wss.Certificate = SettingsProvider.Certificate;
var dataProvider = new WebSocketJsonDataProvider();
_pollerWebSocketServer = new PollerWebSocketServer<WebSocketMessage>(wss, dataProvider, OnProblemDetected);
где OnProblemDetected – обработчик, который вызывается, если сервер обнаружил проблемную ситуацию.
Для запуска сервера нужно задать делегаты для трех событий:
_webSocketServer.Start(socket =>
{
socket.OnOpen = () => OpenSocket(socket);
socket.OnClose = () => CloseSocket(socket);
socket.OnMessage = message => ProcessSocketMessage(socket, message);
});
Интерес представляет обработчик WebSocketMessage:
private void ProcessSocketMessage(IWebSocketConnection socket, string message)
{
if (!IsValidOrigin(socket))
return;
TM wsMessage;
try
{
wsMessage = message.FromJsonStr<TM>();
}
catch (Exception ex)
{
ReturnError(socket, ex, "Fail to parse socket message");
return;
}
if (wsMessage == null)
{
ReturnError(socket, null, "Message is null");
return;
}
switch (wsMessage.CommandType)
{
case WebSocketCommandType.Subscribe:
wsMessage.SocketId = socket.ConnectionInfo.Id;
_subscriptions.AddOrUpdate(wsMessage.Id ?? Guid.NewGuid().ToString("N"), wsMessage, (guid, connection) => wsMessage);
break;
case WebSocketCommandType.Unsubscribe:
Unsubscribe(wsMessage);
return;
}
var data = _webSocketJsonDataProvider.GetJsonData(wsMessage);
var response = new WebSocketResponseMessage(wsMessage.Id, wsMessage.Command, data, NextPollingInSec);
socket.Send(response.JsonData);
}
Обработка события рассылки по сути группирует всех подписчиков по Hash, для каждого уникального запроса получает данные от JSON-провайдера и рассылает подписчикам:
private void OnPollingTimerExecution()
{
var hashGroupedSubs = _subscriptions.GroupBy(s => s.Value.Hash);
foreach (var hashGroup in hashGroupedSubs)
{
var requestMessage = hashGroup.First().Value;
var data = _webSocketJsonDataProvider.GetJsonData(requestMessage);
foreach (var msg in hashGroup)
{
IWebSocketConnection socket;
if (_activeSockets.TryGetValue(msg.Value.SocketId, out socket))
{
var response = new WebSocketResponseMessage(msg.Value.Id, requestMessage.Command, data, NextPollingInSec);
socket.Send(response.JsonData);
}
}
}
}
WebSocket-север можно запустить как в отдельном приложении или службе, так и под веб-сервисом. Следует учитывать, что работает протокол поверх TCP-протокола и инстансу необходим отдельный порт. При запуске под веб-сервисом нужно обратить внимание на особенности перезапуска веб-сервиса (отключить перезапуск с перекрытием для пула) и корректно обрабатывать перезапуск WebSocket-сервера или синхронизацию между процессами.
Как было сказано выше, каждое сообщение имеет определенный формат.
Пример:
{ id: “messageId”, command: 100, type: 1, nextDataTime: 30, data: “...”, ... }.
Рассмотрим подробнее:
● command – это внутренняя команда на выполнение какого-то действия или получение данных. Отправляется как с клиента, так и с сервера;
● id – уникальный идентификатор сообщения, который оправляется с клиента и сервер при ответе всегда пересылает его назад. Это очень важная часть, так как можно легко идентифицировать каждое сообщение и не бояться, что запрос с определенной командой вызовет «чужой» обработчик. Таким образом может быть несколько подписчиков к одной команде, и они не будут конфликтовать;
● type – тип запроса. Может принимать 3 значения: 0 – выполняется один раз, 1 – подписаться на получение обновленных данных, 2 – отписаться от получения данных (передается с клиента);
● nextDataTime – время до следующего обновления данных. Используется для отображения обратного отсчета в виде прогрессбара. Приходит только с сервера;
● data – данные, специфичные для каждой команды;
● дополнительные поля, такие как countryCode, appIds, fromTime, toTime etc, которые выполняют роль фильтров. Отправляются с клиента.
AngularJS и использование библиотеки Highcharts на клиентской стороне
Для работы с веб-сокетами на стороне клиента используется сервис WS, обертка над стандартным WebSocket-классом.
Соединение устанавливается при старте приложения. При этом сервис подписывается на стандартные события веб-сокета (open, close, message, error) и в дальнейшем реагирует на них, реализуя свою логику работы. Также при старте приложения подгружаются словари, необходимые для дальнейшей работы (страны, регионы и т. д.).
В случае разрыва соединения сервис автоматически будет пытаться его восстановить с определенным таймаутом.
Сервис предоставляет 4 основных метода: connect, disconnect, createSubscriber и get. Если функциональность первых двух методов ясна из названий, то два последних рассмотрим подробнее. Метод createSubscriber позволяет создать объект подписчика, который может подписываться на получение данных от заданной команды с учетом определенных фильтров, отписаться от получения данных, а также изменить набор фильтров.
Пример вызова:
WS.createSubscriber(command, filters, callback);
Callback выполняется, когда приходят новые данные (в эту функцию передаются только данные из поля data).
Можно было бы возвращать promise, но так как его нельзя резолвить несколько раз, то этот вариант не подходит. Но можно использовать метод notify, который будет вызываться при поступлении данных. Возможно, в будущем мы попробуем использовать этот подход.
Также есть важный нюанс. При разрыве соединения и успешного переподключения WS-сервис автоматически переподпишет всех активных подписчиков.
Метод get позволяет получить данные один раз, эмулируя метод get $http сервиса, но в рамках сокет-соединения. Возвращает promise:
WS.get(command, filters).then(function(data) {… });
Из интересных моментов по сокетам на клиенте это всё.
Для отображения графиков мы используем библиотеку Highcharts и Highmaps для карт. Карта мира, а также детальные карты стран используются для отображения детальной информации.
Компоненты для карт обернуты в директивы, которые отображают данные.
Подгрузка geoJSON-данных для карт вынесена в отдельный сервис, методы которого возвращают promises.
Примеры вызова:
GeoJSON.getCountry(countryCode).then(function(data) { ... });
GeoJSON.getWorld(isHighRes).then(function(data) { ... });
Сервис автоматически построит ссылку для получения данных и загрузит их. Здесь также регулируются исключения: например, для Франции мы используем более детальную карту, ссылка для которой отличается от стандартной схемы.
Была необходимость отображать на карте отдельных стран большое количество точек (1000 и более). Но рендеринг в данном случае происходит очень медленно, так как точки добавляются в DOM, а это всегда не быстро. Количество оперативной памяти, используемой браузером, в данном случае тоже сильно возрастает, поэтому временно от этой идеи отказались. Возможно, для ускорения стоит использовать канвас, но это накладывает определенные ограничения.
Краткий анализ системы
Трекер обрабатывает ~6–7 тыс. запросов в минуту и ~14 тыс. в пиковой нагрузке, при этом для каждого запроса определяются геоданные. Время определения геоданных – 2–8 миллисекунды. Данные в MongoDB хранятся циклически за месяц. Сервис отображает состояние загрузок в реальном времени с периодическим обновлением. При массовом возникновении ошибок они отображаются на карте индикацией цвета страны. Также можно детально просмотреть статистику по областям и типам ошибок за выбранный промежуток времени и указанным критериям. При возникновении отклонений от нормы автоматически рассылаются уведомления и отображается предупреждение.
Как это выглядит на клиенте: