起因
Router 描述了请求 URL 与 Controller 的对应关系。Eggjs 约定所有的路由都需要在 app/router.js 中申明,目录结构如下:
┌ app
├── router.js
│ ├── controller
│ │ ├── home.js
│ │ ├── ...
复制代码
路由和对应的处理方法分开在 2 个地方维护,开发时经常需要在 router.js
与 Controller
之间来回切换。
前后台协作时,后端需要为每个 Api 都生成一份对应的 Api 文档给前端。
更优雅的实现
得益于 JavaScript 加入的 decorator 特性,可以使我们跟 Java/C# 一样,更加直观自然的做面向切面编程:
// 基础版
@route('/intro')
async intro() { }
// 定义 Method
@route('/intro', { method: 'post' })
async intro() { }
// 增加权限
@route('/intro', { method: 'post', role: xxxRole })
async intro() { }
// Controller 级别中间件
@route('/intro', { method: 'post', role: xxxRole, beforeMiddleware: xxMiddleware })
async intro() { }
复制代码
为什么是这样的方案
为什么设计如此复杂的功能,是不是在滥用
Decorator
?
先看看 route
的功能:
- 路由定义
- 参数校验
- 权限
Controller
级别中间件
router
官方完整定义中包含的功能:路由定义、中间件、权限,及文档中未直接写的“权限”:
router.verb('router-name', 'path-match', middleware1, ..., middlewareN, app.controller.action);
复制代码
比较下来会发现,只是多了“参数校验”功能。
参数校验
Eggjs 中参数校验的官方实践:
class PostController extends Controller {
async create() {
const ctx = this.ctx;
try {
// 校验参数
// 如果不传第二个参数会自动校验 `ctx.request.body`
ctx.validate(createRule);
} catch (err) {
ctx.logger.warn(err.errors);
ctx.body = { success: false };
return;
}
}
};
复制代码
在我们的业务实践中这个方案会有 2 个问题:
-
参数漏校验
比如用户提交的数据为
{ a: 'a', 'b': 'b', c: 'c' }
,如果校验规则只定义了a
,那么b
、c
就被漏掉了,并且后续业务中可能会使用这 2 个值。 -
Eggjs 一个 request 生命周期内,可以随时随地通过
ctx.request
拿到用户数据因为“参数漏校验”问题的存在,导致后续业务变的不稳定,随时可能会因为用户的异常数据导致业务崩溃,或者出现安全问题。
解决方案
为了解决“参数漏校验”问题,我们做了如下约定:
-
Controller 也需要申明入参
class UserController extends Controller { @route('/api/user', { method: 'post' }) async updateUser(username) { // ... } } 复制代码
上面的例子中,即使用户提交了海量数据,业务代码中也只能拿到
username
-
Controller 之外的业务不应该直接访问
ctx.request
上的数据也就是说,当某个 Service 方法依赖用户数据时,应该通过入参获取,而不是直接访问
ctx.request
基于以上约定,分别看看 JS、TypeScript 下我们如何解决参数校验问题:
-
JS
@route('/api/user', { method: 'post', rule: { username: { type: 'string', max: 20 }, } }) async updateUser(username) { // ... } 复制代码
这里使用了
egg-validate
底层依赖的parameter
作为校验库 -
TypeScript
@route('/api/user', { method: 'post' }) async updateUser(username: R<{ type: string, max: 20 }>) { // ... } 复制代码
没看错,手动调用 ctx.validate(createRule)
并捕获异常的逻辑确实被我们省略掉了。“懒惰”是提高生产力的第一要素。参数、规则都有了,为什么还要自己撸代码呢?
新的前后端协作实践
传统的前后端开发协作方式中,后端提供 Api 给前端调用,代码类似这样:
function updateUser() {
request
.post(`/api/user`, { username })
.then(ret => {
});
}
复制代码
前端同学需要关注路由、参数、返回值。而这些信息 Controller 都已经有了,直接生成前台 service 用起来是不是更方便呢:
-
Controller 代码:
export class UserController { @route({ url: '/api/user' }) async getUserInfo(id: number) { return { ... }; } } 复制代码
-
生成的 service:
export class UserService extends Base { /** 首页 */ async getUserInfo(id: number) { const __data = { id }; return await this.request({ method: `get`, url: `/api/user`, data: __data, }); } } export const metaService = new UserService(); export default new UserService(); 复制代码
-
前台使用
import { userService } from 'service/user'; const userInfo = await userService.getUserInfo(id); 复制代码
对比原来的写法:
function updateUser() { return new Promise((resolve, reject) => { request .post(`/api/user`, { username }) .then(ret => { resolve(ret); }); }); } 复制代码
userService.getUserInfo
内部封装了 request 逻辑,前端不需要在关心调用过程。
如何在自己的项目中使用
我们已经把最佳实践抽象为了 egg-controller 插件,可以按下面的步骤安装使用:
-
安装
egg-controller
tnpm i -S egg-controller 复制代码
-
启用插件
打开 config/plugin.js,增加以下配置
aop: { enable: true, package: 'egg-aop', }, controller: { enable: true, package: 'egg-controller', }, 复制代码
-
使用插件
详细用法参考 egg-controller 文档