【Unity】WebGLでHTTPでログを受け取るログビューワーを作る

# 概要
何かしらのログを送り続けて表示してくれるようなツールが欲しいと思い作りました
わざわざUnityで作るようなものでもないと思いつつ、モデルやロジックを使い回したかったので...

# 実装
## 通信部分
HTTPサーバー部分は以下の記事を参考に実装しています

https://qiita.com/fukusin/items/61247231e0d97841ac9b

ただし、WebGLではスレッドを使えないので少しだけ改造しています
(Unity6ではマルチスレッド対応済みなので、プロジェクトのバージョンを挙げられる方は上記の記事を参考にしてください！)

<details><summary>通信部分コード</summary>

```csharp:HttpListener.cs
using System;
using System.Collections;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Text;
using UnityEngine;
using UnityEngine.Events;

namespace HttpDataTool
{
    public class UnityHttpListener : MonoBehaviour
    {
        private HttpListener _listener;

        private const string Domain = "localhost";
        private const int Port = 8080;
        private const string ContentType = "application/json";

        private void Start()
        {
            _listener = new HttpListener();
            _listener.Prefixes.Add("http://" + Domain + ":" + Port + "/");
            _listener.AuthenticationSchemes = AuthenticationSchemes.Anonymous;
            _listener.Start();

            // Unity6以前はWebGLでマルチスレッドが使えないため、コルーチンで非同期処理を行う
            // (メインスレッドで処理される)
            StartCoroutine(StartListenerCoroutine());
            Debug.Log("Server Started");
        }

        private void OnDestroy()
        {
            _listener.Stop();
        }

        private IEnumerator StartListenerCoroutine()
        {
            while (_listener.IsListening)
            {
                var result = _listener.GetContextAsync();
                yield return new WaitUntil(() => result.IsCompleted);
                ListenerCallback(result.Result);
            }
        }

        private void ListenerCallback(HttpListenerContext context)
        {
            var request = context.Request;
            Debug.Log($"Method: {request.HttpMethod}, Url: {request.Url}");

            try
            {
                Action<HttpListenerRequest> process = context.Request.HttpMethod switch
                {
                    var s when s == HttpMethod.Get.Method => HttpMethodProcessor.Get,
                    var s when s == HttpMethod.Post.Method => HttpMethodProcessor.Post,
                    _ => throw new Exception("Method Not Allowed")
                };
                process.Invoke(request);

                // 何か情報を返すことを想定していないので、処理成功時は固定のレスポンスを返す
                context.Response.StatusCode = (int)HttpStatusCode.OK;
                context.Response.ContentType = ContentType;
                using var writer = new StreamWriter(context.Response.OutputStream, Encoding.UTF8);
                writer.Write(JsonUtility.ToJson(new { message = "Success" }));
            }
            catch (Exception e)
            {
                // 処理中の全例外はExceptionで処理する
                ReturnInternalError(context.Response, e);
            }
        }

        private static void ReturnInternalError(HttpListenerResponse response, Exception cause)
        {
            Debug.LogError(cause);
            response.StatusCode = (int)HttpStatusCode.InternalServerError;
            response.ContentType = ContentType;
            try
            {
                using (var writer = new StreamWriter(response.OutputStream, Encoding.UTF8))
                    writer.Write(JsonUtility.ToJson(cause));

                response.Close();
            }
            catch (Exception e)
            {
                Debug.LogError(e);
                response.Abort();
            }
        }
    }
}
```

```csharp:HttpMethodProcessor.cs
using System;
using System.Net;
using System.Text;
using UnityEngine;

namespace HttpDataTool
{
    public static class HttpMethodProcessor
    {
        public static void Get(HttpListenerRequest request)
        {
            // GETはヘルスチェックのみで使用
            if (request.Url.AbsolutePath != "/health")
                throw new Exception("Not Found");
        }

        public static void Post(HttpListenerRequest request)
        {
            switch (request.Url.AbsolutePath)
            {
                case "/api_log":
                    var body = request.RequestBody();
                    DataContainer.AddData(JsonUtility.FromJson<Data>(body));
                    break;
                default:
                    throw new Exception("Not Found");
            }
        }

        private static string RequestBody(this HttpListenerRequest request)
        {
            var body = request.InputStream;
            var bodyBytes = new byte[request.ContentLength64];
            body.Read(bodyBytes, 0, bodyBytes.Length);
            return Encoding.UTF8.GetString(bodyBytes);
        }
    }
}
```
</details>

## データ部分
今回はAPIを叩いた時のログを受け取る前提です
DataContainerは用途に応じて `RemoveAllData`とか`FilterData`とかを追加で実装しましょう

<details><summary>データ部分コード</summary>

```csharp:Data.cs
using System;

namespace HttpDataTool
{
    [Serializable]
    public class Data
    {

        public string endpoint;
        public string method;
        public int responseCode;
        public string response;
        public string request;
    }
}

```

```csharp:DataContainer.cs
using System.Collections.Generic;
using UnityEngine.Events;

namespace HttpDataTool
{
    public static class DataContainer
    {
        public static List<Data> DataList { get; } = new();
        public static readonly UnityEvent OnDataChanged = new();

        public static void AddData(Data data)
        {
            DataList.Add(data);
            OnDataChanged.Invoke();
        }
    }
}
```
</details>

## 表示部分
EnhancedScrollerを使っていますが、持っていない場合は軽量なリストビューを別途用意してください

https://assetstore.unity.com/packages/tools/gui/enhancedscroller-36378?locale=ja-JP&srsltid=AfmBOoofv90Jnm1tQ-HsffMtC1-_ECfnT0xbZvnfNn_MH7H8uZvVQC9d

UIToolを使う際には、ListView用に書き換える必要があります

<details><summary>表示部分コード</summary>

```csharp:ListView.cs
using EnhancedUI.EnhancedScroller;
using UnityEngine;

namespace HttpDataTool
{
    public class ListView : MonoBehaviour, IEnhancedScrollerDelegate
    {
        private EnhancedScroller _scroller;
        [SerializeField] private EnhancedScrollerCellView cellViewPrefab;
        [SerializeField] private DetailViewer detailViewer;

        private void Start()
        {
            _scroller = GetComponent<EnhancedScroller>();
            _scroller.Delegate = this;
            _scroller.ReloadData();

            detailViewer.SetData(null);

            DataContainer.OnDataChanged.AddListener(() => _scroller.ReloadData());
        }

        public int GetNumberOfCells(EnhancedScroller _)
            => DataContainer.DataList.Count;

        public float GetCellViewSize(EnhancedScroller _, int dataIndex)
            => 120;

        private void OnSelected(Data data)
            => detailViewer.SetData(data);

        public EnhancedScrollerCellView GetCellView(EnhancedScroller scroller, int dataIndex, int cellIndex)
        {
            var cellView = scroller.GetCellView(cellViewPrefab) as CellView;
            if (cellView == null) return cellView;

            cellView.SetData(DataContainer.DataList[dataIndex]);
            cellView.Selected = OnSelected;
            return cellView;
        }
    }
}
```

```csharp:CellView.cs
using EnhancedUI.EnhancedScroller;
using UnityEngine;
using TMPro;

namespace HttpDataTool
{
    public class CellView : EnhancedScrollerCellView
    {
        [SerializeField] private TMP_Text methodTypeText;
        [SerializeField] private TMP_Text endpointText;

        private Data _data;
        public delegate void SelectedDelegate(Data data);
        public SelectedDelegate Selected;

        public void SetData(Data data)
        {
            methodTypeText.text = data.method;
            endpointText.text = data.endpoint;

            _data = data;
        }

        public void OnSelected()
        {
            Selected?.Invoke(_data);
        }
    }
}
```

```csharp:DetailViewer.cs
using UnityEngine;
using TMPro;

namespace HttpDataTool
{
    public class DetailViewer : MonoBehaviour
    {
        [SerializeField] private TMP_Text endpointText;
        [SerializeField] private TMP_Text methodTypeText;
        [SerializeField] private TMP_Text responseCodeText;
        [SerializeField] private TMP_Text requestText;
        [SerializeField] private TMP_Text responseText;

        public void SetData(Data data)
        {
            if (data == null)
            {
                gameObject.SetActive(false);
                return;
            }

            gameObject.SetActive(true);

            endpointText.text = data.endpoint;
            methodTypeText.text = data.method;
            responseCodeText.text = data.responseCode.ToString();
            requestText.text = data.request;
            responseText.text = data.response;
        }
    }
}
```
</details>