关于这篇文章
- Node在我的工作实际应用中,具体的业务界限;
- 选型的思考
- Koa-spring到底是个什么概念;
- 中间件的应用
如果你感兴趣,可以fork项目,自己体验一下
Koa-spring: https://github.com/closertb/k...
related-client: https://github.com/closertb/k...
万事开头难
在转型前端前,我是一个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 {
@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 {
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): Promise {
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和实际应用到业务真的是天差地别,在下一篇,将会谈一些深入的优化和疑难点解决,主要关于:
- 自定义装饰器
- 继承的应用
- 多进程通信
提前预告: Koa-spring:后端太忙,让我自己写服务(下)