LoginSignup
40
36

More than 5 years have passed since last update.

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

Last updated at Posted at 2015-10-06

特徴

  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

背景

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]);
        }
    }
}

■実行画面
スクリーンショット 2015-10-04 19.28.43.png

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サーバを書いていく

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
>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側のコード

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側のコード

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時間くらい、あーだこーだ触ってあと、実はサーバ側のバグだったことが何度かありました。同時並行で開発するのは大変だし、生産性が悪かったです。

40
36
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
40
36