Введение


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


  1. AkshinM
    05.04.2018 22:23

    Хорошая статья. Пытаюсь сделать свою мультиплеерную игру. Я не смог совладать с этим unet. Как правильно организовать сервер, клиент и прочее? Интересно было бы прочитать про это


    1. Femidko Автор
      06.04.2018 12:50

      Благодарю за отзыв. Про основы Unet можно много найти в соответствующем разделе форумов Unity. Хотя большинство использует HLAPI. Простейший пример использования LLAPI я привел в скриптах. Спрашивайте, что интересует. У меня есть опыт и в прикладных задачах: организация лобби, асинхронная работа с БД (как это ни звучит странно для Mono 2.0), менеджер боев (комнат), отдельный от лобби игровой сервер и взаимосвязь с лобби.


  1. Femidko Автор
    06.04.2018 12:54

    Извиняюсь, упустил важную деталь, связанную с принудительным обновлением каждый кадр webSocket сервера. Добавил.