UnityでマルチスレッドなHTTPサーバー

Unity と Webアプリ間で データ連携したかったので、Unity で HTTPサーバーを作ってみました。後々、Unityで簡易な WebAPI を作りたいので、再利用しやすい方法をまとめています。

# やりたいこと
・Unityで HTTPサーバーを作成
・利用するライブラリは System.Net.HttpListener
・マルチスレッド処理で描画負荷の影響を抑える
・ Get / Post リクエストを処理
・再利用したいので、サーバー処理とリクエスト処理のコンポーネントを分離
・UnityEvent を使って、インスペクタでイベントを管理
・通信テストは、Postman で行う

#System.Net.HttpListenerとは
HTTP 要求に応答する単純な HTTP プロトコルリスナーを作成できます。
.NET 標準クラスなので、Unityでも標準で使うことができます。
[MS-DOC HttpListener クラス概要](https://docs.microsoft.com/ja-jp/dotnet/api/system.net.httplistener?view=netframework-4.8)

#マルチスレッド処理の準備
Unity は描画負荷が高いので、安定させるために System.Net.HttpListener を別スレッドで実行します。

ただし、リクエスト内容によってはメインスレッドで描画を行う必要があります。Unity ではスレッドをまたいだ関数の実行はできないので、[UnityMainThreadDispatcher](https://github.com/PimDeWitte/UnityMainThreadDispatcher) を使って、メインスレッドのアクションを呼び出せるようにします。

下記からダウンロードし、Assetsに配置します。
[UnityMainThreadDispatcher - GitHub](https://github.com/PimDeWitte/UnityMainThreadDispatcher)



# HTTPサーバーのコンポーネントを作る

以下のようなスクリプトを書きます。
#### HTTPサーバーのコード

``` UnityHttpListener.cs
using System;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Threading;
using UnityEngine;
using UnityEngine.Events;

public class UnityHttpListener : MonoBehaviour
{
    private HttpListener listener;
    private Thread listenerThread;

    public string domain = "localhost";
    public int port = 8080;

    [System.Serializable]
    public class OnGetRequestEvent : UnityEvent<HttpListenerContext> { }
    public OnGetRequestEvent OnGetRequest;

    [System.Serializable]
    public class OnPostRequestEvent : UnityEvent<HttpListenerContext> { }
    public OnPostRequestEvent OnPostRequest;

    void Start()
    {
        listener = new HttpListener();
        listener.Prefixes.Add("http://" + domain + ":" + port + "/");
        listener.AuthenticationSchemes = AuthenticationSchemes.Anonymous;
        listener.Start();

        listenerThread = new Thread(startListener);
        listenerThread.Start();
        Debug.Log("Server Started");
    }

    private void OnDestroy()
    {
        listener.Stop();
        listenerThread.Join();
    }

    private void startListener()
    {
        while (listener.IsListening)
        {
            var result = listener.BeginGetContext(ListenerCallback, listener);
            result.AsyncWaitHandle.WaitOne();
        }
    }

    private void ListenerCallback(IAsyncResult result)
    {
        if (!listener.IsListening) return;
        HttpListenerContext context = listener.EndGetContext(result);
        Debug.Log("Method: " + context.Request.HttpMethod);
        Debug.Log("LocalUrl: " + context.Request.Url.LocalPath);

        try
        {
            if (ProcessGetRequest(context)) return;
            if (ProcessPostRequest(context)) return;
        }
        catch (Exception e)
        {
            ReturnInternalError(context.Response, e);
        }
    }

    private bool CanAccept(HttpMethod expected, string requested)
    {
        return string.Equals(expected.Method, requested, StringComparison.CurrentCultureIgnoreCase);
    }

    private bool ProcessGetRequest(HttpListenerContext context)
    {
        if (!CanAccept(HttpMethod.Get, context.Request.HttpMethod) || context.Request.IsWebSocketRequest)
            return false;
        //メインスレッドでGetリクエストイベントを呼び出し
        UnityMainThreadDispatcher.Instance().Enqueue(() => OnGetRequest.Invoke(context));
        return true;
    }

    private bool ProcessPostRequest(HttpListenerContext context)
    {
        if (!CanAccept(HttpMethod.Post, context.Request.HttpMethod))
            return false;
        //メインスレッドでPostリクエストイベントを呼び出し
        UnityMainThreadDispatcher.Instance().Enqueue(() => OnPostRequest.Invoke(context));
        return true;
    }

    private void ReturnInternalError(HttpListenerResponse response, Exception cause)
    {
        Console.Error.WriteLine(cause);
        response.StatusCode = (int) HttpStatusCode.InternalServerError;
        response.ContentType = "text/plain";
        try
        {
            using(var writer = new StreamWriter(response.OutputStream, Encoding.UTF8))
            writer.Write(cause.ToString());
            response.Close();
        }
        catch (Exception e)
        {
            Console.Error.WriteLine(e);
            response.Abort();
        }
    }
}
```

Get / Post のリクエストがあれば、UnityMainThreadDispatcher を使って、メインスレッドでそれぞれのUnityEventを呼び出しています。

#### リクエスト処理のコード
``` RequestHandler.cs
using System;
using System.Net;
using UnityEngine;

public class RequestHandler : MonoBehaviour
{
    public MyData data;

    private void Start()
    {
        data = new MyData();
    }

    public void OnGetRequest(HttpListenerContext context)
    {
        var request = context.Request;
        var response = context.Response;
        response.StatusCode = (int)HttpStatusCode.OK;
        response.ContentType = "application/json";

        string message = "";
        if (request.QueryString.AllKeys.Length > 0)
        {
            foreach (var key in request.QueryString.AllKeys)
            {
                object value = request.QueryString.GetValues(key)[0];
                Debug.Log("key: " + key + " , value: " + value);
                switch (key)
                {
                    case "GetData":
                        message = JsonUtility.ToJson(data);
                        break;
                    case "SetData":
                        data.success = Convert.ToBoolean(value);
                        message = JsonUtility.ToJson(data);
                        break;
                }
            }
        }
        // message の内容をバイト配列に変換してレスポンスを返す
        var bytes = System.Text.Encoding.UTF8.GetBytes(message);
        response.Close(bytes, false);
    }

    public void OnPostRequest(HttpListenerContext context)
    {
        var request = context.Request;
        var response = context.Response;
        response.StatusCode = (int)HttpStatusCode.OK;
        response.ContentType = "application/json";

        string message = "";
        if (request.QueryString.AllKeys.Length > 0)
        {
            foreach (var key in request.QueryString.AllKeys)
            {
                object value = request.QueryString.GetValues(key)[0];
                Debug.Log("key: " + key + " , value: " + value);
                switch (key)
                {
                    case "GetData":
                        message = JsonUtility.ToJson(data);
                        break;
                    case "SetData":
                        data.success = Convert.ToBoolean(value);
                        message = JsonUtility.ToJson(data);
                        break;
                }
            }
        }
        // message の内容をバイト配列に変換してレスポンスを返す
        var bytes = System.Text.Encoding.UTF8.GetBytes(message);
        response.Close(bytes, false);
    }
}

[System.Serializable]
public class MyData
{
    public bool success = false;
}
```
ここでは、UnityEvent を受けて、リクエストに対応したレスポンスを返しています。

- リクエストキー が `GetData` の場合： Unity 側のデータを返信
- リクエストキー が `SetData` の場合： Unity 側のデータをリクエスト値に変更 -> 変更後のデータを返信

レスポンスの ContentType はひとまず Json にしました。
データを保持する MyData クラスを用意して、JsonUtilityでJsonに変換しています。
通信テストができればいいので、 Get / Post どちらも同じ内容です。

# コンポーネントをアタッチする
空の GameObject を作り、準備していた UnityMainThreadDispatcher と 先ほど作った UnityHttpListener、RequestHandlerをアタッチします。
<img width="654" alt="スクリーンショット 2020-01-24 17.33.23.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/535406/e90d7eab-8c91-c023-32c4-a087afce21ea.png">

次にイベントをアタッチしていきます。
インスペクタの `OnGetRequest` と `OnPostRequest` 下部の `+` ボタンからイベントを追加し、それぞれに`Request Handler (Script)`コンポーネントを貼り付けます。
<img width="317" alt="スクリーンショット 2020-01-24 17.57.26.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/535406/63673d34-ce6c-1e81-ffb2-73010d50e246.png">

プルダウンメニューから `OnGetRequest` と `OnPostRequest` それぞれの呼び出す関数を設定します。
<img width="482" alt="スクリーンショット 2020-01-24 17.57.59.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/535406/5110ce2b-53bb-b845-1af1-0761df332fc8.png">

こんな感じになれば、完成
<img width="655" alt="スクリーンショット 2020-01-24 18.03.43.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/535406/a147c965-ddd0-da22-dae9-ae513f9791a9.png">

通信テストのために、Unity を再生しておきます。

# 通信テストしてみる

Postman を使って、通信テストをします。
[Postman Download](https://www.getpostman.com/downloads/)

インストールの手順や使い方はこちら
[Postmanを使ったAPIテストのやり方 - IT業務で使えるプログラミングテクニック](https://kekaku.addisteria.com/wp/20180606063858) 

##### Postman で Get テスト

メソッドを`Get`にして、`http://localhost:8080/?GetData=`を送ってみます。
<img width="922" alt="スクリーンショット 2020-01-24 18.47.20.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/535406/042f2cf4-8743-8311-fa57-d4b0b31b0af4.png">


Unity で作った MyData が Json で返ってきてました。
<img width="630" alt="スクリーンショット 2020-01-24 18.45.14.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/535406/9cbb77af-67dd-2357-d52c-8c195f8a574a.png">

次は、`http://localhost:8080/?SetData=true`を送ってみます。
<img width="919" alt="スクリーンショット 2020-01-24 18.47.41.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/535406/56bec69b-b19a-5490-2462-794dbf119770.png">

ちゃんと `success` が `true` に変わりました。

メソッドを Post にすれば、Postのテストが出来ます。

# まとめ
Unityだけで Post/Get リクエストを処理出来るのは、とても楽ですね。プロトタイプでは充分使えます。

今回はローカルでテストしましたが、[ngrok](https://ngrok.com/) や[localtunnel](https://github.com/localtunnel/localtunnel) を使って外部公開すれば、Unity でも 簡易な WebAPI が作れそうです。

# 参考にしたサイト
[UnityでHTTPリクエストを処理してみる -Qiita](https://qiita.com/niusounds/items/69bb1a32d04d2959c96f)
[C#でHTTPSサーバ(Ver. HttpListener) -Qiita](https://qiita.com/washikawau/items/bfcd8babcffab30e6d26)