🔗 REST API 設計最佳實踐
📌 什麼是 REST?
REST(Representational State Transfer)是一種設計 API 的風格,不是一種技術。它定義了如何透過 HTTP 協議來操作資源。
想像你經營一家圖書館:
- 資源(Resource) = 書本、會員、借閱記錄
- URL = 書本的分類編號(告訴你去哪個書架找)
- HTTP 方法 = 你要做什麼操作(借書、還書、查書)
- 回應 = 圖書館員給你的答覆和書本
📜 REST 的六大原則
1. 客戶端-伺服器(Client-Server)
→ 像餐廳:客人點餐,廚房做菜,分工明確
2. 無狀態(Stateless)
→ 像售票機:每次購票都要重新選擇,不會記住你上次買了什麼
3. 可快取(Cacheable)
→ 像報紙:今天的頭條可以看好幾次,不用每次都重新印
4. 統一介面(Uniform Interface)
→ 像 USB 接口:不管什麼裝置,插口都一樣
5. 分層系統(Layered System)
→ 像大公司的組織架構:經理不需要知道工廠的細節
6. 按需代碼(Code on Demand,選擇性)
→ 像餐廳提供的食譜:客人可以自己回家做,也可以不用
🛤️ URL 命名規範
// ✅ 好的 URL 設計(名詞、複數、小寫、用破折號分隔)
// 控制器範例
[Route("api/[controller]")] // 基底路由
[ApiController] // 標記為 API 控制器
public class ProductsController : ControllerBase // 商品控制器
{
// GET /api/products → 取得所有商品
[HttpGet] // 對應 GET 方法
public IActionResult GetAll() => Ok(products); // 回傳所有商品
// GET /api/products/5 → 取得單一商品
[HttpGet("{id}")] // 路由參數
public IActionResult GetById(int id) => Ok(product); // 回傳指定商品
// POST /api/products → 建立新商品
[HttpPost] // 對應 POST 方法
public IActionResult Create(Product p) => Created($"/api/products/{p.Id}", p);
// PUT /api/products/5 → 更新整個商品
[HttpPut("{id}")] // 對應 PUT 方法
public IActionResult Update(int id, Product p) => NoContent(); // 更新商品
// DELETE /api/products/5 → 刪除商品
[HttpDelete("{id}")] // 對應 DELETE 方法
public IActionResult Delete(int id) => NoContent(); // 刪除商品
}
❌ 不好的 URL 設計:
/api/getProducts → 動詞不該出現在 URL 中
/api/product → 應該用複數 products
/api/Products → 應該用小寫 products
/api/delete-product/5 → 用 HTTP DELETE 方法,不要把動詞放在 URL
/api/product_list → 用破折號,不要用底線
✅ 好的 URL 設計:
/api/products → 取得所有商品(GET)
/api/products/5 → 取得 ID=5 的商品(GET)
/api/products → 建立新商品(POST)
/api/products/5 → 更新 ID=5 的商品(PUT)
/api/products/5 → 刪除 ID=5 的商品(DELETE)
/api/products/5/reviews → 取得商品 5 的所有評論(子資源)
📄 分頁、篩選與排序
// API 端點:支援分頁、篩選和排序
[HttpGet] // GET /api/products?page=1&pageSize=10&sort=price&order=desc&category=electronics
public IActionResult GetProducts(
[FromQuery] int page = 1, // 第幾頁(預設第 1 頁)
[FromQuery] int pageSize = 10, // 每頁幾筆(預設 10 筆)
[FromQuery] string? sort = null, // 排序欄位
[FromQuery] string? order = "asc", // 排序方向:asc 或 desc
[FromQuery] string? category = null) // 篩選條件
{
var query = _db.Products.AsQueryable(); // 建立可查詢的集合
// 篩選(像圖書館用分類找書)
if (!string.IsNullOrEmpty(category)) // 如果有指定分類
{
query = query.Where(p => p.Category == category); // 篩選符合的商品
}
// 排序(像圖書館按照書名或出版日期排列)
query = sort?.ToLower() switch
{
"price" => order == "desc"
? query.OrderByDescending(p => p.Price) // 價格由高到低
: query.OrderBy(p => p.Price), // 價格由低到高
"name" => order == "desc"
? query.OrderByDescending(p => p.Name) // 名稱 Z 到 A
: query.OrderBy(p => p.Name), // 名稱 A 到 Z
_ => query.OrderBy(p => p.Id) // 預設用 ID 排序
};
// 計算總數(在分頁之前)
var totalCount = query.Count(); // 總共有幾筆資料
// 分頁(像一本書分成好幾頁,一次只看一頁)
var items = query
.Skip((page - 1) * pageSize) // 跳過前面的資料
.Take(pageSize) // 只取這一頁的數量
.ToList(); // 執行查詢
// 回傳分頁資訊
var result = new
{
Data = items, // 這一頁的資料
Page = page, // 目前第幾頁
PageSize = pageSize, // 每頁幾筆
TotalCount = totalCount, // 總筆數
TotalPages = (int)Math.Ceiling((double)totalCount / pageSize) // 總頁數
};
return Ok(result); // 回傳 200 OK
}
🔗 HATEOAS 概念
// HATEOAS(Hypermedia As The Engine Of Application State)
// 簡單說:API 回應中包含「接下來可以做什麼」的連結
// 像餐廳菜單上不只有菜名,還有「加點」「套餐升級」的選項
[HttpGet("{id}")] // 取得單一商品
public IActionResult GetById(int id)
{
var product = _db.Products.Find(id); // 查找商品
if (product == null) // 如果找不到
return NotFound(); // 回傳 404
// 回傳資料時附帶相關操作的連結
var result = new
{
Data = product, // 商品資料
Links = new[] // 可以執行的操作連結
{
new { Rel = "self", Href = $"/api/products/{id}", Method = "GET" },
new { Rel = "update", Href = $"/api/products/{id}", Method = "PUT" },
new { Rel = "delete", Href = $"/api/products/{id}", Method = "DELETE" },
new { Rel = "reviews", Href = $"/api/products/{id}/reviews", Method = "GET" }
}
};
return Ok(result); // 回傳 200 OK 和連結
}
🏷️ API 版本控制策略
// 策略 1:URL 路徑版本控制(最常用,像書的版次)
[Route("api/v1/products")] // 第一版
public class ProductsV1Controller : ControllerBase
{
[HttpGet] // GET /api/v1/products
public IActionResult GetAll() => Ok("V1 格式的商品清單"); // V1 格式回傳
}
[Route("api/v2/products")] // 第二版
public class ProductsV2Controller : ControllerBase
{
[HttpGet] // GET /api/v2/products
public IActionResult GetAll() => Ok("V2 格式的商品清單(更多欄位)"); // V2 格式回傳
}
// 策略 2:HTTP Header 版本控制
// 請求時帶上 Header:api-version: 2
[HttpGet]
public IActionResult GetAll([FromHeader(Name = "api-version")] int version = 1)
{
if (version == 2) // 如果要求 V2
return Ok("V2 回應"); // 回傳 V2 格式
return Ok("V1 回應"); // 預設回傳 V1 格式
}
// 策略 3:Query String 版本控制
// GET /api/products?api-version=2
[HttpGet]
public IActionResult GetAll([FromQuery(Name = "api-version")] int version = 1)
{
return version switch
{
2 => Ok("V2 回應"), // 第二版
_ => Ok("V1 回應") // 預設第一版
};
}
⚠️ 錯誤回應格式:RFC 7807 Problem Details
// ASP.NET Core 內建支援 Problem Details(標準化的錯誤回應格式)
// 就像醫院的病歷表——每個欄位都有固定的格式,方便任何醫生閱讀
[HttpGet("{id}")]
public IActionResult GetById(int id)
{
var product = _db.Products.Find(id); // 查找商品
if (product == null) // 如果找不到
{
return Problem(
detail: $"找不到 ID 為 {id} 的商品,請確認商品 ID 是否正確", // 詳細說明
title: "找不到資源", // 錯誤標題
statusCode: 404, // HTTP 狀態碼
instance: $"/api/products/{id}" // 發生問題的 URL
);
// 回傳格式:
// {
// "type": "https://tools.ietf.org/html/rfc7231#section-6.5.4",
// "title": "找不到資源",
// "status": 404,
// "detail": "找不到 ID 為 5 的商品...",
// "instance": "/api/products/5"
// }
}
return Ok(product); // 回傳 200 OK
}
// 在 Program.cs 啟用全域 Problem Details
builder.Services.AddProblemDetails(); // 註冊 Problem Details 服務
🤔 我這樣寫為什麼會錯?
❌ 錯誤 1:在 URL 中使用動詞
// ❌ 錯誤寫法:URL 用了動詞(像把說明書印在包裝上,多此一舉)
[HttpGet("api/getProducts")] // 不要用 get 動詞
public IActionResult GetProducts() => Ok(); // GET 本身就代表「取得」
[HttpPost("api/createProduct")] // 不要用 create 動詞
public IActionResult CreateProduct(Product p) => Ok(); // POST 就代表「建立」
[HttpPost("api/deleteProduct/{id}")] // 更不該用 POST 來刪除!
public IActionResult DeleteProduct(int id) => Ok(); // 應該用 DELETE 方法
// ✅ 正確寫法:URL 只用名詞,讓 HTTP 方法表達動作
[Route("api/products")] // 名詞、複數、小寫
[ApiController] // API 控制器
public class ProductsController : ControllerBase
{
[HttpGet] // GET /api/products → 取得所有
public IActionResult GetAll() => Ok(); // HTTP 方法已經表達了「取得」
[HttpGet("{id}")] // GET /api/products/5 → 取得單一
public IActionResult GetById(int id) => Ok(); // 用路由參數指定資源
[HttpPost] // POST /api/products → 建立
public IActionResult Create(Product p) => Created($"/api/products/{p.Id}", p);
[HttpDelete("{id}")] // DELETE /api/products/5 → 刪除
public IActionResult Delete(int id) => NoContent(); // 用正確的 HTTP 方法
}
解釋: HTTP 方法本身就代表動作——GET=取得、POST=建立、PUT=更新、DELETE=刪除。在 URL 裡再加上動詞就像說「我要 GET 取得(getProducts)」——重複了。URL 應該像門牌地址,只告訴你「什麼東西在哪裡」,不用告訴你「怎麼拿」。
❌ 錯誤 2:不一致的命名風格
// ❌ 錯誤寫法:整個 API 的 URL 風格不統一(像一棟大樓每層的門牌格式都不同)
[HttpGet("api/Products")] // 大寫開頭
[HttpGet("api/user-profiles")] // 小寫加破折號
[HttpGet("api/order_items")] // 底線分隔
[HttpGet("api/getShippingInfo")] // 駝峰式加動詞
// 用你 API 的開發者會崩潰!
// ✅ 正確寫法:統一使用小寫加破折號
[Route("api/products")] // 統一風格
[Route("api/user-profiles")] // 統一風格
[Route("api/order-items")] // 統一風格
[Route("api/shipping-info")] // 統一風格
// 一致的命名讓 API 更好用、更好記
解釋: API 的 URL 就像城市的路名——如果「中山路」有的地方寫「ZhongShan Road」、有的寫「zhong_shan_rd」、有的寫「中山-路」,開車的人一定會迷路。選擇一種風格,然後整個 API 都用同一種。
❌ 錯誤 3:不使用正確的 HTTP 狀態碼
// ❌ 錯誤寫法:不管發生什麼都回傳 200(像不管你問什麼,醫生都說「你很健康」)
[HttpGet("{id}")]
public IActionResult GetById(int id)
{
var product = _db.Products.Find(id); // 查找商品
if (product == null)
return Ok(new { Error = "找不到商品" }); // 明明找不到卻回 200?
return Ok(product); // 找到了回 200
}
// ✅ 正確寫法:用正確的狀態碼告訴客戶端實際情況
[HttpGet("{id}")]
public IActionResult GetById(int id)
{
var product = _db.Products.Find(id); // 查找商品
if (product == null)
return NotFound(new { Error = $"找不到 ID={id} 的商品" }); // 404
return Ok(product); // 200
}
// 常用狀態碼對照表:
// 200 OK → 成功取得資料
// 201 Created → 成功建立新資源
// 204 No Content → 成功但沒有回傳內容(用於 PUT/DELETE)
// 400 Bad Request → 客戶端送的資料有問題
// 401 Unauthorized → 未登入(沒有提供身分證明)
// 403 Forbidden → 已登入但沒有權限(有身分證但不能進 VIP 室)
// 404 Not Found → 找不到資源
// 409 Conflict → 資源衝突(例如重複建立)
// 500 Internal Error → 伺服器自己出問題了
解釋: HTTP 狀態碼就像交通號誌——綠燈(200)代表通行、紅燈(4xx/5xx)代表有問題。如果不管什麼情況都亮綠燈,前端開發者就無法正確判斷結果,只能去解析回應內容才知道有沒有錯,增加了不必要的複雜度。