『好』维护的 NodeJS 应用

得益于前端社区的活跃,近年来 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日志模块有: log4jsbunyan

次级依赖:模型层

一些像 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 国际许可协议 进行许可。