特徴
- ノンブロッキングI/O使って書いたサーバなので1台のサーバで数千人同時接続できる..はず(いわゆるC10k問題)
- TCP通信なので他の人の書き込みがすぐ反映される(サーバからのpush通知を受け取れる)
- Serverをmono使って書いたのでC#+.netのサーバがlinuxやmacで動く
WebAPIによるchatサーバ実装だと、1と2の要件が満たせません
Visual C#でserverを書くとWindowsでしかサーバが動かず、3の要件が満たせません。
動作画面
背景
C10K問題に対応したechoサーバをなんとか書けるようになってきたので、Unityと組み合わせてchat機能をクライアント(Unity C#)とサーバ(C#)で実装してみました。またUnityにはNetworkViewを使ったChatのチュートリアルがありますが、NetworkView自体の実装が激しくスマホ向けではないため作ってみました。
普通のchatをUnityで実装
まずネットワーク通信しない普通の一人用chatをUnityで実装してみた。ここのコードを参考にしました。
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]);
}
}
}
TCPサーバを書くコトハジメ
VisualC#かXamarinでchatサーバ用の新規ソリューションを作成して、monoでビルドして実行してみます。macだったのでXamarin使ってみました。
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サーバを書いていく
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コマンドで実行してみましょう
>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側のコード
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側のコード
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時間くらい、あーだこーだ触ってあと、実はサーバ側のバグだったことが何度かありました。同時並行で開発するのは大変だし、生産性が悪かったです。