🦡

シンプルなイベントソーシングをC#をまねてRustに続き、Goでも作ってみた

2024/12/14に公開
5

株式会社ジェイテックジャパン CTOの高丘 @tomohisaです。

Sekibanという、C#のイベントソーシングフレームワークを作っています。

https://github.com/J-Tech-Japan/Sekiban

その新しいコンセプト(関数型で効率的な書き方)のために、まず、インメモリで動作する、イベントソーシングのコンセプトをC#で作りました。そちらの記事はこちら。

https://zenn.dev/jtechjapan_pub/articles/f7968a3f2fb6d5

C#で2日くらいでこれができ、その後、Rustでも苦戦しながら、2日で似たものを作りました。

https://zenn.dev/jtechjapan_pub/articles/2ca0d357dffc4b

Rustでできるなら、Goならもう少し楽にできるのではないかと思ったところ、確かに、コピーでできるコードが多かったので、1日でできました。

https://github.com/J-Tech-Japan/SuperSimpleEventSourcing/tree/main/go

https://zenn.dev/jtechjapan_pub/articles/d83e90c20917cd

実行コード

main.go
	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にイベントを保存する、すでに保存された集約に対しては、現在の集約状態を呼び出して、追加のコマンドを定義する

https://x.com/tomohisa/status/1867854900358754318

Branchの集約のパーツも、プロジェクト内に定義しています。以下で、集約パーツのコードを紹介します。

ドメインのコード① - イベント

events.go
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()を定義することで、イベントとして認識するようにしています。理由としては、暗黙的にインターフェースを実装する方法がなかったので、呼ばれないのですが、この実装を書くことによって、それぞれのクラスがイベントであることを明示することができます。

https://x.com/tomohisa/status/1867813587739353150

ドメインのコード② - 集約

aggregate.go
type Branch struct {
	Name    string
	Country string
}

func (b Branch) IsAggregatePayload() bool {
	return true
}

イベントと同じく、データと簡単なメソッドです。このデータがイベントで構成されていきます。イベントの集合に対して、複数の集約の種別を定義することも可能ですが、集約を作るためには、次に書く、集約プロジェクターを定義する必要があります。

ドメインのコード③ - 集約プロジェクター

BranchProjector.go
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式に複数のものを置けないので、それによりちょっと長いコードになってしまいます。

https://x.com/tomohisa/status/1867810667417547207

https://x.com/tomohisa/status/1867869321101504609

ドメインのコード④ - コマンドおよびそのハンドラー

commands.go
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つあります。

  1. コマンドの型だけ定義して、その機能はExecuteCommandに直接渡す
    CreateBranchCommand, ChangeBranchNameCommandはこのスタイルで書いています。
  2. コマンドとハンドラーを一緒に定義して利用できるように準備しておく
    ChangeBranchCountryNameCommandはこのスタイルで書いています。
    個人的には、Handlerの責務はドメインが持っているのが良いと思いますので、こちらの書き方を個人的には主に用いています。

コマンドの責務はどのイベントを保存するかを決めるところまでで、その保存されたイベントが集約にどのような影響を与えるかは、Projectorが決定します。

上記の4つのドメインの構成要素を定義したのちに、最初に書いた実行コードを記述することができます。

Goで書いてみて

Goはシンプルに書けるようになっていて、サクサク書いていけるので、とても書きやすかったです。ただ、シンプルイズベストという設計を理由にして、あったら便利な機能を入れていないという感じもしました。複雑な実装は確かに良くないと思いますが、他の言語と比べての使いやすさという点で遅れを取りかねない機能が多いかもしれません(with句とか、複数のパターンマッチングや、ジェネリックでないtypeの中のジェネリックメソッドなど)
でも全体的には行数は多くても簡単に正しいコードを書ける、とても良い言語と思いました。人気があるわけですね。

SuperSimpleEventSourcingに関しては、現在、C#、Rust 、Goと3つの言語で作りました。どれも人気の言語で、良い言語と感じましたが、個人的にはC#が慣れているというだけでなく、よく設計すれば冗長さを省いて綺麗にかけ、しかもGoと比べるとスピードにも遜色ないので、良いと感じました。

余裕があれば別の言語も書いてみたい。言語や機能のリクエストがあればこちらのコメントやXでのコメントでお願いします!!コメントがあるとやる気が出ます。

https://github.com/J-Tech-Japan/Sekiban

あと、SekibanのGitHubにスターをしてくださるととても喜びます!!

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

Discussion

eihigheihigh

switch文は case 値, 値: のようにカンマ区切りで複数のケースをまとめられます!

tomohisatomohisa

おお!そうですか!やはりGPTのスキルはまだまだですね。ちょっとどれくらいできるか書いてみます。ありがとうございます!

tomohisatomohisa

うむむ、ごめんなさい、頑張っているのですが、出来ませんでした。。。

複数の型スイッチ

ChatGPT

eihigheihigh

おっと、確かにこの場合は使えなさそうです…失礼しました🙇

tomohisatomohisa

そうでしたか!ありがとうございます。できるとだいぶスマートに書けそうなので、機能追加を楽しみにしておきます。コメントありがとうございます!