Koa-spring:后端太忙 让我自己写服务 上
关于这篇文章
- Node 在我的工作实际应用中,具体的业务界限;
- 选型的思考
- Koa-spring 到底是个什么概念;
- 中间件的应用
万事开头难
在转型前端前,我是一个 Java 练习生(Servlet,SSH,SpringMvc 都只会照着写),嗯,真的是练习生。几年后,又走上了接口开发的老路,虽然这不是自己第一次用 Node(先前,去淌过 SSR 的水: 初探 SSR,React + Koa + Dva-Core ),但写接口服务,这仍然是黄花闺女上花轿:头一回。虽然看过,听过很多大佬将 Node 运用(BFF,SSR)到业务,延伸大前端的业务覆盖范围,但自己还是对界限,Node 承担的角色有很多疑惑,为此,还去脉脉上发了个动态,期望大佬指点迷津。但自己的路,真的只有自己知道那个路口是出口。
最后鉴于这是一个测试用的内部系统,就确定前端页面接口全部直接对接数据库;登录,权限,日志作为中间层对接公司的公共服务。确定完边界后,开始纠结框架选型。虽然自己私下都是用 Koa,但感觉离实际运用到业务,还是缺少一定的便捷性。
后面又接触到 EggJs,Nest,routing-controllers。 EggJS 是阿里内部的专用 Node 框架,成熟自然不言而喻,但对我来说,框架太重,但里面很多思想是值得借鉴的。 NestJs 和自己期望的很近,风格和 SpringMvc 非常相似,官方文档看似也比较全,但同时制造了很多概念,和 Egg 一样,太重,也许没选它也和只支持 Express 有关吧。 routing-controllers 给人的感觉就刚刚好,SpringMvc 的开发风格、Koa 的中间件机制,自由发挥,一见钟情的感觉。
工程搭建
主框架:routing-controllers + Koa
import {Controller, Param, Body, Get, Post, Put, Delete} from "routing-controllers"; // 路由相较于示例,有点小改动 @Controller('/user') export class UserController { @Get("/query") getAll() { return "This action returns all users"; } @Get("/query/:id") getOne(@Param("id") id: number) { return "This action returns user #" + id; } @Post("/save") post(@Body() user: any) { return "Saving user..."; } @Put("/update/:id") put(@Param("id") id: number, @Body() user: any) { return "Updating a user..."; } @Delete("/delete/:id") remove(@Param("id") id: number) { return "Removing user..."; } }
routing-controllers 是一个相对于 Egg 和 Nest 较小众的库。
迭代较慢,三年时间才到 0.8.0 的版本,没有官网,只有 Readme。但这些丝毫不掩盖其易扩展的品质,routing-controllers 的引入,未改变 Koa 的洋葱模型中间件机制和错误捕获机制,结合 Typedi,也能做到 Nest 框架的效果。下图是自己使用后整理的 routing-controllers 中间件机制。
全局中间件和路由局部中间件,我觉得设计是十分巧妙的,这对解决通用问题,是及其有效的,在后面的中间件一节会具体分析。 官方提供的 Demo ,也可以下载运行一下试试。
Model:数据库操作:Sequelize
页面接口直接对接数据库,所以希望选择一个类似 JPA,Hibernate 这样的 ORM 框架,简化 Sql 操作,可选项不多,也没做多少对比,最后选择了 Sequelize,结合 sequelize-typescript ,也收获了一个不错的开发体验,下面的代码就是一个日志模型的声明:
import { Table, Column, Model } from 'sequelize-typescript'; import { toTimeStamp } from '../../config/common'; @Table({ tableName: 'change_logs' }) export default class ChangeLog extends Model<ChangeLog> { @Column userId: string; // 用户 Id @Column update_type: string; // 更新表 @Column update_id: number; // 更新表的 Id @Column before!: string; // 字段更新前 @Column after!: string; // 字段更新后 @Column get update_time(): number { // 更新时间,转时间戳 return toTimeStamp(this, 'update_time'); } }
下面一段代码就是 Sequelize 的基本 CUR 操作,看起也是十分便捷的,这里出现了几个自定义的装饰器,在后面会专门讲到:
export default class Repository { private model = Model; @validWithPagination findAll(body: object = {}) { // 列表查询 return this.model.findAll({ where: body }); } findOne(id: number) { // 详情查询 return this.model.findOne({ where: { id } }); } @validBody update(body: AnyObject) { // 更新 const { id, ...others } = body; return this.model.update({ ...others, }, { where: { id } }); } save(body: Model) { // 新增 return body.save(); } }
Sequelize 带给我唯一的困惑就是,其默认返回的响应体,是一个被他的 Model 类封装过的数据集,说起来有点抽象,看下面的响应实例:
期望响应体
{ create_time: 1575642055000, update_time: 1576380905000, id: 5, scene_code: 'special', param_code: 'bit', param_name: '任何', param_type: 'string', operator_add: 'SYS', is_delete: 0 }
实际响应体:太长,截取部分
// Rule { dataValues: { id: 5, scene_code: 'special', param_code: 'bit', param_name: '任何', param_type: 'string', operator_add: 'SYS', is_delete: 0, create_time: 2019-12-06T14:20:55.000Z, update_time: 2019-12-15T03:35:05.000Z }, _modelOptions: { timestamps: false, validate: {}, freezeTableName: true, underscored: false, ... } ... }
看起只需要拿响应体的 dataValues 就是我们期望的响应体,但这个响应体是相关 getter 属性方法并没有执行。官方也提供了{ query: { raw: true }}这个设置去获得简单的响应体,但也有同样的问题,getter 属性未执行。看了一下官方实现,getter 方法是在调用 toJson 方法时,才会执行(疑惑不解脸)。
中间层服务的处理:
在实现登录,权限,日志,存储作为中间层对接公司的公共服务时,Node 需要发起请求,并响应包装转发出去,这里选择了比较成熟的 request 和 request-promise 库。
数据校验:
虽然这是一个内部系统,除了前端提交做校验外,业务方还是希望接口层要有一些必要的校验。如果全部用 If-else 写,想想这还是一个比较大的工作量的,不过还好,有 class-validator 这个库的存在,加上装饰器的写法,还是比较简洁。比如下面这个登录表单的校验示例:
import { MinLength, Length } from "class-validator"; export default class User { @Length(6, 12) name: string; @MinLength(6) pwd: string; }
语言:Typescript
看上面那么多,你应该猜到了,这个项目选择了 Typescript。
中间件
在我的项目中涉及到多个中间件,既有全局中间件,比如鉴权,响应体包装,错误处理;又有局部路由中间件,比如操作日志,分页。
全局中间件-鉴权:AuthCheckMiddleWare
routing-controllers 提供了鉴权认证机制,但操作起来不方便,需要每个路由去添加标志。所以自己实现了鉴权中间件,全局中间件都继承于 KoaMiddlewareInterface,需要区分是路由响应前,还是响应后。鉴权中间件的目的是验证每一个请求,是否有操作权限,验证 token 的有效性。这里的实现是一种简易的形式,只检查了本地缓存信息,未到用户中心继续验证,供参考:
import { Middleware, KoaMiddlewareInterface } from "routing-controllers"; import * as cache from 'memory-cache'; @Middleware({ type: "before" }) // before 表示在请求路由响应前 export default class AuthCheckMiddleWare implements KoaMiddlewareInterface { async use(ctx: any, next: any): Promise<any> { const { request: { body = {}, query = {}, path } } = ctx; const { uid, token } = Object.assign({}, query, body); // 在用户登录时,会以 Uid 存储当前用户的信息,有效期 20 分钟 const user = cache.get(uid); // 如果是非登录,检查携带的 token 是否和缓存的 token 一致 if(path === '/user/login' || (user && user.token === token)) { if (path !== '/user/login') { ctx.user = user; // 将 user 信息挂载到当前请求体 } await next(); } else { ctx.body = { code: '120001', message: uid ? 'Session 过期,请重新登录' : '请先登录', status: 'fail' }; } } }
全局中间件需要在生成 koa 实例时,进行注册:
const koaApp = createKoaServer({ cors: true, // 这里开启了 Cors 跨域 controllers: [__dirname + '/services/*/controller.js'], middlewares: [AuthCheckMiddleWare], });
局部路由中间件-操作记录:RecordMiddleWare
操作日志中间件,其目的是记录某些表的数据新增,修改操作。需要记录下字段修改前和修改的值,操作类型及操作人。如果按常规思维,在每一个需要记录操作的路由 Controller 去加入日志记录代码。代码冗余,且日志记录需求变动时,是一件非常被动的事情,所以局部路由中间件是最好的实现方式,在需要记录的路由加入这个中间件即可。
import Model from '../services/changeLog/model'; import { AnyObject } from '../config/interface'; /** * 新增修改操作日志记录,入库。 * @param ctx * @param next */ export default async function RecordMiddleWare(ctx: any, next: (err?: any) => Promise<any>): Promise<any> { const { user = {}, body: { before, after, update_type, id } } = ctx; const old: AnyObject = {}; const nw: AnyObject = {}; // 最新数据 if (!before) { Object.assign(nw, after); } else { // 记录比较,只保存改变过的值的修改记录 Object.keys(after).forEach((prop) => { // 数字比较时,由于请求体,数字会被转化成字符串,所以这里用了==,来自动转换数据类型 if (before[prop] == after[prop]) { return; } old[prop] = before[prop]; nw[prop] = after[prop]; }); } // 重写 body ctx.body = { msg: 'success', id }; await next(); const repository = new Model({ update_id: id, update_type, userId: user.id || 'SYS', // 获取 userId after: JSON.stringify(nw), before: JSON.stringify(old) }); repository.save() }
在规则数据更新时,加入操作日志记录中间件
import { JsonController, Post, Body, UseAfter } from "routing-controllers"; import { Service } from "typedi"; import RecordMiddleWare from '../../middlewares/RecordMiddleWare'; import RuleRepository from "./repository"; import { AnyObject } from '../../config/interface'; @Service() @JsonController('/rule') export default class RuleController { @Post("/update") @UseAfter(RecordMiddleWare) async update(@Body() body: AnyObject) { const { id } = body; const before = await this.ruleRepository.findOne(id); await this.ruleRepository.update(body); return { before, after: body, id, update_type: 'rule' }; } }
总结
这一篇主要讲了 koa-spring 的一些库应用及项目实现方式,这里不得不强力推广 routing-controllers 与 sequelize-typescript 这两个库,Thanks to @RobinBuschmann for answering my issue so patient(maybe you can't understand what i write or say, just accept my thanks)。感叹一句,写 Demo 和实际应用到业务真的是天差地别,在下一篇,将会谈一些深入的优化和疑难点解决,主要关于:
- 自定义装饰器
- 继承的应用
- 多进程通信
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论