本文已参与 「掘力星计划」 ,赢取创作大礼包,挑战创作激励金。
1. 背景
网络 IO 校验层是必要的
假如我们要开发一个如下功能的服务。
- 请求传参:
{list: ['小明', '张三']}- 将传入的名单存入数据库
- 响应:
{msg: '成功'}
为了服务稳定性,当用户传入的 list 不是数组或者 list 的元素不是字符串,我们需要前置校验,比如:
if (Array.isArray(req.list)
&& req.list.every(item =>
typeof item === 'string')) {
// 输入合法
}
else {
// 输入异常
}
不过,实际开发的接口输入通常比上面例子复杂。分层将避免正常的处理逻辑与类型校验、异常处理混杂在一起。
网络 IO 校验层凭借『协议』校验输入输出
协议
校验层需要我们提供对于 IO 的预期,即协议。以前面的服务为例,我们的预期如下:
// 文字描述
- request:list必填、是一个字符串序列
- response:msg必填、是一个字符串
// typescript 类型声明
interface Request {
list: string[];
}
interface Response {
msg: string;
}
// koa-joi-router
{
validate: {
type: 'json',
body: {
list: Joi.array().items(Joi.string().required()).required(),
},
output: {
200: {
body: {
message: Joi.string().required()
}
}
}
}
}
对于 ts 实现的服务,网络 IO 校验层+协议的设计有额外的好处。
前置的 IO 校验弥补了 ts 无法运行时类型检查的问题。
2. koa-joi-router 简介
和名字一样,koa-joi-router 是 joi 和 koa-router 的结合,一个基于 Koa 具备 IO 校验能力的路由。其中的 joi 专注于 JS 的数据结构校验。
3. 示例
npm i koa-joi-router -D
const koa = require('koa');
const router = require('koa-joi-router');
const Joi = router.Joi;
const public = router();
public.route({
method: 'post',
path: '/signup',
validate: {
body: {
name: Joi.string().max(100),
email: Joi.string().lowercase().email(),
password: Joi.string().max(100),
_csrf: Joi.string().token()
},
type: 'form',
output: {
200: {
body: {
userId: Joi.string(),
name: Joi.string()
}
}
}
},
handler: async (ctx) => {
const user = await createUser(ctx.request.body);
ctx.status = 201;
ctx.body = user;
}
});
const app = new koa();
app.use(public.middleware());
app.listen(3000);
有多种写入新路由的方式
// case 1
public.route({
method: 'post',
path: '/signup',
validate: {
body: {
name: Joi.string().max(100),
email: Joi.string().lowercase().email(),
password: Joi.string().max(100),
_csrf: Joi.string().token()
},
type: 'form',
output: {
200: {
body: {
userId: Joi.string(),
name: Joi.string()
}
}
}
},
handler: async (ctx) => {
const user = await createUser(ctx.request.body);
ctx.status = 201;
ctx.body = user;
}
});
// case 2
public.post('/signup', {
validate: {
body: {
name: Joi.string().max(100),
email: Joi.string().lowercase().email(),
password: Joi.string().max(100),
_csrf: Joi.string().token()
},
type: 'form',
output: {
200: {
body: {
userId: Joi.string(),
name: Joi.string()
}
}
}
},
handler: async (ctx) => {
const user = await createUser(ctx.request.body);
ctx.status = 201;
ctx.body = user;
}
});
// case 3
public.post('/signup', {
validate: {
body: {
name: Joi.string().max(100),
email: Joi.string().lowercase().email(),
password: Joi.string().max(100),
_csrf: Joi.string().token()
},
type: 'form',
output: {
200: {
body: {
userId: Joi.string(),
name: Joi.string()
}
}
}
}
}, async (ctx) => {
const user = await createUser(ctx.request.body);
ctx.status = 201;
ctx.body = user;
});
typescript 支持
引入对应的类型依赖即可。
npm i @types/koa-joi-router -D
// ts.config.json
{
// ...
"compilerOptions": {
// ...
"types": [
// ...
"koa-joi-router",
],
}
}
目录结构划分
实际运用中,以自己对 koa-joi-router 的理解划分了目录结构。
api
|_ run // 实际业务模块,命名对应 path
|_ handler // 正常业务处理
|_ index // 协议描述,异常处理
|_ schema // ts关注,Req 和 Res 的类型声明
main // 服务启动,各中间件挂载
router // 路由写入,调用 api 中各业务模块
3.异常处理
public.post('/signup', {
validate: {
type: 'json',
body: {
list: Joi.array().items(Joi.string().required()).required(),
},
output: {
200: {
body: {
message: Joi.string().required()
}
}
}
},
async handler(ctx) {
// 正常业务处理
}
});
以上面协议为例,当请求体为{},校验不通过,将响应"list" is required。显而易见,这样的响应不符合我们对 body 的校验,期望响应{"message": "\"list\" is required"}。
public.post('/signup', {
validate: {
type: 'json',
body: {
list: Joi.array().items(Joi.string().required()).required(),
},
output: {
200: {
body: {
message: Joi.string().required()
}
}
},
continueOnError: true // 报错仍然执行 handler
},
async handler(ctx) {
if (ctx.invalid) {
ctx.body = {message: ctx.invalid?.body?.msg}; // 将报错包装为合法输出结构
} else {
// 正常业务处理
}
}
});
开启 validate.continueOnError 使 handler 处理函数能在异常时也被执行。在 handler 中将 ctx.invalid 包装为合法的输出结构。
4.协议描述
协议描述使用链式调用,细节参见 joi 文档,罗列一些常用 api。
| 名称 | 描述 |
|---|---|
| any.failover | 校验失败的备选值 |
| any.default | 校验遇到undefined时的默认值 |
| any.required | 不接受值为undefined,默认所有结构都接受undefined |
| any.required | 不接受值为undefined,默认所有结构都接受undefined |
| any.allow | 一些需要豁免的值,比如 string 的空字符串 |
| any.valid | 将类型限制在规定的字面量 |
| object.unknown | 接受未校验的其他 key,默认不接受多余的 key |
| string.regex | 校验是否匹配正则 |
ps: 各类型对应 joi 工厂函数,比如 string 对应 Joi.string(),any 为所有工厂函数都可。
5.校验与ts断言的关系
ts 和 校验层分别在编译和运行时起作用,因此无法自动关联。我们可以假设通过校验进入业务逻辑的是我们预期的数据,给予断言。
interface Req {
}
if (ctx.invalid) {
// 校验异常
}
else {
try {
ctx.body = await main(ctx.request.body as Req); // 类型断言
} catch (err) {
// 内部运行异常
}
}
需要手工保证校验协议能够满足 ts 断言,以免出现通过校验而未处理的『漏网之鱼』。比如下面两个是等价的:
// joi
{
message: Joi.string().required().allow('')
}
// ts
interface Req {
message: string
}
joi 的 API 远比 ts 的类型系统要丰富。协议比 ts 断言更严格也是可以的,比如:
// joi
{
email: Joi.string().required().email()
}
// ts
interface Req {
email: string
}
以上这种情况需要前提,前后端校验使用同样的标准。
试想一下,一个表单如果前端校验通过,但提交时接口报错『输入异常』会很迷惑的。通常有两种思路:
- 前后端均为 js 实现且维持一对一关系,可以都依赖 joi 校验。
- 前后端解耦,关键数据结构由后端提供校验接口。
在弥补 ts 运行时问题上,koa-joi-router 只是其中一种选择。从框架的集成角度,koa-joi-router 是适合 koa 的选择。