從 Clean Architecture 反思抽象化
最近在帶同事做 Temperature Reading 時拿了一個卡了一段時間的功能出來討論,過程中發現在我們學習軟體開發的經驗中,對於抽象化的訓練大多只停留在「定義一個 Animal 物件」這個層次的理解。
花了一點時間大致上讀了一遍維基百科 Abstraction 的條目,也驗證了我感受到的狀況。
需求
先來簡單提一下這個功能的需求,簡單來說是要讓公司的 Data PM(負責維護資料)可以更新我們系統中提供給客戶的資訊。在我們的團隊大部分是以微服務(Microservice)方式來組織系統,因此會涉及到 PM 操作的介面,以及更新、蒐集資料的服務。
這個功能並不複雜,用很簡單的使用者故事(User Story)就可以描述。
- 這裡有一批整理好的電話號碼,資料如下
- 886 165, 安全
- 886 0938 111 222, 詐騙
- (略)
- 身為 Data PM,我可以上傳資料
- 當我上傳資料後,會看到「資料已上傳,約 1 小時後完成更新」的訊息
在這個需求裡面,我們要做的事情是將上傳的資料做一次預處理(標記回報者、檢查格式)然後呼叫我們的資料處理服務,將資料集(Dataset)轉交出去由專門的服務進行處理。
實際上這個機制並不複雜,為什麼同事會在 Code Review 一直被要求重新調整呢?
常見做法
我們團隊是採用 Clean Architecture 的方式來規劃系統,但是這邊我們先以常見會使用 MVC 框架的處理方式來看,也不考慮各種複雜的情境。
以 Ruby on Rails 作為例子,我們可能會有類似這樣的實作。
1class NumberDatasetController < ApplicationController
2 def create
3 # 處理資料
4 dataset = params[:dataset].read
5 proprocessor = CsvDatasetPreprocessor.new
6 cleaned_dataset = proprocessor.execute(dataset)
7
8 cleaned_dataset.each do |data|
9 data.user_id = params[:user_id]
10 end
11
12 # 上傳資料
13 data_api = DataService::Client.new
14 res = data_api.update_numbers(cleaned_dataset)
15
16 # 發布通知
17 notifier = UpdatedNotifier.new
18 event = NumberUpdatedEvent.new(res.id)
19 notifier = notifier.publish(event)
20 end
21end
這段程式大部分的人都能順利實作出來,在很多情況下也是足夠滿足需求的狀況。然而,我們以文章開維基百科在抽象化的描述來看,我們很可能只做了流程抽象化(Control abstraction)在其他地方都沒有細緻的處理。
這在比較不複雜的系統上還可以靠記憶力或者文件之類的處理,然而較大的系統就會變成一個學習成本,因為我們需要知道非常多細節。
舉例來說,DatasetPreprocessor
是處理哪些資料集、DataService::Client
提供了哪些操作、UpdatedNotifier
能發佈哪類型的事件等等,都需要有文件或者有經驗的人才知道操作細節,當這類物件大量出現後,就很難維護。
Clean Architecture 的方式
如果放到 Clean Architecture 的角度,我們又會怎麼看待這件事情呢?我認為可以從 Layered architecture 這個抽象化的角度去看,在 Clean Architecture 書中,我們區分了 Framework、Adapter、Use Case、Entity 四個層級,剛好在每個層級都有不同的抽象化目標。
接下來我用 Golang 來舉例,因為我們有個明確的介面(Interface)定義會更容易理解背後的理由。
1// internal/usecase/interface.go
2
3type NumberDatasetParser interface {
4 Parse(ctx context.Context, dataset io.Reader) (*entity.NumberDataset, error)
5}
6
7type DatasetUpdater interface {
8 UpdateNumbers(ctx context.Context, dataset *entity.NumberDataset) (string, error)
9}
10
11// internal/usecase/batch_number_update.go
12type BatchNumberUpdateUseCase struct {
13 parser NumberDatasetParser
14 updater DatsetUpdater
15 notifier Notifier
16}
17
18// ...
19
20func (uc *BatchNumberUpdateUseCase) Execute(ctx context.Context, input *BatchNumberUpdateInput) (*BatchNumberUpdateOutput, error) {
21 dataset, err := uc.parser.Parse(ctx, input.Dataset)
22 if err != nil {
23 // ...
24 }
25 dataset.ApplyUserId(input.UserId)
26
27 id, err := uc.updater.UpdateNumbers(ctx, dataset)
28 if err != nil {
29 // ...
30 }
31
32 event := event.NewNumberUpdatedEvent(id)
33 if err := uc.notifier.Publish(event); err != nil {
34 // ...
35 }
36
37 return &BatchNumberUpdateOutput{
38 // ...
39 }, nil
40}
從上面的實作看起來跟在 MVC 框架中常見的做法似乎沒有什麼不同,那麼為什麼有更好的抽象化呢?難道單純只是多了介面定義嗎?
這是因為我們只看到了 Use Case(使用者案例)的部分,也就是需求本身的實作。當我們在思考實現這個需求時,我們對於「處理資料集」「轉交資料集」這兩件事情的描述,是哪一種類呢?
- 使用
CSV
格式處理、由DataService
接收 - 使用 Parser 取得 Dataset 物件、由 Updater 受理
假設是 1 的版本,那麼就跟前面直接在 Controller 上實作的狀況是差不多的,抽象化的程度較低。因為我們還知道 CSV
和 DataService
這樣的細節。
當我們轉換到了 Clean Architecture 時,對一個 Use Case 來說他只知道他可以透過某種方式實現特定的目的,所以在 Use Case 觀點中看到的是一種資料處理行為(Praser)跟一種資料更新行為(Updater)這就很考驗我們在選擇詞彙的能力,命名就變成軟體開發最困難的地方。
在這個前提下,Clean Architecture 的 Adapter Layer 就會提供很多物件去支持 Use Case Layer 的需要,我們以 DatasetUpdater
為例子。
1type NumberDatasetUpdater struct {
2 numberService *numberservice.Client
3 smsService *smsservice.Client
4}
5
6// ...
7
8func (u *InternalDatasetUpdater) UpdateNumbers(ctx context.Context, dataset *entity.NumberDataset) (string, error) {
9 // ...
10 request := numberservice.NewUpdateRequest(ctx)
11
12 for item := range dataset.Items() {
13 request.Add(item.Number, item.Rating)
14 }
15
16
17 res, err := u.numberService.Send(request)
18 if err != nil {
19 // ...
20 }
21
22 return res.Id, nil
23}
這邊刻意的在 DatasetUpdater
上加入了 smsService
這個 API Client 定義,此時就能理解為什麼 DatasetUpdater
不是用 Update(context.Context, *entity.NumberDataset)
而是用 UpdateNumbers()
命名,因為在 Use Case 的角度他認爲這個 Updater 是可以更新多種資料的。
當然,是否要這樣設計就取決於開發團隊的判斷。這也衍生出另一個有趣的議題,我們在工作中會抱怨 PM 的規格說不清楚,但是我們真正想看到的是什麼?這造成很多 PM 會把「設計細節」一起寫在產品需求上,反而讓工程師失去抽象思考、專業判斷的機會,即使這個規格有時候確實需要寫這麼清楚。
至少,未來我們可以在開發時思考這類問題。如果在一個使用案例中,把一個使用者需求處理完,我們需要在初期就搞清楚他會使用 CSV 還是 JSON 上傳,還是只需要知道存在「解析」「轉交」這樣的流程就足夠進行開發了?
受限於篇幅還有不少可以延伸討論的,像是
entity.NumberDataset
是否也是一種抽象的類型,我們從單純的 Array 轉變叫做 NumberDataset 的抽象概念,並且限制了能操作的行為,同時也統一在 Use Case 中使用的名詞等等