在Koa中用Zod配合中间件做请求参数校验

在Koa中用Zod配合中间件做请求参数校验

2026-06-15 17:39:0044 浏览926作者:dreamk后端开发

整理了一下前些阵子在 koa-ts-starter 里面搞的 Zod 参数校验这块理一下。天天写那些一堆乱七八糟的接口,各种 querybodyparams 传过来,如果全在业务代码里写 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 的时候,最顶层必须用 bodyqueryparams 这三个 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 条评论

还没有评论,欢迎成为第一个留言的人。