企业级 Node.js Web 应用解决方案设计的零零总总
作者: 发布于:

年前一直在忙着做新版 Midway 升级的事情,不少同学都知道 Midway 是淘宝的 Node.js Web 应用解决方案,目的是为了更好的做前后端分离,让前端同学开发更简单,生活更幸福(笑)。

如今 Midway 5 正式发布了,横跨了几个月的开发个工作,期间带来的感慨,也算是史上最多。

Midway 的诞生也有 2 年多的时间,我个人参与维护也有 1 年多,经历了从 v3 到 v5 的变化,最大的感慨莫过于,分分合合,以前总想着灵活性,要做分离,后来就想着统一升级,又合并回去, 折腾的是自己,也是用户,不管怎么说,之前欠着的债总是要还的,历史包袱总是框架开发者的胸口大石,不破不立才是最终的方案。

代码风格选型

随着 ES6 乃至 ES2015 的出现,generatorpromise 配合的开发方式渐渐的趋于稳定和标准化,再结合未来 async/await 的方式,使用 Koa 1.0 是比较中和的选择,在 2.0 推出之前,可以使用  yield 的写法来简化异步操作,将大部分的异步代码扁平化,同时也可以对未来的 Koa 2.0 代码进行一个很好的兼容和补充。

有人不禁会问,为什么不用 babel ,当然这是一种选择,在 Node.js 没有原生支持这些语法特性,乃至 --harmony 也无法启用的新特性的时候,我们不会考虑使用,这是在做企业级框架的一些基本原则,在面对数千万用户的期待的时候,我们不能拿稳定性来试错。

稳定性

框架的稳定性和业务的稳定性是两个不同的方向,业务需要的是容错,而框架需要的是兜底。很多时候业务代码只需要 try/catch 就能解决,再不然 promise.catch 也好,然后 logger.error 就可以了,但是框架不行。

Midway 使用的是 Master/Agent/Worker 进程方案,同时会启动 N+2 的进程,每个 Worker 进程可能会和 Master/Agent 进程进行通信,一旦有进程错误甚至挂掉,都是一个复杂的情况,所以要处理所有类型的错误就变得非常重要。

进程本身有着一些简单的处理,比如在接受到正常的信息消息的时候正常退出流程,并且杀死其他子进程(碰到过其他子进程杀不死的,所以要强制再杀一下):

// SIGTERM AND SIGINT will trigger the exit event.
process.once('SIGQUIT', function() {
  process.exit(0);
});

process.once('SIGTERM', function() {
  process.exit(0);
});

process.once('SIGINT', function() {
  process.exit(0);
});

process.on('exit', function(code) {
  killAgentWorker();
});

当然,进程也有一些奇奇怪怪的异常,这些异常必须通过日志记录,然后才能进行安全的退出或者其他自定义行为。

process.on('unhandledRejection', function(err, p) {
	 //logger
});

除了以上标准流程之外,就得考虑非主进程出错退出时的情况并做相应的处理,比如 Agent 进程属于非常重要的业务进程,假如第一次启动就出问题,那必然需要强制退出,如果进程在某些情况下意外挂掉,必须有一些自重启机制来保证稳定运行,同时需要处理一些事件(之前出现过事件绑定过多内存泄露的事故)。

agentWorker.once('exit', function(code, signal) {
  coreLogger.error(err);
  // 防止事件泄漏
  agentWorker.removeAllListeners('message');
  agentWorker = null;

  if (allWorkerStartSuccess) {
    // restart agent
    setTimeout(startAgent.bind(null, opts), 1000);
  } else {
    // AgentWorker 初始化过程发生异常,主进程直接退出
    // coreLogger.error('Agent worker init exception occurs. Master exits therefor.');
    process.exit(1);
  }
});

Worker 进程虽然使用 cluster 机制来启动,但是处理方式和 Agent 差不太多,除了挂掉自启之外,还需要有一些不一样的地方,比如进程的数量,原本默认的是 CPU 的核数,但是可能会根据当前的运行环境稍稍进行一些降低以保证内存的可用。此外,进程重启次数过多可能也是一大问题,需要进行额外的计数和报警,当然代码很简单,这边就不再赘述。

当然框架稳定性不仅仅只有这些,进程的处理只是最重要的一环,整个架构的设计中都必须考虑。

框架设计

Midway 新的设计理念是 Everything is a plugin,即所有的都是插件,包括框架和普通应用,这样的设计可以最大化的复用代码,简化使用。

一个简单的应用的结构和插件的结构,乃至框架的结构大致是一样的,经过集团 Node 小组的讨论形成了一套规范,也算是一次大统一。

app_name/
├─app/   
|  ├─extends/
|  │  └─application.js 	 
│  ├─controllers/         
│  │  └─home.js            
│  ├─router.js             
│  └─views/               
│     └─home.xtpl 
├─bin/                     
│  ├─build.sh
│  └─server.js 
├─config/
│  ├─config.js 
│  ├─config.local.conf 
│  ├─config.prod.conf 
├─node_modules/
├─package.json              
└─README.md

看起来非常简单,除了常见的 node_modules 之外,还有一些淘宝特有的 bin/app 目录和一些 xtpl 模板文件。_bin 是启动目录,这边暂且不谈。

所有的插件的目录结构除了没有 controllersrouters 之外,和应用的目录结构是一样的,这其中最重要的一环就是加载方式。

Midway 的加载思路非常清晰简单:

  • 顺序加载插件
  • 把应用作为最后一个插件加载进来
  • 后边的插件覆盖之前的插件

作为一个需要满足大部分场景的框架(插件、应用),需要加载东西有几样,配置文件、Koa 扩展、中间件,控制器,路由,这个时候需要一个通用的加载方法,这个方法可能是长这个样子。

_loadFiles(files, opts) {
  //...

  loadDirs.forEach((dir)=> {
    let fileResults = globby.sync(files, {cwd: dir});

    fileResults.forEach((f)=> {
      let m = util.tryRequire(path.join(dir, f), opts.required);
      let result = (is.function(m) && !is.class(m) && needCall) ? m.apply(this, opts.inject ? [].concat(opts.inject) : [this.app]) : m;

      results.push(opts.resultHandler ? opts.resultHandler.call(this, result, f, dir, m) : result);

      if (opts.target) {
        extend(true, opts.target, result);
      }
    });
  });

  return results;
}

整个方法核心的思路就是加载(tryRequire),除此之外,就是对加载之后的内容进行判断,处理,合并,返回。所有的加载都通过这一方法来做,就目前来看,大部分场景都已经满足了(笑)。

至此,一个框架的主线已经比较明确,核心功能也可用,剩下的就是插件的开发和补充,以及一些细节的修补。

细节和纠结

一个企业级框架的开发肯定没那么简单,主线设计相对容易一些,更麻烦的是细节,往往细节才是区别不同的框架最重要的地方。

兼容性

框架的历史包袱很大一部分体现在升级和兼容性上,但是框架的大版本更新往往是很多的不兼容,要让旧版本用户升级是一件非常头疼的事情。

Midway 也一样。

以前的 Midway 使用的是 Proxy 方式,所有暴露的外部接口都从 midway.getXXXX 中体现,而现有的进程加载方式使得 Midway 从 Worker 进程变为了 Master 进程,导致无法使用原本的方式了。

经历了多次讨论,最后还是为了用户妥协,将入口的文件(require 的部分) 变为 Worker ,而真正用户启动的 server.js 变为了 midway/server,也算是一个圆满的解决方案。

测试和调试

由于将 Worker 机制内置到了 Midway 框架中,本来用户通过 app.js 的调试方式就行不通了,现在必须通过 bin/server.js 的方式来调试,略显繁琐。

根据新升级的 IPC 通信方式,我们想到了可以通过只启动一个进程的方式来调试代码。所以在测试用例中也可以不用启动多个进程来测试代码了。

在大部分情况下测试代码使用 mocha + supertest 已经可以完美的完成了,但是偶尔会在运行多个的时候抽个风,这个问题属于 Agent 进程通信在本地无法判断出相同目录下是否是同一个实例的问题,除此之外,其他还没发现问题(笑:))。

更新机制

新 Midway 的设计理念是简化开发,以往的经历告诉我们,推动用户升级是不现实的,花了许多的时间在给用户升级脚本,升级 Node.js 上,不仅给自己带来了很多不必要的工作量,也给用户带来了很多麻烦和隐患。

在新的设计中,把插件都内置到了自身的依赖中,由框架统一来处理版本,同时,把打包脚本和启动脚本也固化到了框架中,随着框架一起升级,至少在框架使用到现在,已经非常明显的减少客服量。

Midway 本身的升级由 npm tag 版本来控制,这个是由脚手架来处理的,用户每次部署 install,都使用的是该版本最新的框架。

 "publishConfig": {
    "tag": "release-5.1"
  },

当然这样的行为也是有隐患的,比如某个插件升级导致框架出错,不过作为一个内部的框架,我们尽可能保证插件的兼容性和稳定性,必须符合 semver 的版本规范,必须有一定的测试覆盖率,如果有不兼容的情况,整个框架都会一起升级 tag,尽可能减少给用户带来问题的机会。

写在最后

一个解决方案、一个框架的诞生背后总有一群抓耳挠腮的开发者,经常为了一些小的地方,团队会讨论许久,不光是为用户负责,也对自己负责,Midway 不会走 102 年,只是希望在能做的事情上,稍微多做一点罢了。

想来随着 Midway 5 的发布,有一阵子可以不用考虑该如何权衡和取舍了,可以更加把事情专注在服务用户,提升效率这些事情上了(笑)。

最后,铭记,不忘初心,奋勇前行。