1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

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

Azure Active Directory の登録

「Azure Active Directory を使用して、ASP.NET Core Blazor WebAssembly スタンドアロン アプリをセキュリティで保護する を参考に登録。

Azure Active Directory 管理画面

コマンドラインでプロジェクト作成

作成したいフォルダで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  5行追加
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 をインストール

株価クラス作成

※ SignalRで送受信するオブジェクトにはプロパティ{get;set;}を使わないとうまくいかない(未確認)

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();

参考

1
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?