シンプルなイベントソーシングをC#をまねてRustに続き、Goでも作ってみた
株式会社ジェイテックジャパン CTOの高丘 @tomohisaです。
Sekibanという、C#のイベントソーシングフレームワークを作っています。
その新しいコンセプト(関数型で効率的な書き方)のために、まず、インメモリで動作する、イベントソーシングのコンセプトをC#で作りました。そちらの記事はこちら。
C#で2日くらいでこれができ、その後、Rustでも苦戦しながら、2日で似たものを作りました。
Rustでできるなら、Goならもう少し楽にできるのではないかと思ったところ、確かに、コピーでできるコードが多かったので、1日でできました。
実行コード
repository := domain.NewRepository()
createBranch := domain.CreateBranchCommand{
Name: "Tokyo",
Country: "Japan",
}
branchProjector := domain.BranchProjector{}
// command and method to handle
response, err := domain.ExecuteCommand(repository,
createBranch,
branchProjector,
func(command domain.CreateBranchCommand) domain.PartitionKeys {
return domain.PartitionKeys{
AggregateID: uuid.New(),
Group: "default",
RootPartitionKey: "default",
}
},
func(command domain.CreateBranchCommand, context domain.CommandContext) domain.EventPayloadOrNone {
return domain.ReturnEventPayload(domain.BranchCreated{command.Name, command.Country})
})
if err != nil {
fmt.Printf("Error executing command: %v\n", err)
}
changeNameCommand := domain.ChangeBranchNameCommand{
Name: "Osaka",
PartitionKeys: response.PartitionKeys,
}
// command and method to handle
response, err = domain.ExecuteCommand(repository,
changeNameCommand,
branchProjector,
func(command domain.ChangeBranchNameCommand) domain.PartitionKeys {
return command.PartitionKeys
},
func(command domain.ChangeBranchNameCommand, context domain.CommandContext) domain.EventPayloadOrNone {
return domain.ReturnEventPayload(domain.BranchNameChanged{command.Name})
})
if err != nil {
fmt.Printf("Error executing command: %v\n", err)
}
changeCountryCommand := domain.ChangeBranchCountryCommand{
Country: "USA",
PartitionKeys: response.PartitionKeys,
}
// command with Handler includes the command and method to handle
response, err = domain.ExecuteCommandWithHandler(repository, changeCountryCommand)
if err != nil {
fmt.Printf("Error executing command: %v\n", err)
}
aggregate3, err := repository.Load(response.PartitionKeys, branchProjector)
fmt.Printf("aggregate: %+v\n", aggregate3)
出力結果:
aggregate: {Payload:{Name:Osaka Country:USA} PartitionKeys:{AggregateID:3b572488-bdfd-4399-a079-66199aaea4ec Group:default RootPartitionKey:default} Version:3 LastSortableUniqueID:063869765901186333000031493857}
基本クラスは以下のものです。
- Repository : イベントをインメモリに保存したり、保存されたイベントから集約を呼び出す機能(複数のプロジェクタに対して汎用)
- CommandExecutor(これはジェネリックメソッドがないので、メソッドだけで定義) : コマンドを実行したら、Repositoryにイベントを保存する、すでに保存された集約に対しては、現在の集約状態を呼び出して、追加のコマンドを定義する
Branchの集約のパーツも、プロジェクト内に定義しています。以下で、集約パーツのコードを紹介します。
ドメインのコード① - イベント
type BranchCreated struct {
Name string
Country string
}
func (b BranchCreated) IsEventPayload() bool {
return true
}
type BranchNameChanged struct {
Name string
}
func (b BranchNameChanged) IsEventPayload() bool {
return true
}
type BranchCountryChanged struct {
Country string
}
func (b BranchCountryChanged) IsEventPayload() bool {
return true
}
イベントは基本的にはデータクラスなのですが、IsEventPayload()を定義することで、イベントとして認識するようにしています。理由としては、暗黙的にインターフェースを実装する方法がなかったので、呼ばれないのですが、この実装を書くことによって、それぞれのクラスがイベントであることを明示することができます。
ドメインのコード② - 集約
type Branch struct {
Name string
Country string
}
func (b Branch) IsAggregatePayload() bool {
return true
}
イベントと同じく、データと簡単なメソッドです。このデータがイベントで構成されていきます。イベントの集合に対して、複数の集約の種別を定義することも可能ですが、集約を作るためには、次に書く、集約プロジェクターを定義する必要があります。
ドメインのコード③ - 集約プロジェクター
type BranchProjector struct{}
func (p BranchProjector) GetVersion() string {
return "1.0"
}
func (p BranchProjector) Project(payload AggregatePayload, ev *EventCommon) AggregatePayload {
switch ap := payload.(type) {
case EmptyAggregatePayload:
switch ep := ev.Payload.(type) {
case BranchCreated:
return Branch{Name: ep.Name, Country: ep.Country}
default:
return payload
}
case Branch:
switch ep := ev.Payload.(type) {
case BranchNameChanged:
return Branch{
Name: ep.Name,
Country: ap.Country,
}
case BranchCountryChanged:
return Branch{
Name: ap.Name,
Country: ep.Country,
}
default:
return payload
}
default:
return payload
}
}
Sekibanにおいて、すべての集約は、EmptyAggregatePayloadで始まります。新規集約を開始する処理に関しては、集約がEmptyAggregatePayloadで特定のイベントが来たときに、新たな集約の型を返します。この場合、BranchCreatedイベントが来た時に、Branch集約に返します。
その他のイベントの時は、Branch集約をキープしたまま、Branch内のデータを変えていきます。この場合は、Branch型をキープしているのですが、型を複数定義して、状態によって型を変えることもできます。
- ActiveBranch : ユーザーを追加できる
- InactiveBranch : ユーザーを追加できないが、閲覧はできる
- DeletedBranch : 削除されたBranch、閲覧もできない
この型を変えることによって、以下で説明するCommandが、イベントによって特定の型の時だけコマンドを実行できるように構成することができます。(今回のSimple Go ではここまでは書いていません。) - ChangeBranchName は、ActiveBranchにしか実行できない
- MakeBranchInactive はActiveBranchにしか実行できない
- RestartInactiveBranch はInactiveBranchにしか実行できない
など。
Goの実装においては、switch式に複数のものを置けないので、それによりちょっと長いコードになってしまいます。
ドメインのコード④ - コマンドおよびそのハンドラー
type CreateBranchCommand struct {
Name string
Country string
}
func (c CreateBranchCommand) IsCommand() bool {
return true
}
type ChangeBranchNameCommand struct {
Name string
PartitionKeys PartitionKeys
}
func (c ChangeBranchNameCommand) IsCommand() bool {
return true
}
type ChangeBranchCountryCommand struct {
Country string
PartitionKeys PartitionKeys
}
func (c ChangeBranchCountryCommand) IsCommand() bool {
return true
}
func (c ChangeBranchCountryCommand) Handle(context CommandContext) EventPayloadOrNone {
return ReturnEventPayload(BranchCountryChanged{Country: c.Country})
}
func (c ChangeBranchCountryCommand) SpecifyPartitionKeys() PartitionKeys {
return c.PartitionKeys
}
func (c ChangeBranchCountryCommand) GetProjector() AggregateProjector {
return BranchProjector{}
}
コマンドの目的および機能は以下のとおりです
- 入力内容の決定 : Command
- 指定するパーティションの決定(イベントストリームの指定)
- コマンドを受け取り、どんなイベントを返すのか、それとも何も返さないのかを決定する: コマンドハンドラー
Commandにはいくつかの定義方法がありますが、主に2つあります。
- コマンドの型だけ定義して、その機能はExecuteCommandに直接渡す
CreateBranchCommand, ChangeBranchNameCommandはこのスタイルで書いています。 - コマンドとハンドラーを一緒に定義して利用できるように準備しておく
ChangeBranchCountryNameCommandはこのスタイルで書いています。
個人的には、Handlerの責務はドメインが持っているのが良いと思いますので、こちらの書き方を個人的には主に用いています。
コマンドの責務はどのイベントを保存するかを決めるところまでで、その保存されたイベントが集約にどのような影響を与えるかは、Projectorが決定します。
上記の4つのドメインの構成要素を定義したのちに、最初に書いた実行コードを記述することができます。
Goで書いてみて
Goはシンプルに書けるようになっていて、サクサク書いていけるので、とても書きやすかったです。ただ、シンプルイズベストという設計を理由にして、あったら便利な機能を入れていないという感じもしました。複雑な実装は確かに良くないと思いますが、他の言語と比べての使いやすさという点で遅れを取りかねない機能が多いかもしれません(with句とか、複数のパターンマッチングや、ジェネリックでないtypeの中のジェネリックメソッドなど)
でも全体的には行数は多くても簡単に正しいコードを書ける、とても良い言語と思いました。人気があるわけですね。
SuperSimpleEventSourcingに関しては、現在、C#、Rust 、Goと3つの言語で作りました。どれも人気の言語で、良い言語と感じましたが、個人的にはC#が慣れているというだけでなく、よく設計すれば冗長さを省いて綺麗にかけ、しかもGoと比べるとスピードにも遜色ないので、良いと感じました。
余裕があれば別の言語も書いてみたい。言語や機能のリクエストがあればこちらのコメントやXでのコメントでお願いします!!コメントがあるとやる気が出ます。
あと、SekibanのGitHubにスターをしてくださるととても喜びます!!
Discussion
switch文は case 値, 値: のようにカンマ区切りで複数のケースをまとめられます!
おお!そうですか!やはりGPTのスキルはまだまだですね。ちょっとどれくらいできるか書いてみます。ありがとうございます!
うむむ、ごめんなさい、頑張っているのですが、出来ませんでした。。。
おっと、確かにこの場合は使えなさそうです…失礼しました🙇
そうでしたか!ありがとうございます。できるとだいぶスマートに書けそうなので、機能追加を楽しみにしておきます。コメントありがとうございます!