---
title: "從 Clean Architecture 反思抽象化"
date: 2024-11-27T00:00:00+08:00
publishDate: 2024-11-27T00:00:00+08:00
lastmod: 2024-11-23T11:23:37+08:00
tags: ["Clean Architecture","經驗","心得"]
toc: true
permalink: "https://blog.aotoki.me/posts/2024/11/27/rethink-abstraction-with-clean-architecture/"
language: "zh-tw"
---


最近在帶同事做 [Temperature Reading](https://www.scrum.org/resources/blog/temperature-reading-retrospective) 時拿了一個卡了一段時間的功能出來討論，過程中發現在我們學習軟體開發的經驗中，對於抽象化的訓練大多只停留在「定義一個 Animal 物件」這個層次的理解。

花了一點時間大致上讀了一遍維基百科 [Abstraction](https://en.wikipedia.org/wiki/Abstraction_(computer_science)) 的條目，也驗證了我感受到的狀況。

<!--more-->

## 需求{#requirement}

先來簡單提一下這個功能的需求，簡單來說是要讓公司的 Data PM（負責維護資料）可以更新我們系統中提供給客戶的資訊。在我們的團隊大部分是以微服務（Microservice）方式來組織系統，因此會涉及到 PM 操作的介面，以及更新、蒐集資料的服務。

這個功能並不複雜，用很簡單的使用者故事（User Story）就可以描述。

* 這裡有一批整理好的電話號碼，資料如下
	* 886 165, 安全
	* 886 0938 111 222, 詐騙
	* （略）
* 身為 Data PM，我可以上傳資料
* 當我上傳資料後，會看到「資料已上傳，約 1 小時後完成更新」的訊息

在這個需求裡面，我們要做的事情是將上傳的資料做一次預處理（標記回報者、檢查格式）然後呼叫我們的資料處理服務，將資料集（Dataset）轉交出去由專門的服務進行處理。

實際上這個機制並不複雜，為什麼同事會在 Code Review 一直被要求重新調整呢？

## 常見做法{#common-way}

我們團隊是採用 Clean Architecture 的方式來規劃系統，但是這邊我們先以常見會使用 MVC 框架的處理方式來看，也不考慮各種複雜的情境。

以 Ruby on Rails 作為例子，我們可能會有類似這樣的實作。

```ruby
class NumberDatasetController < ApplicationController
  def create
	# 處理資料
    dataset = params[:dataset].read
	proprocessor = CsvDatasetPreprocessor.new
	cleaned_dataset = proprocessor.execute(dataset)

	cleaned_dataset.each do |data|
		data.user_id = params[:user_id]
	end

    # 上傳資料
	data_api = DataService::Client.new
	res = data_api.update_numbers(cleaned_dataset)

    # 發布通知
	notifier = UpdatedNotifier.new
	event = NumberUpdatedEvent.new(res.id)
	notifier = notifier.publish(event)
  end
end
```

這段程式大部分的人都能順利實作出來，在很多情況下也是足夠滿足需求的狀況。然而，我們以文章開維基百科在抽象化的描述來看，我們很可能只做了流程抽象化（Control abstraction）在其他地方都沒有細緻的處理。

這在比較不複雜的系統上還可以靠記憶力或者文件之類的處理，然而較大的系統就會變成一個學習成本，因為我們需要知道非常多細節。

舉例來說，`DatasetPreprocessor` 是處理哪些資料集、`DataService::Client` 提供了哪些操作、`UpdatedNotifier` 能發佈哪類型的事件等等，都需要有文件或者有經驗的人才知道操作細節，當這類物件大量出現後，就很難維護。

## Clean Architecture 的方式{#clean-architecture-way}

如果放到 Clean Architecture 的角度，我們又會怎麼看待這件事情呢？我認為可以從 Layered architecture 這個抽象化的角度去看，在 Clean Architecture 書中，我們區分了 Framework、Adapter、Use Case、Entity 四個層級，剛好在每個層級都有不同的抽象化目標。

接下來我用 Golang 來舉例，因為我們有個明確的介面（Interface）定義會更容易理解背後的理由。

```go
// internal/usecase/interface.go

type NumberDatasetParser interface {
	Parse(ctx context.Context, dataset io.Reader) (*entity.NumberDataset, error)
}

type DatasetUpdater interface {
	UpdateNumbers(ctx context.Context, dataset *entity.NumberDataset) (string, error)
}

// internal/usecase/batch_number_update.go
type BatchNumberUpdateUseCase struct {
	parser NumberDatasetParser
	updater DatsetUpdater
	notifier Notifier
}

// ...

func (uc *BatchNumberUpdateUseCase) Execute(ctx context.Context, input *BatchNumberUpdateInput) (*BatchNumberUpdateOutput, error) {
	dataset, err := uc.parser.Parse(ctx, input.Dataset)
	if err != nil {
		// ...
	}
	dataset.ApplyUserId(input.UserId)

	id, err := uc.updater.UpdateNumbers(ctx, dataset)
	if err != nil {
		// ...
	}

	event := event.NewNumberUpdatedEvent(id)
	if err := uc.notifier.Publish(event); err != nil {
		// ...
	}

	return &BatchNumberUpdateOutput{
		// ...
	}, nil
}
```

從上面的實作看起來跟在 MVC 框架中常見的做法似乎沒有什麼不同，那麼為什麼有更好的抽象化呢？難道單純只是多了介面定義嗎？

這是因為我們只看到了 Use Case（使用者案例）的部分，也就是需求本身的實作。當我們在思考實現這個需求時，我們對於「處理資料集」「轉交資料集」這兩件事情的描述，是哪一種類呢？

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

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

當我們轉換到了 Clean Architecture 時，對一個 Use Case 來說他只知道他可以透過某種方式實現特定的目的，所以在 Use Case 觀點中看到的是一種資料處理行為（Praser）跟一種資料更新行為（Updater）這就很考驗我們在選擇詞彙的能力，命名就變成軟體開發最困難的地方。

在這個前提下，Clean Architecture 的 Adapter Layer 就會提供很多物件去支持 Use Case Layer 的需要，我們以 `DatasetUpdater` 為例子。

```go
type NumberDatasetUpdater struct {
  numberService *numberservice.Client
  smsService *smsservice.Client
}

// ...

func (u *InternalDatasetUpdater) UpdateNumbers(ctx context.Context, dataset *entity.NumberDataset) (string, error) {
    // ...
	request := numberservice.NewUpdateRequest(ctx)

	for item := range dataset.Items() {
		request.Add(item.Number, item.Rating)
	}


	res, err := u.numberService.Send(request)
	if err != nil {
		// ...
	}

	return res.Id, nil
}
```

這邊刻意的在 `DatasetUpdater` 上加入了 `smsService` 這個 API Client 定義，此時就能理解為什麼 `DatasetUpdater` 不是用 `Update(context.Context, *entity.NumberDataset)` 而是用 `UpdateNumbers()` 命名，因為在 Use Case 的角度他認爲這個 Updater 是可以更新多種資料的。

當然，是否要這樣設計就取決於開發團隊的判斷。這也衍生出另一個有趣的議題，我們在工作中會抱怨 PM 的規格說不清楚，但是我們真正想看到的是什麼？這造成很多 PM 會把「設計細節」一起寫在產品需求上，反而讓工程師失去抽象思考、專業判斷的機會，即使這個規格有時候確實需要寫這麼清楚。

至少，未來我們可以在開發時思考這類問題。如果在一個使用案例中，把一個使用者需求處理完，我們需要在初期就搞清楚他會使用 CSV 還是 JSON 上傳，還是只需要知道存在「解析」「轉交」這樣的流程就足夠進行開發了？

> 受限於篇幅還有不少可以延伸討論的，像是 `entity.NumberDataset` 是否也是一種抽象的類型，我們從單純的 Array 轉變叫做 NumberDataset 的抽象概念，並且限制了能操作的行為，同時也統一在 Use Case 中使用的名詞等等

