得益于前端社区的活跃,近年来 NodeJS 应用的场景越来越丰富,JS 慢慢变得这也能做,那也能做,笔者也在这波潮流中,上了 NodeJS 全栈应用的这波车,也曾做出过日均访问千万级的 NodeJS 应用,本文将大概总结一下其中的一些「知识点」:
- 分层设计
- 可测试性设计
- 进程管理(少量谈及)
分层设计
一直很喜欢 Martin Fowler 在《企业应用架构设计模式》中提到的一句话「在架构设计中,我最欣赏的就是层次」。抱着这样的想法,让我在代码设计中尝到了不少好处,回过头来根据已有的代码来画下面的这张图似乎轻松了很多:
P.S. 主体的架构被重构了 4 次,算是追求心中的完美么[坏笑]。
在代码最开始的时候是一张流程图,经过一次次重构,会慢慢变成一张结构设计图
全局依赖:配置
一个 Web 应用,难免需要在不同的几个环境中运行,这个时候配置的管理就变得是否必要,我想你不会希望自己的代码里,出现很多这样的情况的:
if (process.env.NODE_ENV === 'prod') {
// set some value
}
所以,笔者采用的方式是根据process.env.NODE_ENV
去获取对应的配置,比如说在我的应用根目录下有如下文件夹:
- config
- dev.js # 本地开发的配置
- test.js # 单元测试的配置
- daily.js # 集成测试环境的配置
- online.js # 线上的配置
- static.js # 静态的配置
然后在主容器中,去获取对应的配置
const envKey = process.env.NODE_ENV || 'dev';
const config = require('./config/' + envKey);
比如是开发环境dev.js
:
const staticConfig = require('./static'); // 获取一些静态的配置,比如版本号、一些SDK的配置等等
const merge = require('lodash/merge');
const config = merge(staticConfig, {
mongoUrl: 'mongodb://127.0.0.1/dev-db'
});
module.exports = config;
全局依赖:日志
日志这块可能算是前端涉及的比较少的一块领域了,但是在一个 NodeJS 应用中它十分重要,它起到的作用包括:
- 数据记录
- 错误排除
下面我们来看一段比较常见的记录应用请求的中间件代码:
function listenResOver(res, cb) {
const onfinish = done.bind(null, 'finish');
const onclose = done.bind(null, 'close');
res.once('finish', onfinish);
res.once('close', onclose);
function done(event){
res.removeListener('finish', onfinish);
res.removeListener('close', onclose);
cb && cb();
}
}
// 外部注入日志工具、配置、app
module.exports = function(logger, config, app) {
// 对每个请求做一个详细的日志
function log(ctx) {
if(ctx.status === 200) {
// 正常请求记录地址、IP、访问耗时等等
logger.info(`>>>log.res-end:${ctx.href}>>>${ctx.mIP}>>>cost(${Date.now() - ctx.mBeginTime}ms)`);
} else {
// 错误的话记录错误的状态
logger.info(`>>>log.res-error:${ctx.href} error with status ${ctx.status}.`);
}
}
app.use(function*(next) {
this.mBeginTime = Date.now();
const mIP = this.header['x-real-ip'] || this.ip || '';
this.mIP = mIP;
const ctx = this;
listenResOver(this.res, () => {
log(ctx);
});
yield next;
});
};
常见的NodeJS日志模块有: log4js 和 bunyan
次级依赖:模型层
一些像 Mongoose 的 Schema、redis 等可以放在这一层,可以通过一个对象管理起来,比如:
function modTool(logger, config){
const map = {};
async function setMod(key, modFactory) {
map[key] = await modFactory(logger, config);
};
function getMod(key) {
return map[key];
};
return {
setMod,
getMod,
};
};
export default modTool;
比如你可能就有一个 Mongo 管理工具:
const mongoose = require('mongoose');
mongoose.Promise = global.Promise;
async function mongoFactory(logger, config) {
const MONGO_URL = config.get('mongoUrl');
const map = {};
function getModel(modelName) {
if(map[modelName]) {
return map[modelName];
}
let schema = require(`./model/${modelName}`);
let model = mongoose.model(modelName, schema);
map[modelName] = model;
return model;
}
await mongoose.connect(mongoUrl, {
useMongoClient: true,
});
return { getModel };
}
export default mongoFactory;
主容器层
主容器层起到了串联的作用,初始化全局依赖,然后通过一个加载器 clmloader 将全局依赖 [logger, config, modTool]
注入到各个中间件,再通过一个主路由中间件 cl-router 将所有中间件串联起来。
大概的代码如下:
const appStartTime = Date.now();
const envKey = process.env.NODE_ENV || 'dev';
//# 加载配置文件
const config = require('./config/' + envKey);
const rootPath = config.get('rootPath');
//# 初始化日志模块
const logger = require(`${rootPath}/utils/logger`)(config);
const modTool = require(`${rootPath}/utils/mod-tool`)(logger, config);
//# 初始化Koa
const koa = require('koa');
const app = koa();
app.on('error', e => {
logger.error('>>>app.error:');
logger.error(e);
});
//# 加载辅助函数
const loadModules = require('clmloader');
//# 主路由工厂函数
const mainRouterFunc = require('cl-router');
const co = require('co');
//# 应用初始化
co(function*(){
const deps = [logger, config, modTool];
yield modTool.addMod('mongo', modFactory);
//中间件
const middlewareMap = yield loadModules({ path: `${rootPath}/middlewares`, deps: deps });
//接口
const interfaces = yield loadModules({
path: `${rootPath}/interfaces`,
deps: deps,
attach: {
commonMiddlewares: ['common', 'i-helper', 'csrf'],
type: 'interface',
}
});
const routerMap = {
i: interfaces,
};
app.keys = [config.get('appKey')];
app.use(mainRouterFunc({
middlewareMap, //中间件Map
routerMap, //路由Map
defaultRouter: ['i', 'index'], //设置默认路由
logger,
}));
app.listen(config.get('port'), () => {
logger.info(`App start cost ${Date.now() - appStartTime}ms. Listen ${port}.`);
});
}).catch(e => {
logger.fatal('>>>init.fatal-error:');
logger.fatal(e);
});
中间件层
中间件分为两种:通用的中间件和路由的中间件。路由的中间件作为洋葱模型
(P.S. 如果不知道,搜索一下[坏笑])中最后的一个中间件,无法再置于其余中间件之后
。
案例通用中间件middlewares/post/index.js
:
const koaBody = require('koa-body');
module.exports = function(logger, config) {
return Promise.resolve({
middlewares: [koaBody()]
});
};
接口中间件interfaces/example/index.js
;
module.exports = function(logger, config) {
return Promise.resolve({
middlewares: ['post', function*(next) {
const { name } = this.request.body;
this.body = JSON.stringify({
msg: `Hello, ${name}`,
});
}]
});
};
测试设计
前面铺垫了那么多分层设计,大部分都是为了可维护性的设计,而作为测试最为可维护性中最为关键的一部分,当然不能少了。或者说,由于有了这样的分层设计,我们的测试环境将变得十分友好,让我们再来看看之前的分层设计图,如果把其中的全局依赖[logger, config]
和主容器层 mock,我们将不难做到对单个中间件的隔离,并对之进行单测,如图:
具体 Mock 的代码实现helper.js
:
const path = require('path');
const should = require('should');
const rootPath = path.normalize(__dirname + '/..');
const co = require('co');
const koa = require('koa');
// 使用测试的环境配置
const testConfig = require(`${rootPath}/config/test`);
// mock掉logger
const sinon = require('sinon');
const testLogger = {
info: sinon.spy(),
debug: console.log,
fatal: sinon.spy(),
error: sinon.spy(),
warn: sinon.spy()
};
// 可以选择的配置构建
function buildConfig(config) {
config.depMiddlewares = config.depMiddlewares || [];
const l = config.logger || testLogger;
const c = config.config || testConfig;
const deps = [l, c, mdt];
config.mdt = mdt;
config.deps = config.deps || deps;
config.ctx = config.ctx || {};
const dir = config.dir = config.dir || 'interfaces';
config.defaultFile = dir === 'interfaces' ? 'index': 'node.main';
config.before = config.before || function*(next){
yield next;
};
config.after = config.after || function*(next){
if(dir === 'interfaces') {
this.body = this.body || '{ "status": 200, "data":"hello, world"}';
} else {
this.body = this.body || 'hello, world';
}
yield next;
};
config.middlewares = config.middlewares || [];
return config;
}
//## 模拟路由
// * middlewares: 中间件数组,比如['post']
// * routerName: 路由名
// * deps: 工厂传递的参数数组
// * before: 在中间件之前添加一个中间件,测试使用
// * after: 在中间件之后添加一个中间件,测试使用
// * config: 自定义配置,默认为testConfig
// * logger: 自定义日志,默认为testLogger
// * attach: 附加
// * dir: 路由所在的目录
// Return app:koa
function mockRouter(config) {
const {
name, depMiddlewares, deps,
before, after,
ctx, dir, defaultFile,
middlewares,
} = buildConfig(config);
const routerName = name;
return co(function*(){
const rFunc = require(`${rootPath}/${dir}/${routerName}/${defaultFile}`);
const router = yield rFunc.apply(this, deps);
router.name = routerName;
router.path = `${rootPath}/${dir}/${routerName}`;
router.type = dir === 'interfaces' ? 'interface': 'page';
middlewares = middlewares.concat(router.middlewares);
const ms = [];
for (let i = 0, l = middlewares.length; i < l ; i++) {
let m = middlewares[i];
if(typeof m === 'string') {
let mFunc = require(`${rootPath}/middlewares/${m}/`);
let mItem = yield mFunc.apply(this, deps);
ms = ms.concat(mItem.middlewares);
} else if(m.constructor.name === 'GeneratorFunction') {
ms.push(m);
}
}
const app = koa();
app.keys = ['test.helper'];
const keys = Object.keys(ctx);
ms.unshift(before);
ms.push(after);
app.use(function*(next){
const tCtx = this;
keys.forEach(key => {
tCtx[key] = ctx[key];
});
for (let i = ms.length - 1; i >= 0; i--) {
next = ms[i].call(this, next);
}
this.gRouter = router;
this.gRouterKeys = ['i', routerName];
if(next.next) {
yield *next;
} else {
yield next;
}
});
return app;
});
}
global._TEST = {
rootPath,
testConfig,
testLogger,
mockRouter,
};
module.exports = global._TEST;
那么上面案例接口的测试代码就可以这么写了:
const {
mockRouter,
} = _TEST;
const request = require('supertest');
describe('接口测试', () => {
it('should return hello ${name}', async () => {
const app = yield mockRouter({
name: 'example'
});
const res = await request(app.listen())
.post('/')
.send({
name: 'yushan'
})
.expect(200)
res.msg.should.be.equal('Hello, yushan');
});
});
进程管理
进程管理可以考虑场景使用,比如:
- 如果访问量级小于百万的,使用 PM2 完全够用了
- 如果考虑横向扩展,并且公司有成熟的环境(P.S. 用过阿里云的容器方案,一把辛酸泪),可以使用 Docker 的方案
最后
以上的代码不要拿来跑哈,可能会出错哦,写文章的时候对原有代码做了第 5 次重构,但是这次没测试。
本作品采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可。