- ASP.NET Server にホストされた Blazor Webassembly PWA。
- Azure Active Directory で認証
- SignalR 実装
- サンプルチャット実装
- サンプル株価ボード実装
Azure Active Directory の登録
「Azure Active Directory を使用して、ASP.NET Core Blazor WebAssembly スタンドアロン アプリをセキュリティで保護する を参考に登録。
コマンドラインでプロジェクト作成
作成したいフォルダで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();