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

從 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(使用者案例)的部分,也就是需求本身的實作。當我們在思考實現這個需求時,我們對於「處理資料集」「轉交資料集」這兩件事情的描述,是哪一種類呢?

  1. 使用 CSV 格式處理、由 DataService 接收
  2. 使用 Parser 取得 Dataset 物件、由 Updater 受理

假設是 1 的版本,那麼就跟前面直接在 Controller 上實作的狀況是差不多的,抽象化的程度較低。因為我們還知道 CSVDataService 這樣的細節。

當我們轉換到了 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 中使用的名詞等等