はじめに
.NET Aspireの登場により,.NET製の分散アプリのローカルデバッグがより簡単に行うことが可能になりました.しかし,分散アプリの開発補助ツールである.NET Aspireと,分散アプリのフレームワークであるMicrosoft Orleansとの統合に関する記事が,現状公式ドキュメント以外でほとんど存在しません.
本記事では.NET AspireとMicrosoft Orleansを使用したチャットアプリの構築とローカルデバッグまでをやっていきます.
環境
使用環境
- .NET 9
- Visual Studio 2022 ver 17.14.8
- Docker Desktop ver 4.43.2
使用技術
フレームワーク
- Microsoft Orleans
- ASP.NET Core
- Blazor WebApp
ツール
- .NET Aspire
ソリューション構成
「.NET Aspire 空のアプリ」テンプレートをベースに,ソリューションにプロジェクトを追加していきます.プロジェクト名はOrleansTestingという名前を使っていきます.
.AppHost および .ServiceDefaults
.NET Aspireのプロジェクトを作成すると追加されるプロジェクト..AppHost
にリソースの構成を記述し,.ServiceDefaults
に全プロジェクト共通の処理を記述することができます.
.Client
チャットアプリのフロントエンドを担うプロジェクト.Blazor WebAppのプロジェクトテンプレートを使用しています.
.Server
Microsoft Orleansのサーバーサイドのコードを記述するプロジェクト.ASP.NET Core Web APIプロジェクトテンプレートを使用しています.
.Contracts
Orleansで取り扱うやり取りのインターフェイスやデータ型を定義するプロジェクト..Clientと.Serverで共有して使用するため,.Clientと.Serverの2つのプロジェクトからプロジェクト参照しておきます..NETクラスライブラリプロジェクトテンプレートを使用しています.
最終的なソリューション構成
AppHostの構成
パッケージインストール
.AppHost
プロジェクトにNugetパッケージマネージャーから,以下のパッケージをインストールします.
- Aspire.Hosting.Azure.Storage
- Aspire.Hosting.Orleans
Program.csの編集
Azure Storageをクラスタリング及びグレインストレージとして使用しています.今回はローカルデバッグが目標なので,.RunAsEmulator()
でエミュレータとして起動させています.エミュレータはDockerのコンテナとして立ち上がるので,デバッグの際はDocker Desktopの起動を忘れないようにしましょう.
サーバーやクライアントプロジェクトでは,ストレージの立ち上げが完了するまで起動を待機させています.これは,クラスタリングテーブルやグレインストレージが立ち上がる前に参照しようとして,エラー落ちしてしまう場合があります.ストレージ起動後にAspireのダッシュボードから再起動させることができるため,無くても動きます(実際公式ドキュメントには記述が無い).ただ,いちいち再起動させるのは面倒なので待機させています.
また,WithReplicas()
を用いて,サーバーとクライアントで3つずつのインスタンスを立ち上げるよう構成しています.これにより,複数のクライアントと複数のサイロが存在する環境をローカルで再現することができます.
var builder = DistributedApplication.CreateBuilder(args);
// Azure Storageの設定
var storage = builder.AddAzureStorage("storage").RunAsEmulator();
var clusteringTable = storage.AddTables("clustering"); // クラスタリング用のテーブルストレージ
var grainStorage = storage.AddBlobs("grain-state"); // グレインの状態を保存するためのBlobストレージ
// Orleansの設定
var orleans = builder.AddOrleans("default")
.WithClustering(clusteringTable)
.WithGrainStorage("Default", grainStorage);
// Orleansサーバープロジェクトを追加
builder.AddProject<Projects.OrleansTesting_Server>("silo")
.WithReference(orleans)
.WaitFor(clusteringTable) // クラスタリングテーブルが立ち上がるまで待機
.WaitFor(grainStorage) // グレインストレージが立ち上がるまで待機
.WithReplicas(3);
// クライアントプロジェクトを追加
builder.AddProject<Projects.OrleansTesting_Client>("frontend")
.WithReference(orleans.AsClient())
.WaitFor(clusteringTable) // クラスタリングテーブルが立ち上がるまで待機
.WithExternalHttpEndpoints()
.WithReplicas(3);
builder.Build().Run();
OpenTelemetryの有効化
公式ドキュメントにならって,OrleansのOpenTelemetryの有効化を行います.
.ServiceDefaults
プロジェクトのExtensions.cs
を編集します.
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
using OpenTelemetry;
using OpenTelemetry.Metrics;
using OpenTelemetry.Trace;
namespace Microsoft.Extensions.Hosting;
// Adds common .NET Aspire services: service discovery, resilience, health checks, and OpenTelemetry.
// This project should be referenced by each service project in your solution.
// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults
public static class Extensions
{
// 省略
public static TBuilder ConfigureOpenTelemetry<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
{
builder.Logging.AddOpenTelemetry(logging =>
{
logging.IncludeFormattedMessage = true;
logging.IncludeScopes = true;
});
builder.Services.AddOpenTelemetry()
.WithMetrics(metrics =>
{
metrics.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddRuntimeInstrumentation()
+ .AddMeter("Microsoft.Orleans");
})
.WithTracing(tracing =>
{
tracing.AddSource(builder.Environment.ApplicationName)
.AddAspNetCoreInstrumentation()
// Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package)
//.AddGrpcClientInstrumentation()
.AddHttpClientInstrumentation();
+ tracing.AddSource("Microsoft.Orleans.Runtime");
+ tracing.AddSource("Microsoft.Orleans.Application");
});
builder.AddOpenTelemetryExporters();
return builder;
}
// 省略
}
データ型とグレインインターフェイスの定義
パッケージインストール
.Contracts
プロジェクトにNugetパッケージマネージャーから以下のパッケージをインストールします.
- Microsoft.Orleans.Core.Abstractions
データ型定義
やり取りするメッセージとチャットルームの状態を表すデータ型を定義します.
Orleansで取り扱うデータ型にはシリアル化を行わないといけないため,[GenerateSerializer]属性や[Id()]属性が必須となります.また,Orleansが取り扱うクラスやメソッドを識別するための[Alias()]属性も追加しています.これは後述のグレインオブザーバーやグレインインターフェイスにも追加しています.
using Orleans;
namespace OrleansTesting.Contracts.Models;
[GenerateSerializer]
[Alias("OrleansTesting.Contracts.Models.ChatMessage")]
public class ChatMessage
{
[Id(0)]
public string UserName { get; set; }
[Id(1)]
public string Message { get; set; }
[Id(2)]
public DateTime Timestamp { get; set; }
public ChatMessage(string userName, string message)
{
UserName = userName;
Message = message;
Timestamp = DateTime.UtcNow;
}
}
using Orleans;
namespace OrleansTesting.Contracts.Models;
[GenerateSerializer]
[Alias("OrleansTesting.Contracts.Models.ChatRoomState")]
public class ChatRoomState
{
[Id(0)]
public List<ChatMessage> Messages { get; } = new();
}
グレインオブザーバーのインターフェイス定義
今回,リアルタイムなチャットの反映にOrleansのクライアント オブザーバーを使用します.オブザーバーを使用するために必要なインターフェイスをここで定義しておきます.
using Orleans;
using OrleansTesting.Contracts.Models;
namespace OrleansTesting.Contracts.Interfaces;
[Alias("OrleansTesting.Contracts.Interfaces.IChatObserver")]
public interface IChatObserver: IGrainObserver
{
[Alias("ReceiveMessage")]
Task ReceiveMessage(ChatMessage message);
}
グレインインターフェイスの定義
グレインが行う処理のインターフェイスを定義します.基本的なチャット履歴取得や送信と,オブザーバーのSubscribe
,Unsubscribe
が含まれます.
このチャットルームが持つキー(Id)は整数型を使用したいので,IGrainWithIntegerKey
を実装するようにしています.他にはIGrainWithStringKey
で文字列キーにしたり,IGrainWithGuidKey
でGuidキーにしたりできます.
using Orleans;
using OrleansTesting.Contracts.Models;
namespace OrleansTesting.Contracts.Interfaces;
[Alias("OrleansTesting.Contracts.Interfaces.IChatRoomGrain")]
public interface IChatRoomGrain: IGrainWithIntegerKey
{
[Alias("PostMessage")]
Task PostMessage(ChatMessage message);
[Alias("GetMessages")]
Task<List<ChatMessage>> GetMessages();
[Alias("Subscribe")]
Task Subscribe(IChatObserver observer);
[Alias("Unsubscribe")]
Task Unsubscribe(IChatObserver observer);
}
Orleansサーバーの構成
パッケージインストール
.Server
プロジェクトに以下のパッケージをインストールします.
- Aspire.Azure.Data.Tables
- Aspire.Azure.Storage.Blobs
- Microsoft.Orleans.Server
- Microsoft.Orleans.Persistence.AzureStorage
- Microsoft.Orleans.Clustering.AzureStorage
Program.csの編集
Azure StorageとOrleansの構成をProgram.csに記述します.
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
+ builder.AddKeyedAzureTableServiceClient("clustering");
+ builder.AddKeyedAzureBlobServiceClient("grain-state");
+ builder.UseOrleans();
// Add services to the container.
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
builder.Services.AddOpenApi();
var app = builder.Build();
app.MapDefaultEndpoints();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.MapGet("/", () => "Ok");
app.UseHttpsRedirection();
await app.RunAsync();
公式ドキュメントのサンプルでは,.AddKeyedAzureTableClient();
と.AddKeyedAzureBlobClient()
でストレージの登録を行っていましたが,非推奨となっていたため, .AddKeyedAzureTableServiceClient()
と.AddKeyedAzureBlobServiceClient()
を使用するようにしています.
リファレンス
グレインの実装
using Orleans.Utilities;
using OrleansTesting.Contracts.Interfaces;
using OrleansTesting.Contracts.Models;
namespace OrleansTesting.Server.Grains;
public class ChatRoomGrain : Grain, IChatRoomGrain
{
private readonly ILogger<ChatRoomState> _logger;
private readonly IPersistentState<ChatRoomState> _state;
private readonly ObserverManager<IChatObserver> _observer;
public ChatRoomGrain(
ILogger<ChatRoomState> logger ,
[PersistentState("ChatMessage")] IPersistentState<ChatRoomState> state)
{
_logger = logger;
_state = state;
_observer = new ObserverManager<IChatObserver>(TimeSpan.FromMinutes(5), logger);
}
public Task<List<ChatMessage>> GetMessages()
{
// チャットの全件取得
return Task.FromResult(_state.State.Messages
.OrderBy(m => m.Timestamp)
.ToList());
}
public Task PostMessage(ChatMessage message)
{
if (string.IsNullOrWhiteSpace(message.UserName) || string.IsNullOrWhiteSpace(message.Message))
{
throw new ArgumentException("UserName and Message cannot be empty.");
}
_state.State.Messages.Add(message);
_logger.LogInformation("New message posted: {Message}", message);
// オブザーバーにメッセージ受け取りの通知
_observer.Notify(o => o.ReceiveMessage(message));
return _state.WriteStateAsync(); // 状態の永続化
}
public Task Subscribe(IChatObserver observer)
{
_observer.Subscribe(observer, observer);
return Task.CompletedTask;
}
public Task Unsubscribe(IChatObserver observer)
{
_observer.Unsubscribe(observer);
return Task.CompletedTask;
}
}
クライアントの構成
パッケージインストール
.Client
プロジェクトに以下のパッケージをインストールします.
- Aspire.Azure.Data.Tables
- Aspire.Azure.Storage.Blobs
- Microsoft.Orleans.Client
- Microsoft.Orleans.Persistence.AzureStorage
- Microsoft.Orleans.Clustering.AzureStorage
グレインオブザーバーの実装
.Client
プロジェクトにグレインオブザーバーの実装を記述します.
IChatObserver
を実装し,インターフェイスにはないOnMessageReceived
というイベントを追加しています.このイベントにメソッドを登録することで,メッセージを受け取ったタイミングで登録したメソッドの実行をさせます.
using OrleansTesting.Contracts.Interfaces;
using OrleansTesting.Contracts.Models;
namespace OrleansTesting.Client.Observers;
public class ChatObserver : IChatObserver
{
public event Action<ChatMessage>? OnMessageReceived;
public Task ReceiveMessage(ChatMessage message)
{
if (OnMessageReceived != null)
{
OnMessageReceived.Invoke(message);
}
return Task.CompletedTask;
}
}
Program.csの編集
Orleansの設定と,ChatObserverのサービス登録を追加しています.
using OrleansTesting.Client.Components;
using OrleansTesting.Client.Observers;
using OrleansTesting.Contracts.Interfaces;
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
+ builder.AddKeyedAzureTableServiceClient("clustering");
+ builder.UseOrleansClient();
// Add services to the container.
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
// ChatObserverクラスを登録
+ builder.Services.AddScoped<ChatObserver>();
+ builder.Services.AddScoped<IChatObserver>(sp => sp.GetRequiredService<ChatObserver>());
var app = builder.Build();
app.MapDefaultEndpoints();
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseAntiforgery();
app.MapStaticAssets();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
app.UseFileServer();
await app.RunAsync();
UI作成
UIは簡単にフォームとメッセージ表示欄を作っています.クエリパラメータでグレインIdを指定できるようにしているため,クエリパラメータをいじって,Idごとに独立した状態が管理されている様子を見ることができます.グレインインターフェイスの定義で,Idは整数型に指定しているため,クエリパラメータからは整数を受け取るようにしています.
@page "/chat"
@rendermode InteractiveServer
@inject IGrainFactory GrainFactory
@inject ChatObserver ChatObserverService
@implements IAsyncDisposable
<h3>Chat</h3>
<div>
<input @bind="userName" placeholder="Enter your name" />
<input @bind="message" placeholder="Enter your message" />
<button @onclick="SendMessage">Send</button>
</div>
<hr />
<ul>
@foreach (var msg in messages)
{
<li>
<strong>@msg.UserName</strong> <br/>
@msg.Message <br/>
<small>@TimeZoneInfo.ConvertTimeFromUtc(msg.Timestamp, TimeZoneInfo.FindSystemTimeZoneById("Tokyo Standard Time")).ToString("g")</small>
</li>
}
</ul>
@code {
private IChatRoomGrain chatRoomGrain;
private IChatObserver chatObserver;
private List<ChatMessage> messages = new();
private string userName = string.Empty;
private string message = string.Empty;
private bool isSubscribed = false;
// クエリパラメータでチャットルームの指定
[SupplyParameterFromQuery(Name = "roomId")]
private int roomId { get; set; } = 0;
// 初期化処理
protected override async Task OnInitializedAsync()
{
// 指定Idのチャットルームのグレインを取得
chatRoomGrain = GrainFactory.GetGrain<IChatRoomGrain>(roomId);
// グレインからチャット履歴を取得
messages = await chatRoomGrain.GetMessages();
// メッセージ受信時のメソッドを登録
ChatObserverService.OnMessageReceived += HandleMessageReceived;
// オブザーバーの登録
chatObserver = GrainFactory.CreateObjectReference<IChatObserver>(ChatObserverService);
await chatRoomGrain.Subscribe(chatObserver);
isSubscribed = true;
}
// 登録解除処理
public async ValueTask DisposeAsync()
{
if(isSubscribed && chatObserver != null)
{
await chatRoomGrain.Unsubscribe(chatObserver);
isSubscribed = false;
}
ChatObserverService.OnMessageReceived -= HandleMessageReceived;
}
// メッセージ受け取り時の処理
private void HandleMessageReceived(ChatMessage chatMessage)
{
messages.Add(chatMessage);
InvokeAsync(StateHasChanged);
}
// メッセージ送信処理
private async Task SendMessage()
{
if(string.IsNullOrWhiteSpace(userName) || string.IsNullOrWhiteSpace(message))
{
return;
}
var sendingMessage = new ChatMessage(userName, message);
await chatRoomGrain.PostMessage(sendingMessage);
message = string.Empty;
}
}
ローカルデバッグ
実際に実行してみます.AppHostを起動すると,ダッシュボードが表示されます.
フロントエンドのUrlをクリックした後,/chat
に移動します.
タブを複製して,フォームに適当なユーザー名とメッセージを入力して送信すると,もう一方のタブにもリアルタイムに反映されます.
終わりに
.NET AspireとMicrosoft Orleansを用いてチャットアプリを構築してみました.今回作成したアプリはこちらのリポジトリで詳しく見ることができます.間違い等ございましたらコメントでお願いします.
参考