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

輸入檢查 - Clean Architecture in Go

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

在前面的案例中,我們並沒有考量到「輸入檢查」這件事情,因此在 gRPC 的案例中,我們發現即使沒有填寫 Name 也可以順利建立訂單,這並不符合我們的期待。

驗證時機

驗證(Validate)到底該在什麼時機點進行,一直以來都沒有一個容易判斷的方式。有些框架會設計在 Model 上,也就是在持久化時才進行檢查,有些則是會附加在 Controller 上,做為參數輸入時就要確認。

然而,我們在處理的過程中會出現好幾種不同的情境。

第一種是編碼(Marshalling)的處理,像是 Golang 將 JSON 反序列化(Deserialize)為一個 Struct 時,如果跟結構的型別無法對應,那麼會發生錯誤。

第二種則是輸入數值的處理,像是 Name 這個必填欄位沒有填寫,價格不能為 0 這類情境,也是我們比較常在 Web 框架上會看到的類型。

第三種則是行為上的處理,我們在 OrderAddItem() 方法,設計了一個檢查商品名稱不能重複的確認,這種檢查通常會跟我們的狀態有關,只有 Entity 本身才能判斷。

也就是說,以 Clean Architecture 的設計來看,我們的驗證處理分別在 Adapter 的 Controller、Repository 會發生關於「編碼」的檢查,到了 UseCase 則有資訊是否滿足的檢查,以及 Entity 的狀態是否正確的檢查。

那麼,訂單沒有填入姓名、商品的單價和數量不對,應該是第二種類型。

Validator Package

要在 UseCase 加入驗證,我們應該要依照 UseCase 不會知道細節的原則,因此在 UseCase 定義一個 Validator 介面,描述驗證的需求。

1// internal/usecase/usecase.go
2
3type Validator interface {
4	Validate(ctx context.Context, input any) error
5}

修改 Place Order UseCase 加入驗證的步驟,這裡我們選擇使用 validator 這個套件,利用 Golang 的 Struct Tags 就不會有直接的依賴關係。

 1// ...
 2type PlaceOrderItem struct {
 3	Name      string `validate:"required"`
 4	Quantity  int    `validate:"required,gte=1"`
 5	UnitPrice int    `validate:"required,gte=1"`
 6}
 7
 8type PlaceOrderInput struct {
 9	Name  string           `validate:"required"`
10	Items []PlaceOrderItem `validate:"required,gt=0,dive,required"`
11}
12
13// ...
14
15type PlaceOrder struct {
16	orders    OrderRepository
17	tokens    TokenRepository
18	validator Validator
19}
20
21func NewPlaceOrder(orders OrderRepository, tokens TokenRepository, validator Validator) *PlaceOrder {
22	return &PlaceOrder{
23		orders:    orders,
24		tokens:    tokens,
25		validator: validator,
26	}
27}
28
29func (u *PlaceOrder) Execute(ctx context.Context, input *PlaceOrderInput) (*PlaceOrderOutput, error) {
30	if err := u.validator.Validate(ctx, input); err != nil {
31		return nil, err
32	}
33
34	// ...
35}

像這樣,我們就可以運行這個 UseCase 之前確定必填的欄位是否都有填寫,以及數量、單價不能為 0

使用 Golang 的 Struct Tags 類似於 Java 的 Annotation 都是透過語言的反射(Reflection)機制來達成的效果,因為是 Metadata 的形式,也就不會直接依賴於某個套件。

因為不同套件使用的 Struct Tags 不同,因此還是一定程度受到影響,然而當我們要抽換時只要使用相同方式就能夠做替換。

最後,我們只需要在 internal/validator 加入簡單的封裝即可。

 1// internal/validator/validator.go
 2
 3// ...
 4
 5type Validator struct {
 6	validate *validator.Validate
 7}
 8
 9func New() *Validator {
10	return &Validator{
11		validate: validator.New(),
12	}
13}
14
15func (v *Validator) Validate(ctx context.Context, i any) error {
16	return v.validate.StructCtx(ctx, i)
17}

之後只需要利用像是 wire 這類工具,針對有 Validator 介面需求的 UseCase 注入這個物件,就可以快速地提供驗證機制。

難以統整

雖然我們將驗證的發生時機點設定在 UseCase 上,但是還是有很多不同的情境。以 OpenAPI 生態系來說,我們是可以在 HTTP 請求階段就做處理。

kin-openapi 這個套件來說,他提供了一個 ValidateRequest 的機制,可以檢查發過來的 HTTP 請求和 API 文件是否一致,包含 HTTP Status Code 和支援的內容類型(Content-Type)都可以處理。

這個做法可以很好的改善開發過程中對 API 處理的失誤,但裡面很大一部分的處理都跟 UseCase 的驗證重疊,假設我們使用了這個機制進行驗證,那麼 UseCase 的驗證基本上是不會觸發的。但是,在 gRPC 的角度來看 Protobuf 定位是跟 JSON 差不多的角色,因此 Protobuf 本身無法做任何額外的驗證方式標記。

這就造成了我們可能在使用 OpenAPI 方式設計 RESTful API 時沒有注意到驗證問題,當支援了 gRPC 協定時,就因為各種輸入驗證的缺少而造成問題。

除此之外,OpenAPI 驗證的訊息跟我們自己封裝的 Validator 也會有差異,這也會造成後續在測試時因為不一致而導致的混亂,這些都會是我們未來會需要去處理和改善的問題。