👏

マイクロサービス作らなくても .NET Aspire って便利

2024/06/10に公開

最近は 第3回 Azure Travelers 勉強会 山形の旅 と .NET ラボの 7 月で登壇 (予定) で話す .NET Aspire について色々調べたりしています。

.NET Aspire はマイクロサービスを開発する感じでデモをされていたりします。実際に公式の .NET の Web アプリのリファレンス実装に eShop が .NET Aspire が使われているのですが、以下のように 10 個近いサービスからなるマイクロサービス アーキテクチャになっています。

これを見て思うのが「こんなに細かくわけて作る機会なんて、そんなに無いよ…!!」というのが私の素直な感想です。

じゃぁ .NET Aspire ってこんな風なアプリじゃないと使えないのか?というとそうではなく、実は .NET Aspire は単一プロジェクトからなるような Web アプリでも便利に使えます。この記事では、そこらへんについて書いていきます。

マイクロサービスじゃない場合の .NET Aspire の使い所

.NET Aspire の機能の 1 つにローカル開発の際にデバッグ実行で関連するプロジェクト一式と関連するコンテナや exe を起動してくれるという機能があります。さらには接続文字列なども自動的に環境変数に仕込んでくれるといった機能もあります。

普通の Web アプリでも普通はデータベースを使ったり、キャッシュを使ったりすると思います。そんな時に .NET Aspire が便利です。

例えば、以下のような構成の Web アプリを考えてみます。

Web App の部分が .NET 製の Web アプリで SQL Server やキャッシュはアプリではないので通常はアプリとは別管理になります。開発環境構築時にローカルに入れたり、最近だと Docker で立ち上げたり、場合によってはクラウドサービスの方に作ったりといった感じになると思います。Docker compose とかも使っていい感じに起動できるような仕組みを作りこんだりする感じになると思いますが、yaml 書いたり色々したりするのがメンドクサイところです。

.NET Aspire を使うと、このような構成でも yaml を書いたりせずに C# でタイプセーフに設定を書いておいて、デバッグ実行時には自動的に起動してくれるようになります。また、接続文字列なども自動的に環境変数に設定してくれるので、アプリ側でそれを使うようにしておけば、ローカルでの開発時には特に何も考えずに開発が出来るようになります。

.NET Aspire を使って解決してみる

やってみましょう。

Web アプリケーションを作成します。ここで説明したいのは .NET Aspire の機能なので Web アプリは一番シンプルな ASP.NET Core Minimal APIs を使って作成します。

ASP.NET Core Web API のプロジェクト テンプレートで Minimal APIs のプロジェクトを作ります。

以下のように .NET Aspire オーケストレーションへの参加を有効にして作成しておきます。こうすることで .NET Aspire が使えるようになります。後で手動でも追加できますが、今回は最初から有効にしておきます。

そうすると以下のようなプロジェクトが出来ます。

そして、AppHost のプロジェクトで右クリックから「追加」→「.NET Aspire パッケージ…」を選んで .NET Aspire のホスティングのパッケージだけにフィルタリングされた NuGet パッケージマネージャーを開きます。

そこで以下の 2 つのパッケージを追加します。

  • Aspire.Hosting.SqlServer
  • Aspire.Hosting.Redis

AppHost プロジェクトの Program.cs を以下のように変更して SQL Server と Redis を追加します。

AspireSampleApp.AppHost/Program.cs
var builder = DistributedApplication.CreateBuilder(args);

// Redis と SQL Server を追加
var redis = builder.AddRedis("redis");
var sqlServer = builder.AddSqlServer("sqlserver");
var sampleDb = sqlServer.AddDatabase("sampleDb");

// Web アプリに Redis と SQL Server を参照として追加
builder.AddProject<Projects.AspireSampleApp>("aspiresampleapp")
    .WithReference(redis)
    .WithReference(sampleDb);

builder.Build().Run();

この状態で実行すると、以下のようにダッシュボードが起動して Web アプリと共に SQL Server と Redis が起動します。

さらに Web アプリには以下のように環境変数に Redis と SQL Server への接続文字列がちゃんと設定されています。

あとはアプリ側ではこの接続文字列を使って接続してもいいですし、.NET Aspire コンポーネントの NuGet パッケージを使って、この命名規約に従って、なおかつリトライとかが構成されたクライアントが自動的に追加される機能にのっかってもいいです。

ここではのっかりましょう。

Web アプリの実装

Web アプリのプロジェクトに以下の 2 つのパッケージを追加します。

  • Aspire.StackExchange.Redis.OutputCaching
  • Aspire.Microsoft.EntityFrameworkCore.SqlServer

そして DB に繋ぐための DbContext クラスを以下のように作成します。

AspireSampleApp/SampleDbContext.cs
using Microsoft.EntityFrameworkCore;

namespace AspireSampleApp;

public class SampleDbContext(DbContextOptions<SampleDbContext> options) : DbContext(options)
{
    public DbSet<Tweet> Tweets => Set<Tweet>();
}

public class Tweet
{
    public int Id { get; set; }
    public required string Text { get; set; }
}

そして Program.cs に以下のように追加します。

AspireSampleApp/Program.cs
using AspireSampleApp;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.OutputCaching;

var builder = WebApplication.CreateBuilder(args);

builder.AddServiceDefaults();

// Add services to the container.

// Redis の出力キャッシュの構成と SQL Server のデータベースの構成
builder.AddRedisOutputCache("redis");
builder.AddSqlServerDbContext<SampleDbContext>("sampleDb");


var app = builder.Build();

app.MapDefaultEndpoints();

// Configure the HTTP request pipeline.

app.UseHttpsRedirection();

// 出力キャッシュのミドルウェアを追加
app.UseOutputCache();


// 1 分キャッシュする
app.MapGet("/tweets",
    [OutputCache(Duration = 60)]
    (SampleDbContext sampleDbContext) =>
    {
        return sampleDbContext.Tweets.AsAsyncEnumerable();
    });

// データ追加
app.MapPost("/tweets", async (
    [FromBody]Tweet tweet, 
    SampleDbContext sampleDbContext,
    ILogger<Program> logger) =>
{
    sampleDbContext.Tweets.Add(tweet);
    var updated = await sampleDbContext.SaveChangesAsync();
    logger.LogInformation(
        "Tweet {text} was added. Updated {updated} rows.", 
        tweet.Text, 
        updated);
});

app.Run();

ここで実行してエンドポイントを叩くとエラーになります。まぁ DB があるという定義はしましたが実際の DB やテーブルなどは出来ていませんからね…。ということで EF Core のマイグレーション機能を使って DB を作っていきます。

Visual Studio 2022 接続済みサービス機能と .NET Aspire が連携してくれていれば、ここらへんは GUI でポチポチやるだけでよかったのですが、対応してないっぽいのでここは手動で頑張ります。

以下のパッケージを Web アプリのプロジェクトに追加して EF Core のコマンドを使えるようにします。

  • Microsoft.EntityFrameworkCore.Design

そしてプロジェクトのフォルダーで以下のコマンドを実行してマイグレーションの初期化を行います。

dotnet ef migrations add InitialCreate

これで Migrations フォルダーが作成されて、その中にマイグレーションのファイルが作成されます。
そして、Web アプリの起動タイミングで開発時のみマイグレーションを実行してもいいですし、.NET Aspire だと DB 作成を行うワーカープロジェクトを作って、そこでマイグレーションを実行してもいいです。ここではワーカーを作ってみましょう。

DB 初期化のワーカーサービスの追加

ワーカー サービスのプロジェクトを作成します。ここでは AspireSampleApp.DbInitializer というプロジェクトを .NET Aspire オーケストレーションへの追加にチェックを入れて作成します。

そして AppHost プロジェクトの Program.cs を以下のように変更して DB 初期化用のワーカーに DB への参照を追加します。さらにローカルでの開発用の実行時にしか動いてほしくないので IsRunModetrue の時だけ追加するようにします。

AspireSampleApp.AppHost/Program.cs
var builder = DistributedApplication.CreateBuilder(args);

// Redis と SQL Server を追加
var redis = builder.AddRedis("redis");
var sqlServer = builder.AddSqlServer("sqlserver");
var sampleDb = sqlServer.AddDatabase("sampleDb");

// Web アプリに Redis と SQL Server を参照として追加
builder.AddProject<Projects.AspireSampleApp>("aspiresampleapp")
    .WithReference(redis)
    .WithReference(sampleDb);

// デプロイ用のマニフェストを作る時には実行しないように RunMode のときだけ DB 初期化のワーカーを追加
if (builder.ExecutionContext.IsRunMode)
{
    // DB 初期化のワーカー
    builder.AddProject<Projects.AspireSampleApp_DbInitializer>("aspiresampleapp-dbinitializer")
        // DB への参照を追加
        .WithReference(sampleDb);
}

builder.Build().Run();

AspireSampleApp.DbInitializer に DbContext を持っている Web アプリへの参照を追加して DB 初期化のコードを書いていきます。

AspireSampleApp.DbInitializer/Program.cs
using AspireSampleApp;
using Microsoft.EntityFrameworkCore;

var builder = Host.CreateApplicationBuilder(args);

builder.AddServiceDefaults();
// sampleDb のための DbContext を追加 
builder.AddSqlServerDbContext<SampleDbContext>("sampleDb");

var host = builder.Build();

// マイグレーションを実行してテスト用のデータを追加
using var scope = host.Services.CreateScope();
await using (var dbContext = scope.ServiceProvider.GetRequiredService<SampleDbContext>())
{
    await dbContext.Database.MigrateAsync();

    if (!await dbContext.Tweets.AnyAsync())
    {
        await dbContext.Tweets.AddAsync(new Tweet { Text = "Hello, World!" });
        await dbContext.Tweets.AddAsync(new Tweet { Text = "こんにちは、世界!" });
        await dbContext.SaveChangesAsync();
    }
}

これで DB 初期化のワーカーが追加されて、ローカルでの開発時には DB が初期化されてテスト用のデータが追加されるようになります。

実行して動作確認

実行すると以下のようにダッシュボードが起動します。

暫く待っていると DbInitializer のプロジェクトが finished になります。DB の初期化が完了したことを示しています。

これで Web アプリにアクセスすると以下のように Tweet が取得できるようになります。以下のような HTTP のエンドポイントを叩く .http 拡張子ファイルを作って試してみます。

@AspireSampleApp_HostAddress = http://localhost:5190

GET {{AspireSampleApp_HostAddress}}/tweets/
Accept: application/json

###
POST {{AspireSampleApp_HostAddress}}/tweets/
Content-Type: application/json

{
  "text": "The first tweet!!"
}

以下のように、ちゃんとテスト用に投入したデータが取得出来ていることが確認できます。

Deploy to ...?

このまま .NET Aspire のプロジェクトをデプロイすると Azure Container Apps にアプリと Redis と SQL Server がコンテナとしてデプロイされます。これは嬉しくない…!
Azure なら Redis は Azure Cache for Redis で、SQL Server は Azure SQL Database にしてほしいです。そんな時は、以下のようにデプロイ時には Redis と SQL Server は接続文字列を受け取るようにすることで解決できます。

AspireSampleApp.AppHost/Program.cs
var builder = DistributedApplication.CreateBuilder(args);

// Redis と SQL Server を追加
IResourceBuilder<IResourceWithConnectionString> createDatabase()
{
    // ローカル実行時には SQL Server のコンテナを使って発行時には接続文字列を使う
    if (builder.ExecutionContext.IsRunMode)
    {
        var sqlServer = builder.AddSqlServer("sqlserver");
        return sqlServer.AddDatabase("sampleDb");
    }
    else
    {
        return builder.AddConnectionString("sampleDb");
    }
}

var redis = builder.ExecutionContext.IsRunMode ? 
    builder.AddRedis("redis") :
    builder.AddConnectionString("redis");
var sampleDb = createDatabase();

// Web アプリに Redis と SQL Server を参照として追加
builder.AddProject<Projects.AspireSampleApp>("aspiresampleapp")
    .WithReference(redis)
    .WithReference(sampleDb);

// デプロイ用のマニフェストを作る時には実行しないように RunMode のときだけ DB 初期化のワーカーを追加
if (builder.ExecutionContext.IsRunMode)
{
    // DB 初期化のワーカー
    builder.AddProject<Projects.AspireSampleApp_DbInitializer>("aspiresampleapp-dbinitializer")
        // DB への参照を追加
        .WithReference(sampleDb);
}

builder.Build().Run();

もしくは、自分で環境変数が設定された Azure App Service 等を準備して、そこに zip deploy する形でも大丈夫です。また、Azure App Service にデプロイする際には AspireSampleApp.ServiceDefaultsExtensions.cs にある AddOpenTelemetryExporeter メソッドでコメントアウトされている Azure Monitor (Application Insights) を使うようにする部分のコメントを外して Azure.Monitor.OpenTelemetry.AspNetCore パッケージを追加することでログが Azure Monitor に出るようになるので、やっておいた方が良いと思います。

AspireSampleApp.ServiceDefaults/Extensions.cs
    private static IHostApplicationBuilder AddOpenTelemetryExporters(this IHostApplicationBuilder builder)
    {
        var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]);

        if (useOtlpExporter)
        {
            builder.Services.AddOpenTelemetry().UseOtlpExporter();
        }

        // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package)
        //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"]))
        //{
        //    builder.Services.AddOpenTelemetry()
        //       .UseAzureMonitor();
        //}

        return builder;
    }

まとめ

.NET Aspire をマイクロサービスを開発しない状況でも便利に使えるということを示すために、単純な 1 プロジェクト構成の Web アプリを作成して、そこで .NET Aspire を使ってみました。

Redis や SQL Server を使うのは普通の Web アプリ開発でも結構よくあるシナリオだと思います。そういうときに Redis と SQL Server の起動などを .NET Aspire におまかせして楽してしまおうという感じで使いました。そして DB の初期化はワーカー サービスを作成して、そこで行うようにしました。
こうすることで開発の際には特別なセットアップ無しでソリューションを開いて実行することでお膳立てがされた状態で開発が始められるようになります。(Docker は必要ですが…)

実際に、今回作成したコードを GitHub に上げておきますので、クローンしてソリューションを開いて実行することで簡単に動くことが確認できると思います。

https://github.com/runceel/AspireSampleApp

ただ、課題としてはデバッグ実行のタイミングで毎回 DB の初期化が走るので規模の大きなアプリだと起動に時間がかかりすぎてしまうという問題があるかもしれません…。実際には Azure Container Registry などのプライベートリポジトリに開発用にデータなども設定された SQL Server のカスタムイメージを用意しておいて、それを使うようにしておいた方が良いかもしれません。
ただ、開発初期段階で DB のスキーマなども安定しない段階では、こういう感じで初期化するのもありだと思います。

Microsoft (有志)

Discussion