Request Body 預設是什麼
HTTP request 的 body 是一串 bytes。Framework 把它解析成 object(JSON.parse / body-parser),但解析後的型別在多數框架裡是 any 或 Object——沒有任何保證說裡面有 name 欄位、email 格式正確、age 是數字。
// Express:req.body 是 any
app.post('/users', (req, res) => {
const { name, email, age } = req.body; // ← 全部 any,沒有驗證
// name 可能是 undefined
// email 可能是 "not-an-email"
// age 可能是 "twenty" (string 不是 number)
userService.create({ name, email, age });
});這個 any 就是後端大量 bug 的入口:null pointer、型別錯誤進資料庫、格式不對的資料被儲存。
資料綁定(Binding):把 raw request data 轉換成強型別的物件。
驗證(Validation):確認資料符合業務規則(必填、格式、範圍)。
Pydantic(FastAPI / Python)
FastAPI 的做法是最 elegant 的——你宣告型別,驗證自動發生:
from pydantic import BaseModel, EmailStr, Field
class UserCreateSchema(BaseModel):
name: str = Field(min_length=1, max_length=100)
email: EmailStr # ← 自動驗證 email 格式
age: int = Field(ge=0, le=150) # ← 必須是整數,0-150
@app.post("/users", status_code=201)
def create_user(user: UserCreateSchema): # ← 宣告型別就完成了
# user.name 是 str,已驗證
# user.email 是 validated email,已驗證
# user.age 是 int,已驗證
# 格式錯誤 → FastAPI 自動回 422 Unprocessable Entity
return user_service.create(user)FastAPI 看到 user: UserCreateSchema,自動:
- 從 request body 讀 JSON
- 用 Pydantic 解析成
UserCreateSchema實例 - 驗證失敗 → 直接回 422,帶詳細的錯誤資訊
- 驗證成功 →
user是完整的 typed object 傳進 handler
你不需要寫任何驗證邏輯,型別宣告即是驗證規則。
class-validator + Pipe(NestJS)
NestJS 的做法稍微明確一點,需要 DTO class + Pipe:
// create-user.dto.ts
import { IsEmail, IsString, IsInt, Min, Max, Length } from 'class-validator';
export class CreateUserDto {
@IsString()
@Length(1, 100)
name: string;
@IsEmail()
email: string;
@IsInt()
@Min(0)
@Max(150)
age: number;
}
// user.controller.ts
@Post()
create(@Body() dto: CreateUserDto) { // ← ValidationPipe 自動處理
return this.userService.create(dto);
// 驗證失敗 → NestJS 回 400 Bad Request
}
// main.ts — 全局開啟 ValidationPipe
app.useGlobalPipes(new ValidationPipe({ whitelist: true }));whitelist: true 很重要——它會把 DTO 裡沒有宣告的欄位自動刪掉(防止 mass assignment 攻擊,例如 client 送了 isAdmin: true 進來)。
@Valid + JSR-380(Spring Boot)
Java 的做法用標準的 Bean Validation(JSR-380):
// UserCreateDto.java
public class UserCreateDto {
@NotBlank
@Size(min = 1, max = 100)
private String name;
@Email
@NotBlank
private String email;
@NotNull
@Min(0)
@Max(150)
private Integer age;
// getters & setters...
}
// UserController.java
@PostMapping("/users")
@ResponseStatus(HttpStatus.CREATED)
public User create(@RequestBody @Valid UserCreateDto dto) {
// @Valid 觸發驗證
// 失敗 → Spring 回 400 Bad Request
return userService.create(dto);
}Java 的 annotation 和 Pydantic 的 Field() 在功能上類似,但語法更 verbose。@ControllerAdvice 可以捕獲 MethodArgumentNotValidException 統一格式化錯誤回應。
express-validator(Express)
Express 沒有內建驗證。express-validator 是主流選擇:
// validators/user.validator.ts
import { body, validationResult } from 'express-validator';
export const createUserValidators = [
body('name').isString().isLength({ min: 1, max: 100 }).trim(),
body('email').isEmail().normalizeEmail(),
body('age').isInt({ min: 0, max: 150 }),
];
// routes/users.ts
router.post('/users', createUserValidators, UserController.store);
// controllers/user.controller.ts(或 BaseController 統一處理)
static async store(req: Request, res: Response) {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { name, email, age } = req.body; // ← 仍然是 any,但已驗證過
await userService.create({ name, email, age });
}注意 Express 這個做法的缺點:req.body 驗證後仍然是 any,TypeScript 不知道它的型別。你要手動用 type assertion 或 zod parse() 讓它變成 typed object:
// 更強的做法:用 zod 同時做 parse + validate
import { z } from 'zod';
const UserCreateSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
age: z.number().int().min(0).max(150),
});
type UserCreateDto = z.infer<typeof UserCreateSchema>; // ← 從 schema 推斷型別
static async store(req: Request, res: Response) {
const result = UserCreateSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ errors: result.error.flatten() });
}
const dto: UserCreateDto = result.data; // ← 現在是 typed
await userService.create(dto);
}各框架的 binding 發生點
不同框架在不同的地方做 binding:
| 框架 | binding 發生點 | 開發者感受 |
|---|---|---|
| FastAPI | function parameter 型別宣告時 | 看不到,自動發生 |
| NestJS | ValidationPipe + @Body() decorator | 需要設定 Pipe,之後自動 |
| Spring | @RequestBody @Valid | annotation 明確 |
| Express | 你呼叫 validationResult() 的時候 | 完全手動 |
FastAPI 和 NestJS 最省事:你宣告型別,框架做剩下的事。
Express 最 explicit:每一步都要你決定,但你也最清楚每一步在做什麼。
Validator 層:放在哪裡
Express / Gin 這類 minimal framework 沒有把驗證邏輯「放哪裡」的規定。兩種主流做法:
做法一:Validator 掛在 route(middleware 形式)
// routes/users.ts
router.post('/users',
createUserValidators, // ← 先跑驗證
UserController.store, // ← 驗證過再進 controller
);優點是邏輯分層清楚——route 聲明了「這個 endpoint 需要哪些驗證」,controller 只處理業務邏輯,不管驗證。
做法二:Validator 在 Controller 最開頭
static async store(req: Request, res: Response) {
const errors = validationResult(req); // ← controller 第一行就 check
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
// ...
}優點是驗證和 handler 邏輯在同一個地方,找問題更直觀。缺點是每個 controller action 都要寫這段 boilerplate。
proto 的做法:把驗證結果的 check 提取到 BaseController,子類的 action 只需要呼叫 this.validate(req) 一行:
class BaseController {
protected validate(req: Request) {
const errors = validationResult(req);
if (!errors.isEmpty()) throw new ValidationError(errors.array());
}
}
class UserController extends BaseController {
async store(req: Request, res: Response) {
this.validate(req); // ← 一行,格式統一
const user = await userService.create(req.body);
res.status(201).json(user);
}
}Response Formatter
Validation 說的是 request 的型別安全;response 也有同樣的問題——每個 handler 直接 res.json(data) 的話,成功和失敗的 response 格式可能長得完全不一樣,client 要 normalize 很煩。
Response formatter 是在 controller 或 middleware 層統一包裝 response 格式:
// 統一格式:{ success, data, meta } 或 { success, error }
res.json({ success: true, data: user });
res.json({ success: false, code: 'NOT_FOUND', message: 'User not found' });詳細設計放在錯誤處理章節(22. 錯誤處理機制),因為 response formatter 和 error formatter 是一體的——兩者定義了整個 API 的 response contract。
Express 的驗證實作
[[backend/framework/express/request-validation|[express][M6] Request Validation]] 有 proto 裡 express-validator + BaseController 的完整設計,說明為什麼把驗證結果檢查放在 BaseController 而不是每個 handler。
