Blazor WebAssenbly入門(チャットアプリを作ろう・2)

プログラミング C# Microsoft アドベントカレンダー2021

皆さん...17日目の次は18日目だと思いますよね...残念、時は遡り15日目となります。ちなみに16日目の内容は [NullReferenceException: オブジェクト参照がオブジェクト インスタンスに設定されていません。] DigicreBlog.Article.GetContent() in C:\Users\Shibaji\documents\visual studio 2008\Projects\DigicreCalifornia\BlogView.aspx.cs:56

さてさて、Blazor WebAssenblyのお話です。 前回UI周りの実装を行いました。 今回は通信周りを実装していきます。せっかくなのでBlazor WebAssenblyの利点であるロジックコードの共有ができるように通信周りのコードはUI周りに依存しないようにしっかりと分けていきます。

通信

今回、通信にはWebSocketを使用します。 WebSocketではテキストデータ、今回はJSONを送受信します。 JSONはJavaScriptでのオブジェクトをテキスト形式で記述できるようにしたもので現在ではJavaScriptでないシステムでも広く利用されています。 C#でJsonを扱うものとして昔からサードパーティ製のJsonライブラリJson.NETが利用されていましたが、最近のC#環境ではSystem.Text.Jsonが用意されているのでこちらを利用します。Unityなどの新しいC#が利用出来ない環境では引き続きJson.NETの方が使いやすかったりします。

System.Text.Jsonの利用法

JavaScriptではJSONはデシリアライズすることでJavaScriptのObjectとして利用できますが、C#ではそのようなものはありません。その為C#ではクラスにてJSONの形式を指定しデシリアライズでインスタンスを生成します。

Jsonのデシリアライズ

Json文字列を任意のクラスのインスタンスに変換する例です。(C#10の文法を利用しています)

using System.Text.Json;

string jsonText = "{\"name\":\"太郎\",\"age\":22}";
var taro = JsonSerializer.Deserialize<Hoge>(jsonText);
Console.WriteLine($"{taro.name}は{taro.age}歳です");

class Hoge
{
    public string name { get; set; }
    public int age { get; set; }
    public string? school { get; set; }
}

JsonSerializer.Deserialize<任意のクラス>(JSONのテキスト);で任意のクラスのインスタンスを得られます。 <>はC#のジェネリック機能です。ここでは詳しく解説しません。詳しくはこちら

Jsonのシリアライズ

先ほどよりも楽です。 コード読めば分かるでしょう。

using System.Text.Json;

Hoge hoge = new() { name = "Mogami", age = 22, school = "芝浦工業大学" };
var jsonText = JsonSerializer.Serialize(hoge);
Console.WriteLine(jsonText);

class Hoge
{
    public string name { get; set; }
    public int age { get; set; }
    public string? school { get; set; }
}

実行結果は

{"name":"Mogami","age":22,"school":"\u829D\u6D66\u5DE5\u696D\u5927\u5B66"}

となります。芝浦工業大学がUTF-8表記になっていますが問題ありません。 では少しここからは実験です。 まず、hogeインスタンスからschoolを抜いてみます。

Hoge hoge = new() { name = "Mogami", age = 22 };

実行結果は

{"name":"Mogami","age":22,"school":null}

まあ、想像できますね。 次はageも消してみます。

Hoge hoge = new() { name = "Mogami" };

実行結果

{"name":"Mogami","age":0,"school":null}

おっと、ちょっと以外な挙動しちゃいましたね... これはC#のNull許容がageにされていなかったからですね。 なので扱うJsonでもしかしたら入れないかもしれない、必須ではない項目はNull許容で書きましょう。

ちなみにNullな項目をテキストに載せたくない場合はJsonSerializerOptionsを作成しシリアライズの際にオプションを指定します。

using System.Text.Json;
using System.Text.Json.Serialization;

Hoge hoge = new() { name = "Mogami", age = 22 };
JsonSerializerOptions options = new()
{
    DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
var jsonText = JsonSerializer.Serialize(hoge, options);
Console.WriteLine(jsonText);

class Hoge
{
    public string name { get; set; }
    public int age { get; set; }
    public string? school { get; set; }
}

こちらの実行結果は以下のようになります。

{"name":"Mogami","age":22}

しっかりとnullなschoolが記載されていないことが分かります。 じゃあ全部null許容にしよう!とするとnullチェックが必要なところが増えますしね。しっかりと使いどころを考えて実装しましょう。 nullを甘くみると一番上みたいにぬるぽします

Json用のクラスを作成する

ChatMessage.csを作成します。VisualStudioの人はソリューションエクスプローラーのWebChatAppを右クリックして追加、新しい項目、クラスで名前をChatMessage.csにします。 今回、サーバーとの通信はシンプルでメッセージとメッセージの発信者の名前のみにします。しっかりとDBでユーザー管理しているサーバーを用意しているならメッセージの発信者のUserIDにするべきですが、今回はサーバーはWebSocket通信しかしない簡素なものになっています。 ChatMessage.csの中身を以下のようにします。

public class ChatMessage
{
    public string message { get; set; }
    public string? fromName { get; set; }
}

今回fromNameは送信時には指定しないつもり(サーバー側が判断)なのでnullの可能性があります。そのためNull許容にします。

通信クラスを作成する

先ほどと同じ手順でConnection.csを作成します。 とりあえず以下のコードを写して下さい。

using System;
using System.Net;
using System.Net.Http;
using System.Net.WebSockets;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using System.Text;
public class Connection
{
    private bool testMode;
    private string baseUri, roomName, userName;
    CancellationTokenSource tokenSource = new();
    ClientWebSocket webSocket = new();

    public Connection(bool testMode, string roomName, string baseUri, string userName)
    {
        this.testMode = testMode;
        this.roomName = roomName;
        this.baseUri = baseUri;
        this.userName = userName;
        StartWebSocket();
    }
    private async void StartWebSocket()
    {
        if (webSocket.State == WebSocketState.Open) return;
        if (testMode) baseUri = "http://localhost:8000";
        Uri uri = new($"{baseUri}/{roomName}?name={userName}");
        await webSocket.ConnectAsync(uri, tokenSource.Token);
        _ = ReceiveLoop();
    }
    private async Task ReceiveLoop()
    {
        var buffer = new ArraySegment<byte>(new byte[1024]);
        while (!tokenSource.IsCancellationRequested)
        {
            var received = await webSocket.ReceiveAsync(buffer, tokenSource.Token);
            var jsonText = Encoding.UTF8.GetString(buffer.Array, 0, received.Count);
            var chatMsg = JsonSerializer.Deserialize<ChatMessage>(jsonText);
            //chatMsgをフロントに送る処理

        }
    }
}

コンストラクタでは通信に必要なデータを受け取っています。 StartWebSocketメソッドではコンストラクタから引き続き非同期なコードを実行しています。 ここでWebSocketが開始されています。WebSocketの接続先はサーバーのURI/部屋名?name=ユーザー名としています。 サーバー側のコードを見た方は分かると思いますが、1人でも接続するとサーバー側のコードでRoomインスタンスが立てられ、Userがリスト格納されます。Room自体もリストなので複数Roomを同時に扱えます。 ReceiveLoopではWebSocket通信が終わるまで無限ループで受信処理をしています。 ここにはまだ受け取ったデータをフロント側に渡す処理を書いていません。 ReceiveLoopメソッドはTaskなのですが、実行時に別に必要ないので_(破棄指定の変数)とします。

デリゲート活用でフロント側にデータを送る

さらに追加でchatMsgをフロント側に送る機構とChatMessageをサーバーに送信するpublicなメソッドを実装します。 メンバー変数に以下を追加します。

Action<ChatMessage> onChatMessage;

また、これをコンストラクタで受け取れるように修正します。

public Connection(bool testMode, string roomName, string baseUri, string userName, Action<ChatMessage> onChatMessage)
{
    this.testMode = testMode;
    this.roomName = roomName;
    this.baseUri = baseUri;
    this.userName = userName;
    this.onChatMessage = onChatMessage;
    StartWebSocket();
}

Actionはメソッドを変数のように扱えるようにするものです。(デリゲート) 試しの例です。

Hoge hoge = new() { show = Show};
hoge.RunShow();

void Show(string text)
{
    Console.WriteLine(text);
}
class Hoge
{
    public Action<string> show { get; set; }
    public void RunShow()
    {
        show("Hello");
    }
}

hogeインスタンスのshow変数にShowメソッドを入れ、RunShowから呼び出しています。 Actionとして使用したい引数はAction<引数1の型,引数2の型...>のように指定ができます。 戻り値は必要な場合はFunc`が利用出来ます。 詳しくはこちら

話しは実装に戻ります。 onChatMessageを用意したのでReceiveLoop内に実装します。

private async Task ReceiveLoop()
{
    var buffer = new ArraySegment<byte>(new byte[1024]);
    while (!tokenSource.IsCancellationRequested)
    {
        var received = await webSocket.ReceiveAsync(buffer, tokenSource.Token);
        var jsonText = Encoding.UTF8.GetString(buffer.Array, 0, received.Count);
        var chatMsg = JsonSerializer.Deserialize<ChatMessage>(jsonText);
        //chatMsgをフロントに送る処理
        onChatMessage(chatMsg!);
    }
}

chatMsgはnullである可能性があるのでとりあえず!でエラーを抑制しておきます。余力があれば後でエラー用のActionも作りNullチェックや通信途絶時はエラー表示がでるようにしましょう。 今回はデリゲートを用いましたが、イベントハンドラーで実装することも可能です。 bolideではイベントハンドラーで実装していますので気になるひとはこちら(GitHub)も見てみましょう。

サーバーに送るメソッドを作る

public async void PostMessage(ChatMessage chatMsg)
{
    JsonSerializerOptions options = new()
    {
        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
    };
    var jsonText = JsonSerializer.Serialize(chatMsg, options);
    var content = new ArraySegment<byte>(Encoding.UTF8.GetBytes(jsonText));
    await webSocket.SendAsync(content,WebSocketMessageType.Text,true,tokenSource.Token);
} 

これで通信関連の処理は全て完成です。

お疲れ様でした。あとはフロントエンド側のコードと紐付けるだけです。 また、今回作成したコードはBlazorの機能は一切利用していないのでWindowsFormやXamarinアプリなど別プラットフォームへもすぐ移植できます。