
整理了一下前些阵子在 koa-ts-starter 里面搞的 Zod 参数校验这块理一下。天天写那些一堆乱七八糟的接口,各种 query、body、params 传过来,如果全在业务代码里写 if (!username) 这种判断,代码根本没法看。而且 TypeScript 这玩意也就编译的时候有用,运行起来根本管不着。
后来参考网上的思路写了个中间件,用 Zod 把运行时校验和类型推导一起搞定了。核心代码也就 25 行左右,完全够用了。我直接把思路和代码贴在下面,大家凑合看。
核心设计
请求进来的流程很简单,就是个直线的管道:
HTTP 请求
↓
bodyParser(先把 JSON 字符串转成对象)
↓
validate(schema) 中间件(把 body、query、params 塞进 Zod 校验)
├─ 没问题:把干净的数据塞给 ctx.state.validated,直接 next()
└─ 出错了:直接 throw AppError(400, ...)
↓
具体的路由处理函数(直接从 ctx.state.validated 拿数据,这时候数据绝对合法)
↓
全局错误处理(接住错误,给前端返回固定的 JSON)为了省事,我定了个规矩:定义 Schema 的时候,最顶层必须用 body、query、params 这三个 key。这样中间件就能自动把请求里的数据对号入座,一个 Schema 就能把一个接口所有地方传过来的参数都给验了。
核心中间件代码
代码在 src/middlewares/validate.ts,直接看代码:
import type { Context, Next } from 'koa';
import { ZodType, ZodError } from 'zod';
import { AppError } from '@/errors/AppError';
export const validate = <T extends Record<string, unknown>>(schema: ZodType<T>) => {
return async (ctx: Context, next: Next) => {
try {
// 把三个地方的数据打包一起塞给 Zod
const validatedData = await schema.parseAsync({
body: ctx.request.body,
query: ctx.query,
params: ctx.params,
});
// 校验通过的数据挂到 state 上,后面路由直接用
ctx.state.validated = validatedData;
await next();
} catch (error) {
// 捞出 Zod 的错误,转成我们自己的业务错误抛出去
if (error instanceof ZodError) {
throw new AppError(400, error.issues[0]?.message ?? '参数校验失败');
}
throw error;
}
};
};这里有几个我踩坑后的做法:
- 用的是
parseAsync而不是parse。因为有时候你要去数据库查“用户名重不重复”,异步校验必须要用这个。 - 校验完的数据别往
ctx.request里面瞎塞,挂在ctx.state.validated上最干净。 - 顺便在
src/types/koa.d.ts里给 Koa 加个类型声明,省得写代码时报错:
declare module 'koa' {
interface DefaultState {
validated?: unknown;
}
}具体怎么用(基础和进阶技巧)
1. 最简单的例子
在 src/routes/home.ts 里面,校验一个 POST 请求的 body:
import { validate } from '@/middlewares/validate';
import Router from '@koa/router';
import z from 'zod';
const router = new Router();
const userSchema = z.object({
body: z.object({
username: z.string().min(3),
age: z.coerce.number().min(1).default(1),
ids: z
.string()
.transform((val) => val.split(',').map(Number))
.pipe(z.array(z.number()).min(1, '至少需要一个ID')),
}),
});
type UserReqData = z.infer<typeof userSchema>;
// 把 validate(userSchema) 插在路由处理函数前面就行
router.post('/validate', validate(userSchema), async (ctx) => {
// 用 as 断言一下,类型推导就全出来了
const { body } = ctx.state.validated as UserReqData;
return ctx.ok({ body });
});
export default router;2. 几个好用的技巧
z.coerce.number():这个太实用了。前端用 URL 传?age=18或者params里的 ID,拿过来全都是字符串。用这个它会自动先执行一次Number(),省得我自己去写parseInt。我在配全局环境变量(src/config/index.ts)校验端口的时候也用了这个。transform+pipe:上面的ids字段就是这么搞的。前端传个"1,2,3"字符串,transform直接切成数组并转成数字,接着用pipe喂给z.array()检查。路由函数里拿到手直接就是number[],非常省心。- 多地方同时校验:如果一个路由既有 query 翻页,又有 url 参数,直接这样写:
const updateUserSchema = z.object({
query: PageSchema,
params: z.object({ id: z.coerce.number() }),
body: z.object({ name: z.string() }),
});- 公共 Schema 复用:翻页这种到处都是的东西,直接抽出来放
src/schemas/base.ts导出就行:
import z from 'zod';
export const PageSchema = z.object({
pageNo: z.coerce.number().min(1).default(1),
pageSize: z.coerce.number().min(1).max(100).default(10),
});怎么返回给前端
中间件里不是报了错就 throw new AppError(400) 吗,我们在全局搞个捕获的地方(src/app/mapError.ts),直接把错误格式化掉:
export function mapError(err: unknown): ApiBody<null> {
if (err instanceof AppError) {
return envelope.fail(err.code, err.expose ? err.message : 'error');
}
if (err instanceof ZodError) {
return envelope.fail(400, err.issues[0]?.message ?? '参数校验失败');
}
// 其他错误继续往下走...
}最后前端拿到的数据长这样,不管成不成功,HTTP 状态码我都给的 200,用业务 code 来区分错没错。前端只要判断 code !== 0 就是有错。
{
"code": 400,
"msg": "String must contain at least 3 character(s)",
"data": null
}我们的 AppError 也很简单,就是继承了普通的 Error:
export class AppError extends Error {
constructor(
public readonly code: number,
message: string,
public readonly expose = true,
) {
super(message);
this.name = 'AppError';
}
}别把中间件顺序弄反了
在 src/app/index.ts 里挂中间件的时候,必须先挂 bodyParser。不然校验中间件去拿 ctx.request.body 的时候直接就是个 undefined,肯定报错。
app.use(cors());
app.use(bodyParser()); // 1. 先把 body 解析出来
app.use(requestLogger);
app.use(responseHandler); // 2. 全局错误捕获要在最外层
// 3. 最后才是路由
app.use(router.routes()).use(router.allowedMethods());至于写测试,用 supertest 跑一下成功和失败的分支就清楚了。我测过把 "1,2,3" 传过去,拿出来的确实变成了 [1, 2, 3] 数组,说明这一整条链条都在正常工作。
这个项目的完整结构和代码都在 GitHub 上,有需要的自己去看吧:koa3-ts-starter
评论区
0 条评论还没有评论,欢迎成为第一个留言的人。