---
title: "Domain-Driven Design 的指標與聚合的關聯"
date: 2023-06-28T00:00:00+08:00
publishDate: 2023-06-28T00:00:00+08:00
lastmod: 2023-06-24T20:56:00+08:00
tags: ["Golang","心得","Domain-Driven Design","指標","Aggregate"]
toc: true
permalink: "https://blog.aotoki.me/posts/2023/06/28/the-relation-of-aggregate-and-pointer-in-ddd/"
language: "zh-tw"
---


端午節連假利用空檔轉換心情時，把去年重寫後沒有完成的玩具再次拿出來挑戰，剛好在處理一對多的關聯時發現 Domain-Driven Design（領域驅動設計） 中對於 Aggregate（聚合）要作為統一操作其關聯的 Entity（實體）的原因。

<!--more-->

> 這個玩具叫做 [Walrus vs Slime](https://github.com/elct9620/wvs) 是 2015 年巴哈姆特 Game on Weekend 活動跟大學同學臨時開發的一對一對戰遊戲，目前正在用這幾年新學到的理論重寫作為練習，這篇文章的進度大約是 Commit [128165a](https://github.com/elct9620/wvs/commit/128165aa7f097f785172926d89bac2938858b7f1) 左右的地方，正處於發現 Aggregate 使用有問題的狀態。

## 房間建立{#room-creation}

在我的遊戲實作中，每個對戰都是一間房間（Room）現在要在線上配對對手，所以會有一個 `Lobby.StartMatch` 命令被執行，並且要滿足以下條件。

* 玩家同時只能在一場對戰中
* 當沒有可以加入的房間時，建立新的房間

因此，當下我想到的 `usecase.Room` 實作大致上是這樣。

```go
func (uc *Room) FindOrCreate(sessionID string, team int) *FindRoomResult {
  // 檢查玩家存在
  player := uc.players.FindOrCreate(sessionID)
  if player == nil {
    return &roomNotAvailableResult
  }

  // 找出可加入的房間
  rooms, err := uc.rooms.ListWaitings()
  if err != nil {
    return &roomNotAvailableResult
  }

  // 沒有可加入的房間
  if len(rooms) == 0 {
    // 建立新房間
    room := entity.NewRoom(uuid.NewString())
    player.Join(room)

    // 保存房間
    err := uc.rooms.Save(room)
    if err != nil {
      return &roomNotAvailableResult
    }

    // 保存玩家狀態
	err = uc.players.Save(player)
	if err != nil {
	  return &roomNotAvailableResult
	}

	return &FindRoomResult {
	  RoomID: room.ID,
	  IsFound: true,
	}
  }

  // TODO: 篩選正確的隊伍並且加入
  return &roomNotAvailableResult
}
```

然而，在這個版本中我們會預期 `entity.Room` 和 `entity.Player` 是在同一個 Transaction（交易）下被保存，不應該存在「個別成功」的情況。

在 Ruby on Rails 中，我們可以像這樣處理

```ruby
def create
  # ...
  ActiveRecord::Base.transaction do
    room.save!
    player.save!
  end
  # ...
end
```

在 Golang 裡面我們依照 Clean Architecture 和 Domain-Driven Design 的設計，Use Case 中能依賴的物件大致上只會有 Domain Model（領域模型）的 Entity、Service 或者 Repository 的實作，那麼這個方式就不太可行。

> 雖然 Repository 被分類在 Infrastructure Layer 中，我仍認為並不是所有的物件都可以作為依賴的，這會增加耦合以及物件中邏輯的複雜度，就這點而言上述的 Ruby on Rails 使用方式也許不是個好方法。

## 釐清關係{#clarify-association}

在上述的例子中，我將 `entity.Player` 視為一個獨立的實體，並且想要跟一個實際的使用者關聯起來，然而在這個情境裡面 `entity.Player` 更像是 `entity.Room` 裡面呈現「參與者」的情報，這表示我對 `entity.Player` 的意義有所誤解。

在這個情境裡面，更加合理的應該是 「the room has many players（某個房間擁有多個玩家）」的狀況，那麼至少可以確定 `entity.Room` 會是一個 Aggregate 的定位。

那麼，在資料表中要表示這樣的關係，可以像這樣呈現。

```go
type roomSchema struct {
  ID string
}

type playerSchema struct {
  ID string // aka SessionID
  RoomID string
}
```

假設我們想要以玩家的角度去看「the player has many rooms」的話也沒問題，上面的 `playerSchema` 剛好就是扮演 Join Table 的角色，但這就會是在其他的 Boundary Context（上下文邊界）中，屬於另一個 Domain（領域）的實作。

> 在 Ruby on Rails 中有著 [has many to many](https://guides.rubyonrails.org/association_basics.html#choosing-between-has-many-through-and-has-and-belongs-to-many) 的機制存在，過去也很常因為這類型資料要怎麼在 Controller 中操作而頭痛，從上述的角度來看是因為我們沒有區分出 Boundary Context 才會有這樣的困擾。

因此，在錯誤的預期下我實作了 `repository.InMemoryPlayer` 和 `repository.InMemoryRoom` 兩個 Repository，就出現了這樣的情況。

```go
func (repo *InMemoryPlayer) FindOrCreate(id string) *entity.Player {
  // ...
  options := make([]entity.PlayerOptionFn, 0)

  // 有 RoomID
  if len(playerSchema.RoomID) > 0 {
    // ...
    options = append(
      options,
      // 根據資料庫撈回來的 Room 建立 *entity.Room
      entity.WithPlayerRoom(buildRoomFromSchema(roomSchema)),
    )
  }

  return entity.NewPlayer(playerSchema.ID, options...)
}

func (repo *InMemoryRoom) ListWaitings() []*entity.Room {
  // ...
  for row := it.Next(); row != nil; row = it.Next() {
    // ...

    // 找出管關聯使用者
    for playerRow := playerIt.Next(); playerRow != nil; playerRow = playerIt.Next() {
      // ...
      options = append(options, entity.WithRoomPlayer(buildPlayerFromSchema(playerSchema)))
    }

    rooms = append(rooms, entity.NewRoom(roomSchema.ID, options...))
  }

  return rooms
}
```

在上述的實作中，我們會發現從 Player 或者 Room 的物件被建立時，關聯的物件（指標）會是不同的，那麼在後續同一個 Use Case 做任何動作就會變成無法連動，那麼就必定需要分開進行儲存的操作。

> 分開儲存的另一個情境是同一個 Use Case 需要對兩個不同 Domain 進行呼叫處理，在這種狀況下需要分開儲存是比較合理的狀況，所以會需要考慮 Idemoptency（冪等性）的實現，這也是為什麼 Microservice（微服務）大多是沿著 Domain 切割的原因之一。

要改進這件事情，最簡單的方式就是不要直接的透過 Repository 單獨取出 `entity.Player` 而是永遠跟著 `entity.Room` 出現，那麼問題就會簡單很多。

原本的 Use Case 實作調整成像這樣，都是以 Room 為基礎操作。

```go
func (uc *Room) FindOrCreate(sessionID string, team int) *FindRoomResult {
  // 檢查是否加入某個房間
  prevRoom := uc.rooms.FindRoomBySessionID(sessionID)
  if prevRoom != nil {
    return &roomNotAvailableResult
  }

  // 找出可加入的房間
  rooms, err := uc.rooms.ListWaitings()
  if err != nil {
    return &roomNotAvailableResult
  }

  // 沒有可加入的房間
  if len(rooms) == 0 {
    // 建立新房間
    player := entity.NewPlayer(sessionID, entity.WithPlayerTeam(team))
    room := entity.NewRoom(uuid.NewString())

    // 決定是否可以加入（如：隊伍錯誤）
    err := room.AddPlayer(player)
    if err != nil {
      return &roomNotAvailableResult
    }

    // 保存房間
    err := uc.rooms.Save(room)
    if err != nil {
      return &roomNotAvailableResult
    }

	return &FindRoomResult {
	  RoomID: room.ID,
	  IsFound: true,
	}
  }

  // TODO: 篩選正確的隊伍並且加入
  return &roomNotAvailableResult
}
```

因為在對戰的 Domain 下，玩家結束對戰後就會離開房間，並且跟著房間消滅。我們就可以固定在需要 `room.AddPlayer()` 的前提下，建立一個屬於某個隊伍的 `entity.Player` 放到裡面，再由 `entity.Room` 確認是否可以加入。

那麼 Repository 在讀取時只要依序建立物件即可。

```go
func (repo *InMemoryRoom) ListWaitings() []*entity.Room {
  // ...
  for row := it.Next(); row != nil; row = it.Next() {
    // ...
    room := entity.NewRoom(roomSchema.ID)

    // 找出管關聯使用者
    for playerRow := playerIt.Next(); playerRow != nil; playerRow = playerIt.Next() {
      // ...
      player := buildPlayerFromSchema(room, playerSchema)
      err := room.AddPlayer(player)
      // ...
    }

    rooms = append(rooms, room)
  }

  return rooms
}

// ...
func buildPlayerFromSchema(room *entity.Room, player *playerSchema) *entity.Player {
  return entity.NewPlayer(
    player.ID,
    entity.WithPlayerRoom(room), // 指標正確對應，然而在這個狀況下可能不需要有 Room 欄位
  )
}
```

同樣的道理，在儲存時我們就只會對 `entity.Room` 儲存，並且依序將關聯的物件展開成資料表的欄位並且寫入。

```go
// 取出房間中的玩家
for _, player := range room.Players {
  // Insert 是因為 `hashicorp/go-memdb` 的更新也是用這個方法
  txn.Insert(PlayerTableName, &playerSchema{
    ID: player.ID,
    RoomID: room.ID,
  })
}
// ...
```

這樣一來，我們就能夠在一個 Use Case 下確立某個 Domain 的物件都是以 Aggregate 來做動，就不需要花太多力氣思考指標、原子性的問題，大部分的行為都會變的相對單純。

過去在寫 Ruby on Rails 的時候因為 ORM（Object Relation Mapping）的機制很習慣的會想到什麼就用什麼，然而在 Domain-Driven Design 風格的實作下，倒是會發現有這樣的行為差異，反而對於物件的階層、依賴關係更加的清楚。

未來將這樣的觀念應用回 Ruby on Rails 時，也能讓許多「難以維護」或者「複雜操作」的問題變得更清晰易懂。

