iT邦幫忙

2026 iThome 鐵人賽

DAY 8
0
佛心分享-IT 人自學之術

出發吧!後端菜鳥:30 天的後端學習紀錄系列 第 8

Day 08|API 不一定會成功:錯誤處理

  • 分享至 

  • xImage
  •  

前言

前面幾天,我們已經完成 Note API 的基本 CRUD,也知道怎麼透過 Express 接收 Request、取得參數與 Request Body,再回傳 JSON Response。

不過目前的 API 大多只處理「事情順利完成」的情況,但實際開發時,API 不一定每次都能順利完成 Request。

例如 Client 可能查詢一筆不存在的 Note、新增 Note 時漏掉必要欄位、傳入不符合預期格式的資料,也可能是 Request 本身沒有問題,但 Server 在執行過程中發生錯誤。

如果遇到這些情況時,API 仍然回傳和成功時相同的結果,前端就很難判斷究竟發生了什麼,因此完成 CRUD 之後,還需要補上對各種異常情況的處理,讓 API 在成功或失敗時都能回傳清楚的結果。

錯誤處理應該從哪裡開始?

剛開始寫 API 錯誤處理時,很容易變成想到什麼就補什麼。發現 Note 不存在,就加一個 404;發現 title 沒有傳,就補一個 400;之後接上資料庫,遇到執行錯誤,又再加入 500

這些處理本身沒有錯,但如果每次都等到問題出現才補,就很難知道自己是不是還漏掉了其他情況。

因此設計錯誤處理時,可以先不要從「有哪些 Error」或「這裡應該用哪個 Status Code」開始,而是先問一個更根本的問題:

一支 API 要成功,需要哪些條件成立?

從 Request 進入 Server,到最後成功回傳 Response,大致可以從四個方向思考:

Request 進來
↓
1. 輸入是否有效?
↓
2. 要操作的資源是否存在?
↓
3. 這個操作是否被允許?
↓
4. Server 是否成功完成操作?
↓
Response

只要處理流程中的必要條件沒有成立,或執行過程發生問題,API 就無法按照原本的成功流程繼續執行,因此需要回傳對應的結果。

換句話說,錯誤處理的重點不是盡可能列出所有可能發生的 Error,而是先找出「成功需要哪些條件」,再思考當這些條件不成立時應該怎麼處理。

第一層:輸入是否有效?

Request 進入 Server 之後,第一件事通常是確認 Client 傳進來的資料能不能使用。

Client 可能透過 URL Parameter、Request Body、Query String 或 Header 傳入資料,只要資料來自 Client,就不能直接假設內容一定符合 API 的預期。

例如我們有一支取得單筆 Note 的 API:

GET /notes/:id

其中 id 是由 Client 提供的。如果 Note 的 ID 預期是正整數,但 Client 卻請求:

GET /notes/abc

那麼問題其實還不是「有沒有這筆 Note」,而是 Client 傳進來的 id 根本不符合 API 預期的格式。

因此可以先把 id 轉成數字,再確認它是不是正整數:

app.get("/notes/:id", function (req, res) {
  const id = Number(req.params.id);

  if (!Number.isInteger(id) || id <= 0) {
    return res.status(400).json({
      message: "id 格式錯誤"
    });
  }
});

新增 Note 時也是一樣。假設 POST /notes 預期收到:

{
  "title": "學習 Express",
  "content": "今天練習錯誤處理"
}

但 Client 只傳入:

{
  "content": "今天練習錯誤處理"
}

這時 title 缺少,也屬於輸入資料本身的問題,因此可以在真正建立 Note 之前先檢查必要欄位:

app.post("/notes", function (req, res) {
  const { title, content } = req.body;

  if (!title || !content) {
    return res.status(400).json({
      message: "title 和 content 為必填欄位"
    });
  }
});

所以在「輸入是否有效」這一層,可以集中思考幾件事:必要欄位有沒有傳、資料型別是否符合預期、格式是否正確、數值是否落在允許範圍,以及字串是否允許為空。

目前的 Note API 先做最基本的檢查即可。實際開發時,還可能需要確認欄位型別、去除空白後是否為空、字串長度或特定格式,之後欄位越來越多時,也可以再透過 Zod 這類 Validation Library 統一處理輸入驗證。

這類輸入不符合 API 要求的情況,常見會回傳 400 Bad Request

第二層:資源是否存在?

確認輸入沒有問題之後,下一步才是檢查 Client 想操作的資源是否真的存在。

例如 Client 請求:

GET /notes/999

假設 999 本身是一個合法的 ID,所以第一層的輸入檢查可以通過,但資料中可能根本沒有 ID 為 999 的 Note。這時 Request 的格式沒有錯,真正的問題是「Client 指定的資源不存在」。

這種情況常出現在需要操作單筆資料的 API,例如:

GET /notes/:id
PATCH /notes/:id
DELETE /notes/:id

這三支 API 雖然做的事情不同,但都有同一個前提:指定的 Note 必須存在。

因此不需要把「GET 找不到」、「PATCH 找不到」、「DELETE 找不到」看成三種完全不同的錯誤,它們其實都屬於同一類問題:資源不存在。

如果是透過 find() 尋找指定 Note,可以這樣處理:

const note = notes.find(function (note) {
  return note.id === id;
});

if (!note) {
  return res.status(404).json({
    message: "找不到這筆 Note"
  });
}

如果是 DELETE,使用 findIndex() 找位置,也可以檢查是否為 -1

const index = notes.findIndex(function (note) {
  return note.id === id;
});

if (index === -1) {
  return res.status(404).json({
    message: "找不到這筆 Note"
  });
}

這幾種情況背後的判斷其實都是一樣的:

Client 提供的 ID 格式正確
↓
Server 嘗試尋找指定資料
↓
找不到這筆資料
↓
404 Not Found

所以只要一支 API 需要操作某筆指定資源,就可以固定問自己:這筆資料真的存在嗎?

第三層:操作是否被允許?

有些情況下,輸入是正確的,資源也確實存在,但這個操作仍然不能執行。

例如未來做會員系統時,可能遇到使用者想修改別人的文章,或一般會員嘗試執行只有管理員才能做的操作;做訂單系統時,也可能遇到訂單已經取消,不能再次取消;做購物功能時,則可能遇到庫存不足,無法完成購買。

這些情況和前兩層不太一樣。Client 傳來的資料沒有格式問題,要操作的資源也存在,但根據目前的系統規則,這個動作仍然不能執行。

這一層可能包含權限驗證,也可能是系統本身的商業邏輯。

可以把它理解成:前兩層在確認「資料能不能處理」,第三層則是在確認「這件事情能不能做」。

例如需求中出現「只有作者本人才能修改文章」、「只有管理員可以刪除會員」、「已取消的訂單不能再次取消」這類敘述時,就代表這些規則之後需要轉換成程式中的判斷。

目前的 Note API 還沒有登入、權限或複雜狀態,因此這一層暫時不需要加入太多程式碼,但先建立這個思考方向即可。

第四層:Server 是否成功完成操作?

前面幾層都沒有問題之後,Server 才會真正執行工作。

但即使 Client 完全沒有做錯事情,Server 仍然可能失敗,例如資料庫突然無法連線、第三方 API 沒有回應、讀取檔案失敗,或者程式在執行過程中發生未預期的例外。

這類問題的共同點是:Request 原本是可以處理的,但 Server 自己沒有成功完成。

這時通常就會進入 Server Error,其中最常見的是:

500 Internal Server Error

目前我們的 Note 資料還只是存在陣列裡,所以這一層暫時不需要寫太多。等後面真正開始操作資料庫、外部服務或其他非同步流程時,再進一步介紹 try...catch 與 Express 的 Error Handling Middleware 會比較自然。

把四層套進同一支 API

建立這四個方向之後,就可以把它們套進不同 API,不需要每次重新想一套錯誤處理方式。

PATCH /notes/:id為例,目前的 Note API 還沒有權限、資料庫或複雜的欄位驗證,因此可以先示範最直接的兩個問題:id 是否有效,以及指定的 Note 是否存在。

app.patch("/notes/:id", function (req, res) {
  const id = Number(req.params.id);

  if (!Number.isInteger(id) || id <= 0) {
    return res.status(400).json({
      message: "id 格式錯誤"
    });
  }

  const note = notes.find(function (note) {
    return note.id === id;
  });

  if (!note) {
    return res.status(404).json({
      message: "找不到這筆 Note"
    });
  }

  const { title, content } = req.body;

  if (title !== undefined) {
    note.title = title;
  }

  if (content !== undefined) {
    note.content = content;
  }

  res.status(200).json({
    data: note
  });
});

閱讀這段程式時,可以按照同樣的順序理解:先檢查輸入是否有效,再確認資源是否存在,條件都成立之後才執行修改並回傳成功結果。

現在這支 API 還很簡單,所以只看得到前兩層;之後加入登入、權限、資料庫或更多商業邏輯時,才會逐漸出現第三層和第四層的處理。

為什麼錯誤處理常使用 return?

沿著前面的流程設計錯誤處理之後,會發現程式通常會先檢查一個條件,如果條件不成立,就直接回傳錯誤,不再執行後面的流程。

例如:

if (!Number.isInteger(id) || id <= 0) {
  return res.status(400).json({
    message: "id 格式錯誤"
  });
}

這裡真正負責把 Response 傳給 Client 的是:

res.status(400).json(...)

前面的 return 則是 JavaScript 本身的語法,用來直接結束目前這個 Route Handler。如果 id 已經確定不是有效格式,就沒有必要繼續尋找 Note,更不可能繼續執行修改或刪除。

如果只有送出錯誤 Response,卻沒有停止後面的程式,例如:

if (!note) {
  res.status(404).json({
    message: "找不到這筆 Note"
  });
}

res.status(200).json({
  data: note
});

當 Note 不存在時,程式會先送出 404,但接著仍然繼續往下執行,又嘗試送出一次 200 Response。同一個 Request 不應該重複送出 Response,因此可能會看到:

Cannot set headers after they are sent to the client

所以當某個條件不成立,而且這次 Request 已經確定不能繼續時,就很常看到:

return res.status(...).json(...);

整體邏輯就是先檢查成功需要的條件,只要其中一個條件失敗,就提早回傳錯誤並結束;只有必要條件都成立,程式才會繼續執行到最後的成功 Response。

Status Code 是結果,不是起點

學習 Error Handling 時,很容易先從 400404500 分別代表什麼開始背,但真正設計 API 時,更適合把 Status Code 放在後面決定。

不要一開始就先問「這裡應該使用哪個 Status Code?」,而是先確認:這支 API 原本需要成立的哪一個條件失敗了?

例如 Client 傳來的 id 格式不正確,可以先判斷這屬於輸入問題,再決定回傳 400 Bad Request;如果 id 格式正確,但 Server 找不到對應資料,就屬於資源不存在,因此回傳 404 Not Found;如果 Request 本身完全正確,但 Server 在執行過程中發生未預期的錯誤,則可能回傳 500 Internal Server Error

所以 Status Code 比較像是前面判斷完成之後的結果,而不是設計錯誤處理時的起點。

目前可以先記住幾個和這篇最相關的狀態碼:

400 Bad Request
Client 傳入的資料不符合 API 要求

404 Not Found
找不到指定的資源

500 Internal Server Error
Server 在處理 Request 時發生未預期的錯誤

至於操作不被允許這一層,之後還會遇到 401403409 等不同情況,可以等做到登入、權限或更完整的商業邏輯時再慢慢補充,不需要現在一次全部背起來。

總結

設計 Error Handling 時,不需要先背所有錯誤或 Status Code,而是可以先問:這支 API 要成功,需要哪些條件成立?

接著沿著處理流程依序檢查:

第一層:輸入
Client 傳進來的資料有效嗎?

第二層:資源
要操作的資料存在嗎?

第三層:規則
目前允許執行這個操作嗎?

第四層:執行
Server 有成功完成操作嗎?

這四層不一定每支 API 都會全部出現,例如 GET /notes 不需要指定單筆資源,因此可能沒有第二層;目前的 Note API 沒有登入或權限,因此第三層幾乎還用不到;還沒有串接資料庫時,第四層也比較單純。

重點不是要求每支 API 都硬塞四種錯誤,而是提供一個固定的檢查方向,需要哪一層,就處理哪一層。

錯誤處理的核心,就是先找出一支 API 成功必須成立的條件,再針對不成立的條件回傳明確而合理的結果。


上一篇
Day 07|CRUD 寫完後:搞懂 Params、Query、Body,並修正 ID
下一篇
Day 09|API 越寫越多怎麼辦?用 Router 拆分路由
系列文
出發吧!後端菜鳥:30 天的後端學習紀錄10
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言