
反思:必要性 - Clean Architecture in Go
我們討論了不少 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 想解決的核心問題,就能打造容易維護、擴充的系統。
如果對這篇文章有興趣,可以透過以下連結繼續閱讀這系列的其他文章。
- 連載介紹 - Clean Architecture in Go
- 目標設定 - Clean Architecture in Go
- wire 的依賴注入 - Clean Architecture in Go
- 案例說明 - Clean Architecture in Go
- 操作介面設計 - Clean Architecture in Go
- Place Order 實作 Controller 部分 - Clean Architecture in Go
- Place Order 實作 Entity 部分 - Clean Architecture in Go
- Place Order 實作 Repository 部分 - Clean Architecture in Go
- Lookup Order 功能 - Clean Architecture in Go
- Tokenization 機制設計 - Clean Architecture in Go
- 在 Place Order 實作 Token 機制 - Clean Architecture in Go
- 在 Lookup Order 實作 Token 機制 - Clean Architecture in Go
- Token 內容加密 - Clean Architecture in Go
- gRPC Server 準備 - Clean Architecture in Go
- gRPC Server 實作 - Clean Architecture in Go
- 輸入檢查 - Clean Architecture in Go
- 資料庫抽換 - BoltDB - Clean Architecture in Go
- 資料庫抽換 - SQLite(一) - Clean Architecture in Go
- 資料庫抽換 - SQLite(二) - Clean Architecture in Go
- 實作 LRU Cache - Clean Architecture in Go
- 反思:必要性 - Clean Architecture in Go