Введение
В этой статье хочу поделиться своим опытом интеграции сетевого приложения (в моем случае — игры) с социальными сетями. Так как я стараюсь, по возможности, не прибегать к сторонним решениям, то сетевая часть была разработана на том, что предлагает 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), менеджер боев (комнат), отдельный от лобби игровой сервер и взаимосвязь с лобби.