框架設計:如何基於 Egg 設計 Node 的服務框架

  • 2019 年 10 月 11 日
  • 筆記

快樂活在當下,盡心就是完美。——林清玄

Node 的工具化價值自不多言,而服務化價值需要長期探索,小菜前端在服務化路上依然是小學生,目前的嘗試是是 Cross 框架,嘗到了一些甜頭。

我想,幾乎沒有前端工程師會對 Node 不感興趣,但用它適合干哪些事情,每個人的答案都不同了,比如小菜前端,我們對於 Node 的深度嘗試,可以在這裡找到答案:《技術棧:為什麼 Node 是前端團隊的核心技術棧》[1],但關於讓 Node 做服務端的事情,卻只有少數團隊有這樣的勇氣。

之所以缺乏自信和勇氣,本質的原因在於 Node 還沒有一個足夠順手的框架來讓你快速證明驅動業務的價值,也在於對 Node 缺乏足夠的了解和信心,以及相對於服務端的強勢,往往前端在侵蝕服務端領域的時候,會受到這般那樣的挑戰甚至刁難,這也成為了在團隊推廣 Node 常遇到的阻力,希望大家從小菜團隊身上可以找到一些答案,其中答案的一部分就是要對 Node 要有足夠的了解和認知,才可以為通用問題抽象出通用的方案去實施,在小菜,就是對於 Node 框架的封裝,這個框架尚未開源,名叫 Cross,寓意沒有邁不過的技術門檻。

分清楚 Node 的邊界

前後端的團隊本身是相愛相殺的關係,是左右手的雙十合十,既有介面聯調上的上下游數據立場,也有必須與對方精誠合作才能一次次拿下項目的戰役,而在服務這件事情,前端就直接介入到了服務端的領域,而且從整個行業來看,這種介入在大中型公司已成為不可阻擋的趨勢,無論是淘寶、天貓、支付寶、騰訊、網易、百度,包括創業獨角獸大搜車、貝貝網、Rokid,海內外不分國籍不分領域的眾多公司都有一個個團隊在深度耕耘,所以這裡的第一個邊界是前後端的邊界。

一千家公司可能就有一千種商業模式,一千種用戶畫像,一千種業務特徵,既有高依賴演算法的高實時計算的井噴式訪問場景,也有日均幾十 UV 幾百 PV 的 toB 大客戶產品,什麼場景用 Node 合適,什麼不合適,這第二個邊界就是 Node 在業務領域裡的服務邊界。

只有弄清楚這兩個邊界,才有 Node 的生存土壤,脫離了這兩個邊界,就難免處處碰壁無法落地,針對前後端邊界,我從前寫過這樣一段話:

數據的控制權和與視圖所依賴的 API,這裡就是目前前後端的邊界,數據控制權屬於後端,API 屬於後端,把前後端簡單看做是一個完整的系統,這個系統中自 API 向下自然是後端的,API 向上則屬於前端。

在 API 下面,對於數據的業務流轉流轉邏輯,在上面對於數據的調用和組裝,這就是數據層面的天然分界點,而 Node 植入進去,也必須在 API 這一層與 Java 保持規範的統一和兼容,通過 RPC 無縫的調用才能來談邊界,而這個邊界我的理解它可以是非強業務耦合的,比如獨立的內部協同系統,也可以是非高計算型的,可以是相對獨立的非同步的高並發的模組,比如消息堆棧的頻繁拉取推送,比如日誌的收集整理等等,總結起來就是非複雜業務流程的,非高計算型的這個地方可以作為 Node 進入的邊界。

而對於業務的服務邊界,只要的小而美的相對獨立的系統,只要不是核心業務,都可以用 Node 快速開發,比如小菜這裡就有報表系統、打包系統、發布系統、市調系統、日誌系統、可視化平台、招聘面試系統、Bug 跟蹤系統等等。

以上的兩個邊界,大家在仔細評估的時候,一定不要忘了自己團隊人員的能力配置,能不能 Hold 住 Node,有沒有 Node 技術專家坐鎮,不然倉促使用可能還適得其反。

為什麼要封裝 Cross

在弄清楚上述的邊界後,小菜前端在 1 年多的時間裡,對 Node 進行深度的使用,從基建系統到相對獨立的業務系統,整個走下來,團隊更多同學掌握了 Node 的使用,同時每個系統之間的差異性也越來越大,有的用的是 Koa 有的是 Koa2,有的是 Thinkjs 有的是 Express,還有的是原生 NodeJS。

顯然每個人的偏好都不同,程式碼品質也不同,工程架構方式也不同,這為後期的維護帶來巨大的麻煩,尤其是做 Node 監控時候,發現沒法用一套方案做批量的部署,也同樣不能做水平的快速擴展,需要挑選一個框架基於它做統一的封裝,從而把前端參與的所有服務端建設可以統一起來,而且現實是我們的前端和 Node 應用由於整個工程的構建與服務部署方式的不同,已經散落到各個伺服器上,導致維護成為了瓶頸,也必須到做出改變的時候了,這是當時的部分零散的應用圖:

為什麼選擇 Eggjs

小菜前端在使用 Eggjs 作為 Nodejs 的基礎服務框架之前使用過諸如 Koa、Express、Koa2、Thinkjs 等框架,其中與 Eggjs 最接近的當屬奇舞團開源的 Thinkjs[2] , 同樣的約定大於配置,同樣的基於 Koa2 進行包裝完善,同樣的採用多級分層的設計方式(Controller, Service 等等),讓應用開發變得更加清晰明了,然而有趣的是, Thinkjs 的開源時間(2013)早於 Eggjs 的開源時間,其在 github 上的 star 的增長速度卻是遠遠落後於 Eggjs,NPM 下載數亦然,雖然 thinkjs 開發體驗也不錯,小菜最後會選定 Eggjs 作為 Nodejs 服務框架的原因,除了上述提到的優點之外,還有如下幾點 :

  • 高度可擴展的插件機制
  • 方便訂製上層框架
  • 豐富且活躍的社區生態
  • 漸進式開發
  • 多進程管理

小菜前端從 18 年年初就開始使用 Eggjs 了,我們的很多項目都是基於 Eggjs 搭建的,其中包括我們的報表系統、GraphQL 網關、小程式後台服務等。在使用 Eggjs 開發這些項目的過程中我們逐漸形成了自己的一套適用於宋小菜的基於 Eggjs 的上層框架,基於小菜特定業務場景長出來的 Framework,它的訂製程度很高,大家可以參考我們實現這套框架時用到的技巧與方法,這些套路應該是通用的。

秉承怎樣的設計理念

考慮授人以魚不如授人以漁嘛,我們先分享下我們的設計理念,這是最簡單卻也最重要的開始部分,我們的目標是風格統一、上手容易、維護方便:

然後就是整體需求的整理和開發集成,在開發集成個過程中不斷調優:

image.png

定完目標,設計好流程,就要準備具體的實施了,我們實施涉及到過程,主要從下面四個方面著手:

  • 框架關係
  • 通用 API
  • 插件訂製
  • 工程管理

如何設計 Framework

框架關係

我們將所有通用的 API 和常用工具函數以及常用的插件(redis、gateway)等統一集成在基礎框架 baseFramework 中,由於 Egg 支援多級框架繼承,所以我們可以根據基礎框架 baseFramework 衍生出其他框架如 GraphQL 相關的框架、微服務相關的框架,它相當於是一顆框架種子,可以往不同的方向訂製:

image.png

通用 API

1. 請求參數統一獲取

假定某個 HomeController 有成員函數 testAction 既要處理 post 請求又要處理 get 請求,就有可能出現以下情況:

const { Controller } = require('egg');    module.exports = class HomeController extends Controller {  	testAction(){      const { ctx } = this;      const { method } = ctx.request;      const id = method === 'GET'? ctx.request.query.id : ctx.request.body.id;      ...  	}  }

我們可以將其優化為:

/* yourapp/app/controller/home.js */  const { BaseController } = require('egg');  // 或者  const { BaseController } = require('your-egg-framework');    module.exports = class HomeController extends BaseController {  	testAction(){      const id = this.getParam('id');//      ...  	}  }    /* egg-baseframework/core/base_controller.js */  const { Controller } = require('egg')    module.exports = class BaseController extends Controller {  	getParam(key) {    	const { ctx } = this;      const { method } = ctx.request;      if (method === 'GET') {      	if(key) {        	...        } else {        	...        }      } else {  			...      }    }  }  /* your-egg-baseframework/lib/index.js */  const { BaseController } = require('../core/base_controller');    module.exports = {    BaseController,    ...  }    /* your-egg-framework/app.js */  module.exports = (app) => {  	require('egg').BaseController = BaseController  }
2. 返回數據格式化

方法同上,我們可以在 BaseController 中定義統一的調用成功和調用失敗返回函數,並在函數中處理返回數據從而避免返回數據不規範的問題

3. 通用工具函數

我們可以將平時業務開發中可能會用到的工具函數統一通過框架擴展的額形式定義到內置對象 helper 上,這些都可以以框架擴展(extend)的方式集成進來,比如參數轉化啊,錯誤資訊格式化等等。

4. 增加參數校驗層

我們可以將參數校驗這一步抽離出來成為 logic 層。有兩種方式可以做到:

  • 在框架載入時調用 app.loader.loadToContext 將所有 controller 對應的參數校驗函數掛載到 context 上,在 controller 執行相應的處理函數時調用
  • 在你的框架繼承的 appWorkerLoader 中覆寫 eggjs 的 loadController , 對每一個 controller 的處理函數都使用對應的 logic 進行代理

插件訂製

Egg 的擁有著豐富的插件生態,然而總有些我們需要用到的插件不太符合我們的要求,比如:

  • egg-redis 長久不支援哨兵模式
  • egg-graphql 不支援連接其他 graphql 服務
  • egg-kafka 長久沒有維護

這個時候就需要我們自己動手編寫或修改相應的插件了,而有些在公司層面上通用的功能如:Java 服務端網關請求(egg-gateway)、用戶鑒權(egg-auth)等我們也將其封裝為插件集成到基礎框架中,講實話,整個框架開發中,讓人最開心最後成就感的部分就是寫插件的時候:

image.png

工程管理

由於插件和插件之間,插件和框架之間,框架和框架之間存在相互依賴的關係,程式碼管理就成為了比較頭疼的問題,推薦使用目前比較火的 monorepo 來進行管理。規範版本發布流程,避免出現不兼容問題。

總結

關於 Cross 的建設我們差不多投入了一個多月的周期,從投入產出比來看還是很划算的一次嘗試,但是在落地時候也會遇到不少問題,從人和團隊的角度來看,這樣的一套 Framework 需要有一定的 Node 編程能力的同學才能較好的用起來,對於所有人依然有一定的心智成本,有沒有可能把這個成本繼續降低呢,走向 Pass 跟高階的只關心業務邏輯不關心背後實現的階段呢,這是一個很值得研究的課題,另外就是從事情的角度,如果業務中沒有那麼多的場景來承載這個框架,事實上它是很難繼續進階的,因為沒有足夠的應用和測試場景來暴露問題,這也是我們當下遇到的一個實際困難,缺少 Node 好手掣肘了我們前進的步子,不過好消息是接下來的業務場景已經鋪開了,團隊也剛剛進了一個 Node 選手,接下來看看應用後發力效果如何。

參考資料

[1]

《技術棧:為什麼 Node 是前端團隊的核心技術棧》: https://www.yuque.com/sxc/front/vh1kg5

[2]

Thinkjs: https://thinkjs.org/

[3]

關注 Scott 跟進我的動態: https://www.yuque.com/iscott