koa-joi-router做koa下的网络 IO校验

1,294 阅读5分钟

本文已参与 「掘力星计划」 ,赢取创作大礼包,挑战创作激励金。

1. 背景

网络 IO 校验层是必要的

假如我们要开发一个如下功能的服务。

  1. 请求传参{list: ['小明', '张三']}
  2. 将传入的名单存入数据库
  3. 响应{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-routerjoikoa-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 的选择。