ReactとASP.NET CoreでWebSocket通信

ASP.NET Core
プログラミング

概要

サーバー側で蓄積するログデータをある程度リアルタイムにブラウザに表示したかったので、WebSocket通信を使って、サーバーから通知した方法です。

環境

ASP.NET Core
React
Typescript

実装方針

サーバー側でlogファイルへの書き込み内容を、クライアント(ブラウザ)へ通知するプログラム。
最終的にはgifのような動きになります。

実装

サーバー実装

まずASP.NET CoreのWebApplicationBuilderにWebSocketを使用するよう行を追加します。

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllersWithViews();

var app = builder.Build();

if (!app.Environment.IsDevelopment())
    app.UseHsts();

app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();

// 追加
app.UseWebSockets();

app.MapControllerRoute(
    name: "default",
    pattern: "{controller}/{action=Index}/{id?}");

app.MapFallbackToFile("index.html");;

app.Run();

次にWebSocketの接続要求の入り口になるControllerを追加します。

using Microsoft.AspNetCore.Mvc;

namespace websocket_react_aspdotnetcore.Controllers;

[ApiController]
public class LogController : ControllerBase
{
    [Route("/ws/TrailLog")]
    public async Task TrailLog(string fileName)
    {
        if (!HttpContext.WebSockets.IsWebSocketRequest)
            return;

        using var webSocket = await HttpContext.WebSockets.AcceptWebSocketAsync();
    }
}

ここで重要なのはTrailLog関数にRoute属性がついていることです。
通常のWebApiのようにPostやGetではないので注意が必要です。

次にログファイルのStreamのReadとWebSocketへの書き込み処理を追加していきますが、その前にWebSocketクラスに拡張メソッドを追加して、処理をシンプルにしていきます。

WebSocketExtensionクラスでは、WebSocket通信をJsonでやり取りするための拡張メソッドを定義しました。

using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
namespace websocket_react_aspdotnetcore;

public class WebSocketData<T>
{
    public bool IsClosed { get; set; }
    public T? Data {  get; set; }
}

public static class WebSocketExtension
{
    public static async Task SendAsync<T>(this WebSocket self, T obj)
    {
        await self.SendAsync(
            new ArraySegment<byte>(Encoding.ASCII.GetBytes(JsonSerializer.Serialize(obj))),
            WebSocketMessageType.Text,
            true,
            CancellationToken.None);
    }

    public static async Task<WebSocketData<T>> ReadAsync<T>(this WebSocket self) where T : class
    {
        var buffer = new byte[1024];
        var receivedResult = await self.ReceiveAsync(new ArraySegment<byte>(buffer), CancellationToken.None);
        if (receivedResult.Count == 0)
            return new WebSocketData<T>
            {
                // WebSocketではデータのやり取りと同様に、切断の通知がくるため
                // receivedResult.CloseStatusで切断が通知されたかチェックする
                IsClosed = receivedResult.CloseStatus.HasValue,
                Data = null
            };

        var read = buffer.Take(receivedResult.Count);
        var ascii = Encoding.ASCII.GetString(read.ToArray());

        var res = JsonSerializer.Deserialize<T>(ascii);
        return new WebSocketData<T>
        {
            IsClosed = receivedResult.CloseStatus.HasValue,
            Data = res
        };
    }
}

上記で用意した拡張メソッドを使って、ログファイルのStreamのReadとWebSocketへの書き込み処理をControllerに追加します。

using Microsoft.AspNetCore.Mvc;

namespace websocket_react_aspdotnetcore.Controllers;

[ApiController]
[Route("[controller]")]
public class LogController : ControllerBase
{
    [Route("/ws/TrailLog")]
    public async Task TrailLog(string fileName)
    {
        if (!HttpContext.WebSockets.IsWebSocketRequest)
            return;

        using var webSocket = await HttpContext.WebSockets.AcceptWebSocketAsync();

        // ログファイルのStream取得
        using var logStream = new FileStream(fileName, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
        using var logStreamReader = new StreamReader(logStream);

        bool open = true;
        try
        {
            // 接続後は非同期的にログファイルの内容をWebSocketに書き込み続ける
            _ = Task.Run(async () =>
            {
                while (open)
                {
                    var line = await logStreamReader.ReadLineAsync();
                    if (string.IsNullOrEmpty(line))
                    {
                        // Streamが最終行になったら1秒待機してから読み込みを再開する
                        Thread.Sleep(1000);
                        continue;
                    }

                    
                    await webSocket.SendAsync(new LogResponse()
                    {
                        Log = line + "\n"
                    });
                }
            });

            // クライアントからの要求を処理する
            while (true)
            {
                var request = await webSocket.ReadAsync<LogRequest>();
                if (request.IsClosed || request.Data == null)
                    break;

                if (request.Data.LogCommand == LogCommand.Ping)
                    continue;
            }
        }
        finally
        {
            open = true;
        }
    }
}

public enum LogCommand
{
    Ping = 1,
}
public class LogRequest
{
    public LogCommand LogCommand { get; set; }
}

public class LogResponse
{
    public string Log { get; set; }
}

デフォルトでは2分間の無通信時にタイムアウトするため、クライアントからのPingコマンドを受信できるようにしてあります。

クライアント実装

クライアント側は表示時にWebSocket通信を開始して、受信したメッセージを画面に表示していきます。タイムアウトを防ぐため、30秒に一度コマンドを送信する処理も入れました。

import { useEffect, useRef, useState } from 'react';
import './custom.css';

export default function App() {
  const socketRef = useRef<WebSocket | null>();
  const [log, setLog] = useState("");

  const createWebSocket = (fileName: string) => {
    const webSocket = new WebSocket(`ws://localhost:5123/ws/TrailLog?fileName=${fileName}`)
    socketRef.current = webSocket

    // サーバーからの通知に対するコールバックの設定
    webSocket.addEventListener("message", event => {
      const response = JSON.parse(event.data);
      setLog(log => log + response.Log);
    })
  }

  const closeSocket = () => {
    socketRef.current?.close()
    socketRef.current = null;
  }

  const sendLogCommand = ((command: number) => {
    socketRef.current?.send(JSON.stringify({
      LogCommand: command
    }))
  })
  
  useEffect(() => {
    // 30秒に一度Ping
    const intervalId = setInterval(() => {
      sendLogCommand(1)
    }, 30000)

    // ソケット通信の開始
    createWebSocket("log.txt")

    // 画面遷移時の破棄処理
    return () => {
      clearInterval(intervalId);
      closeSocket();
    }
  }, [])
  return (
    <pre>{log}</pre>
  );
}

確認

プログラムを起動して、プロジェクトルートにlog.txtファイルを追加します。
log.txtに書き込んだ内容が、ブラウザに表示されれば完了です。

最後に

主題はWebSocketなので、ログファイルを定期チェックさせていますが、FileSystemWatcherクラスなどで監視させれば、もう少しリアルタイム性が上がりますね。

ソースファイル
https://github.com/skspwork/websocket-react-aspdotnetcore

参考サイト
https://learn.microsoft.com/ja-jp/aspnet/core/fundamentals/websockets?view=aspnetcore-8.0
https://qiita.com/_ytori/items/a92d69760e8e8a2047ac

タイトルとURLをコピーしました