🪨

.NET Aspire と Sekiban を使ってアプリケーション作り始める方法

2024/01/25に公開

株式会社ジェイテックジャパン CTOの高丘 @tomohisaです。この記事は、.NET ラボ 2024年1月での僕の登壇「C# とAzure Cosmos DBで簡単にイベントソーシングを始められるSekibanのデモ」におけるデモ部分でどんなことを行っているかをまとめた記事です。

この記事では、.NET Aspireと、Sekiban Event Sourcing and CQRS Framework を使用して、簡単なアプリケーションを作成する方法をまとめています。

Azure Cosmos DB の準備

Sekibanでは、現在Azure Cosmos DBとAmazon Dynamo DBをデータストアとして対応しています。今回はCosmos DBを使用しますが、私の環境がMacOSということもあり、ローカルエミュレーターは使用せず、Azureにインスタンスを作成します。

Azure 1

Azure 2

Azure 3
個人的にはサーバーレスがおすすめです。がんがんテストで使っても個人で使う範囲では1ドル以下で十分使えます。
プロビジョンスループットは、コンテナごとに定額かかったと記憶していますが、システムをリリースして大量に使用する場合は、単価はサーバーレスより安価と思います。

サーバーレスの場合、データベースとコンテナを事前に作成しておく必要があります。以下のとおりです。

データベースId : SekibanDb (Sekibanで使用するときの規定、変更可能)
コンテナId : events (Sekibanで使用するときの規定、変更可能)

バーティションキー: (Sekibanを使うときは以下のもの、変更不可能) 階層パーティションを使用して、3つのキーを設定します

/RootPartitionKey
/AggregateType
/PartitionKey

Cosmos 1

同様に、items コンテナも作成する

データベースId : SekibanDb (Sekibanで使用するときの規定値、変更可能)
コンテナId : items (Sekibanで使用するときの規定値、変更可能)

バーティションキー: (Sekibanを使うときは以下のもの、変更不可能)
/RootPartitionKey
/AggregateType
/PartitionKey

Cosmos 2

各コンテナが作成されて、データがからであることがわかります。

Cosmos 3

接続文字列をAzureからコピーして、記録しておきます
Cosmos 4

こちらはテストではほぼ使用しませんが、SekibanはスナップショットなどをAzure Blobに保存するため、Blob Storageも作成しておきます。

Blob 1

Blob 2

作成できたら、こちらも接続文字列をコピーしておきます。

Blob 3

これで準備完了です。

Aspire でプロジェクトを作成する

  • .NETコマンドラインの最新バージョンがインストールされていることを確認する

Aspire 1

プロジェクトディレクトリへ移動

  • プロジェクトをテンプレートより作成する
dotnet new aspire-starter --output DotNetLab202401
  • Riderでソリューションを開いてAppHostを実行する
    ローカルでDockerが起動していることを確認する

Aspire 2

Aspire 3

Aspire 4

これでAspireのプロジェクトを動作させることに成功しました。

プロジェクトにSekibanのドメインを作成する

  • ドメイン用のProjectをClass Libraryで作成する
    Domain 1

  • ドメインプロジェクトにNuget でSekiban.Coreを追加する
    Domain 2

  • Book集約を作成する(Aggregate, Event, Command)

using Sekiban.Core.Aggregate;
using Sekiban.Core.Command;
using Sekiban.Core.Events;
namespace Bookshelf.Domain.Aggregates.Books;

public record Book(string Name, string Isbn, string Author) : IAggregatePayload<Book>
{
    public static Book CreateInitialPayload(Book? _) => new(string.Empty, string.Empty, string.Empty);
}
public record BookCreated(string Name, string Isbn, string Author) : IEventPayload<Book, BookCreated>
{
    public static Book OnEvent(Book aggregatePayload, Event<BookCreated> ev) => aggregatePayload with
    {
        Name = ev.Payload.Name,
        Isbn = ev.Payload.Isbn,
        Author = ev.Payload.Author
    };
}
public record CreateBook(string Name, string Isbn, string Author) : ICommand<Book>
{
    public Guid GetAggregateId() => Guid.NewGuid();

    public class Handler : ICommandHandler<Book, CreateBook>
    {
        public IEnumerable<IEventPayloadApplicableTo<Book>> HandleCommand(CreateBook command, ICommandContext<Book> context)
        {
            yield return new BookCreated(command.Name, command.Isbn, command.Author);
        }
    }
}

ドメインの依存関係を定義する

using Bookshelf.Domain.Aggregates.Books;
using Sekiban.Core.Dependency;
using System.Reflection;
namespace Bookshelf.Domain;

public class BookshelfDependency : DomainDependencyDefinitionBase
{

    public override Assembly GetExecutingAssembly() => Assembly.GetExecutingAssembly();
    public override void Define()
    {
        AddAggregate<Book>()
            .AddCommandHandler<CreateBook, CreateBook.Handler>();
    }
}

SekibanのWeb APIを作成する

ApiServiceプロジェクトに以下の変更を行う

  • Sekiban.Aspire.Infrastructure.Cosmos Nugetパッケージを追加する
  • Sekibanのドメインを依存関係と一緒に追加する
builder.AddSekibanWithDependency<BookshelfDependency>();
  • AspireとCosmosを追加する
builder.AddSekibanCosmosDB().AddSekibanCosmosAspire("SekibanAspireCosmos").AddSekibanBlobAspire("SekibanAspireBlob");
  • Sekiban.Webを追加する
builder.Services.AddSekibanWebFromDomainDependency<BookshelfDependency>()
    .AddSwaggerGen(options => options.ConfigureForSekibanWeb());
  • Swaggerを追加する
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}
  • Controllerをマッピングする
app.MapControllers();
  • 実行の前に、接続文字列を設定する
{
    "ConnectionStrings:SekibanAspireCosmos" : "",
    "ConnectionStrings:SekibanAspireBlob" : ""
}
  • launchsettings.jsonでswaggerを追加する
{
  "$schema": "http://json.schemastore.org/launchsettings.json",
  "profiles": {
    "http": {
      "commandName": "Project",
      "dotnetRunMessages": true,
      "launchBrowser": true,
      "launchUrl": "swagger",
      "applicationUrl": "http://localhost:5321",
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development"
      }
    }
  }
}

Web 1

Web 2

データをAzure Cosmos DBで確認できる
Web 3

Blazor でSekibanのデータを表示する

では、このデータをBlazorのフロントエンドで表示してみましょう。

  • APIサービス側からWeatherAPIを削除する。

  • WebプロジェクトにBookshelf.Domainを参照する

  • WetherApiClientにBookリストを追加する

using Bookshelf.Domain.Aggregates.Books;
using Sekiban.Core.Query;
using Sekiban.Core.Query.QueryModel;
namespace DotNetLab202401.Web;

public class WeatherApiClient(HttpClient httpClient)
{
    public async Task<ListQueryResult<QueryAggregateState<Book>>?> GetBooksAsync() =>
        await httpClient.GetFromJsonAsync<ListQueryResult<QueryAggregateState<Book>>>("/api/query/book/simpleaggregatelistquery1");
}
  • WeatherのBlazorをBook用に改変
@page "/weather"
@using Bookshelf.Domain.Aggregates.Books
@using Sekiban.Core.Query
@using Sekiban.Core.Query.QueryModel
@attribute [StreamRendering]
@attribute [OutputCache(Duration = 5)]

@inject WeatherApiClient WeatherApi

<PageTitle>Books</PageTitle>

<h1>Books</h1>

<p>This component demonstrates showing data loaded from a backend API service.</p>

@if (books == null)
{
    <p>
        <em>Loading...</em>
    </p>
}
else
{
    <table class="table">
        <thead>
        <tr>
            <th>AggregateId</th>
            <th>Name</th>
            <th>ISBN</th>
            <th>Author</th>
        </tr>
        </thead>
        <tbody>
        @foreach (var forecast in books.Items)
        {
            <tr>
                <td>@forecast.AggregateState.AggregateId</td>
                <td>@forecast.AggregateState.Payload.Name</td>
                <td>@forecast.AggregateState.Payload.Isbn</td>
                <td>@forecast.AggregateState.Payload.Author</td>
            </tr>
        }
        </tbody>
    </table>
}

@code {
    private ListQueryResult<QueryAggregateState<Book>>? books;

    protected override async Task OnInitializedAsync()
    {
        books = await WeatherApi.GetBooksAsync();
    }
}

  • 実行して動作を確認

Blazor 1

ここまでで、データを作って、表示するところまでをバックエンド、データストア、フロントエンドまで繋げることができました。

テストの作成

Sekibanはテストを簡単に記述し、実環境、実データで起きる問題をドメインテストで発見することを支援するテストフレームワークが用意されています。
テストプロジェクトを作ってみましょう

  • Bookshelf.Test プロジェクトの作成 [xUnit]
  • Bookshelf.Testプロジェクトに Sekiban.Testing Nugetパッケージを追加
  • Bookshelf.Testプロジェクトに Bookshelf.Domainプロジェクトを参照する
  • Bookのテストを作成

using Bookshelf.Domain;
using Bookshelf.Domain.Aggregates.Books;
using Sekiban.Testing.SingleProjections;
namespace Bookshelf.Test;

public class BookSpec : AggregateTest<Book, BookshelfDependency>
{
    [Fact]
    public void CreateSuccess()
    {
        WhenCommand(new CreateBook("a", "111", "john"));
        ThenNotThrowsAnException();
        ThenPayloadIs(new Book("a", "111", "john"));
    }
}
  • CreateBookに属性を追加する
public record CreateBook(
    [property: Required]
    string Name,
    string Isbn,
    [property: Required]
    string Author) : ICommand<Book>
{
    public Guid GetAggregateId() => Guid.NewGuid();

    public class Handler : ICommandHandler<Book, CreateBook>
    {
        public IEnumerable<IEventPayloadApplicableTo<Book>> HandleCommand(CreateBook command, ICommandContext<Book> context)
        {
            yield return new BookCreated(command.Name, command.Isbn, command.Author);
        }
    }
}
  • 各パラメーターにからを渡したときの動作をテストする
using Bookshelf.Domain;
using Bookshelf.Domain.Aggregates.Books;
using Sekiban.Testing.SingleProjections;
namespace Bookshelf.Test;

public class BookSpec : AggregateTest<Book, BookshelfDependency>
{
    [Fact]
    public void CreateSuccess()
    {
        WhenCommand(new CreateBook("a", "111", "john"));
        ThenNotThrowsAnException();
        ThenPayloadIs(new Book("a", "111", "john"));
    }
    [Fact]
    public void CreateFailsBecauseNameIsRequired()
    {
        WhenCommand(new CreateBook(string.Empty, "111", "john"));
        ThenHasValidationErrors();
    }
    [Fact]
    public void CreateSucceedWhenIsbnEmpty()
    {
        WhenCommand(new CreateBook("a", string.Empty, "john"));
        ThenNotThrowsAnException();
        ThenPayloadIs(new Book("a", string.Empty, "john"));
    }
}

このようにテストでは各コマンドごとにどんな結果が起きるかをテストすることができます。コマンドの段階で不正なデータが入らないように記述していくことにより、正しいイベントが生成されていくことを担保することができます。

まとめ

このようにシンプルなコードの追加で

  • データコンテナの準備
  • プロジェクトの作成
  • ドメインコードの作成
  • Web APIの定義
  • フロントのコードとの接続
  • テストの作成

までを行うことができました。Sekibanでバックエンド、Aspireのテンプレートでフロントも作成を開始することができるのは嬉しいですね。

このサンプルとほぼ同じサンプルが、Sekibanのリポジトリ内にも追加されています。ご確認ください

Aspire and Sekiban Sample

SekibanのGithubページは以下となっています。

J-Tech-Japan/Sekiban

よろしければご覧ください。

ジェイテックジャパンブログ

Discussion