在GO中建立DynamoDB事件商店:快照和投影
草稿
在Part 2中,我们为活动商店构建了Append
和Query
功能,这使我们列出了此事件列表:
store.Event{Id:"58f02691-78ed-4ca5-8e59-8f4deb44e063", Version:0, CharacterName:"cpustejovsky", CharacterHitPoints:8, Note:"Init"}
store.Event{Id:"58f02691-78ed-4ca5-8e59-8f4deb44e063", Version:1, CharacterName:"cpustejovsky", CharacterHitPoints:-2, Note:"Slashing damage from goblin"}
store.Event{Id:"58f02691-78ed-4ca5-8e59-8f4deb44e063", Version:2, CharacterName:"cpustejovsky", CharacterHitPoints:-3, Note:"bludgeoning damage from bugbear"}
要利用从查询中返回的一长串事件并管理越来越长的事件列表,我们将构建为Project
和Snapshot
我们的事件构建功能。
投影
我们可以在列表中的每个事件中手动将CharacterHitPoints
添加在一起,并将它们添加在一起以获取我们角色的当前生命值。 (8 + -2 + -3 = 3
)。
为了使其易于使用,我们可以创建一个投影。投影将采取我们拥有的事件流,并将其读取。在这种情况下,您可以想象投影到UI的值以显示播放器角色的当前生命值。 Project
将占用Event
ID,并以重构状态返回Event
。让我们首先写我们的测试:
t.Run("Project Events from Event Store", func(t *testing.T) {
aggEvent, err := es.Project(ctx, id)
require.Nil(t, err)
assert.Equal(t, 8, aggEvent.CharacterHitPoints)
assert.Equal(t, "cpustejovsky", aggEvent.CharacterName)
})
通过测试失败,我们可以开始使其通过。我们需要查询我们的活动商店并进行范围遍历活动,将它们重新安置到一个状态。
// Project takes an id, queries events since the last snapshot, and returns a reconstituted Event
func (es *EventStore) Project(ctx context.Context, id string) (*Event, error) {
var agg Event
//Query events
events, err := es.QueryAll(ctx, id)
if err != nil {
return nil, err
}
//Reconstitute the event
for i, event := range events {
if i == len(events)-1 {
agg.Id = event.Id
agg.CharacterName = event.CharacterName
agg.Version = event.Version + 1
}
agg.CharacterHitPoints += event.CharacterHitPoints
}
return &agg, nil
}
现在我们的测试应该通过。作为重构,我们可以将重构逻辑从Project
方法分开:
// Project takes an id, queries events since the last snapshot, and returns an aggregated Event
func (es *EventStore) Project(ctx context.Context, id string) (*Event, error) {
events, err := es.QueryAll(ctx, id)
if err != nil {
return nil, err
}
agg := reconstituteEvent(events)
return &agg, nil
}
func reconstituteEvent(events []Event) Event {
var agg Event
for i, event := range events {
if i == len(events)-1 {
agg.Id = event.Id
agg.CharacterName = event.CharacterName
agg.Version = event.Version + 1
}
agg.CharacterHitPoints += event.CharacterHitPoints
}
return agg
}
快照
由于我们有更多的DND会话,并且对玩家角色的生命值进行了越来越多的更改,因此我们将遇到麻烦处理日益增长的事件。为了帮助这一点,我们可以在活动日志中的点上拍摄状态的快照。这将意味着活动商店每次投影时都不需要查询每个事件。
首先,让我们写我们的测试。我们的Snapshot
方法将采用上下文和重构事件e
并返回错误:
t.Run("Snapshot should return no error", func(t *testing.T) {
e, err := es.Project(ctx, id)
assert.Nil(t, err)
err = es.Snapshot(ctx, e)
assert.Nil(t, err)
})
有多种方法可以实现此功能,但是目前,我坚持使用DynamoDB的单个表设计,并使用同一表作为快照与标准事件使用。结果,我们的Snapshot
方法是使用Append
方法将快照事件添加到:
// using a constant instead of a hardcoded value for it to be easily used in multiple places
const SnapshotValue string = "SNAPSHOT"
//...
func (es *EventStore) Snapshot(ctx context.Context, agg *Event) error {
e := &Event{
Id: agg.Id,
Version: agg.Version,
CharacterName: agg.CharacterName,
CharacterHitPoints: agg.CharacterHitPoints,
Note: SnapshotValue,
}
return es.Append(ctx, e)
}
在Snapshot
测试通过时,我们的下一步应该是查看我们的QueryAll
是否仍然有效。让我们写测试:
t.Run("QueryAll should not return the snapshot", func(t *testing.T) {
queriedEvents, err := es.QueryAll(ctx, id)
assert.Nil(t, err)
assert.Equal(t, len(events), len(queriedEvents))
for _, event := range events {
assert.NotEqual(t, store.SnapshotValue, event.Note)
}
})
此测试失败了,因为我们没有修改QueryAll
来跳过快照。我们可以对DynamoDB参数进行修改以使测试工作:
func (es *EventStore) QueryAll(ctx context.Context, id string) ([]Event, error) {
params := dynamodb.QueryInput{
TableName: aws.String(es.Table),
KeyConditionExpression: aws.String("Id = :uuid"),
FilterExpression: aws.String("Note <> :note"),
ExpressionAttributeValues: map[string]types.AttributeValue{
":uuid": &types.AttributeValueMemberS{Value: id},
":note": &types.AttributeValueMemberS{Value: SnapshotValue},
},
}
//...
评估
您可以从第3部分here查看我们当前的代码。有很多功能可以添加,但是我想在接下来的两个部分中解决两个关键问题。
首先,活动商店只能跟踪玩家角色的生命值。我们需要抽象Event
才能代表无数的不同事件。
第二,我们将活动存储列为DynamoDB客户端。这对于我们的集成测试很好,但会为任何依赖我们的活动商店的代码进行负担,但不关注活动商店的行为。为了解决这个问题,我们将使用接口和模拟。
有任何问题或评论吗?关于事件预测或快照,我错过或错了吗?让我在评论中知道或在Twitter或Gopher's Slack上与我联系。