Domain-Driven Design 的指標與聚合的關聯
端午節連假利用空檔轉換心情時,把去年重寫後沒有完成的玩具再次拿出來挑戰,剛好在處理一對多的關聯時發現 Domain-Driven Design(領域驅動設計) 中對於 Aggregate(聚合)要作為統一操作其關聯的 Entity(實體)的原因。
這個玩具叫做 Walrus vs Slime 是 2015 年巴哈姆特 Game on Weekend 活動跟大學同學臨時開發的一對一對戰遊戲,目前正在用這幾年新學到的理論重寫作為練習,這篇文章的進度大約是 Commit 128165a 左右的地方,正處於發現 Aggregate 使用有問題的狀態。
房間建立
在我的遊戲實作中,每個對戰都是一間房間(Room)現在要在線上配對對手,所以會有一個 Lobby.StartMatch
命令被執行,並且要滿足以下條件。
- 玩家同時只能在一場對戰中
- 當沒有可以加入的房間時,建立新的房間
因此,當下我想到的 usecase.Room
實作大致上是這樣。
1func (uc *Room) FindOrCreate(sessionID string, team int) *FindRoomResult {
2 // 檢查玩家存在
3 player := uc.players.FindOrCreate(sessionID)
4 if player == nil {
5 return &roomNotAvailableResult
6 }
7
8 // 找出可加入的房間
9 rooms, err := uc.rooms.ListWaitings()
10 if err != nil {
11 return &roomNotAvailableResult
12 }
13
14 // 沒有可加入的房間
15 if len(rooms) == 0 {
16 // 建立新房間
17 room := entity.NewRoom(uuid.NewString())
18 player.Join(room)
19
20 // 保存房間
21 err := uc.rooms.Save(room)
22 if err != nil {
23 return &roomNotAvailableResult
24 }
25
26 // 保存玩家狀態
27 err = uc.players.Save(player)
28 if err != nil {
29 return &roomNotAvailableResult
30 }
31
32 return &FindRoomResult {
33 RoomID: room.ID,
34 IsFound: true,
35 }
36 }
37
38 // TODO: 篩選正確的隊伍並且加入
39 return &roomNotAvailableResult
40}
然而,在這個版本中我們會預期 entity.Room
和 entity.Player
是在同一個 Transaction(交易)下被保存,不應該存在「個別成功」的情況。
在 Ruby on Rails 中,我們可以像這樣處理
1def create
2 # ...
3 ActiveRecord::Base.transaction do
4 room.save!
5 player.save!
6 end
7 # ...
8end
在 Golang 裡面我們依照 Clean Architecture 和 Domain-Driven Design 的設計,Use Case 中能依賴的物件大致上只會有 Domain Model(領域模型)的 Entity、Service 或者 Repository 的實作,那麼這個方式就不太可行。
雖然 Repository 被分類在 Infrastructure Layer 中,我仍認為並不是所有的物件都可以作為依賴的,這會增加耦合以及物件中邏輯的複雜度,就這點而言上述的 Ruby on Rails 使用方式也許不是個好方法。
釐清關係
在上述的例子中,我將 entity.Player
視為一個獨立的實體,並且想要跟一個實際的使用者關聯起來,然而在這個情境裡面 entity.Player
更像是 entity.Room
裡面呈現「參與者」的情報,這表示我對 entity.Player
的意義有所誤解。
在這個情境裡面,更加合理的應該是 「the room has many players(某個房間擁有多個玩家)」的狀況,那麼至少可以確定 entity.Room
會是一個 Aggregate 的定位。
那麼,在資料表中要表示這樣的關係,可以像這樣呈現。
1type roomSchema struct {
2 ID string
3}
4
5type playerSchema struct {
6 ID string // aka SessionID
7 RoomID string
8}
假設我們想要以玩家的角度去看「the player has many rooms」的話也沒問題,上面的 playerSchema
剛好就是扮演 Join Table 的角色,但這就會是在其他的 Boundary Context(上下文邊界)中,屬於另一個 Domain(領域)的實作。
在 Ruby on Rails 中有著 has many to many 的機制存在,過去也很常因為這類型資料要怎麼在 Controller 中操作而頭痛,從上述的角度來看是因為我們沒有區分出 Boundary Context 才會有這樣的困擾。
因此,在錯誤的預期下我實作了 repository.InMemoryPlayer
和 repository.InMemoryRoom
兩個 Repository,就出現了這樣的情況。
1func (repo *InMemoryPlayer) FindOrCreate(id string) *entity.Player {
2 // ...
3 options := make([]entity.PlayerOptionFn, 0)
4
5 // 有 RoomID
6 if len(playerSchema.RoomID) > 0 {
7 // ...
8 options = append(
9 options,
10 // 根據資料庫撈回來的 Room 建立 *entity.Room
11 entity.WithPlayerRoom(buildRoomFromSchema(roomSchema)),
12 )
13 }
14
15 return entity.NewPlayer(playerSchema.ID, options...)
16}
17
18func (repo *InMemoryRoom) ListWaitings() []*entity.Room {
19 // ...
20 for row := it.Next(); row != nil; row = it.Next() {
21 // ...
22
23 // 找出管關聯使用者
24 for playerRow := playerIt.Next(); playerRow != nil; playerRow = playerIt.Next() {
25 // ...
26 options = append(options, entity.WithRoomPlayer(buildPlayerFromSchema(playerSchema)))
27 }
28
29 rooms = append(rooms, entity.NewRoom(roomSchema.ID, options...))
30 }
31
32 return rooms
33}
在上述的實作中,我們會發現從 Player 或者 Room 的物件被建立時,關聯的物件(指標)會是不同的,那麼在後續同一個 Use Case 做任何動作就會變成無法連動,那麼就必定需要分開進行儲存的操作。
分開儲存的另一個情境是同一個 Use Case 需要對兩個不同 Domain 進行呼叫處理,在這種狀況下需要分開儲存是比較合理的狀況,所以會需要考慮 Idemoptency(冪等性)的實現,這也是為什麼 Microservice(微服務)大多是沿著 Domain 切割的原因之一。
要改進這件事情,最簡單的方式就是不要直接的透過 Repository 單獨取出 entity.Player
而是永遠跟著 entity.Room
出現,那麼問題就會簡單很多。
原本的 Use Case 實作調整成像這樣,都是以 Room 為基礎操作。
1func (uc *Room) FindOrCreate(sessionID string, team int) *FindRoomResult {
2 // 檢查是否加入某個房間
3 prevRoom := uc.rooms.FindRoomBySessionID(sessionID)
4 if prevRoom != nil {
5 return &roomNotAvailableResult
6 }
7
8 // 找出可加入的房間
9 rooms, err := uc.rooms.ListWaitings()
10 if err != nil {
11 return &roomNotAvailableResult
12 }
13
14 // 沒有可加入的房間
15 if len(rooms) == 0 {
16 // 建立新房間
17 player := entity.NewPlayer(sessionID, entity.WithPlayerTeam(team))
18 room := entity.NewRoom(uuid.NewString())
19
20 // 決定是否可以加入(如:隊伍錯誤)
21 err := room.AddPlayer(player)
22 if err != nil {
23 return &roomNotAvailableResult
24 }
25
26 // 保存房間
27 err := uc.rooms.Save(room)
28 if err != nil {
29 return &roomNotAvailableResult
30 }
31
32 return &FindRoomResult {
33 RoomID: room.ID,
34 IsFound: true,
35 }
36 }
37
38 // TODO: 篩選正確的隊伍並且加入
39 return &roomNotAvailableResult
40}
因為在對戰的 Domain 下,玩家結束對戰後就會離開房間,並且跟著房間消滅。我們就可以固定在需要 room.AddPlayer()
的前提下,建立一個屬於某個隊伍的 entity.Player
放到裡面,再由 entity.Room
確認是否可以加入。
那麼 Repository 在讀取時只要依序建立物件即可。
1func (repo *InMemoryRoom) ListWaitings() []*entity.Room {
2 // ...
3 for row := it.Next(); row != nil; row = it.Next() {
4 // ...
5 room := entity.NewRoom(roomSchema.ID)
6
7 // 找出管關聯使用者
8 for playerRow := playerIt.Next(); playerRow != nil; playerRow = playerIt.Next() {
9 // ...
10 player := buildPlayerFromSchema(room, playerSchema)
11 err := room.AddPlayer(player)
12 // ...
13 }
14
15 rooms = append(rooms, room)
16 }
17
18 return rooms
19}
20
21// ...
22func buildPlayerFromSchema(room *entity.Room, player *playerSchema) *entity.Player {
23 return entity.NewPlayer(
24 player.ID,
25 entity.WithPlayerRoom(room), // 指標正確對應,然而在這個狀況下可能不需要有 Room 欄位
26 )
27}
同樣的道理,在儲存時我們就只會對 entity.Room
儲存,並且依序將關聯的物件展開成資料表的欄位並且寫入。
1// 取出房間中的玩家
2for _, player := range room.Players {
3 // Insert 是因為 `hashicorp/go-memdb` 的更新也是用這個方法
4 txn.Insert(PlayerTableName, &playerSchema{
5 ID: player.ID,
6 RoomID: room.ID,
7 })
8}
9// ...
這樣一來,我們就能夠在一個 Use Case 下確立某個 Domain 的物件都是以 Aggregate 來做動,就不需要花太多力氣思考指標、原子性的問題,大部分的行為都會變的相對單純。
過去在寫 Ruby on Rails 的時候因為 ORM(Object Relation Mapping)的機制很習慣的會想到什麼就用什麼,然而在 Domain-Driven Design 風格的實作下,倒是會發現有這樣的行為差異,反而對於物件的階層、依賴關係更加的清楚。
未來將這樣的觀念應用回 Ruby on Rails 時,也能讓許多「難以維護」或者「複雜操作」的問題變得更清晰易懂。