從繁到簡:使用 Zodios 重塑您的 API 串接體驗

當前,API 已經成為了現代軟體開發的重要組成部分,然而,由於其繁雜的串接體驗,對於開發人員來說,這並不總是一個容易的過程。為了簡化這個過程,Zodios 提供了一個解決方案,它可以幫助您從繁雜的 API 串接中解放出來,讓您能夠更輕鬆地開發應用程式。在本文中,我們將探討如何使用 Zodios 來重塑您的 API 串接體驗,並為您提供更簡單、更高效的開發解決方案。

The English version simultaneously published on DEV Community: From Tedious to Simple: Reshaping Your API Integration Experience with Zodios

技術背景

漸強實驗室 Crescendo Lab」在多個大型產品中的技術基礎主要採用 Typescript 和 React。Typescript 能夠幫助我們編寫更穩定且易於維護的程式碼,而 React 則是一個熱門的 JavaScript 前端框架,能夠協助我們快速打造互動性強的網頁應用程式。

我們的網頁應用程式是以 Single Page Application 的形式呈現,讓使用者能夠享受流暢的使用體驗。同時,我們採用 RESTful API 進行資料交換,以便更好地管理和控制資料傳輸。值得一提的是,由於公司產品功能不斷更新,我們常常需要反覆確認 API 和新增功能。因此,這些與 API 有關的串接工作對我們來說非常繁重。

接下來,我們將列出我們在這樣的環境開發中遇到的幾個痛點或常見問題,並提出我們的觀點,分析問題,尋找解決方案。

useEffect 的誤用

以下是一個誤用 useEffect 的經典案例:

useEffect(() => {
async function fetchData() {
const response = await fetch(`https://example.com/api/orgs/${orgId}`);
const json = await response.json();
setData(json);
}
fetchData();
}, [orgId]);

這樣的寫法在 unmount 時會收到錯誤警告,而在切換 orgId 時也可能會出現 race condition。有些人或許會想要 disable 這個 eslint rule react-hooks/exhaustive-deps,以自己控制 useEffect 何時被觸發,但這些操作都是極為危險且會破壞開發體驗。

正確的做法是使用 abort controller signal 或是 cancel flag,這方面網路上資料很多,這裡就不再贅述。

此外,這個範例中也出現了不當的 URL 組合方式,接下來會進一步提到。

不良的 URL 組合方式

URL 中可能出現的參數包括 path parameters 和 search parameters。最常見的錯誤是錯誤地串接這些參數,例如以下的範例:

const url = `users/${userId}/tags?q=${name}`;

以上的參數串接方式可能會因存在特定字元而導致錯誤,因此使用 urlencode 是必要的:

const url = `users/${strictUriEncode(userId)}/tags?q=${strictUriEncode(name)}`;

然而,這種串接方式確實非常醜陋。特別是在搜尋參數的部分,也缺乏彈性。因此,我們更傾向於使用 path-to-regexp 搭配 query-string 或 qs:

const url = pathToRegexp.compile('users/:userId/tags')({ userId }) + '?' + queryString.stringify({ q: name });

如此一來,我們已經成功從 URL 中拆解出參數。然而,我們還有一個難題需要解決,因為我們的 API 使用的是 snack_case 命名規則,而前端的 coding style 則是使用 camelCase。

snake_case vs camelCase

由於命名規則的不同,前端在發出請求之前必須將 search parameter keys 和 request body 內的 property keys 都轉換為 snake_case,並在取得 response 後將 data 內的 property keys 都轉換為 camelCase。這個過程相當繁瑣且重複。理想的設計應該是在更底層就進行轉換,並且在使用時完全不去在意它在傳輸過程的樣貌。

Date 型態的轉換問題

由於在 API 中無法傳遞 Date 物件,通常我們會使用序列化資料,常見的格式有 ISO-8601 / RFC3339(傳數字的都是邪門歪道),我們在每個元件中取得的都是字串,每次使用時都必須再進行一次轉換。假如能自始至終取得 Date 型別的物件,使用資料時的體驗就會好很多,除了很明確地知道該值是 Date 之外(否則都是字串),也可以直接使用 Date 的方法。如果使用 Day.js 的話,直接使用 Day.js 物件也是不錯的選擇。

人類語言的誤解

這是我們公司發生的一件真實故事。為了一個特定的資源,我們設計了新的規範。當初,後端工程師與前端的約定是:舊版本不會有某一個屬性,只有新版本才有。由於沒有這件事的紀錄,我(作為前端工程師)使用了很激進的判斷方式,即 hasOwnProperty。然而,後端工程師只是將該屬性值回傳為 null。這導致了判斷錯誤,因為我將舊版本的資料傳遞到了新版本的處理方式,最終導致程式錯誤並中斷。

另一個有趣的例子也經常發生在工程師與設計師的溝通上,也就是何謂「必填」(required),以 API 的角度 required 是表示該 field 必須存在,但以使用者的角度卻成了「不可為空」(non-empty)。

這樣的資訊落差在人類溝通介面時用簡單的描述很容易發生,但若能夠透過像 schema 或是文件等來協助釐清,甚至能夠提供 playground 測試,真正地透過統一語言達成共識。

在型態定義不一致的情況下,錯誤往往發生在程式運作時,使問題難以追溯。因此更好的做法是在接收到 API 回應後,先對資料進行初步驗證。從團隊角度來看,這可以讓團隊第一時間確定問題應由前端工程師還是後端工程師來處理。有些人可能會認為,在大部分情況下,不進行驗證也能讓程式正常運作。然而,這樣心存僥倖的做法最終會使程式變得不可靠。不過,像 GraphQL 或 tRPC 這種能透過 schema 同時產生前後端接口的方式,就不需要額外進行資料驗證了。

散亂各處的 API

將 API 的實作混雜在 app 中會讓維護變得困難。例如,若我們對 API 進行了調整,我們將很難在一個龐大的專案中找到受影響的程式碼。因此,更理想的做法是將 API 的定義放在一個單獨的 package 中,這樣一旦 API 的規格有任何變化,我們只需要進入該 API package 進行相對應的調整即可。

Cache Key 之亂

對於初學者來說,在使用 TanStack Query 或是 SWR 時最常遇到的挑戰是 cache 及 cache key 的管理。為了讓更新能夠在介面上呈現,通常我們會在請求後使指定的 query invalidate 以重新向後端同步資料。若在 component 內處理此事會變得非常繁瑣,且在開發或維護的過程中很容易有遺漏。繁複的 cache 管理,就像記憶體管理一樣複雜。我們在編寫 JavaScript 程式時,雖然會關注記憶體的使用情況,但並不會真的發出指令去控制它,對吧?若能建立一套邏輯或策略,使 cache 及 cache key 的管理自動化,甚至能讓資料自動更新,這將大大減輕開發的負擔。最簡單也是最極端的方法是每次 mutate 後都 invalidate 所有 query,而像 tRPC 就提供了這樣的策略:https://trpc.io/docs/useContext#invalidate-full-cache-on-every-mutation

與 Zodios 相遇

為了解決上述問題,在尋找工具的過程中,我們建立了一些標準以評估工具:

  • 為了避免使用 useEffect 帶來的心智負擔,需要能夠整合 TanStack Query 或其他類似工具。
  • 必須能夠定義 Date 並將 response 中的 ISO8601 轉換為 Date 型別。
  • 定義必須簡單明瞭。
  • 如果能夠提供 type guard 的話,就更好了。

最近我發現 Zod 在 v3.20 發佈了 z.coerce 功能,尤其是其中的 z.coerce.date() 可以同時驗證並轉換 API 回傳的日期格式,因此我開始在 Zod 的生態系統中尋找解決方案,結果找到了 Zodios。

Zodios 是一個方便的工具,可以透過簡單描述 API 規格,實現程式即文件。它使用了 zod 來實現 end-2-end typesafe。此外,由於它整合了 axios 和 Tanstack Query,因此無論是擴充能力、開發體驗甚至是使用體驗,都能獲得良好的效果。以下是一個簡單的範例,介紹如何透過 Zodios 從定義 API 規格到在 component 中應用:

import { makeApi, Zodios } from '@zodios/core';
import { ZodiosHooks } from '@zodios/react';
import { z } from 'zod';

const api = makeApi([
{
alias: 'getUsers',
method: 'get',
path: 'orgs/:orgId/users',
response: z.array(
z.object({
id: z.string().uuid(),
name: z.string(),
email: z.string().email(),
createdAt: z.coerce.date(),
}),
),
},
]);

const apiClient = new Zodios('/api', api);
const apiHooks = new ZodiosHooks('myAPI', apiClient);
const query = apiHooks.useGetUsers({
params: {
orgId,
},
});

在此之前,我們已經調整了 pnpm workspace 的架構。我們在這個 repository 中建立了一個新的 package,專門用於定義 API。我們在這個專案中建立了一個名為 models.ts 的檔案,用於定義所有 models 的 schema,可以將其視為 Open API 的 Models 的概念。同時,我們會盡量讓模型可以重複使用,對於一些特殊情況,我們會在 endpoint 中 inline 單獨處理。例如,某些特定的 API 會在 response 中缺少一個 status 欄位,此時我們會直接省略它,例如:

// frontend-repo/packages/api-sdk/user.ts

{
alias: 'update',
method: 'put',
path: 'api/v1/orgs/:orgId/users/:userId',
parameters: [
{
name: 'body',
type: 'Body',
schema: CreateUpdateRequestBodySchema,
},
],
response: UserSchema.omit({
status: true,
}),
},

我們將 API 功能切割成多個不同的 module,每個 module 都是一個獨立的檔案,並且都各自使用一個 makeAPI()。此外,我們也對 @zodios/react 做了一些調整,讓每個模組都能夠自動 invalidate 同一個 module 的 query,並且可以在 module 之間建立關聯,使它們可以互相影響,從而省去所有 cache 和 cache key 管理的麻煩,並且不會在每次 mutate 時使畫面上的所有 query invalidate。

/frontend-repo
└── packages
├── api-sdk
│ ├── api
│ │ ├── message.ts
│ │ ├── organization.ts
│ │ ├── team.ts
│ │ └── user.ts
│ └── models.ts
└── app
├── src
├── vite.config.ts
├── package.json
└── tsconfig.json

值得注意的是,上述範例中的 CreateUpdateRequestBodySchema 將被保留在該 module 的檔案中,而不是放在全局共用的 models.ts。這樣做可以避免由於過多僅在特定區域重複使用的內容而使 models.ts 過於臃腫。

由於 Zodios 整合了 axios,因此我們利用這個特點,在 axios 上將 request body 和 query string 的 camelCase 轉成 snake_case,同時將 response 的 snake_case 轉為 camelCase,並且在 axios 上處理 token 的管理以及 baseURL 等瑣事。此外,useMutation().reset() 本身並不會中斷請求,儘管已經重置狀態,但當 response 回來時仍會觸發 onSuccess、onError、onSettled 等 callback。因此,我們還建立了一個簡單的共用函數,可以將其插入 useMutation 回傳的物件中,以同時 reset mutation 並真正取消請求。

我們將最終結果傳送到名為 cantata 的物件中(也是這個 product 的 server 開發代號),從現在開始,我們只需要將 API 規格書寫在這個 package 中,就能夠直接通過這個物件使用所有 API 請求。一旦規格有變化,我們也可以立即透過 typescript 的 type checking 了解受影響範圍並進行相應的調整。

const userListQuery = cantata.user.useList({ orgId });
const currentUserQuery = cantata.user.userGetById({ orgId, userId });

const updateCurrentUserMutation = cantata.user.useUpdate({ orgId, userId });
// Note that both `userListQuery` and `currentUserQuery` will be automatically invalidated after each mutation.

API 規範

list 或 getById 等 query 使用 GET Method; create / update / delete / enable / disable 等 mutation 使用 POST, PUT, PATCH, DELETE。部分的 query 比如說 search 可能會使用 POST 而不是 GET, 此時只要將 API definition 加上 immutable: ture 即可, 該 API 就會從 mutation 變成 query。

為了讓 API 的使用更加簡潔明瞭,我們建議在呼叫 API 時使用 aliases 代替 useQuery 和 useMutation。這樣可以避免因瑣碎的 path 和 method 影響到應用程式。然而,我們不希望這些 aliases 覆蓋原有的 zodios hooks。因此,我們會盡量避免使用 get 和 delete 等名稱作為 aliases,而是使用 getById 和 deleteById 等替代方案。

// ✅ Preferred approach
api.useList();

// ❌ Avoid using this approach
api.useQuery('/list');

更多好處與未來的規劃

使用 Zodios 創建 API SDK 非常容易維護,即使是不熟悉 TypeScript 的後端工程師也能輕鬆上手。並且有了型別檢查,我們甚至可以包裝 makeApi,讓檢查更加嚴格。把這些檔案集中放在一個 package 內,也能讓維護 API 規範更加方便,不受無關內容的干擾。Zodios 還可以直接輸出 OpenAPI 文件,並透過 Swagger UI 等工具瀏覽。此外,既然 API contract 在 repository 中,這意味著它可以像其他程式碼一樣被 commit、發布 Pull Request 進行 code review,甚至還能產生 changelog。

目前 Zodios 官方提供了 React 和 Solid 的整合,同時也有 Express 的整合。我們的下一步計畫是透過 @zodios/express 建立一個 stub server,並在其中維護 test cases,再提供一個介面讓每個 API 可以在 test cases 之間切換,或是直接 bypass 到後端維護的 server,以方便前端或測試工程師在開發或檢視相對應的畫面時切換不同的狀態,並降低前端對後端開發效率和共用環境的依賴。

結語

對於前端程式碼中 API 的維護工作,我們堅信將 API SDK 的開發和應用程式開發分離是改善方向。透過將重複且瑣碎的事情交給底層做統一的處理,直接在應用程式中使用 API 的痛苦將會減輕許多。使用 API SDK 可以大大降低工作成本,提高工作效率。此外,由於維護 API SDK 只需要關注 API contracts 本身,因此也十分輕鬆。將 API 的維護工作從應用程式中抽離出來,對開發人員來說,這是「將一件原本困難的事情變成兩件簡單的事情透過 Zodios 的應用驗證,我們已經得到了一個良好的結果。因此,我們在這裡分享了我們過去所見到的問題和想法,希望能為大家提供幫助。

Reference

這篇文章透過 ChatGPT 協助完成,這張圖片也透過 Stable Diffusion 生成。

--

--