Введение
В этой статье хочу поделиться своим опытом интеграции сетевого приложения (в моем случае — игры) с социальными сетями. Так как я стараюсь, по возможности, не прибегать к сторонним решениям, то сетевая часть была разработана на том, что предлагает 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 ConnectionServerExample: NetworkConnection { NetworkWriter mWriter = new NetworkWriter(); public void SendHelloWorld() { mWriter.StartMessage((short)EnumRpc.RpcHelloWorld); mWriter.Write("Hello from server"); mWriter.FinishMessage(); SendWriter(mWriter, Channels.DefaultReliable); } } // class } // namespace #endif
Сервер (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; }, });
Надеюсь у Вас все заработает! Задавайте любые вопросы, не стесняйтесь.