ASP Hosted .NET 7 Blazor Webassembly PWA  AAD認証 SignalR チャット 株価ボード 備忘録

- ASP.NET Server にホストされた Blazor Webassembly PWA。
- Azure Active Directory で認証
- SignalR 実装
- サンプルチャット実装
- サンプル株価ボード実装

# Azure Active Directory の登録
[「Azure Active Directory を使用して、ASP.NET Core Blazor WebAssembly スタンドアロン アプリをセキュリティで保護する](https://learn.microsoft.com/ja-jp/aspnet/core/blazor/security/webassembly/standalone-with-azure-active-directory?view=aspnetcore-7.0」) を参考に登録。

[Azure Active Directory 管理画面](https://portal.azure.com/#view/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/~/RegisteredApps)

# コマンドラインでプロジェクト作成
作成したいフォルダでDOS窓を実行
```.bat
dotnet new blazorwasm --hosted -au SingleOrg --client-id "{CLIENT ID}" -o {APP NAME} --tenant-id "{TENANT ID}" --pwa
```

# 全ページ認証が必要に設定
AAD認証を利用する
```Client//program.cs
：
builder.Services.AddMsalAuthentication(options =>
{
    options.ProviderOptions.DefaultAccessTokenScopes.Add("https://graph.microsoft.com/User.Read");
    builder.Configuration.Bind("AzureAd", options.ProviderOptions.Authentication);
});
：
```
全ページ認証
```Client//_Imports.razor
:
// 全ページ認証
@using Microsoft.AspNetCore.Authorization
@attribute [Authorize]
```
```Client//Pages/Authentication.razor
@page "/authentication/{action}"
@using Microsoft.AspNetCore.Components.WebAssembly.Authentication
@attribute [AllowAnonymous] ← この行追加
:
```
未承認者に無駄な案内をなくす
```Client//App.razor
:
            <AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">
                <NotAuthorized>
                    <RedirectToLogin />  ← ここの前後の行を削除
                </NotAuthorized>
：
        <NotFound>
            <AuthorizeView>
                <NotAuthorized>
                    <RedirectToLogin />
                </NotAuthorized>
                <Authorized>
                    <PageTitle>Not found</PageTitle>
                    <LayoutView Layout="@typeof(MainLayout)">
                        <p role="alert">Sorry, there's nothing at this address.</p>
                    </LayoutView>
                </Authorized>
            </AuthorizeView>
        </NotFound>
:
```
```Server//Controllers/WeatherForecastController.cs
[Authorize]    2行削除
[RequiredScope(RequiredScopesConfigurationKey = "AzureAd:Scopes")]
```

# SignalR チャット
NuGetで
クライアント `Microsoft.AspNetCore.SignalR.Client`
サーバー `Microsoft.AspNetCore.SignalR.Common` 
をインストール。


サーバーサイドにHubsフォルダ作成して
ScalperHUB.cs 作成。
```Server//Hubs/ChatHUB.cs
using Microsoft.AspNetCore.SignalR;

namespace MyApp.Server.Hubs
{
    public class ChatHub : Hub
    {
        public async Task SendMessage(string user, string message)
        {
            await Clients.All.SendAsync("ReceiveMessage", user, message);
        }
    }
}
```
サーバーサイド `programs.cs` にサービス追加
```Server//programs.cs
using MyApp.Server.Hubs;   先頭に追加
:
// SignalR  ５行追加
builder.Services.AddSignalR();
builder.Services.AddResponseCompression(opts => {   // 送信データ圧縮ミドルウェア
    opts.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(
        new[] { "application/octet-stream" });
});

var app = builder.Build();

app.UseResponseCompression();   // SignalR 追加
:
:
app.MapHub<ChatHUB>("/chathub");     // SignalR 追加
app.MapFallbackToFile("index.html");

app.Run();
```
クライアントIndex.razorにチャット用の Razor コンポーネント コードを丸ごと置き換え。
```Client//Pages/Index.razor
@page "/"
@using Microsoft.AspNetCore.SignalR.Client
@inject NavigationManager Navigation
@implements IAsyncDisposable

<PageTitle>Index</PageTitle>

<div class="form-group">
    <label>
        User:
        <input @bind="userInput" />
    </label>
</div>
<div class="form-group">
    <label>
        Message:
        <input @bind="messageInput" size="50" />
    </label>
</div>
<button @onclick="Send" disabled="@(!IsConnected)">Send</button>

<hr>

<ul id="messagesList">
    @foreach (var message in messages)
    {
        <li>@message</li>
    }
</ul>

@code {
    private HubConnection? hubConnection;
    private List<string> messages = new List<string>();
    private string? userInput;
    private string? messageInput;

    protected override async Task OnInitializedAsync()
    {
        hubConnection = new HubConnectionBuilder()
            .WithUrl(Navigation.ToAbsoluteUri("/chathub"))
            .Build();

        hubConnection.On<string, string>("ReceiveMessage", (user, message) =>
        {
            var encodedMsg = $"{user}: {message}";
            messages.Add(encodedMsg);
            StateHasChanged();
        });

        await hubConnection.StartAsync();
    }

    private async Task Send()
    {
        if (hubConnection is not null)
            {
                await hubConnection.SendAsync("SendMessage", userInput, messageInput);
            }
    }

    public bool IsConnected =>
        hubConnection?.State == HubConnectionState.Connected;

    public async ValueTask DisposeAsync()
    {
        if (hubConnection is not null)
        {
            await hubConnection.DisposeAsync();
        }
    }
}
```

# 株価ボード
クライアント `SyncFusion.Blazor.Grid` をインストール

#### 株価クラス作成
<font color="red">*※ SignalRで送受信するオブジェクトにはプロパティ`{get;set;}`を使わないとうまくいかない（未確認）*</font>
```Shard//Stock.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace MyApp.Shared;

public class Stock
{
    private decimal _price;

    public string Symbol { get; set; }

    public decimal Price
    {
        get {
            return _price;
        }
        set {
            if (_price == value)
            {
                return;
            }

            _price = value;

            if (DayOpen == 0)
            {
                DayOpen = _price;
            }
        }
    }

    public decimal DayOpen { get; set; }

    public decimal Change
    {
        get {
            return Price - DayOpen;
        }
    }

    public double PercentChange
    {
        get {
            return (double)Math.Round(Change / Price, 4);
        }
    }
}

```
#### 疑似株価発生クラス作成
```Server//Stocks/StockTiker.cs
using System.Collections.Concurrent;
using System.Diagnostics;
using Microsoft.AspNetCore.SignalR;
using Microsoft.AspNetCore.SignalR.Client;
using MyApp.Server.Hubs;
using MyApp.Shared;

namespace MyApp.Server.Stocks;

public class StockTicker
{
    private readonly IHubContext<StockTickerHub> _hubContext;
    private readonly ConcurrentDictionary<string, Stock> _stocks;
    private readonly double _rangePercent;
    private readonly TimeSpan _updateInterval;
    private readonly Timer _timer;
    private volatile bool _updatingStockPrices;

    // コンストラクタ
    public StockTicker(IHubContext<StockTickerHub> hubContext)
    {
        // SignalR コンテキストを DI により取得して設定
        _hubContext = hubContext;

        // 株価情報を保持する
        _stocks = new ConcurrentDictionary<string, Stock>();

        // 株価情報の初期値を設定
        InitializeStockPrices(_stocks);

        // 株価は下の TryUpdateStockPrice メソッドでランダムに
        // 変更されるが、その上限・下限を 2% に設定している
        _rangePercent = .002;

        // Timer を使って UpdateStockPrices メソッドを呼ぶ間隔
        _updateInterval = TimeSpan.FromMilliseconds(100);

#nullable disable
        // _updateInterval (250 ミリ秒間隔) で UpdateStockPrices
        // メソッドが呼ばれるよう Timer を設定
        _timer = new Timer(UpdateStockPrices,
                           null,
                           _updateInterval,
                           _updateInterval);
#nullable enable

        // 株価情報が更新中であることを示すフラグ
        _updatingStockPrices = false;
    }

    private void _timer_Elapsed(object? sender, System.Timers.ElapsedEventArgs e)
    {
        UpdateStockPrices(new object());

    }

    // 株価情報の初期値を設定するヘルパメソッド
    private void InitializeStockPrices(
        ConcurrentDictionary<string, Stock> stocks)
    {
        var init = new List<Stock>
        {
                new Stock { Symbol = "MSFT", Price = 30.31m },
                new Stock { Symbol = "APPL", Price = 578.18m },
                new Stock { Symbol = "GOOG", Price = 570.30m }
            };
        stocks.Clear();
        // bool TryAdd (TKey key, TValue value) メソッドで
        // キー/値ペアを追加する。成功すると true を返す。
        // キーが既に存在する場合は false を返す
        init.ForEach(init => _stocks.TryAdd(init.Symbol, init));
    }

    // ConcurrentDictionary<string, Stock> のインスタンス　
    // から Value プロパティで IEnumerable<Stock> を取得
    public IEnumerable<Stock> GetAllStocks()
    {
        return _stocks.Values;
    }
    public async Task<IEnumerable<Stock>> GetAllStocksAsync()
    {
        return _stocks.Values;
    }

    private readonly object _updateStockPricesLock = new object();

    // Timer を使って 250ms 毎に以下のメソッドが呼ばれる。
    // 株価に変更があった場合は BroadcastStockPrice メソッ
    // ドで株価情報をクライアントへ送信する
    private void UpdateStockPrices(object state)
    {
        lock (_updateStockPricesLock)
        {
            if (!_updatingStockPrices)
            {
                _updatingStockPrices = true;

                // _stocks.Values は IEnumerable<Stock>
                foreach (var stock in _stocks.Values)
                {
                    if (TryUpdateStockPrice(stock))
                    {
                        // 株価に変更があった場合は株価
                        // 情報をクライアントへ送信
                        BroadcastStockPrice(stock);
                    }
                }

                _updatingStockPrices = false;
            }
        }
    }

    private readonly Random _updateOrNotRandom = new Random();

    private bool TryUpdateStockPrice(Stock stock)
    {
        // 0.0 以上 1.0 未満のランダム値
        var r = _updateOrNotRandom.NextDouble();
        if (r > .1)
        {
            return false;
        }

        // 株価をランダムに変更する
        var random = new Random((int)Math.Floor(stock.Price));
        var percentChange = random.NextDouble() * _rangePercent;
        var pos = random.NextDouble() > .51;
        var change = Math.Round(stock.Price * (decimal)percentChange, 2);
        change = pos ? change : -change;

        stock.Price += change;
        return true;
    }

    // 株価情報をクライアントへ送信する
    private void BroadcastStockPrice(Stock stock)
    {
        _hubContext.Clients.All.SendAsync("UpdateStockPrice", stock);
        Debug.Print($"{stock.Symbol}:{stock.Price}");
    }
}

```

#### サーバーサイドに HUB 作成
```Server//Hubs/StockTickerHub.cs
using Microsoft.AspNetCore.SignalR;
using MyApp.Server.Stocks;
using MyApp.Shared;

namespace MyApp.Server.Hubs;

public class StockTickerHub : Hub
{
    private readonly StockTicker _stockTicker;

    public StockTickerHub(StockTicker stockTicker)
    {
        // Broadcaster インスタンスを DI により取得して設定。
        // Program.cs で AddSingleton メソッドを使ってシング
        // ルトンになるようにしている
        _stockTicker = stockTicker;
    }

    // StockTicker.GetAllStock メソッドは株価情報を
    // IEnumerable<Stock> として返す
    public IEnumerable<Stock> GetAllStocks()
    {
        return _stockTicker.GetAllStocks();
    }
    public async Task<IEnumerable<Stock>> GetAllStocksAsync()
    {
        return await _stockTicker.GetAllStocksAsync();
    }
}

```


#### クライアントに株価ボード作成
```Client//Pages/StockTicker.razor@.html
@page "/stockticker"
@using Microsoft.AspNetCore.SignalR.Client
@using Syncfusion.Blazor.Grids
@using MyApp.Shared
@inject NavigationManager NavigationManager
@implements IAsyncDisposable

<h1>ASP.NET SignalR Stock Ticker Sample</h1>

<h2>Live Stock Table</h2>

<div>
    <SfGrid DataSource="@stockList" AllowPaging="true" >
        <GridEvents QueryCellInfo="CellInfoHandler" TValue="Stock"></GridEvents> 
        <GridColumns>
            <GridColumn Field=@nameof(Stock.Symbol) HeaderText="銘柄" TextAlign="TextAlign.Center"  Width="70" ></GridColumn>
            <GridColumn Field=@nameof(Stock.DayOpen) HeaderText="始値" Type="ColumnType.Number" TextAlign="TextAlign.Right"  Width="70"></GridColumn>
            <GridColumn Field=@nameof(Stock.Price) HeaderText="価格" Type="ColumnType.Number" TextAlign="TextAlign.Right"  Width="70"></GridColumn>
            <GridColumn Field=@nameof(Stock.Change) HeaderText="増減"  Type="ColumnType.Number" Format="▲0.00;▼0.00" TextAlign="TextAlign.Right"  Width="70"></GridColumn>                   
            <GridColumn Field=@nameof(Stock.PercentChange) HeaderText="増減率" Type="ColumnType.Number" Format="p2" TextAlign="TextAlign.Right" Width="70"></GridColumn>
        </GridColumns>
    </SfGrid>
</div>

<style> 
    .negative-color{ 
        color: red; 
    } 
    .positive-color{ 
        color: lime; 
    } 
    .e-grid .e-spinner-pane{ /** Grid スピナーを非表示*/
        display:none; 
    } 
</style> 

@code {
    private HubConnection? connection;
    private List<Stock> stockList = new List<Stock>();
    private Dictionary<string, Stock> stocks = new Dictionary<string, Stock>();

    protected override async Task OnInitializedAsync()
    {
        // 接続を作成。"/stockTickerHub" は Program.cs で
        // app.MapHub<StockTickerHub>("/stockTickerHub");
        // としてマップしたエンドポイントらしい
        connection = new HubConnectionBuilder()
            .WithUrl(NavigationManager.ToAbsoluteUri("/stockTickerHub"))
            .Build();

        // StockTicker クラスの BroadcastStockPrice メソッドの 
        // SendAsync("UpdateStockPrice", stock); で SignalR コ
        // ンテキストを通じて下の On<Stock>() { ... } が起
        // 動される。引数の stock に含まれる情報により株価情報
        // の表示を更新する。
        connection.On<Stock>("UpdateStockPrice", (stock) =>
        {
            if (stocks.ContainsKey(stock.Symbol))
            {
                stocks[stock.Symbol] = stock;
            } else {
                stocks.Add(stock.Symbol, stock);
            }
            Console.WriteLine($"{stock.Symbol}:{stocks[stock.Symbol].Price}:{stocks[stock.Symbol].PercentChange}%");
            stockList = stocks.Select(x => x.Value).ToList();
            //InvokeAsync(StateHasChanged);
            StateHasChanged();
        });

        await connection.StartAsync();
    }

    // セル変更イベント、マイナスなら赤にする
    private void CellInfoHandler(QueryCellInfoEventArgs<Stock> e)
    {
        bool? isPositive = e.Column.Field == nameof(Stock.Change)        ? 0 <= e.Data.Change
                         : e.Column.Field == nameof(Stock.PercentChange) ? 0 <= e.Data.PercentChange : null;

        e.Cell.AddStyle(new string[] { isPositive == null  ? ""
                                     : isPositive == true  ? "color:lime"
                                     : isPositive == false ? "color:red" : "" });
    }

    public async ValueTask DisposeAsync()
    {
        if (connection is not null)
        {
            await connection.DisposeAsync();
        }
    }
}
```


#### ナビに「株価ボード」メニュー追加 及び SyncFusion
```Client//Shared/NavNenu.razor@.html
・・・
        <div class="nav-item px-3">
            <NavLink class="nav-link" href="stockticker">
                <span class="oi oi-list-rich" aria-hidden="true"></span> 株価ボード
            </NavLink>
        </div>
    </nav>
</div>
```

#### SyncFusion の Script と css を追加
````Client//Pages/_Layout.csthml@.html
・・・
    <link id="theme" href="_content/Syncfusion.Blazor.Themes/bootstrap4.css" rel="stylesheet" />
    <script src="_content/Syncfusion.Blazor.Core/scripts/syncfusion-blazor.min.js" type="text/javascript"></script>
</head>
・・・
````


#### サーバーサイドにサービスの追加
```Server//Programs.cs
// 追加
using MyApp.Server.Stocks;
using MyAppServer.Hubs;

・・・

// StockTickerHub でコンストラクタ経由 DI によりシングル
// トンインスタンスを取得できるよう以下の設定を行う
builder.Services.AddSingleton<StockTicker>();

var app = builder.Build();

・・・

app.MapHub<ChatHUB>("/chathub");     // SignalR 追加
app.MapHub<StockTickerHub>("/stockTickerHub"); // 株価ボード
app.MapFallbackToFile("index.html");

app.Run();
```


#### クライアントサイドにSyncFusion
```Client//Programs.cs
:
// SyncFusion Service を追加
Syncfusion.Licensing.SyncfusionLicenseProvider.RegisterLicense("EZTcXVHc1JqR3BkUkhYQUxDUfErZHZlU3JON2JjPQ==");
builder.Services.AddSyncfusionBlazor();

await builder.Build().RunAsync();
```


#### 参考
- [ASP.NET Core SignalR を Blazor と共に使用する チャットアプリ](https://learn.microsoft.com/ja-jp/aspnet/core/blazor/tutorials/signalr-blazor?view=aspnetcore-7.0&tabs=visual-studio&pivots=webassembly#create-a-hosted-blazor-webassembly-app)
- [Azure Active Directory を使用して、ASP.NET Core Blazor WebAssembly スタンドアロン アプリをセキュリティで保護する](https://learn.microsoft.com/ja-jp/aspnet/core/blazor/security/webassembly/standalone-with-azure-active-directory?view=aspnetcore-7.0」)
- [アプリ全体での承認を要求する](https://learn.microsoft.com/ja-jp/aspnet/core/blazor/security/webassembly/?view=aspnetcore-7.0#require-authorization-for-the-entire-app)
- [WebSurfer's Home 株価ボード](http://surferonwww.info/BlogEngine/post/2021/12/29/aspnet-core-signalr.aspx)
- [無料で使える「Syncfusion」を導入する手順](https://blazor-master.com/blazor-syncfusion/)