RustとWebSocketでチャットルームを実現してみる


[PONOS Advent Calendar 2019](https://qiita.com/advent-calendar/2019/ponos)の22日目の記事です。

## はじめに
[PONOS Advent Calendar 2019](https://qiita.com/advent-calendar/2019/ponos)の17日目の記事で、RustとWebSocketにUnityで通信する記事を書かせていただきました。
その記事の最後に「ゲームオブジェクトのXYZ座標を共有できるようなサーバ側、クライアント側の実装をしていきたいと思います。」と記述しましたが、**WS-RSライブラリやC#のWebSocketの知識を深めるためにまずはチャットルームの実現とそれに接続するクライアントを作成**してみようかと考えました。

## 実装
RustでWebSocketを実現したサーバとチャットツールは将来Unityでクライアントを実装すると考えて、C#(.NET Core)で実装したチャットクライアントを実装していきます。実装環境としてはローカル(macOS)で実施することを前提しています。

|開発環境|バージョン|
|:-:|:-:|
|macOS|10.14.1|
|Rust(rustc、cargo)|1.39.0|
|WS-RS|0.9.1|
|C#|8.0|
|.NET Core|3.1.100|

### サーバ側の実装
サーバ側はRustを導入しなくてはいけませんが、導入する方法は[17日目の記事](https://qiita.com/nisei275/items/a60cb3d3e97aeb429fa7)で記載しておりますので、今回は省略します。

1. 必要なパッケージを導入する

    ```toml:Cargo.toml
    [dependencies]
    env_logger = "0.7.1"
    ws = "0.9.1"
    chrono = "0.4.10
    ```

1. WS-RSライブラリを使用したメッセージを配信する仕組みを実装する

    ```rust:main.rs
    extern crate ws;
    extern crate env_logger;

    use chrono::{Local, DateTime};
    use ws::{listen, CloseCode, Sender, Handler, Handshake, Message, Result};

    fn main() {
        // ロガーの初期化
        env_logger::init();

        // WebSocketの開始
        listen("127.0.0.1:3012", |out| {
            Server { 
                out: out,
                user_name: String::new()
            }
        }).unwrap();

        struct Server {
            out: Sender,
            user_name: String
        }

        impl Handler for Server {

            // WebSocketとのコネクション接続を開始した場合
            fn on_open(&mut self, handshake: Handshake) -> Result<()> {
                let hashed_key: String = handshake.request.hashed_key().unwrap();
                let headers: &Vec<(String, Vec<u8>)> = handshake.request.headers();

                // ヘッダーで送信されてきたユーザ名を取得する
                for (k, v) in headers {
                    if k == "User-Name" {
                        self.user_name = String::from_utf8(v.to_vec()).unwrap();
                    }
                }
            
                // ログイン情報を接続している全てのクライアントに配信する
                println!("[{}] {} Connected. hash_key: {}", str_datetime(), self.user_name, hashed_key);
                let send_message: String = format!("[{}] {} Join the Chat Room.", str_datetime(), self.user_name);
                return self.out.broadcast(send_message);
            }

            // メッセージを受信した場合
            fn on_message(&mut self, message: Message) -> Result<()> {
                // 受信したメッセージを接続している全てのクライアントに配信する
                let send_message: String = format!("[{}] {}: {}", str_datetime(), self.user_name, message);
                println!("{}", send_message);
                return self.out.broadcast(send_message);
            }

            // WebSocketとのコネクション接続が閉じた場合
            fn on_close(&mut self, code: CloseCode, reason: &str) {
                // ログイン情報を接続している全てのクライアントに配信する
                println!("[{}] {} Disconnected for ({:?}) {}", str_datetime(), self.user_name, code, reason);
                let send_message: String = format!("[{}] {} Left the Chat Room.", str_datetime(),self.user_name);
                let _ = self.out.broadcast(send_message);
            }
        }

        // 日付の文字列を取得
        fn str_datetime() -> String {
            // メッセージに日付を付与
            let local_datetime: DateTime<Local> = Local::now();
            let formatted_local_datetime: String = local_datetime.format("%Y-%m-%d %T").to_string();
            return formatted_local_datetime;
        }
    }
    ```

1. 実行

    ```
    $ cargo run
    ```
    ※ 実行した際に不足しているパッケージは自動でビルドされます。

### クライアント側の実装
クライアント側は今回Unityではなく、.NET Coreで実装しようかと考えました。Unity側はC#ですので、その言語でコンソールアプリを実装できるフレームワークを選択しました。

1. .NET Coreの導入
[Download .NET](https://dotnet.microsoft.com/download)にある「Download .NET Core SDK」を押下し、パッケージをダウンロードする。そのパッケージを展開しインストールをする。

1. 新規プロジェクトを作成

    ```
    $ dotnet new console -n ChatClient
    ```

1. チャットツールの実装

    ```
    $ cd ChatClient
    $ vi src/Program.cs
    ```

    ```csharp:Program.cs
    using System;
    using System.Text;
    using System.Collections.Generic;
    using System.Threading;
    using System.Threading.Tasks;
    using System.Net.WebSockets;

    namespace ChatClient
    {
        class Program
        {
            private const string clientName = "-- Chat Client for WebSocket --";
            private const string url = "ws://localhost:3012";
            private static List<string> messageList = new List<string>();
            private static ClientWebSocket webSocket = null;
            private static string userName = "";

            private static void Main(string[] args)
            {
                // 表示の初期化
                InitializeDisplay();
                if (String.IsNullOrWhiteSpace(userName)) {
                    Console.ForegroundColor = ConsoleColor.Red;
                    Console.WriteLine("Please tell me your name.");
                    Console.ResetColor();
                    return;
                }
                // WebSocketの接続(接続するまで待つ)
                Connect(url).Wait();

                // メッセージの受信する機構を非同期で実行
                ReceiveMessage();
                // メッセージを送信する機構を非同期で実行
                SendMessage();

                // メッセージを送信ループから抜けたらWebSocketの接続を閉じる
                if (webSocket != null) webSocket.Dispose();
                Console.ForegroundColor = ConsoleColor.Red;
                Console.WriteLine("WebSocket Client Closed.");
                Console.ResetColor();
            }


            // WebSocketの接続
            private static async Task Connect(string uri)
            {
                webSocket = new ClientWebSocket();
                // ヘッダーでユーザ名を送信しておく
                webSocket.Options.SetRequestHeader("User-Name", userName);
                await webSocket.ConnectAsync(new Uri(uri), CancellationToken.None);
            }

            // メッセージ送信
            private static async void SendMessage()
            {
                string inputMessage = "";

                // 接続中はループする
                while (webSocket.State == WebSocketState.Open) {
                    // メッセージの入力待ち
                    inputMessage = Console.ReadLine();
                    // 何も入力がない場合に終了
                    if (inputMessage == "") break;
                    // WebSocketにメッセージを送信する
                    ArraySegment<byte> buff = new ArraySegment<byte>(Encoding.UTF8.GetBytes(inputMessage));
                    await webSocket.SendAsync(buff, WebSocketMessageType.Text, true, CancellationToken.None);
                    // 表示の更新
                    UpdateDisplay();
                }
                return;
            }

            // メッセージ受信
            private static async void ReceiveMessage()
            {
                byte[] buff = new byte[1024];
                ArraySegment<byte> segment = new ArraySegment<byte>(buff);
                WebSocketReceiveResult receiveResult;

                // 接続中はループする
                while (webSocket.State == WebSocketState.Open) {
                    // 送られているメッセージを受信する
                    receiveResult = await webSocket.ReceiveAsync(segment, CancellationToken.None);
                    // メッセージがある場合は過去含めて再表示
                    if (receiveResult.Count != 0) {
                        messageList.Add(System.Text.Encoding.UTF8.GetString(buff, 0, receiveResult.Count));
                        UpdateDisplay();
                    }
                }
                return;
            }

            // 表示の初期化
            private static void InitializeDisplay()
            {
                Console.Clear();
                messageList = new List<string>();
                Console.WriteLine(clientName);
                string loginMessage = "May I ask your name?: ";
                Console.Write(loginMessage);
                userName = Console.ReadLine();
                messageList.Add(loginMessage + userName);
                Console.Write("Message: ");
            }

            // 表示の更新
            private static void UpdateDisplay()
            {
                Console.Clear();
                Console.WriteLine(clientName);
                foreach (string display in messageList) {
                    Console.WriteLine(display);
                }
                Console.Write("Message: ");
            }
        }
    }
    ```

1. 実行

    ```
    $ dotnet run
    ```

## 動作確認

![20191219_movie.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/191987/e2f56e41-619c-66d0-8e32-e7cd2272404e.gif)

上記で貼り付けている動画のように、2台のチャットクライアントがWebSocketを介して双方向通信をし、片方のクライアントがメッセージを送信したら他のクライアントに配信していることがわかりました。

## おわりに
[前回の記事](https://qiita.com/nisei275/items/a60cb3d3e97aeb429fa7)については、クライアント側はwebsocket-sharpのライブラリを使用しました。
ここから双方向通信の実装を実施していく上でクライアント側のWebSocket処理がいまいち理解することができませんでした。
そこでクライアント側はライブラリを使用せずにC#が用意しているSystem.Net.WebSocketsを使用して通信を行うクライアントの実装をしていこうかと考えました。

次回は、前回実装しようとしたUnityを複数台接続してゲームオブジェクトのXYZ座標を共有できるようなサーバ側、クライアント側の実装をしていきたいと考えております。
また、公式サイトのサンプルを主に参考にして実装していますが、チャットツールとして自分なりに作成したので間違った部分もあるとは思います。それについてはコメントでしていきしていただけると助かります。