iT邦幫忙

2026 iThome 鐵人賽

DAY 9
1
Modern Web

Angular 22 Signal 進化論系列 第 9 篇

Day 9:httpResource 實務篇,統一回覆格式與 chain 相依請求

  • 分享至 

  • xImage
  •  

昨天我們透過 Orval 串起 API Contract,了解如何根據 OpenAPI 規格產生前端需要的型別與 API 呼叫方式。

接下來延續這個基礎,看看後端採用「統一回覆格式」時,Response 可以怎麼設計,以及這種格式要如何和 Orval 搭配。

統一回覆格式與前端處理

實務上,後端常會把 API Response 統一包成固定格式,讓前端能用一致的方式判斷請求結果,以及不同狀態碼或業務代碼代表的情況。

例如每支 API 都維持 code、message、data 這幾個固定欄位,前端就不需要針對不同 API 各自理解不同的回傳結構。

  • 成功時:透過 code 表示操作成功,實際資料放在 data 中。
{
  "code": "SUCCESS",
  "message": "取得成功",
  "data": {
    "id": 1,
    "name": "Antonio"
  }
}
  • 失敗時:透過 code 表示失敗原因,再搭配 message 提供對應的錯誤訊息。
{
  "code": "USER_NOT_FOUND",
  "message": "查無此使用者",
  "data": null
}

這樣前端就能用固定的結構處理 Response,再根據不同的 code 決定後續邏輯。

不過,如果這套格式只存在於後端實作中,而 OpenAPI 文件仍然只描述原本的資料內容,那麼 Orval 產生的型別就會和實際收到的 Response 不一致。

如果後端使用 NestJS,可以透過自定義 Decorator,把統一回覆格式一起描述到 OpenAPI:

  • 成功回應:透過 @ApiWrappedResponse() 指定 data 的實際型別。
  • 成功訊息:搭配 @ResponseMessage() 設定回應中的 message。
  • 錯誤回應:透過 @ApiWrappedErrorResponse() 描述不同 HTTP Status 對應的錯誤格式。

以取得待辦事項清單為例:

@Get()
@ApiOperation({
  summary: '取得所有待辦事項',
  operationId: 'listTodos',
})
@ApiWrappedResponse(Todo, { isArray: true })
@ResponseMessage('取得成功')
findAll(): Todo[] {
  return this.todosService.findAll();
}

Controller 實際回傳的仍然只是 Todo[],{ code, message, data } 這層統一格式,則由 Interceptor 在 Response 送出前統一包裝。

這些 Decorator 不會改變 Controller 真正回傳的資料,它們的用途是讓 OpenAPI 文件能描述「包裝完成後」的 Response 結構。

把後端一路到前端的流程攤開來看,大致會像這樣:

Nest + Orval 到前端實際應用

因為 OpenAPI 描述的是包好外層之後的格式,所以 Orval 產生的型別,例如圖中的 ListTodos200Output,也會保留 code、message、data 這三個欄位,而不是直接產生 Todo[]。

到了前端,可以再由 Service 把這層 Response 外殼拆掉,只把畫面需要的 data 與 Resource 狀態提供給 Component。

這樣 Component 不需要每支 API 都重複處理 { code, message, data },也不用知道統一回覆格式是怎麼產生的。

除了成功回應之外,前端通常也會透過 Interceptor 或共用的錯誤處理機制,處理不同層級的失敗情況:

  • 連線層錯誤:Request 沒有取得正常的 HTTP Response,例如網路中斷、Server 無法連線,或受到 CORS 阻擋。
  • API 錯誤:後端有收到 Request,但回傳 4xx、5xx 等 HTTP Status,這時可以再搭配統一回覆中的 code 判斷實際錯誤。
  • Runtime Schema 驗證錯誤:HTTP Response 已經成功回來,但資料格式不符合前端預期的 Schema,例如 Zod 驗證失敗。

這三種錯誤可以整理成下面的流程:

前端針對不同層級的錯誤處理策略

把不同層級的錯誤分開處理後,Component 就不需要負責所有錯誤判斷,只要處理真正和 UI 有關的狀態。

這樣也能讓各層職責更清楚。Service 負責整理成功回應並取出畫面需要的資料,而連線錯誤、API 錯誤與 Runtime Validation Error,則交給對應的共用錯誤處理機制。

請求資料彼此相依

有些 API Request 之間會存在先後依賴關係。

例如 B 請求需要 A 請求回傳的資料才能發出,就不能一開始同時送出兩個 Request,而是要先等 A 完成,再根據 A 的結果決定 B 的參數。

這類情境可以搭配 httpResource() 的 chain 功能,讓後面的 Resource 直接依賴前一個 Resource。

例如先取得使用者資料,再根據使用者的 companyId 取得公司資料:

interface User {
  id: number;
  name: string;
  companyId: number;
}

interface Company {
  id: number;
  name: string;
}

userResource = httpResource<User>(
  () => `/api/users/${this.userId()}`
);

companyResource = httpResource<Company>(({ chain }) => {
  const user = chain(this.userResource);

  return `/api/companies/${user.companyId}`;
});

這裡 companyResource 不會一開始就發出 Request,而是會先等待 userResource 取得資料。

整個流程大致會像這樣:

請求互相依賴 httpResource 處理方式

chain 和直接讀取 userResource.value() 的差別,不只在於取得前一個 Resource 的資料,還會把上游 Resource 的狀態一起帶進這段依賴關係。

當上游還在載入、重新載入或發生錯誤時,下游不會繼續發出自己的 Request;只有在上游有可使用的值時,才會繼續往下執行。

httpResourse chain 上下游依賴請求狀態統一

依照上游狀態,可以分成幾種情況:

  • idle:companyResource 也會進入 idle,params function 不會繼續執行。
  • loading / reloading:companyResource 會進入 loading,但不會發出自己的 Request。即使上游正在重新載入,chain() 也不會先拿前一次的舊值繼續往下執行。
  • error:companyResource 也會進入 error,後續 Request 不會發出。
  • resolved / local:chain() 才會回傳目前的值,讓 params function 繼續執行,接著根據 companyId 發出下一個 Request。

補充:local 表示 Resource 的值曾透過 set() 或 update() 在前端直接修改。

當 chain() 正在傳遞 idle、loading、reloading 或 error 狀態時,params function 會停在 chain() 這裡,不會繼續執行後面的程式。

例如:

companyResource = httpResource<Company>(({ chain }) => {
  const user = chain(this.userResource);

  return `/api/companies/${user.companyId}`;
});

只有在 userResource 進入 resolved 或 local 狀態後,才會繼續執行:

return `/api/companies/${user.companyId}`;

另外,即使上游 Resource 已經進入 resolved 或 local,目前的 value() 仍然可能是 undefined。

如果這種情況本來就是允許的,可以在下游 Request 建立前先做判斷:

companyResource = httpResource<Company>(({ chain }) => {
  const companyId = chain(this.userResource)?.companyId;

  return companyId !== undefined
    ? `/api/companies/${companyId}`
    : undefined;
});

這種狀態傳遞也讓畫面處理簡單很多。面對一串彼此相依的請求,可以主要觀察最後一個 Resource:

@if (companyResource.isLoading()) {
  <p>載入中...</p>
}

@if (companyResource.error()) {
  <p>取得資料失敗</p>
}

@if (companyResource.hasValue()) {
  <p>{{ companyResource.value().name }}</p>
}

不需要再自己把 userResource 和 companyResource 的 loading、error 狀態另外組合起來。

這種依賴關係也不只發生在第一次載入。

例如當 userId 改變:

this.userId.set(2);

userResource 會先根據新的 userId 重新取得資料,等新的使用者資料回來後,companyResource 才會使用新的 companyId 再發出 Request:

因此 chain 建立的不是單純「先做 A,再做 B」的一次性流程,而是一段會跟著上游資料與狀態變化重新執行的非同步依賴關係。

如果只是根據 A 的資料同步計算另一個值,並不需要再發出非同步請求,就不需要使用 chain,直接用 computed() 會更合適。

本日結語

今天的兩個主題看起來一前一後,其實在處理同一件事:讓資料的「形狀」與「狀態」在源頭就被說清楚,下游只要跟著走。

統一回覆格式的關鍵不在 { code, message, data } 這個結構本身,而在於它有沒有進到 API Contract。只要 OpenAPI 描述的是真實的 Response,從 Orval 產生的型別、Service 拆殼到錯誤分層,才有同一份可靠的依據。

chain 則把同樣的想法用在非同步流程上:上游的狀態,就是下游能不能開始的前提。不過這也代表上游失敗時,下游一定會跟著失敗。如果下游只是「有更好、沒有也能顯示」的補充資訊,例如拿不到公司資料時仍要照常顯示使用者,就不適合用 chain 把錯誤一路帶下來,讓兩個 Resource 各自呈現狀態會更合適。

資料來源


上一篇
Day 8:用 Orval 串起 Angular API Contract:同時產生 HttpClient、httpResource 與 Zod
下一篇
Day 10:linkedSignal 讓相依狀態也能被修改
系列文
Angular 22 Signal 進化論 共 12 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言