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

Lookup Order 功能 - Clean Architecture in Go

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

基於 Place Order 的實作,我們的專案已經有一個不錯的雛形可以在相當容易擴充的狀況下繼續進行。然而,如果只能建立訂單仍不足以驗證功能完善,因此我們接下來要加入 Lookup Order 來確認是否順利的將資料儲存到記憶體中。

擴展 API

原本我們利用 oapi-codegen 產生了 POST /orders 這個端點(Endpoint),接下來我們需要更新一下文件,加入 GET /orders/{orderId} 這個新的端點用於 Lookup Order 的實作。

 1# ...
 2paths:
 3  /orders/{orderId}:
 4    get:
 5      summary: Lookup an order
 6      operationId: lookupOrder
 7      tags:
 8        - order
 9      parameters:
10        - in: path
11          name: orderId
12          required: true
13          description: The UUID of order
14          schema:
15            type: string
16            example: 2ef749d9-b25e-49df-8ff3-54f3873fffb8
17      responses:
18        200:
19          description: Order found
20          content:
21            application/json:
22              schema:
23                $ref: '#/components/schemas/LookupOrderResponse'
24# ...
25components:
26  schemas:
27# ....
28    LookupOrderResponse:
29      type: object
30      required:
31        - id
32        - name
33        - items
34      properties:
35        id:
36          type: string
37          example: 2ef749d9-b25e-49df-8ff3-54f3873fffb8
38        name:
39          type: string
40          example: Aotoki
41        items:
42          type: array
43          minimum: 1
44          items:
45            $ref: '#/components/schemas/OrderItem'
46# ..

基本上跟我們在處理 Place Order 的方式相同,然而 LookupOrderResponse 在這裡選擇跟 PlaceOrderResponse 重複的原因,是考量到這兩個行為的回傳並不一定會在未來相同,這跟實作時,不要太快嘗試消除重複的想法是類似的。

接下來,在 internal/api/rest/lookup_order.go 實作新產生的方法,我們就可以接續到 UseCase 的處理。

 1package rest
 2
 3// ...
 4
 5func (api *Api) LookupOrder(w http.ResponseWriter, r *http.Request, orderId string) {
 6	out, err := api.LookupOrderUsecase.Execute(r.Context(), &usecase.LookupOrderInput{
 7		Id: orderId,
 8	})
 9
10	if err != nil {
11		http.Error(w, err.Error(), http.StatusBadRequest)
12		return
13	}
14
15	res := buildLookupOrderResponse(out)
16	if err := json.NewEncoder(w).Encode(res); err != nil {
17		http.Error(w, err.Error(), http.StatusInternalServerError)
18		return
19	}
20}
21
22func buildLookupOrderResponse(out *usecase.LookupOrderOutput) *LookupOrderResponse {
23	outItems := make([]OrderItem, 0, len(out.Items))
24	for _, item := range out.Items {
25		outItems = append(outItems, OrderItem{
26			Name:      item.Name,
27			Quantity:  float32(item.Quantity),
28			UnitPrice: float32(item.UnitPrice),
29		})
30	}
31
32	return &LookupOrderResponse{
33		Id:    out.Id,
34		Name:  out.Name,
35		Items: outItems,
36	}
37}

實作 UseCase

UseCase 處理上跟 Place Order 大致上相同。因為原本的 Repository 介面並沒有描述如何讀取資料,因此我們需要先擴充 OrderRepository 的方法。

1type OrderRepository interface {
2	Find(ctx context.Context, id string) (*orders.Order, error)
3	Save(ctx context.Context, order *orders.Order) error
4}

基於 Golang 在 io.Reader 的設計,還能透過組合多個介面提供像是 io.ReadWriter 等不同情境,在規劃介面時也需要仔細思考是否附加太多方法,這可能會造成測試上的不便。

有了 Find() 方法後,我們就可以實作一個取出特定 Order 的 UseCase 來滿足這個需求。

 1package usecase
 2
 3import "context"
 4
 5type LookupOrderItem struct {
 6	Name      string
 7	Quantity  int
 8	UnitPrice int
 9}
10
11type LookupOrderInput struct {
12	Id string
13}
14
15type LookupOrderOutput struct {
16	Id    string
17	Name  string
18	Items []LookupOrderItem
19}
20
21type LookupOrder struct {
22	orders OrderRepository
23}
24
25func NewLookupOrder(orders OrderRepository) *LookupOrder {
26	return &LookupOrder{
27		orders: orders,
28	}
29}
30
31func (u *LookupOrder) Execute(ctx context.Context, input *LookupOrderInput) (*LookupOrderOutput, error) {
32	order, err := u.orders.Find(ctx, input.Id)
33	if err != nil {
34		return nil, err
35	}
36
37	out := &LookupOrderOutput{
38		Id:    order.Id(),
39		Name:  order.CustomerName(),
40		Items: []LookupOrderItem{},
41	}
42
43	for _, item := range order.Items() {
44		out.Items = append(out.Items, LookupOrderItem{
45			Name:      item.Name(),
46			Quantity:  item.Quantity(),
47			UnitPrice: item.UnitPrice(),
48		})
49	}
50
51	return out, nil
52}

接下來,我們繼續把 Repository 實作到裡面,就可以讓 Lookup Order 功能正常運作。

實作 Repository

Repository 的部分基本上沒有太大需要修改,然而我們會遇到「找不到」的情境,因此需要先在 internal/entity/orders/order.go 中加入新的錯誤類型。

1// ...
2var (
3	ErrItemNameMustBeUnique = errors.New("item name must be unique")
4	ErrOrderNotFound        = errors.New("order not found")
5)
6// ...

在這裡選擇在 Entity 中定義錯誤,因為「訂單不存在」如果在 Repository 中定義的話,UseCase 就必須依賴於 Repository 上。同時,這個錯誤的知識存在於訂單相關的實體,也會比在 Repository 中合理不少。

撰寫本系列時,仍然沒有找到非常優秀的設計方式,以 Clean Architecture 的思考方式來看,在 Entity 或 UseCase 會合理不少。

在定義好錯誤後,就可以更新 internal/repository/in_memory_orders.go 來增加 Find() 的實作。

 1package repository
 2
 3// ...
 4
 5type InMemoryOrderRepository struct {
 6	orders map[string]InMemoryOrderSchema
 7}
 8
 9// ...
10
11func (r *InMemoryOrderRepository) Find(ctx context.Context, id string) (*orders.Order, error) {
12	orderSchema, ok := r.orders[id]
13	if !ok {
14		return nil, orders.ErrOrderNotFound
15	}
16
17	order := orders.New(id, orderSchema.CustomerName)
18	for _, itemSchema := range orderSchema.Items {
19		err := order.AddItem(itemSchema.Name, itemSchema.Quantity, itemSchema.UnitPrice)
20		if err != nil {
21			return nil, err
22		}
23	}
24
25	return order, nil
26}

到此為止,我們就有一個非常常見的 API 雛形,在 MVC 框架的架構下也不難做出這樣的實作。因此,下一篇開始我們要開始逐步讓這個功能稍微複雜一些,來了解 Clean Architecture 對於較大的系統能提供的彈性以及擴充性。