【Unity】スマホ向け複数接続可能なChat機能を実装してみた

# 特徴
1. ノンブロッキングI/O使って書いたサーバなので1台のサーバで数千人同時接続できる..はず(いわゆるC10k問題)
2. TCP通信なので他の人の書き込みがすぐ反映される（サーバからのpush通知を受け取れる）
3. Serverをmono使って書いたのでC#+.netのサーバがlinuxやmacで動く

WebAPIによるchatサーバ実装だと、1と2の要件が満たせません
Visual C#でserverを書くとWindowsでしかサーバが動かず、3の要件が満たせません。

# 動作画面
![スクリーンショット 2015-10-06 21.14.16.png](https://qiita-image-store.s3.amazonaws.com/0/65312/843fe5bd-cd74-2fe2-ae1b-57d5b4c520e5.png)


# 背景
C10K問題に対応したechoサーバをなんとか書けるようになってきたので、Unityと組み合わせてchat機能をクライアント(Unity C#)とサーバ(C#)で実装してみました。またUnityにはNetworkViewを使ったChatのチュートリアルがありますが、[NetworkView自体の実装が激しくスマホ向けではない](http://qiita.com/haminiku/items/ef80b6e8a4b4270efcd4)ため作ってみました。


# 普通のchatをUnityで実装
まずネットワーク通信しない普通の一人用chatをUnityで実装してみた。[ここ](http://blog.livedoor.jp/bribser_dev/archives/3628336.html)のコードを参考にしました。

```csharp
using UnityEngine;
using System.Collections;
using System.Collections.Generic;

public class Chat : MonoBehaviour {
	// メッセージを管理するリスト
	private List<string> messages = new List<string>();
	// Chat用のテキスト
	private string currentMessage = string.Empty;

	private void OnGUI()
	{
		GUILayout.Space(10);
		GUILayout.BeginHorizontal(GUILayout.Width (250));

		// 入力情報取得
		currentMessage = GUILayout.TextField(currentMessage);
			
		// Sendボタン
		if ( GUILayout.Button("Send") )
		{
			// 入力が空ではない場合処理
			if ( !string.IsNullOrEmpty(currentMessage.Trim ()) )
			{
				Debug.Log(currentMessage);
				messages.Add(currentMessage);

				// 送信後は、入力値を空
				currentMessage = string.Empty;
			}
		}

		GUILayout.EndHorizontal();

		// Chat欄の生成
		createMessage (messages);
	}

	private void createMessage(List<string> messages){
		// 入力されたメッセージを逆順に表示
		for ( int i=messages.Count-1; i>=0; i-- )
		{
			GUILayout.Label(messages[i]);
		}
	}
}

```

■実行画面
![スクリーンショット 2015-10-04 19.28.43.png](https://qiita-image-store.s3.amazonaws.com/0/65312/d44dca89-34ff-52d9-79af-9dac7e62c93c.png)

# TCPサーバを書くコトハジメ
VisualC#かXamarinでchatサーバ用の新規ソリューションを作成して、monoでビルドして実行してみます。macだったのでXamarin使ってみました。

```shell-session
mkdir /tmp/server
xbuild /tv:4.0 ~/Projects/Server/Server/Server.csproj /t:Build /p:OutputPath=/tmp/server/
sudo mono --debug /tmp/server/Server.exe
> Hello World!
```

# monoでechoサーバを書いていく
```csharp:ChatServer.cs
using System;
using System.Threading;
using System.Threading.Tasks;
using System.Net;
using System.Net.Sockets;
namespace Server
{
	class MainClass
	{
		public static void Main (string[] args)
		{
			Console.WriteLine ("START");
			// 非同期でチャットルームを立ち上げる
			Task.Run (() => ChatRoom ("room name"));

			// TCPサーバを立ち上げる
			string ipString = "127.0.0.1";
			System.Net.IPAddress ipAdd = System.Net.IPAddress.Parse(ipString);
			//Listenするポート番号
			int port = 10021;

			//TcpListenerオブジェクトを作成する
			TcpListener server = new TcpListener(ipAdd, port);

			//Listenを開始する
			server.Start();
			Console.WriteLine("Listenを開始しました({0}:{1})。",
				((System.Net.IPEndPoint)server.LocalEndpoint).Address,
				((System.Net.IPEndPoint)server.LocalEndpoint).Port);

			while (true) {
				//接続要求があったら受け入れる
				TcpClient client = server.AcceptTcpClient ();

				//クライアントからのTCP接続は別スレッドに投げる
				Task.Run(() => ChatStream(client));
			}

			Console.WriteLine ("FINISH");
		}

		static void ChatRoom(string tag){
			Console.WriteLine ("Start Chat");
			Console.WriteLine ("Finish Chat");
		}

		static void ChatStream(TcpClient client){
			Console.WriteLine ("クライアント({0}:{1})と接続しました。",
				((IPEndPoint)client.Client.RemoteEndPoint).Address,
				((IPEndPoint)client.Client.RemoteEndPoint).Port);

			//NetworkStreamを取得
			NetworkStream stream = client.GetStream ();


		}
	}
}

```

早速monoコマンドで実行してみましょう

```shell-session:shell
>xbuild /tv:4.0 ~/Projects/Server/Server/Server.csproj /t:Build /p:OutputPath=/tmp/server/
>sudo mono --debug /tmp/server/Server.exe
START
Start Chat
Finish Chat
Listenを開始しました(127.0.0.1:10021)。
```

ServerがListenを開始しました。次はUnity側でTCP接続する箇所を書いていきます。

# この後Unityの実装でハマりまくる
この後軽くUnityでTCPの送信と受信処理を書いていこうとしたところハマって3日掛かりました。まずUnityの.NETバージョンが3だったためTaskやasync/await使えず、仕方なくTCP待ち受けをコルーチン使って書いてみたらUnityのコルーチンはシングルスレッド動作のため同期してフリーズの嵐となりました。最終的にBeginRead/EndReadを使って実装してことなきを得ました。

# 【完成版】Unity側のコード
```csharp:Chat.cs
using UnityEngine;
using System;
using System.Threading;
using System.Collections;
using System.Collections.Generic;
using System.Net.Sockets;
using System.IO;
using System.Text;
using System.Net;

public class Chat : MonoBehaviour {
	// メッセージを管理するリスト
	private List<string> messages = new List<string>();
	// Chat用のテキスト
	private string currentMessage = string.Empty;
	// Server
	NetworkStream stream = null;
	bool isStopReading = false;
	byte[] readbuf;

	private IEnumerator Start(){
		Debug.Log("START START");
		readbuf = new byte[1024];

		while (true) {
			if(!isStopReading){StartCoroutine(ReadMessage ());}
			yield return null;
		}
	}

	private void OnGUI()
	{
		GUILayout.Space(10);
		GUILayout.BeginHorizontal(GUILayout.Width (250));

		// 入力情報取得
		currentMessage = GUILayout.TextField(currentMessage);
			
		// Sendボタン
		if ( GUILayout.Button("Send") )
		{
			// 入力が空ではない場合処理
			if ( !string.IsNullOrEmpty(currentMessage.Trim ()) && currentMessage != "")
			{
				Debug.Log(currentMessage);

				// Chatサーバに送信
				StartCoroutine(SendMessage (currentMessage));

				// 送信後は、入力値を空
				currentMessage = string.Empty;
			}
		}

		GUILayout.EndHorizontal();

		// Chat欄の生成
		createMessage (messages);
	}

	private void createMessage(List<string> messages){
		// 入力されたメッセージを逆順に100表示
		int count = 1;
		for ( int i=messages.Count-1; i>=0; i-- )
		{
			GUILayout.Label(messages[i]);
			count ++;
			if (count > 100)break;
		}

	}

	private IEnumerator SendMessage(string message){
		Debug.Log ("START SendMessage:" + message);

		if (stream == null) {
			stream = GetNetworkStream();
		}
		string playerName = "[A]: ";
		//サーバーにデータを送信する
		Encoding enc = Encoding.UTF8;
		byte[] sendBytes = enc.GetBytes(playerName + message + "\n");
		//データを送信する
		stream.Write(sendBytes, 0, sendBytes.Length);
		yield break;
	}
	
	private IEnumerator ReadMessage(){
		stream = GetNetworkStream ();
		// 非同期で待ち受けする
		stream.BeginRead (readbuf, 0, readbuf.Length, new AsyncCallback (ReadCallback), null);
		isStopReading = true;
		yield return null;
	}

	public void ReadCallback(IAsyncResult ar ){
		Encoding enc = Encoding.UTF8;
		stream = GetNetworkStream ();
		int bytes = stream.EndRead(ar);
		string message = enc.GetString (readbuf, 0, bytes);
		message = message.Replace("\r", "").Replace("\n", "");
		isStopReading = false;
		messages.Add(message);
	}	
	
	private NetworkStream GetNetworkStream(){
		if (stream != null && stream.CanRead) {
			return stream;
		}
		
		string ipOrHost = "127.0.0.1";
		int port = 10021;
		
		//TcpClientを作成し、サーバーと接続する
		TcpClient tcp =	new TcpClient(ipOrHost, port);
		Debug.Log("success conn server");
		
		//NetworkStreamを取得する
		return tcp.GetStream();
	}

	private Socket GetSocket(){
		IPAddress ipAddress = IPAddress.Parse("127.0.0.1");
		IPEndPoint localEndPoint = new IPEndPoint(ipAddress, 10021);
		Socket listener = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp );
		listener.Bind(localEndPoint);
		listener.Listen(10);
		return listener;
	}
}

```

# 【完成版】Server側のコード
```csharp:Server.cs
using System;
using System.Threading;
using System.Threading.Tasks;
using System.Net;
using System.Net.Sockets;
using System.IO;
using System.Text;
using System.Collections.Generic;

namespace Server
{
	class MainClass
	{
		public static Dictionary<TcpClient, int> clientDict = new Dictionary<TcpClient, int>();

		public static void Main (string[] args)
		{
			Console.WriteLine ("START");
			// 非同期でチャットルームを立ち上げる
			Task.Run (() => ChatRoom ("room name"));

			// TCPサーバを立ち上げる
			string ipString = "127.0.0.1";
			System.Net.IPAddress ipAdd = System.Net.IPAddress.Parse(ipString);
			//Listenするポート番号
			int port = 10021;

			//TcpListenerオブジェクトを作成する
			TcpListener server = new TcpListener(ipAdd, port);

			//Listenを開始する
			server.Start();
			Console.WriteLine("Listenを開始しました({0}:{1})。",
				((System.Net.IPEndPoint)server.LocalEndpoint).Address,
				((System.Net.IPEndPoint)server.LocalEndpoint).Port);

			// test
			Task.Run(()=>TestChat());


			while (true) {
				//接続要求があったら受け入れる
				TcpClient client = server.AcceptTcpClient ();

				//クライアントからのTCP接続は別スレッドに投げる
				Task.Run(() => ChatStream(client));

			}

			Console.WriteLine ("FINISH");
		}

		static void ChatRoom(string tag){
			Console.WriteLine ("Start Chat");
			Console.WriteLine ("Finish Chat");
		}

		static async Task ChatStream(TcpClient client){
			Console.WriteLine ("クライアント({0}:{1})と接続しました。",
				((IPEndPoint)client.Client.RemoteEndPoint).Address,
				((IPEndPoint)client.Client.RemoteEndPoint).Port);

			clientDict.Add (client, 0);

			//NetworkStreamを取得
			NetworkStream stream = client.GetStream ();
			StreamReader reader = new StreamReader (stream);


			//接続されている限り読み続ける
			while (client.Connected) {
				string line = await reader.ReadLineAsync () + '\n';
				Console.WriteLine ("Message:" + line);

				// bloadcastで接続しているclient全員に通知
				Task.Run(()=>Broadcast(line));
			}
			clientDict.Remove (client);
		}

		static async Task Broadcast(string message){
			if (System.String.IsNullOrEmpty(message)){
				return;
			}

			foreach (KeyValuePair<TcpClient, int> pair in clientDict) {
				if (pair.Key.Connected) {
					NetworkStream stream = pair.Key.GetStream ();
					await stream.WriteAsync (Encoding.ASCII.GetBytes(message), 0, message.Length);
					Console.WriteLine ("Send Done:" + message);
				}
			}
		}


		static async Task TestChat(){
			Task.Delay(1000);
			Console.WriteLine ("-Start TestChat");

			// 接続試験
			string ipOrHost = "127.0.0.1";
			int port = 10021;
			TcpClient client = new TcpClient(ipOrHost, port);
			var stream = client.GetStream();

			// 送信
			Thread.Sleep(1000);
			Encoding enc = Encoding.UTF8;
			byte[] sendBytes = enc.GetBytes("test message" + '\n');
			//データを送信する
			stream.Write(sendBytes, 0, sendBytes.Length);

			// 受信
			Console.WriteLine ("--Start Read");
			StreamReader reader = new StreamReader (stream);
			string line = await reader.ReadLineAsync ();
			Console.WriteLine ("-TestChat Message:" + line);
			Console.WriteLine ("-Finish TestChat");

			// 定期送信試験
			int count = 0;
			while (true) {
				sendBytes = enc.GetBytes("[Test]: message test" + count.ToString() + '\n');
				//データを送信する
				stream.Write(sendBytes, 0, sendBytes.Length);
				Thread.Sleep(5000);
				count++;
			}
		}
	}
}

```

# 開発を振り返って
Unity側実装のバグかと思って2時間くらい、あーだこーだ触ってあと、実はサーバ側のバグだったことが何度かありました。同時並行で開発するのは大変だし、生産性が悪かったです。