前言

身為一個後端工程師,我們每天都在跟 API 打交道。設計一個好的 API,不只是讓它能動就好,更重要的是要「好用」、「好理解」、「好維護」。一個友善的 API 能讓前端同事笑咪咪,也能讓未來的自己少掉很多麻煩。

今天 Hikari 我想跟大家聊聊,在設計 API 時,有幾個非常常見但又常常被討論的議題:分頁(Pagination)、篩選(Filtering)和排序(Sorting)。這些功能幾乎是每個列表型資料都會需要,但設計上卻有各種不同的風格。到底怎麼做,才算是比較「慣例」且「好用」的設計呢?我們一起來拆解看看!

分頁(Pagination):資料太多怎麼辦?

想像一下,如果你要顯示一個有幾十萬筆使用者資料的列表,一次全部撈出來回傳給前端,那會發生什麼事?

  1. 網路延遲爆表:傳輸大量資料需要時間,使用者等到天荒地老。
  2. 伺服器壓力山大:資料庫一次要撈幾十萬筆,記憶體、CPU 可能都吃不消。
  3. 前端卡到不行:瀏覽器一次渲染幾十萬筆 DOM 元素,直接死機給你看。

所以,為了效能考量和使用者體驗,我們一定要做分頁。常見的分頁方式有兩種:傳統的「頁碼分頁」(Offset-based)和比較新潮的「游標分頁」(Cursor-based)。

1. 頁碼分頁(Offset-based Pagination)

這是最直覺、最常見的分頁方式,就像書本的頁碼一樣。通常會透過兩個 Query Parameter 來控制:

  • page (頁碼):從第幾頁開始,通常從 1 或 0 開始算。
  • limit (每頁筆數):每頁要回傳幾筆資料。

範例:

1
2
GET /products?page=1&limit=10  // 取得第一頁的 10 筆商品
GET /products?page=2&limit=20 // 取得第二頁的 20 筆商品

背後原理(SQL 範例):

1
2
3
4
5
6
7
SELECT * FROM products
ORDER BY created_at DESC
LIMIT 10 OFFSET (1 - 1) * 10; -- page=1, limit=10

SELECT * FROM products
ORDER BY created_at DESC
LIMIT 20 OFFSET (2 - 1) * 20; -- page=2, limit=20

優點:

  • 直觀易懂:使用者習慣頁碼,前端開發也簡單。
  • 可以跳頁:可以直接跳到第 10 頁或最後一頁。

缺點:

  • 深層分頁效能差:OFFSET 會讓資料庫先掃描並跳過前面的 N 筆資料,如果 N 很大,效能會非常差。
  • 資料異動問題:在不同頁之間,如果資料有新增或刪除,可能導致使用者看到重複的資料,或遺漏某些資料。

什麼時候用?

  • 資料量不大,或者使用者不太需要翻到很深層的頁面。
  • 需要提供直接跳頁功能,例如後台管理系統。

2. 游標分頁(Cursor-based Pagination)

這種方式不依賴頁碼,而是依賴一個「游標」(Cursor),通常是資料庫中的某個唯一且可排序的欄位值,例如 ID 或時間戳。它會告訴你「從這個點之後,再給我 N 筆資料」。

範例:

1
2
GET /products?after=product_id_123&limit=10  // 取得 ID 在 product_id_123 之後的 10 筆商品
GET /products?before=product_id_456&limit=10 // 取得 ID 在 product_id_456 之前的 10 筆商品 (往前翻頁)

這裡的 after 或 before 值,通常是前一頁最後一筆資料的 ID,或是一個 Base64 編碼過的字串,裡面包含多個排序欄位的值。

背後原理(SQL 範例):

1
2
3
4
SELECT * FROM products
WHERE id > 'product_id_123' -- 或 created_at > '2023-10-27T10:00:00Z'
ORDER BY id ASC
LIMIT 10;

優點:

  • 效能好:直接利用索引進行查詢,沒有 OFFSET 的效能問題。
  • 資料異動影響小:因為是基於特定點後的資料,即使有新資料加入,也不會導致重複或遺漏。
  • 適合無限捲動:前端常見的「滑到底自動載入更多」模式很適合。

缺點:

  • 無法跳頁:只能「下一頁」、「上一頁」,不能直接跳到特定頁碼。
  • 實作較複雜:游標的設計(要包含哪些欄位?要不要編碼?)需要仔細考慮。

什麼時候用?

  • 資料量非常大,且使用者多半是連續翻閱(例如社群媒體的動態牆)。
  • 追求極致效能的場景。

分頁額外資訊:總筆數(Total Count)

在頁碼分頁中,前端常常會需要知道「總共有多少筆資料」來顯示頁碼導覽列。這時候,API 可能需要回傳一個 total 或 total_count 的欄位。

1
2
3
4
5
6
7
8
9
10
{
"data": [
{ "id": "prod-1", "name": "商品 A" },
{ "id": "prod-2", "name": "商品 B" }
],
"page": 1,
"limit": 10,
"total": 100,
"has_next_page": true
}

注意:撈取 total 也是一個 COUNT(*) 的資料庫操作,在資料量極大的情況下,也可能造成效能問題。如果不是非必要,可以考慮只回傳 has_next_page (還有沒有下一頁) 這類布林值即可。

篩選(Filtering):精準找到我要的!

使用者通常不會想看所有資料,他們會想找「某個分類」、「某個價格區間」的資料。這就是篩選功能派上用場的時候了。

最常見的設計方式是利用 Query Parameter。每個參數代表一個篩選條件。

範例:

1
2
3
4
5
6
7
8
9
10
11
// 查詢分類為 'electronics' 的商品
GET /products?category=electronics

// 查詢價格在 100 到 500 之間的商品 (通常會用後綴來表示運算子)
GET /products?price_gte=100&price_lte=500

// 查詢名稱包含 '手機' 的商品 (模糊比對)
GET /products?name_like=手機

// 查詢狀態為 'active' 或 'pending' 的商品
GET /orders?status=active,pending // 用逗號分隔表示 OR 關係 (或用 status[]=active&status[]=pending)

常見的運算子表示法:

在 Query Parameter 中,我們可以透過參數命名來表示不同的運算子:

  • _eq: 等於 (通常省略,直接 param=value 就表示等於)
  • _neq: 不等於
  • _gt: 大於
  • _gte: 大於等於
  • _lt: 小於
  • _lte: 小於等於
  • _like: 模糊比對 (SQL 中的 %LIKE%)
  • _in: 包含在列表中 (多個值,例如 status=active,pending)

實作考量:

  • 安全性:前端傳來的篩選條件務必經過嚴格的參數驗證和淨化,避免 SQL Injection 或其他惡意攻擊。不要直接將參數拼接到 SQL 語句中!
  • 索引優化:根據常用的篩選欄位建立資料庫索引,可以大大提升查詢效能。
  • 參數數量限制:如果篩選條件非常多,可以考慮將部分複雜篩選包裝成一個 JSON 字串,但會增加解析複雜度,不建議過度使用。

排序(Sorting):資料怎麼排才好看?

資料列表通常需要一個預設的排序方式(例如最新建立的在最前面),但使用者也常常需要自己決定要怎麼排序,例如依價格由高到低,或依名稱字母順序。

排序功能通常也透過 Query Parameter 來設計。

範例:

最常見的設計方式是使用一個 sort 參數,後面接 欄位名稱 和 排序方向。

1
2
3
4
5
6
// 依價格由高到低排序
GET /products?sort=price_desc

// 依名稱字母順序排序 (預設 asc,可省略 _asc)
GET /products?sort=name
GET /products?sort=name_asc

如果需要多欄位排序,可以用逗號分隔:

1
2
// 先依價格由高到低,再依名稱字母順序排序
GET /products?sort=price_desc,name_asc

另一種常見寫法是分成兩個參數 sortBy 和 sortOrder:

1
2
GET /products?sortBy=price&sortOrder=desc
GET /products?sortBy=name&sortOrder=asc

Hikari 我個人比較偏好 sort=price_desc,name_asc 這種單一參數,因為在前端組裝和後端解析上都比較簡潔,也更容易擴充多欄位排序。

實作考量:

  • 預設排序:當前端沒有指定排序時,後端應該提供一個合理的預設排序(例如依建立時間倒序 created_at_desc)。
  • 合法欄位檢查:後端必須嚴格檢查傳入的排序欄位是否是合法的、可排序的欄位。千萬不要允許前端指定任何欄位來排序,否則可能會導致資料庫錯誤或洩漏內部結構。可以維護一個白名單 (whitelist)。

綜合應用與眉角

當分頁、篩選、排序這些功能綜合起來時,它們通常都會以 Query Parameters 的形式出現在同一個 API 請求中。

綜合範例:

1
GET /products?page=2&limit=10&category=electronics&price_gte=100&price_lte=500&sort=price_desc,name_asc

幾點實作上的眉角:

  1. 預設值:所有這些參數都應該在後端設定合理的預設值。這樣前端就不需要每次都傳一堆參數,減少溝通成本,也避免前端忘記傳參數導致非預期結果。
    • page: 1
    • limit: 10 或 20 (看產品需求)
    • sort: created_at_desc 或其他合理的預設排序
  2. 驗證機制:再次強調,所有 Query Parameters 都必須經過嚴格的驗證!包括類型、數值範圍、是否在白名單內等等。這是保護 API 安全和穩定的基本功。
  3. API 文件:設計再好的 API,如果沒有清楚的 API 文件,等於白搭。詳細說明每個參數的用途、類型、可接受的值、預設值、運算子等,讓前後端都能快速理解和使用。

小結

分頁、篩選、排序這些看似基本的功能,在 API 設計上卻有很多值得玩味的地方。選擇哪種分頁方式、如何命名篩選參數、怎麼處理多欄位排序,都會影響到 API 的可用性和維護性。

其實沒有一套「絕對正確」的設計,而是要根據你的專案需求、資料量大小、效能考量和前端使用情境來做權衡。但只要我們掌握了這些常見的慣例和背後的考量,並且記得保持一致性、提供彈性、做好驗證、寫好文件,相信你就能設計出一個讓大家都說讚的 API!

希望今天這篇分享對你有幫助!如果你有其他更好的設計方式或踩雷經驗,也歡迎在留言區跟我分享喔!