企業級 Node.js Web 應用解決方案設計的零零總總

lingxizhi 8年前發布 | 24K 次閱讀 Node.js JavaScript開發

 

年前一直在忙著做新版 Midway 升級的事情,不少同學都知道 Midway 是淘寶的 Node.js Web 應用解決方案,目的是為了更好的做前后端分離,讓前端同學開發更簡單,生活更幸福(笑)。

如今 Midway 5 正式發布了,橫跨了幾個月的開發個工作,期間帶來的感慨,也算是史上最多。

Midway 的誕生也有 2 年多的時間,我個人參與維護也有 1 年多,經歷了從 v3 到 v5 的變化,最大的感慨莫過于, 分分合合 ,以前總想著靈活性,要做分離,后來就想著統一升級,又合并回去, 折騰的是自己,也是用戶,不管怎么說,之前欠著的債總是要還的,歷史包袱總是框架開發者的胸口大石,不破不立才是最終的方案。

代碼風格選型

隨著 ES6 乃至 ES2015 的出現, generator 和 promise 配合的開發方式漸漸的趨于穩定和標準化,再結合未來 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 是啟動目錄,這邊暫且不談。

所有的插件的目錄結構除了沒有 controllers 和 routers 之外,和應用的目錄結構是一樣的,這其中最重要的一環就是加載方式。

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 的發布,有一陣子可以不用考慮該如何權衡和取舍了,可以更加把事情專注在服務用戶,提升效率這些事情上了(笑)。

最后,銘記,不忘初心,奮勇前行。

來自: http://taobaofed.org/blog/2016/04/08/node-web-framework-design/

 本文由用戶 lingxizhi 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!