【Blazor入門】Blazor初心者がログインからチャット機能まで付けてデプロイしてみた ～その2～

##前回
[【Blazor入門】Blazor初心者がログインからチャット機能まで付けてデプロイしてみた ～その1～](https://qiita.com/OXamarin/items/08a829d0ea77e2554231)
の続きです。

前回までは、ログイン画面をそれとなく作って、チャットページに遷移するまで作りました。
今回は実際にチャットができる所までやってみたいと思います。

##チャット画面の作成
そこまで難しくないので頑張っていきましょー

###Chat.razor Chat.razor.csの作成
新たに、`Chat.razorファイル`・`Char.razor.csファイル`を作りましょう。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/172223/2688d2eb-e661-e287-4049-85c27b6f4b80.png)

```HTML:Chat.razor
@page "/Chat"

<h1>チャット</h1>

<div align="center">
    <div class="form-group">
        <input @bind="_messageInput" size="50"/>
        <button class="btn btn-primary" @onclick="SendAsync">送信</button>
    </div>

    <hr>

    <div align="left">
        @foreach (var message in _messages)
        {
            @*HTMLのタグをそのまま出力する為にMarkUpString型にキャストする*@
            @((MarkupString)message)<br>
        }
    </div>
</div>
```

```C#:Chat.razor.cs
using System.Collections.Generic;

namespace LoginTest.Pages
{
    public partial class Chat
    {
        #region フィールド
        private List<string> _messages = new List<string>();
        private string _messageInput;
        #endregion

        #region メソッド
        /// <summary>
        /// 送信ボタン押下時に発火
        /// </summary>
        public void SendAsync()
        {
            _messages.Insert(0, _messageInput);
        }
        #endregion
    }
}
```

これを実行してみると、ブラウザ間の通信がうまくいきません。
それもそのはず、`SignalR`が未実装だからです。
![Counter.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/172223/fd49f045-1d97-71b0-a334-aef5ee598330.gif)

###SignalR
`SignalR`とは何ぞや、というのは以下の記事が参考になると思います。
[ASP.NET SignalRを知る (1/5)](https://www.atmarkit.co.jp/ait/articles/1303/19/news099.html)

簡単に言えば、サーバーとクライアント間の非同期通信を簡単に実装できるフレームワークです。
`SignalR`を使用した典型的な例としてチャットが挙げられるので、今回はチャットを作成します。

###SignalRを実装してみる
`.NET MVC`でも`SignalR`を実装した事がありますが、当然(?)ながら当時は`JavaScript`を使用してクライアントへの画面描画を行っていました。

しかし`Blazor`ではクライアント処理も`フルC#`で書くことが出来る為煩わしさが若干軽減されます。

`Nuget`から`Microsoft.AspNetCore.SignalR.Client`をインストールします。
(`Microsoft.AspNetCore.SignalR.Client.Core`ではなく。)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/172223/261b9230-e0f4-3c56-c717-f45ab2aeb15a.png)

あとは、`Chat.razor.cs`を以下のように書き換えてください。

```C#:Chat.razor.cs
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using System.Web;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.SignalR.Client;

namespace LoginTest.Pages
{
    public partial class Chat : ComponentBase, IDisposable
    {
        #region フィールド
        private List<string> _messages = new List<string>();
        private string _messageInput;
        private HubConnection _hubConnection;
        #endregion

        #region プロパティ
        [Inject]
        public NavigationManager Navigation { get; set; }
        public bool IsConnected => _hubConnection.State == HubConnectionState.Connected;
        private async Task SendAsync()
        {
            await _hubConnection.SendAsync("SendMessageClientsAll", _messageInput);
            _messageInput = string.Empty;
        }
        #endregion

        #region メソッド
        /// <summary>
        /// 
        /// </summary>
        /// <returns></returns>
        protected override async Task OnInitializedAsync()
        {
            _hubConnection = new HubConnectionBuilder()
                .WithUrl(Navigation.ToAbsoluteUri("/chathub")) //startupのMapHubで指定したHubのURLを指定する.
                .WithAutomaticReconnect(new RandomRetryPolicy()) //自動接続
                .Build();

            _hubConnection.On<string>("ReceiveMessage", (message) =>
            {
                if (string.IsNullOrEmpty(message)) return;

                //入力文字列の中にURLが存在するかどうかを判定だけする
                var urlPattern = new Regex(@"(https?|ftp)(:\/\/[-_.!~*\'()a-zA-Z0-9;\/?:\@&=+\$,%#]+)");
                var urlPatternMatch = urlPattern.Match(message);

                //入力文字列をサニタイズする
                message = HttpUtility.HtmlEncode(message);

                //入力文字列の中にURLが存在する場合はアンカータグに変換する
                if (urlPatternMatch.Success)
                {
                    message = message.Replace(urlPatternMatch.Value, $"<a href=\"{urlPatternMatch}\">{urlPatternMatch}</a>");
                }

                _messages.Insert(0, message);
                StateHasChanged();
            });

            _hubConnection.Reconnected += async connectionId =>
            {
                await _hubConnection.StartAsync();
            };

            //画面の更新を行う
            await _hubConnection.StartAsync();
        }
        /// <summary>
        /// 
        /// </summary>
        public void Dispose()
        {
            _ = _hubConnection.DisposeAsync();
        }
        #endregion

        #region クラス
        public class RandomRetryPolicy : IRetryPolicy
        {
            private readonly Random _random = new Random();

            public TimeSpan? NextRetryDelay(RetryContext retryContext)
            {
                //2～5秒の間でランダムに再接続を試みる
                return TimeSpan.FromSeconds(_random.Next(2, 5));
            }
        }
        #endregion
    }
}
```

コンストラクタに書いても良かったんですが、`OnInitializedAsyncメソッド`を使用する為に`ComponentBaseクラス`を継承しています。(`razorファイル`内でのコンストラクタってどうやって書くんだろう。。。)

ハブの追加は以下のページに詳細に書いてあるので、この手順に沿って追加すればおｋ。
[ASP.NET Core SignalR を Blazor WebAssembly と共に使用する](https://docs.microsoft.com/ja-jp/aspnet/core/tutorials/signalr-blazor-webassembly?view=aspnetcore-3.1&tabs=visual-studio)

ソリューションに`Hubsフォルダ`と`ChatHub.cs`を追加。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/172223/26b58305-b295-4826-b871-a777cf122eb2.png)

メッセージを受け取ったら、全クライアントにメッセージを流す処理を追加する。

```C#:ChatHub.cs
using System.Threading.Tasks;
using Microsoft.AspNetCore.SignalR;

namespace LoginTest.Hubs
{
    public class ChatHub : Hub
    {
        public async Task SendMessageClientsAll(string message)
        {
            await Clients.All.SendAsync("ReceiveMessage", message);
        }
    }
}
```
全部載せる必要もないと思いますが、上記の`URL`通りに`Startup.cs`を以下の様に`SignalR`に対応させる記述を書きます。

```C#:Startup.cs
using System.Linq;
using LoginTest.Hubs;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.ResponseCompression;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

namespace LoginTest
{
    public class Startup
    {
        public Startup(IConfiguration configuration)
        {
            Configuration = configuration;
        }

        public IConfiguration Configuration { get; }

        public void ConfigureServices(IServiceCollection services)
        {
            services.AddSignalR();
            services.AddRazorPages();
            services.AddServerSideBlazor();
            services.AddResponseCompression(opts =>
            {
                opts.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(
                    new[] { "application/octet-stream" });
            });
        }

        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            app.UseResponseCompression();

            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }
            else
            {
                app.UseExceptionHandler("/Error");
                app.UseHsts();
            }

            app.UseHttpsRedirection();
            app.UseStaticFiles();
            app.UseRouting();
            app.UseEndpoints(endpoints =>
            {
                endpoints.MapBlazorHub();
                endpoints.MapHub<ChatHub>("/chathub");
                endpoints.MapFallbackToPage("/_Host");
            });
        }
    }
}
```
これで`SignalR`の実装ができました。実際に動作を見てみましょう。
![Counter.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/172223/37c457d9-e461-fa70-f353-939ffba6ba51.gif)


####SignalRの動作について
どういう順序で処理が発生しているのかを追ってみましょう。

①まず最初に、[送信]ボタンを押す所から処理がスタートするので、`Chat.razor`を見ます。

```HTML:Chat.razor
<button class="btn btn-primary" @onclick="SendAsync">送信</button>
```

②送信ボタン押下時に発火するのは`SendAsyncメソッド`であり、この処理が紐づいているのはコードビハインドとなります。

```C#:Chat.razor.cs
private async Task SendAsync()
{
    await _hubConnection.SendAsync("SendMessageClientsAll", _messageInput);
    _messageInput = string.Empty;
}
```

③`SendAsyncメソッド`では定義した`ChatHub`の`SendMessageClientsAllメソッド`を呼んでいます。

```C#:ChatHub.cs
public async Task SendMessageClientsAll(string message)
{
    await Clients.All.SendAsync("ReceiveMessage", message);
}
```
④`SendMessageClientsAllメソッド`内で`ReceiveMessage`が呼ばれます。これは`Chat.razor.cs`の`OnInitializedAsyncメソッド`でハブを定義した際に作った処理です。

```C#:Chat.razor.cs
_hubConnection.On<string>("ReceiveMessage", (message) =>
{
    if (string.IsNullOrEmpty(message)) return;

    //入力文字列の中にURLが存在するかどうかを判定だけする
    var urlPattern = new Regex(@"(https?|ftp)(:\/\/[-_.!~*\'()a-zA-Z0-9;\/?:\@&=+\$,%#]+)");
    var urlPatternMatch = urlPattern.Match(message);

    //入力文字列をサニタイズする
    message = HttpUtility.HtmlEncode(message);

    //入力文字列の中にURLが存在する場合はアンカータグに変換する
    if (urlPatternMatch.Success)
    {
        message = message.Replace(urlPatternMatch.Value, $"<a href=\"{urlPatternMatch}\">{urlPatternMatch}</a>");
    }

    _messages.Insert(0, message);
    StateHasChanged();
});
```

⑤送信時に入力されているメッセージが`_messages`に追加され最後に`StateHasChangedメソッド`を呼ぶ事で`Chat.razor`の処理を通って画面に反映されます。

```HTML:Chat.razor
<div align="left">
    @foreach (var message in _messages)
    {
        @*HTMLのタグをそのまま出力する為にMarkUpString型にキャストする*@
        @((MarkupString)message)<br>
    }
</div>
```

###Enter押下時にメッセージを送信する方法
今の記述だと、メッセージを送信する為に毎回ボタンを押す必要があり、少し手間です。
そこで`Enter押下時`にもメッセージが送信されるように修正してみましょう。

```HTML:Chat.razor
<div class="form-group">
    @*<input @bind="_messageInput" size="50"/>*@
    <input @bind="_messageInput" @bind:event="oninput" size="50" @onkeydown="KeyDownAsync"/>
    <button class="btn btn-primary" @onclick="SendAsync">送信</button>
</div>
```

修正方法としては簡単で、`@bind:event="oninput"`と`@onkeydown="KeyDownAsync"`を付けてください。

`@onkeydown="KeyDownAsync"`はわかりやすいと思いますが、割り付けている項目に対してキーダウンイベントを発生させるようにします。

`@bind:event`にはデフォルトでは`@bind:event="onchange"`が割付いており、これはフォーカスロストした場合に発火します。つまり文字を入力して次にエンターを押下しても、項目に対してフォーカスが入ったままなのでプロパティに反映されず`_messageInput`には`null`が入っている事になります。

これを避ける為にも、`@bind:event="oninput"`とすることにより入力文字が変化する度にプロパティへの値の反映をさせることができます。

あとは`KeyDownAsyncメソッド`を追加して、`Enter押下時`にのみ`SendAsyncメソッド`を呼び出すようにしてあげれば完了です。

```C#:Chat.razor.cs
private async Task KeyDownAsync(KeyboardEventArgs e)
{
    if (e.Key == "Enter")
    {
        await SendAsync();
    }
}
```
###分からなかった点
もし分かる方がいらっしゃれば教えて頂きたいです。
`SignalR`の自動再接続についてです。

以下の様に`WithAutomaticReconnectメソッド`を噛ましており、`RandomRetryPolicyクラス`を引数として渡しているのでサーバーが落ちた場合に2～5秒おきに無限回の再接続要求がされると考えていました。

次のような想定
1. デバッグでサーバー側を起動
2. 同じURLを指定し、別のタブでチャットを起動
3. デバッグを終了
4. デバッグを再開
5. 2で開いたタブが2~5秒おきに再接続を試みており復帰

```C#:Chat.razor.cs
_hubConnection = new HubConnectionBuilder()
    .WithUrl(Navigation.ToAbsoluteUri("/chathub"))
    .WithAutomaticReconnect(new RandomRetryPolicy())
    .Build();

_hubConnection.Reconnected += async connectionId =>
{
    await _hubConnection.StartAsync();
};

public class RandomRetryPolicy : IRetryPolicy
{
    private readonly Random _random = new Random();

    public TimeSpan? NextRetryDelay(RetryContext retryContext)
    {
        //2～5秒の間でランダムに再接続を試みる
        return TimeSpan.FromSeconds(_random.Next(2, 5));
    }
}
```

しかし実際には、サーバー側が復帰してもクライアント側は自動接続されず、リロードが求められてしまいます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/172223/d7ec8e8d-4ec5-96ce-0582-7b9185a33224.png)

`Reconnectedイベント`に`StartAsyncメソッド`を実装しているのでリロードを必要とせず復帰するかと思いきやそんなことはなかった。何か思い違いをしているのでしょうか。。。

###まとめ
`SignalR`を使った場合でも`Blazor`であれば`フルC#`で書けることが分かりました。

`Enter押下時`の処理なども`JavaScript`で`if(keycode == '13'){}`などのような記述をしていたりしましたが、`@onkeydown`と書くことにより処理を`C#`に任せられるというのはかなりよいですね。

次回は正しいユーザーでログインした場合にログインを許可するような実装を行います。

**参考にさせて頂いたページ**

- [ASP.NET SignalRを知る (1/5)](https://www.atmarkit.co.jp/ait/articles/1303/19/news099.html)
- [ASP.NET Core SignalR を Blazor WebAssembly と共に使用する](https://docs.microsoft.com/ja-jp/aspnet/core/tutorials/signalr-blazor-webassembly?view=aspnetcore-3.1&tabs=visual-studio)
- [ASP.NET Core SignalR .Net クライアント](https://docs.microsoft.com/ja-jp/aspnet/core/signalr/dotnet-client?view=aspnetcore-3.1&tabs=visual-studio)
- [ASP.NET Core Blazor データ バインディング](https://docs.microsoft.com/ja-jp/aspnet/core/blazor/components/data-binding?view=aspnetcore-3.1)
- [BlazorアプリケーションでHTMLタグを出力する](https://www.ipentec.com/document/csharp-blazor-application-output-html-tag)
- [SignalRの自動再接続をもう少しくわしく.](https://shuhelohelo.hatenablog.com/entry/2019/10/19/124307)