蒼時弦也
蒼時弦也
資深軟體工程師
發表於

反思:必要性 - Clean Architecture in Go

這篇文章是 Clean Architecture in Go 系列的一部分,你可以透過 Leanpub 提前閱讀內容。

我們討論了不少 Clean Architecture 在 Golang 實踐的方式和技巧,然而我們是否真的有必要在 Golang 使用,以及使用之後我們能獲得怎樣「實質」的優點。我們經常會聽到 Clean Architecture 的優點,卻很少有實際的案例討論。

區隔商業邏輯

在 Clean Architecture 中提到的容易修改通常是指底層元件的抽換,也就是商業邏輯以外的部分。像是我們對資料儲存、API 協定這類情境的範例,就是在展示不修改商業邏輯的前提下,能夠任意地進行替換的特性。

我們不這樣設計也是完全沒問題的,然而很多情境我們經常會有這樣的需求。舉例來說,我們預計從 GCP 轉換到 AWS 上,此時需要將 SDK 轉換,假設我們將這些跟雲端環境呼動的 SDK 跟商業邏輯寫在一起,就會變得難以修改。

1// ...
2func (uc *VerifyUseCase) Execute(ctx context.Context, input *VerifyUseCaseInput) (*VerifyUseCaseOutput, error) {
3  // ...
4
5  // 上傳檔案到 GCS
6  obj := bkt.Object("user-document.pdf")
7  w := obj.NewWriter(ctx)
8  // ...
9}

以上述的例子,GCS 和 S3 的 SDK 實作差異非常大,然而我們的目的都是要把 user-document.pdf 保存到某種 Object Storage 的服務,在商業邏輯的角度上並不需要知道使用哪個服務的細節,因此我們可以像這樣處理。

1type DocumentWriter interface {
2  Write(ctx context.Context, io.Reader) error
3}
4
5func (uc *VerifyUseCase) Execute(ctx context.Context, input *VerifyUseCaseInput) (*VerifyUseCaseOutput, error) {
6  // ...
7  uc.documentWriter.Write(ctx, input.DocumentContent)
8  // ...
9}

那麼未來除非商業邏輯改變,這個步驟不再需要修改檔案,我們就不需要去修改這個商業邏輯。這是實踐 Clean Architecture 後才能有的優點,也讓可測試性可以利用實作各種 Mock 物件來得到改善。

實際上我們依照軟體開發的一些慣例逐步實踐,最終仍會得到這樣的結果,只是 Clean Architecture 透過一個系統性的方式向我們介紹。

可擴充性

另一方面,因為我們將商業邏輯切割開來後,在功能的擴充性也能得到不錯的改善。以下是一個實際的情境,原版的設計只預期到我們只需要限定特定使用者才能存取,因此設計了這樣的行為。

 1type Config struct {
 2	AllowedUserIds []string
 3}
 4
 5// ...
 6type PermissionChecker interface {
 7  IsAllowed(ctx context.Context, userId string) bool
 8}
 9
10// ...
11func NewPermissionChecker(config *config.Config) *MyPermissionChecker {
12  return &MyPermissionChecker{
13    allowedIds: config.AllowedUserIds
14  }
15}
16
17// ...
18var DefaultSet = wire.NewSet(
19  NewPermissionChecker,
20  wire.Bind(new(PermissionChecker), new(*MyPermissionChecker)),
21)
22
23// ...
24func (uc *BatchUpdateUseCase) Execute(ctx context.Context, input *BatchUpdateCaseInput) (*BatchUpdateUseCaseOutput, error) {
25  if !uc.permission.IsAllowed(ctx, input.UserId) {
26    return nil, ErrPermissionNotAllowed
27  }
28}

此時,當我們需要對另一個功能增加使用者權限,但是需要提供不同的使用者權限,此時就可以利用 wire 和 Golang 的特性做一些有趣的調整。

 1type Config struct {
 2	AllowedUpdateUserIds []string
 3	AllowedDeleteUserIds []string // 加入刪除權限
 4}
 5
 6// ...
 7type PermissionChecker interface {
 8  IsAllowed(ctx context.Context, userId string) bool
 9}
10
11// 利用新增型別讓 wire 注入不同物件
12type UpdatePermissionChecker PermissionChecker
13type DeletePermissionChecker PermissionChecker
14
15// ...
16func NewUpdatePermissionChecker(config *config.Config) *MyUpdatePermissionChecker {
17  return &MyUpdatePermissionChecker{
18    allowedIds: config.AllowedUpdateUserIds
19  }
20}
21
22func NewDeletePermissionChecker(config *config.Config) *MyDeletePermissionChecker {
23  return &MyDeletePermissionChecker{
24    allowedIds: config.AllowedDeleteUserIds
25  }
26}
27
28// ...
29var DefaultSet = wire.NewSet(
30  NewUpdatePermissionChecker,
31  wire.Bind(new(UpdatePermissionChecker), new(*MyUpdatePermissionChecker)),
32  NewDeletePermissionChecker,
33  wire.Bind(new(DeletePermissionChecker), new(*MyDeletePermissionChecker)),
34)
35
36// ...
37func (uc *BatchUpdateUseCase) Execute(ctx context.Context, input *BatchUpdateCaseInput) (*BatchUpdateUseCaseOutput, error) {
38  if !uc.permission.IsAllowed(ctx, input.UserId) {
39    return nil, ErrPermissionNotAllowed
40  }
41}
42
43func (uc *BatchDeleteUseCase) Execute(ctx context.Context, input *BatchDeleteCaseInput) (*BatchDeleteUseCaseOutput, error) {
44  if !uc.permission.IsAllowed(ctx, input.UserId) {
45    return nil, ErrPermissionNotAllowed
46  }
47}

因為我們在設計商業邏輯時,為了跟底層細節區隔開來,幾乎所有傳遞給 UseCase 的物件都是介面,因此在擴充時我們只需要考慮哪個物件要注入到哪個介面即可。

基於這樣的理由,在擴充上就不需要在每段使用到特定機制的實作修改,而是調整注入的物件,就可以達到改變行為或者擴充的目的。

因此原有系統的複雜度並不會消失,我們透過切割和轉移將複雜的問題分配在適當的位置,來讓維護系統的難度降低。

可能性

在學習 Clean Architecture 的過程中,另一個會讓人感到疑惑的地方,是一種似乎沒有正確解答的感覺。這是很正常的,因為架構議題通常是大方向,也因此會有許多不一樣的形式和做法。

舉例來說,我們在目前的設計上都沒有使用過 Presenter 類型的物件,因為這樣我們會需要讓 Presenter 拿到當下的 http.ResponseWriter 才能順利指定要呈現的內容。

如果還需要注入到 UseCase 裡面的話,就會跟現有的做法非常不同(可能要有個 UseCaseFactory)但是在 RubyJam 的交流中,我看到 Ruby on Rails 框架倒是很容易能這樣做。

 1class JsonPresenter
 2  def fail(error)
 3    # ...
 4  end
 5
 6  def success(payload)
 7    @controller.render json: payload
 8  end
 9end
10
11# ...
12def update
13  json_presenter = JsonPresenter.new(self)
14
15  usecase = VerifyUseCase.new(
16    presenter: json_presenter
17  )
18
19  usecase.call(params)
20end
21# ...

一部分是因為 Rails 沒有預期使用依賴注入框架,因此都是在呼叫時產生物件,那麼就可以很輕易的把回傳處理的實作當作依賴注入進去。

不同語言也會根據不同語言特性有不同的做法,即使在 Golang 我們使用 wire 或者 fx 可能都會有不一樣的設計方式,然而只要掌握 Clean Architecture 想解決的核心問題,就能打造容易維護、擴充的系統。