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

對話紀錄 API - Clean Architecture in TypeScript

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

上一階段我們透過 DESIGN.md 根據設計風格限定的 TailwindCSS Design Token 初步完成了 Hono JSX 的對話介面,接下來我們需要將假資料移動到後端,使用 Hono RPC 來處理前後端的整合。

CONVENTIONS.md

因為 Hono RPC 是非常新的概念,在大多數語言模型可能沒有足夠的資料被訓練,我們可以透過加入 CONVENTIONS.md 來描述怎麼使用,不同 IDE 可以放到對應的設定中,像是 GitHub Copilot Agent 可以用 .github/copilot-instructions.md 的方式設定,或者定義新的 Prompt File 來處理。

在這邊我們有一些目的,這個目的是希望 Agent 在幫我們實作時,可以依照特定的方式撰寫,讓 Hono RPC 可以正常運作,可以參考以下檔案內容。

 1# Coding Guidelines
 2
 3These are coding guidelines that should be followed when writing code for the application.
 4
 5## Hono RPC
 6
 7We use Hono RPC style for our API endpoints. Make sure to follow the conventions outlined in the [Hono RPC documentation](https://hono.dev/docs/guides/rpc).
 8
 9### Server
10
11The RPC service is RESTful which defines in the `src/controller` directory. Each service should have its own controller file, and define like this:
12
13```typescript
14import { Hono } from "hono";
15import { z } from "zod";
16import { zValidator } from "@hono/zod-validator";
17
18const app = new Hono<{ Bindings: Env }>();
19
20const echoSchema = z.object({
21  message: z.string(),
22});
23
24const routes = app
25  .get("/", (c) => {
26	  return c.json({ message: "Hello, World!" });
27  })
28  .post("/", zValidator("json", echoSchema),	(c) => {
29	  const body = c.req.json();
30
31	  return c.json({ echo: body });
32 });
33
34 export default routes;
35```
36
37The above example shows how to define a simple RPC service with a GET and POST endpoint. The `zValidator` is used to validate the request body against the defined schema.
38
39The `routes` MUST be exported as the default export of the module. It is important to Hono RPC to know the type of the controller and its endpoints.
40
41The service will register itself as a route in the Hono application, and can be accessed via the defined endpoints.
42
43```typescript
44import { Hono } from "hono";
45
46import EchoController from "@/controller/echo";
47
48const app = new Hono<{ Bindings: Env }>();
49app.route("/api/v1/echo", EchoController);
50```
51
52### Client
53
54The RPC client is defined in the `src/api` directory. Each service should have its own client file, and define like this:
55
56```typescript
57import { hc } from "hono/client";
58
59
60import EchoController from "@/controller/echo";
61
62const client = hc<typeof EchoController>('/api/v1/echo');
63
64export type EchoSchema = {
65  echo: string;
66};
67
68export async function echo(message: string) {
69  const response = await client.index.$post({ json: { message } });
70  return (await response.json()) as EchoSchema;
71}
72```
73
74The type of the client is inferred from the controller, and the client can be used to make requests to the defined endpoints.

上面描述了 Server 端和 Client 該怎麼實作,雖然無法完全保證 AI 會正確的參考,但我們只要不要一口氣修改太多內容,大多可以很好的遵守上述的任務。

修改計劃

下一個階段我們要評估可以把多少任務交給 Agent 來幫我們進行處理,有些太過複雜、特殊,或者缺少參考的案例大多不太適合。

然而我們已經將 Server 和 Client 的案例透過 CONVENTIONS.md 說明慣例,這表示我們在抽離 React Component 內寫死的 Mock Data 後幾乎沒有太多特殊行為,也不會有大範圍修改的問題,可以考慮讓 Agent 一次性完成修改。

以 Aider 為例,我將預計修改的已知檔案加入到編輯範圍中。

/add src/index.tsx
/add src/view/Chat.tsx

接下來使用如下的提示(Prompt)來進行重構,我們已經預期會出現這些檔案,所以可以給予非常精確的指示,來避免不理想的處理。

  • src/index.tsx 加入 /api/chat 的端點
  • src/view/Chat.tsx 重構成呼叫後端 API
  • src/api/Chat.ts 使用 Hono RPC Client 呼叫後端
  • src/controller/ChatController.ts 回傳一段假的對話紀錄
 1Refactor `Chat` to get message from backend. 
 2
 3## Route
 4
 5Update `src/index.tsx` to mounte `/api/chat` to `ChatController`
 6
 7## ChatController
 8
 9Implement a `GET /:id` endpoint with mock data, no need care about the `id` parameter for now.
10
11## Chat API
12
13Define a Chat API client in `src/api/chat.ts` to fetch chat messages from the backend.
14
15## Chat Component
16
17Update `src/components/Chat.tsx` to use the Chat API client to fetch messages from the backend.

因為 Aider 的 Repository Map 機制,在這邊只需要用 Chat 提示就會知道要修改 Chat Component,在不同的 Agent 環境可能會使用不太一樣的方式標記檔案或物件。

下達指示後,等待 Agent 完成修改,我們可以用 npm run dev 啟動 View 確認功能沒有損壞,並切可以讀取到後端的假資料。

檢查實作

很多時候 Agent 的表現不一定會照我們預期的方向發展,這個時候有可能會需要調整提示來改善這個問題,以這一次的情境來說,大致上都符合預期。

除了檔案命名外,我偏好 src/controller/ChatController.ts 這樣的風格,但是在這一個版本的 CONVENTIONS.md 並沒有約定實作的風格,因此表現不如預期是預料之內的事情。

至於是否要調整則看需求而定,有時候大多數訓練資料都採用 src/controller/chat.ts 命名時,要每次都要求 Agent 依照指示並不容易,可以自行評估要使用怎樣的方式維護,因為 Agent 選擇這樣的命名也可能只是偶然。

以下提供這次產生的程式碼實作作為參考,如果產生的實作沒有和範例差異太大,那麼就沒有太多問題。

 1// src/api/chat.ts
 2
 3import { hc } from "hono/client";
 4import ChatController from "@/controller/chat";
 5
 6const client = hc<typeof ChatController>('/api/chat');
 7
 8export interface Message {
 9  id: string;
10  text: string;
11  isUser: boolean;
12  timestamp: string;
13}
14
15export interface ChatResponse {
16  messages: Message[];
17}
18
19export async function getChatMessages(chatId: string): Promise<Message[]> {
20  const response = await client[":id"].$get({
21    param: { id: chatId }
22  });
23
24  const data = await response.json() as ChatResponse;
25  return data.messages;
26}

API Client 的部分遇到了 client 型別是 unknown 的問題,這是因為 Controller 的實作沒有遵守 export default routes 的做法,以下是正確的版本,這是 Hono RPC 很容易被 Agent 寫錯的地方,但不會影響運作。

 1// src/controller/chat.ts
 2
 3import { Hono } from "hono";
 4import { z } from "zod";
 5
 6const app = new Hono<{ Bindings: Env }>();
 7
 8// Mock data for chat messages
 9const mockMessages = [
10  {
11    id: "1",
12    text: "Hello! How can I help you today?",
13    isUser: false,
14    timestamp: new Date().toLocaleTimeString()
15  },
16  {
17    id: "2",
18    text: "I have a question about the application.",
19    isUser: true,
20    timestamp: new Date().toLocaleTimeString()
21  },
22  {
23    id: "3",
24    text: "Sure, I'd be happy to help with any questions you have about the application.",
25    isUser: false,
26    timestamp: new Date().toLocaleTimeString()
27  }
28];
29
30// GET /:id endpoint to fetch chat messages
31const routes = app.get("/:id", (c) => {
32  // For now, we're ignoring the id parameter and returning mock data
33  return c.json({ messages: mockMessages });
34});
35
36export default routes;

在原本的 src/view/Chat.tsx 裡面,假資料的部分應該會被替換成下面的樣子。

 1  useEffect(() => {
 2    const fetchMessages = async () => {
 3      try {
 4        // For now, we're using a hardcoded chat ID
 5        const chatId = "default";
 6        const fetchedMessages = await getChatMessages(chatId);
 7        setMessages(fetchedMessages);
 8      } catch (error) {
 9        console.error("Failed to fetch messages:", error);
10      } finally {
11        setIsLoading(false);
12      }
13    };
14
15    fetchMessages();
16  }, []);

因為我們暫時還沒有要細部處理這個邏輯,所以 API 回應的 Schema 是否正確、Chat ID 的規格是否正確都不是必要的,我們可以在後續微調,現階段只要確保我們在大方向的進展即可。