前言
基于本人在现在公司的Node微服务实践, 不断维护升级着一个Node Restful API种子项目, 特此共享出来以供借鉴和讨论. 项目中几乎所有的东西都使用了node/javascript及相应模块的最新功能, 语法, 和实践.
接上一篇帖子, 本次分享将会对此项目提供的各个主要功能不分先后做下详细介绍.
项目github仓库地址, 欢迎star: https://github.com/xiaozhongliu/node-api-seed
详解
项目目录结构
.vscode VSC服务调试/测试调试配置
config 多环境服务配置, 不依赖外部逻辑
ctrl 控制器, 基本与路由对应
log 服务请求日志, 自动生成
midware express服务中间件
model 数据库模型: mongo, postgres/mysql
service 服务层, 供控制器/中间件调用
test API测试, 运行命令npm t
util 各种工具库, 仅依赖系统配置
.eslintrc.js eslint规则配置
app.js 应用服务入口文件
global-helper.js 挂载少许全局helper
message.js 集中管理接口/系统消息
package.json 应用服务包配置文件
pm2.config.js 多环境pm2配置文件
router.js 集中管理服务路由
项目首次运行
首次运行项目进行测试, 先脚本建表或执行User.sync()将表结构同步到数据库.
服务运行起来之后, 直接使用postman来实验提供的接口:
路由注册扩展
代码文件: router.js自动判断有没有控制器对应的接口数据校验规则集合, 如有则采用.
包装控制器来统一捕捉抛出的非预期错误, 并将在app.js中最后一个中间件发送告警邮件.
提供基础健康检查接口.
接口数据校验
代码文件: midware/validate.js& util/validator.js按约定声明与控制器名称相同的接口数据校验规则集合, 即可在请求时进行验证. 例如:
/**
* validate api: login
*/
login: [
// 参数名 参数类型 是否必传
['sysType', Type.Number, true],
['username', Type.String, true],
['password', Type.String, true],
],
类型校验方法大多是express-validator模块提供的, 可以自定义类型及其校验方法. 例如:
isHash(value) {
return /^[a-f0-9]{32}$/i.test(value)
},
isUnixStamp(value) {
return /^[0-9]{10}$/.test(value)
},
无效请求过滤
代码文件: midware/auth.js此中间件做的无效请求过滤, 和认证没关系. 具体通过header中传来的ts和token校验请求有效性.
ts或token未传则会直接回绝请求, 这个可以过滤掉95%以上的无效请求了.
ts和token对校验失败回绝请求, 不会执行后续业务逻辑.
ts和token的计算规则参考中间件代码, 客户端要以相同的规则计算后传入, 参考postman中Pre-request Script:
const ts = new Date().getTime();
const TOKEN = "08fbf466b37a924a8b3d3b2e6d190ef3";
postman.setGlobalVariable("ts", ts);
postman.setGlobalVariable("token", CryptoJS.MD5(TOKEN+ts));
结果处理扩展
代码文件: util/extender.js给express的response添加扩展方法, 简化使用. 例如:
// 无需返回数据
res.success()
// 需要返回数据
res.success(payload)
res.success({
accessToken,
sysType: getRes.sysType,
username: getRes.username,
avatar: getRes.avatar,
redirectUrl,
})
接口请求日志
代码文件: midware/httplog.js记录请求地址, 请求数据, 响应数据, 响应状态码及处理时长. 例如:
2018-02-02 13:23:46 - [B1qkId-Lf] Start POST /login
2018-02-02 13:23:46 - [B1qkId-Lf] Data {"sysType":1,"username":"unittest","password":"e10adc3949ba59abbe56e057f20f883e"}
2018-02-02 13:23:46 - [B1qkId-Lf] Resp {"code":1,"msg":"success","data":{"accessToken":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InVuaXR0ZXN0IiwiaWF0IjoxNTE3NTQ5MDI2LCJleHAiOjE1MTg0MTMwMjZ9.-U4P6ksOUN6WsmI3ZEWow9npYDmO-QI020eVY5Mg2bQ","sysType":1,"username":"unittest","avatar":"https://nodejs.org/static/images/logo.svg"}}
2018-02-02 13:23:46 - [B1qkId-Lf] Done 200 (134ms)
2018-02-02 13:23:49 - [SJCJUuZLM] Start GET /verify
2018-02-02 13:23:49 - [SJCJUuZLM] Resp {"code":1,"msg":"success","data":{"username":"unittest"}}
2018-02-02 13:23:49 - [SJCJUuZLM] Done 200 (7ms)
高并发时可通过请求ID来找到同一次请求的多行日志记录.
通过给原生res.json方法增加一个切面来实现非侵入记录响应数据:
// add a logging aspect to the primary res.json function
const origin = express.response.json
express.response.json = function (json) {
logger.info(`[${this.reqId}] Resp `, JSON.stringify(json))
return origin.call(this, json)
}
支持日志在线预览, 可在浏览器查看日志文件内容(首次会有http auth认证):
当然如果使用的ELK(或者Elastic Stack), 则对于一次请求最好就输出一行json, 以方便logstash或者filebeat抓取.
服务监控面板
代码文件: midware/monitor.js可以打开这个地址查看服务监控面板(首次会有http auth认证): /dashboard
Jest接口测试
代码文件: test/base.test.js已经集成VSC Jest测试配置, 选择Jest All这个profile, 加断点并F5即可开始调试. 或者对当前打开的文件选择Jest File这个profile.
我开始用Jest的时候它才8000多star, 和ava差不多并列第三, 但现在已经排第一了, 不得不服自己的眼光, 啊哈哈哈哈…嗝. 样例:
describe('base ctrl tests', () => {
test('login succeeds ', async () => {
const data = {
sysType: 1,
username: 'unittest',
password: 'e10adc3949ba59abbe56e057f20f883e'
}
const res = await client.POST(`${host}/login`, data)
expect(res.code).toBe(1)
expect(res.data.username).toBe('unittest')
})
test('login fails ', async () => {
const data = {
sysType: 1,
username: 'unittest',
password: 'invalid password'
}
const res = await client.POST(`${host}/login`, data)
expect(res.code).toBe(message.LoginFail.code)
})
})
执行npm t, 测试结果如下:
接口示例说明
提供了3个基于jsonwebtoken (jwt) 的接口示例: 注册, 登录, 验证.
验证接口仅供参考, 实际使用时应在中间件中验证jwt, 这样的中间件类似:
module.exports = async (req, res, next) => {
if (
![
'/path/needs/jwt/verification' // TODO: 考虑放到配置
].includes(req.path)
) {
return next()
}
// // test generating a jwt token
// const jwtToken = await jwtSvc.sign({
// foo: 'bar'
// })
// console.log(jwtToken)
// verify
const { authorization } = req.headers
if (!authorization) {
return next(new Error('verify fail')) // TODO: 修改错误处理, 下同
}
const jwtToken = authorization.substr(7)
let payload
try {
payload = await jwtSvc.verify(jwtToken)
} catch (e) {
return next(new Error('verify fail'))
}
if (!payload) {
return next(new Error('verify fail'))
}
console.log(payload) // TODO: 设置到req上, 后续就能拿到
next()
}
thunk函数包装
代码文件: service/*.js
node进化到今天, 用原生async/await做代码异步流程控制也已经好久了. 很多库提供了基于promise的API, 但难免还有很多基于thunk的库, 或者同时提供了promise的API但还不完善的库.
对于thunk函数我们可以使用node提供的util.promisify来包装为promise. 例如:
/**
* set value of a hash field
* @param {string} key hash key
* @param {string} field field name
* @param {string} value field value
*/
async hset(key, field, value) {
if (typeof value === 'object') {
value = JSON.stringify(value)
}
return promisify(redis.hset)(key, field, value)
},
/**
* get value of a hash field
* @param {string} key hash key
* @param {string} field field name
*/
async hget(key, field) {
const value = await promisify(redis.hget)(key, field)
try {
return JSON.parse(value)
} catch (e) {
return value
}
},