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

側欄 Use Case - Clean Architecture in TypeScript

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

購物車側欄第一階段的 API 處理完畢後,我們一樣需要重構為 Use Case 來保持架構的一致性,以及後續的可擴充性,我們會繼續使用 AI 來協力完成這個修改。

定義介面

開始修改之前,我們需要先在 Entity 和 Use Case 把我們預期的商業邏輯定義出來。

加入 src/entity/Cart.ts 這個檔案,實作以下內容。

 1export class CartItem {
 2	constructor(
 3		public readonly id: number, // index in the cart
 4		public readonly name: string,
 5		public readonly unitPrice: number,
 6		public readonly quantity: number,
 7	){}
 8
 9	get totalPrice(): number {
10		return this.unitPrice * this.quantity;
11	}
12}
13
14export class Cart {
15	private _items: CartItem[] = [];
16
17	constructor(
18		public readonly id: string
19	) {}
20
21	get items(): CartItem[] {
22		return [...this._items];
23	}
24
25	updateItem(name: string, unitPrice: number, quantity: number): void {
26		const existingItemIndex = this._items.findIndex(item => item.name === name);
27		if (existingItemIndex >= 0) {
28			this._items[existingItemIndex] = new CartItem(existingItemIndex, name, unitPrice, quantity);
29		} else {
30			const newItem = new CartItem(this._items.length, name, unitPrice, quantity);
31			this._items.push(newItem);
32		}
33	}
34
35	removeItem(name: string): void {
36		const itemIndex = this._items.findIndex(item => item.name === name);
37		if (itemIndex >= 0) {
38			this._items.splice(itemIndex, 1);
39			for (let i = itemIndex; i < this._items.length; i++) {
40				this._items[i] = new CartItem(i, this._items[i].name, this._items[i].unitPrice, this._items[i].quantity);
41			}
42		}
43	}
44}

基本上只需要描述 Cart 的行為,細節的部分像是 GitHub Copilot 的補全機制在這邊可以很快的把剩下的行為實作出來,唯一需要注意的是在商品資料更新的行為有可能跟預期不同,補全後還是需要仔細確認。

接下來在 src/usecase/interface.ts 定義我們所需的 Repository 和 Presenter 到裡面。

1// ...
2
3export interface CartRepository {
4	byId(id: string): Promise<Cart>;
5}
6
7export interface CartPresenter {
8	addItem(item: CartItem): void;
9}

最後加入 src/usecase/getCart.ts 描述我們預期的功能。

 1import { CartRepository, CartPresenter } from './interface';
 2
 3export class GetCartUsecase {
 4  constructor(
 5  	private readonly cartRepository: CartRepository,
 6  	private readonly cartPresenter: CartPresenter,
 7	){}
 8
 9	async execute(id: string): Promise<void> {
10		const cart = await this.cartRepository.byId(id);
11		cart.items.forEach((item) => {
12			this.cartPresenter.addItem(item);
13		});
14	}
15}

進行重構

當我們必要的介面定義完畢後,就可以跟前面的處理一樣讓 AI 來把剩下的細節處理完,而且我們已經有 GetChatMessage 的範例當基準,使用的提示(Prompt)可以直接要求參考這些檔案。

以 Aider 為例子,這次會將 src/controller/cart.tssrc/usecase/interface.tssrc/usecase/getCart.ts 和 Entity 加入外,還會把 src/controller/chat.tssrc/presenter/JsonChatMessagePresenter.ts 等檔案也加入參考,因此提示會變成像下面這樣。

 1Reference to `src/controller/chat.ts` to refactor `src/controller/cart.ts` to use `GetCartUsecase`
 2
 3## CartRepository
 4
 5- Reference `KvChatMessageRepository` to implement `CartRepository` as `KvCartRepository`
 6- Save in memory for now
 7- Define `CartSchema` and `CartItemSchema` for saving in kv
 8- Predefine mock data in repository for development purposes
 9
10## CartPresenter
11
12- Reference `JsonChatMessagePresenter` to implement `CartPresenter` as `JsonCartPresenter`
13- Define `CartSchema` and `CartItemSchema` for presenting in json
14- Define `toJson` method to convert `Cart` to JSON

大多數時候我們的 Repository、Presenter 這類低階元件都會非常類似,以往會耗費大量的時間维護跟修改,以及從裡面抽離商業邏輯出來,但是透過 AI 就能把「類似但不規則」這樣的情況解決,而不需要人工去手動區分差異來维護。

工具使用

取得購物車內容的 Use Case 實作後,我們還可以把它轉換成 AI 可以使用的工具,我們先在 src/service/LlmAssistantService.ts 中調整原本生成訊息的實作,修改為以下版本。

 1// ...
 2
 3const { text } = await generateText({
 4  model: openai(this.env.OPENAI_MODEL),
 5  system: `
 6  You are a helpful assistant that can answer questions and provide information about the shopping cart. You can also retrieve the current contents of the cart when requested.
 7  `,
 8  messages: aiMessages,
 9  maxSteps: 5,
10  tools: {
11    getCart: tool({
12      description: 'Get the current shopping cart contents',
13      parameters: z.object({}),
14      execute: async () => {
15        // TODO: use getCartUseCase
16        return { items: [] };
17      }
18    })
19  }
20});
21
22// ...

我們先簡單的加入 System Prompt 避免被 Tool Use 的資訊影響,並且定義一個尚未實作的工具。

不過 AssistantService 的設計無法知道目前對話的 ID 是多少,我們需要更新一下 src/usecase/interface.tssrc/usecase/chatWithAssistant.ts 加入可以識別對話的資訊。

1// ...
2export interface AssistantService {
3	ask(chatId: string, messages: ChatMessage[]): Promise<string>;
4}
 1export class ChatWithAssistant {
 2
 3	// ...
 4	async execute(id: string, message: string): Promise<void> {
 5		// ...
 6
 7		await this.chatMessageRepository.save(id, newMessages);
 8
 9		newMessages.forEach((message) => {
10			this.chatMessagePresenter.addMessage(message);
11		});
12	}
13}

最後,讓 Aider 參考像是 src/controller/cart.ts 和 Use Case、Entity 等檔案,並且允許編輯 src/controller/chat.ts 加入新的依賴,透過以下提示要求重構。

1Update `LlmAssistantService` to implement `getCart` tool.
2
3- The `cartId` is same as `chatId`
4- Use `getCartUsecase` to retrieve the cart.
5- Add `ToolCartPresenter` to format the cart response, use `toTool()` method.
6- Use dependency injection to provide repository and presenter
7- Create a new usecase in tool handler

完成之後處理一些設計上的細節,現在我們就可以在 LLM 的視窗向 AI 提問購物車有的商品。在測試時要注意 LLM 模型對工具使用的支援狀況,如果使用 Ollama 測試會需要支援工具的模型才能順利呼叫。

除此之外, AI SDK 的 maxSteps 設定必須手動調整,大約在 5 ~ 15 的範圍即可,這是因為目前比較新的模型會自己使用工具到可以回答,如果只有預設的 1 會造成使用完工具就停止,而沒有任何訊息回傳。