Введение
В этой статье хочу поделиться своим опытом интеграции сетевого приложения (в моем случае — игры) с социальными сетями. Так как я стараюсь, по возможности, не прибегать к сторонним решениям, то сетевая часть была разработана на том, что предлагает Unity3D, а именно — Unet с использованием его низкоуровневой части (LLAPI). NetworkClient на клиенте соединяется с NetworkServer на удаленном сервере. Игровой сервер тоже написан на Unity3D. Работа в такой связке, хоть и требует углубленных знаний Unet , но имеет свои неоспоримые плюсы.
Проблема
И вот настало время разместить приложение в соц. сети. Подключив API соц. сети, настроил приложение в самой соц. сети, скомпилировал WebGl сборку и выложил игру на сервер. Первый запуск приложения сразу выявил ошибку: WebGl сборка не может открыть обычный Socket и требует работать только через WebSocket. Здесь все понятно — это мое упущение, ведь крайнее приложение я делал еще под Unity Web Player, который работал на обычных Socket'ах. Решив вопрос настройки сети для работы с WebSocket'ами (об этом чуть позже), я столкнулся со следующей проблемой. Т.к. соц. сети (по крайней мере та, с которой я интегрировался) работает по защищенному протоколу (Https) и предъявляет требование к своему контенту, так же работать через защищенный протокол. «Не беда» — подумал я, сейчас сделаем… Несколько дней «копания» интернета, общения с разработчиками Unity ввергло меня в уныние: поддержки защищенных WebSocket'ов (WSS) в Unet нет и неизвестно когда будет. Насколько я понимаю, большинство разработчиков в этом месте уходят в «фотон». Но мы не такие!
Так вот при чем тут соц. сети. Для обычной web-сборки работаем с обычными WebSocket'ами и радуемся жизни.
Общие скрипты
Клиент (c#):
#if _CLIENT_
using UnityEngine;
using UnityEngine.Networking;
namespace EXAMPLE.CLIENT
{
public class NetworkClientExample : NetworkClient
{
NetworkWriter mWriter = new NetworkWriter();
public NetworkClientExample() : base()
{
Configure(NetworkableConfig.GetConnectionConfig(), 1);
RegisterHandler(MsgType.Connect, OnConnect);
RegisterHandler(MsgType.Disconnect, OnDisconnect);
RegisterHandler(MsgType.Error, OnError);
RegisterHandler((short)EnumRpc.RpcHelloWorld, RpcLobbbyHelloWorld);
}
public void OnConnect(NetworkMessage _msg)
{
Debug.Log("OnConnectLobby");
mWriter.StartMessage((short)EnumRpc.CmdHelloWorld);
mWriter.Write("Hello from client");
mWriter.FinishMessage();
SendWriter(mWriter, Channels.DefaultReliable);
}
void OnDisconnect(NetworkMessage _msg)
{
Debug.Log("OnDisconnectLobby");
UnregisterHandler(MsgType.Connect);
UnregisterHandler(MsgType.Disconnect);
UnregisterHandler(MsgType.Error);
UnregisterHandler((short)EnumRpc.RpcHelloWorld);
}
void OnError(NetworkMessage _msg)
{
Debug.Log("OnErrorLobby");
}
void RpcLobbbyHelloWorld(NetworkMessage _msg)
{
Debug.Log(_msg.reader.ReadString());
}
} // class
} // namespace
#endif
Использование NetworkClientExample (c#):
NetworkClientExample client = new NetworkClientExample();
client.Connect(NetworkableConfig.ServerHost, NetworkableConfig.ServerPort);
Сервер (c#):
#if _SERVER_
using UnityEngine;
using UnityEngine.Networking;
namespace EXAMPLE.SERVER
{
public class NetworkServerExample : MonoBehaviour
{
public const int sMaxConnections = 5000;
void Start()
{
NetworkServer.SetNetworkConnectionClass<ConnectionServerExample>();
ConnectionConfig config = NetworkableConfig.GetConnectionConfig();
NetworkServer.Configure(config, sMaxConnections);
NetworkServer.RegisterHandler(MsgType.Connect, OnConnect);
NetworkServer.RegisterHandler(MsgType.Disconnect, OnDisconnect);
NetworkServer.RegisterHandler(MsgType.Error, OnError);
NetworkServer.RegisterHandler((short)EnumRpc.CmdHelloWorld, CmdHelloWorld);
NetworkServer.Listen(NetworkableConfig.ServerPort );
}
void OnConnect(NetworkMessage _msg)
{
Debug.Log("OnConnect");
}
void OnDisconnect(NetworkMessage _msg)
{
Debug.Log("OnDisconnect");
}
public void OnError(NetworkMessage _msg)
{
Debug.Log("OnError");
}
void CmdHelloWorld(NetworkMessage _msg)
{
Debug.Log(_msg.reader.ReadString());
((ConnectionServerExample)_msg.conn).SendHelloWorld();
}
} // class
} // namespace
#endif
Сетевые константы(c#):
using UnityEngine.Networking;
namespace EXAMPLE
{
public enum EnumRpc : short
{
RpcHelloWorld = 1,
CmdHelloWorld,
}
public static class NetworkableConfig
{
/// <summary>
/// Порт Lobby-сервера
/// </summary>
public const int ServerPort = 5000;
/// <summary>
/// Порт Lobby-web-сервера
/// </summary>
public const int ServerPortWebSocket = 5500;
/// <summary>
/// Адрес Lobby-сервера
/// </summary>
public const string ServerHost = "127.0.0.1";
/// <summary>
/// Timeout соединения
/// </summary>
public const int DisconnectTimeout = 10000;
/// <summary>
/// Настройка для сетевых компонент
/// </summary>
public static ConnectionConfig GetConnectionConfig()
{
ConnectionConfig config = new ConnectionConfig();
config.AddChannel(QosType.ReliableSequenced);
config.AddChannel(QosType.Unreliable);
config.DisconnectTimeout = DisconnectTimeout;
return config;
}
} // class
} // namespace
Переходим на WebSocket'ы
В моем случае серверная игровая логика должна быть общей, как для «мобильных», так и для «браузерных» клиентов, поэтому я решил перейти на WebSocket'ы. Но начитавшись о «тормознутости» последних было принято решение сделать два параллельных сервера. Пока обдумывал взаимодействия этих серверов, случайно наткнулся на одном форуме на упоминание о классе NetworkServerSimple и возрадовался. Его-то мы и натравим на WebSocket'ы. И нам останется всего лишь перекидывать его клиентов на откуп основному серверу.
Сервер для WebSocket'ов(c#):
#if _SERVER_
using UnityEngine.Networking;
namespace EXAMPLE.SERVER
{
class NetworkServerWebSocket : NetworkServerSimple
{
public void Start(ConnectionConfig _config, int _maxConnections)
{
base.Initialize();
base.Configure(_config, _maxConnections);
// Говорим серверу слушать webSocket'ы
useWebSockets = true;
Listen(NetworkableConfig.ServerPortWebSocket);
}
public override void OnConnected(NetworkConnection _conn)
{
base.OnConnected(_conn);
// Отдаем соединение с клиентом на обработку основному серверу
NetworkServer.AddExternalConnection(_conn);
}
public override void OnDisconnected(NetworkConnection _conn)
{
// Изымаем соединение клиента из обработки основного сервера
NetworkServer.RemoveExternalConnection(_conn.connectionId);
base.OnDisconnected(_conn);
}
} // class
} // namespace
#endif
Для того, чтобы запустить WebSocket-сервер, необходимо отредактировать скрипт нашего основного сервера:
Исправления в NetworkServerExample (c#):
public class NetworkServerExample : MonoBehaviour
{
//...
NetworkServerWebSocket mServerWebSocket;
//...
void Start()
{
//...
mServerWebSocket = new NetworkServerWebSocket();
mServerWebSocket.Start(config, sMaxConnections);
//...
}
//...
void Update()
{
//...
// WebSocket сервер необходимо обновлять каждый кадр
if(mServerWebSocket != null)
{
mServerWebSocket.Update();
}
//...
}
//...
}
И все сразу заработало… но недолго. До тех пор, пока не пошли соединения к обоим серверам одновременно. Оказалось что Unet неспособен решить простую задачу уникальности идентификаторов соединений всех работающих серверов. Обидно, досадно, но… куда без костылей. Мы просто введем свой класс для WebSocket-соединения, который будет подменять свой идентификатор сдвигая на максимально возможное количество соединений основного сервера. Т.к. сервер удаленный и, зачастую, выделенный — мы можем прикрыть глаза на небольшую трату памяти для выделения двойного массива ссылок на соединения. Итак:
Серверный класс WebSocket-соединения (c#):
#if _SERVER_
using UnityEngine.Networking;
namespace EXAMPLE.SERVER
{
public class ConnectionLobbyServerWebSocket : NetworkConnection
{
public override void Initialize(string networkAddress, int networkHostId, int networkConnectionId, HostTopology hostTopology)
{
base.Initialize(networkAddress, networkHostId, networkConnectionId + NetworkServerExample.sMaxConnections, hostTopology);
}
public override bool TransportSend(byte[] bytes, int numBytes, int channelId, out byte error)
{
return NetworkTransport.Send(hostId, connectionId - NetworkServerExample.sMaxConnections, channelId, bytes, numBytes, out error);
}
} // class
} // namespace
#endif
Вот теперь все ладненько!
Защищенные WebSocket'ы
Т.к. я являюсь полным профаном в администрировании серверов — для меня этот пункт оказался самым сложным. Для хостинга я использую Windows Server. И чем больше я погружался в тему проброса из WSS в WS для IIS тем мне становилось хуже. Наверняка для «Атца-Одмина» это задача тривиальна, но я — не он. Однако, в ходе исследования
IIS я понял, что на Apache это сделать очень просто. Ура! Я знаю слово Apache. Наверняка вышеупомянутый админу станет плохо, от сочетания Windows Server+Apache, ну и ладно.
Для того, чтобы в Apache пробросить из WSS в WS необходимо поддержать на нем SSL (об этом есть много информации от настоящих спецов), включить модули «mod_proxy.so» и «mod_proxy_wstunnel.so» и настроить проброс.
httpd.conf
Здесь «my_proxy» — любой наш идентификатор, который будет использоваться для подключения на клиенте. 5500 — порт, который слушает наш WebSocket-сервер
#Находим и раcкомментируем следующие строчки
LoadModule proxy_module modules/mod_proxy.so
LoadModule proxy_wstunnel_module modules/mod_proxy_wstunnel.so
#Вставляем следующие строчки в конце файла
ProxyPass /my_proxy/ ws://localhost:5500/
ProxyPassReverse /my_proxy/ ws://localhost:5500/
Здесь «my_proxy» — любой наш идентификатор, который будет использоваться для подключения на клиенте. 5500 — порт, который слушает наш WebSocket-сервер
WebSocket-сервер уже слушает нужный порт, осталось настроить подключение клиента.
Исправляем NetworkClientExample (c#)
//...
public NetworkClientExample() : base()
{
//...
// У меня сделано через директивы компилятора. _VK_ - значит сборка для соц. сеть
#if _VK_
// да-да именно NetworkServer
NetworkServer.useWebSockets = true;
#endif
//...
}
//...
Использование NetworkClientExample (c#)
ваш_домен — имя Вашего домена без протокола, например: habrahabr.ru
my_proxy — прокси-идентификатор, который мы обозначили в «httpd.conf»
Не забудьте "/" после прокси-идентификатора!
NetworkClientExample client = new NetworkClientExample();
#if _VK_
client.Connect("ваш_домен/my_proxy/", 443);
#else
client.Connect(NetworkableConfig.ServerHost, NetworkableConfig.ServerPort);
#endif
ваш_домен — имя Вашего домена без протокола, например: habrahabr.ru
my_proxy — прокси-идентификатор, который мы обозначили в «httpd.conf»
Не забудьте "/" после прокси-идентификатора!
Осталось совсем немного. Теперь нужно запросы Unet через обычные web-сокеты (WS) переделать в защищенные web-сокеты WSS. Это можно сделать с помощью небольшого JavaScript-скрипта, либо вставив его напрямую в тело Вашей web-странички, либо подключив как плагин к проекту. Мне был удобнее второй вариант. Поэтому в папке «Assets/Plugins» нашего проекта создаем скрипт, например «WssHack.jspre» (именно с расширением ".jspre", иначе «юнька» может закапризничать) и следующим содержимым:
WssHack.jspre (JavaScript)
Object.defineProperty(Module, "asmLibraryArg", {
set: function (value) {
value._JS_UNETWebSockets_SocketCreate = function (hostId, urlPtr) {
var url = Pointer_stringify(urlPtr).replace(/^ws:\/\//, "wss://");
urlPtr = Runtime.stackAlloc((url.length << 2) + 1);
stringToUTF8(url, urlPtr, (url.length << 2) + 1);
return _JS_UNETWebSockets_SocketCreate(hostId, urlPtr);
};
Module._asmLibraryArg = value;
},
get: function () {
return Module._asmLibraryArg;
},
});
Надеюсь у Вас все заработает! Задавайте любые вопросы, не стесняйтесь.
Комментарии (3)
Femidko Автор
06.04.2018 12:54Извиняюсь, упустил важную деталь, связанную с принудительным обновлением каждый кадр webSocket сервера. Добавил.
AkshinM
Хорошая статья. Пытаюсь сделать свою мультиплеерную игру. Я не смог совладать с этим unet. Как правильно организовать сервер, клиент и прочее? Интересно было бы прочитать про это
Femidko Автор
Благодарю за отзыв. Про основы Unet можно много найти в соответствующем разделе форумов Unity. Хотя большинство использует HLAPI. Простейший пример использования LLAPI я привел в скриптах. Спрашивайте, что интересует. У меня есть опыт и в прикладных задачах: организация лобби, асинхронная работа с БД (как это ни звучит странно для Mono 2.0), менеджер боев (комнат), отдельный от лобби игровой сервер и взаимосвязь с лобби.