Egg 架構模型簡述 (一)
簡單的骨架認知
1-1. 簡述
1-2. 簡單層級關系
1-3. 路由(Router)
1-4. 内置對象(Router)
1-5. 配置(Config)
1-6. 中間件(MiddleWare)
1. 簡述
egg.js是基于koa為底層,由阿裡nodejs團隊封裝的企業級Web應用解決方案,以限制和規範化團隊開發,幫助開發團隊和開發人員降低開發和維護成本為核心設計理念的優秀解決方案。
官方文檔對 egg.js 的闡述極緻細緻,撰寫本文的目的僅僅是對 Egg 的整體結構做一個簡述,以引導學習為主要目的。
P.S. 本文示例代碼部分使用 TypeScript 進行編寫,是以所有源碼檔案都以 .ts 作為擴充名。
MVC(Model View Controller)是一種軟體設計模式,一種以“展示界面、業務邏輯、資料模型”分離的方法組織代碼,将業務設計打散分離,以便實作高可複用性,及可維護性。
早些年的項目中,Controller層級中需要處理的事情非常之多:接受使用者請求、驗證請求有效性、計算或發送請求至Model抓取資料或修改、計算響應資料、傳回響應資料等。
image
随着一些項目逐漸龐大,這樣的設計造成了同一檔案(或函數)的代碼劇增,可維護性降低。同時,有一些可公用的業務操作也急需單獨提取,是以形成了獨立的業務層,分化了Controller部分。
image
至此,形成了常見的軟體設計層次結構的主線路:
View:作為使用者的 視圖表現 部分,常見的展示形式如浏覽器作為載體的網頁、原生APP應用界面、桌面應用界面等,用于提供使用者界面以便收集、響應使用者行為産生的資料;
Controller:作為 控制器層 部分,控制使用者界面(View)的資料流轉途徑,主要行為包含接收使用者資料請求、發送請求至業務層(Service)、擷取業務層(Service)資料響應,将響應資料發送至使用者界面(View),或生成相應的模闆界面發送至使用者;
Service:作為 業務處理層 部分,主要負責收集及對資料進行相應的運算處理,主要行為包含收集控制器請求資料、資料有效性驗證、運算、請求資料模型(Model)、接收資料模型(Model)響應消息、響應結果至控制器等;
Model:作為 資料模型層 部分,主要用于将資料持久化(OUT)、查詢持久化資料(IN),常見行為如對資料庫進行操作、緩存資料庫資料等;
// 這是一個 egg 項目的目錄結構
├─ app
│ ├─ controller
│ │ └─ home.ts
│ ├─ service
│ │ └─ home.ts
│ └─ model
│ └─ user.ts
3. 路由(Router)
路由主要用于對資料流向進行指引,并處理請求轉發。生活中常見的就是家用的路由器:
image
在Web應用進行前後端互動的過程中,路由亦起到了通過URL位址定位控制器函數的作用,當然,更準确的說法應該是定位靜态資源(無論是接口資料、頁面、圖檔等其他檔案)。如假設 app/controller/home.ts 中存在函數 a() 和函數 b(),我們約定了跳轉 http://luv-ui.com/a 則執行函數 a();跳轉 http://luv-ui.com/b 則執行函數 b()。這是Web應用中的控制器-路由的常見表現手段。
在JAVA項目中,常見的路由表現手段例如
在XML配置檔案中對路由進行統一描述:
/aa.jsp
/bb.jsp
在JAVA控制器檔案中以注解的形式進行單獨描述:
@RestController
@RequestMapping("/home")
public class HomeController {
@RequestMapping(value = "/aa", method = RequestMethod.POST)
public Message aa(){
// do something
}
@RequestMapping(value = "/bb", method = RequestMethod.POST)
public Message bb(){
// do something
}
}
在 Egg 中,約定了路由統一由 app/router.ts 進行定義,理由是:通過統一的配置,我們可以避免路由規則邏輯散落在多個地方,進而出現未知的沖突,集中在一起我們可以更友善的來檢視全局的路由規則。
是以,我們的目錄結構變化為:
// 這是一個 egg 項目的目錄結構
├─ app
│ ├─ controller
│ │ └─ home.ts
│ ├─ service
│ │ └─ home.ts
│ ├─ model
│ │ └─ user.ts
│ └─ router.ts
而 router.ts 中的處理方式如:
import { Application } from 'egg';
export default (app: Application) => {
const { controller, router } = app;
router.get('/aa', controller.home.aa);
router.get('/bb', controller.home.bb);
router.post('/user/cc', controller.user.cc);
// ...
}
其業務邏輯如下圖所示:
image
4. 内置對象
Egg 中包含兩種内置對象:
由 Koa 繼承的對象:Application、Context、Request、Response
架構擴充的對象:Controller、Service、Helper、Config、Logger
其主要作用如下:
對象名
注釋
Application
全局應用對象,在一個應用中,隻會執行個體化一個,我們可以為其挂載一些全局的方法和對象。在架構運作時,會在 Application 執行個體上觸發一些事件。我們幾乎可以在編寫應用時的任何一個地方擷取到 Application 對象用于操作。
Context
一個請求級别的對象,在每一次收到使用者請求時,架構都會執行個體化一個 Context 對象,這個對象封裝了這次使用者請求的資訊,并提供了許多便捷的方法來擷取請求參數或者設定響應資訊。通常在 Middleware、Controller、Service 中擷取操作。
Request
一個請求級别的對象,封裝了 Node.js 原生的 HTTP Request 對象,提供了一系列輔助方法擷取 HTTP 請求常用參數。通過 Context 對象的 ctx.request 來擷取其執行個體。
Response
一個請求級别的對象,封裝了 Node.js 原生的 HTTP Response 對象,提供了一系列輔助方法設定 HTTP 響應。通過 Context 對象的 ctx.response 來擷取其執行個體。
Controller
Controller 控制器的基類,所有的 Controller 都應該繼承于該基類。它提供了如下常用屬性:
- ctx: 擷取目前請求中的Context對象;
- app: 應用的 Application 執行個體;
- config:目前應用的配置對象。
- service:包含應用所有 Service 的對象。
- logger:為目前 Controller 封裝的 logger 日志對象。
Service
Service 業務層的基類,所有的 Service 都應該繼承于該基類。其提供的屬性和基類調用的方式,都與 Controller 類似。
Helper
用來提供一些實用的 utility 函數。它的作用在于我們可以将一些常用的隸屬于工具對象的動作抽離在 helper.js 裡面成為一個獨立的函數,避免邏輯分散各處,同時可以更好的編寫測試用例。
Config
Egg 推薦應用開發遵循配置和代碼分離的原則,将一些需要寫死的業務配置都放到配置檔案中。在不同的運作環境可以應用不同的配置改變架構運作方式。(如開發環境和生産環境不同,對資料源、日志、插件等的應用也可能有所不同)
Logger
Egg 内置了功能強大的日志功能,可以非常友善的列印各種級别的日志到對應的日志檔案中,每一個 logger 對象都提供了 4 個級别的方法:
- logger.debug():用于調試階段日志記錄。
- logger.info():用于正常流程日志記錄。
- logger.warn():用于警告級别的日志記錄。
- logger.error():用于嚴重錯誤的日志記錄。
4.1 應用過程 - Controller
結合資料流轉過程,當資料傳遞至 Controller 時,我們需要進行相應的處理。Egg 約定了所有的 Controller 對象都放在 app/controller/ 位置。 Controller 部分大緻長這個樣子:
import { Context, Controller } from 'egg';
export default class HomeController extends Controller {
constructor(ctx: Context) {
super(ctx);
// do something
}
// 具體的請求函數
public async foo() {
const { ctx } = this; // this 代表目前 Controller 對象本身
const { code } = ctx.query; // 擷取 Get 請求中的參數 code
ctx.body = await ctx.service.home.foo( code ); // 異步調用 Service 對象中的相應業務處理,并将結果對調用者響應
}
}
在應用的過程中,我們也可以建立自己的 BaseController 繼承自 Controller 基類。再由具體的控制器類繼承自 BaseController ,以便于實作統一的代碼部分封裝。
該示例中,預設導出的類命名方式為 XxxController ,此時,在 router.ts 中,便可以通過 app.controller.home.foo 來指定業務流轉至該函數,來擷取相應資源。
同理,ctx 對象中包含的 service 對象, 囊括了所有 app/service/ 層級下的 Service 繼承類,是以可以簡單的使用 ctx.service.xxx.yyy 來定位業務函數。
4.2 應用過程 - Service
在業務處理的 Service 部分,Egg 約定了所有的 Service 對象都放在 app/service/ 位置。大概長這個樣子:
import { Context, Service } from 'egg';
export default class HomeService extends Service {
constructor(ctx: Context) {
super(ctx);
// do something
}
// 具體的業務處理函數
public async foo( code: string ) {
const { ctx } = this; // this 代表目前 Service 對象本身
const where = { code };
return await ctx.model.user.findAll({where}); // 通過 Model(資料模型) 部分擷取靜态資源
}
}
至此,我們所看到的業務流程就變成了這個樣子:
image
5. 配置(Config)
Egg 使用代碼的方式配置目前應用的運作方式,Egg 約定了所有的配置檔案都放在 ./config/ 位置。目錄結構如下:
// 這是一個 egg 項目的目錄結構
├─ app
│ ├─ controller
│ │ └─ home.ts
│ ├─ service
│ │ └─ home.ts
│ ├─ model
│ │ └─ user.ts
│ └─ router.ts
├─ config
│ ├─ config.default.ts
│ ├─ config.prod.ts
│ └─ config.local.ts
配置檔案傳回的是一個 object 對象,可以覆寫架構的一些配置,應用也可以将自己業務的配置放到這裡友善管理。配置檔案大概長這個樣子:
// 配置檔案的寫法 (config.default.ts)
import { EggAppConfig, EggAppInfo, PowerPartial } from 'egg';
export default (appInfo: EggAppInfo) => {
const config = {} as PowerPartial;
// 其他的配置内容...
return {
...config
};
}
我們常常在配置檔案中定義 中間件、日志、其他插件 的運作方式,比如在整個應用啟動的過程中,運作哪些中間件;日志輸出的方式、其他一些插件在運作過程中的參數配置之類的。這樣的配置,可能會區分為 開發環境、測試環境、生産環境 等等,在每個環境中的配置方式都可能有所不同。例如你的本地開發使用本地資料庫跑資料,連接配接本地庫的 IP、使用者、密碼、端口等,與線上環境的肯定有所不同。是以,針對不同環境應用不同的配置非常有意義。
值得注意的是,config.default 在任何環境中都會被加載,但加載的過程中,若環境配置中有重複項,則會覆寫 default 中的内容。
image
由于 config.{env}.ts 的優先級更大 (它需要覆寫預設配置,來彰顯自己的獨立性),是以應用啟動時配置檔案的加載順序是:
config.default.ts
config.{env}.ts
如何變更目前運作環境中的啟動配置:
在 config 目錄下建立檔案 env,在檔案中鍵入目前環境關鍵字。如鍵入 prod,則在應用啟動時加載檔案 config/config.prod.ts;
配置環境變量 EGG_SERVER_ENV 指定運作環境,啟動應用的過程中會讀取 process.env.EGG_SERVER_ENV 來判斷目前應使用何種方式配置應用。
注意,與其他語言開發項目不同的是,nodejs 作為伺服器端環境,自提供了一個 webserver,而無需使用其他容器作為應用載體。是以,應用的啟動就代表着伺服器的啟動。
此時,我們的項目結構變成了這個樣子:
image
6. 中間件(MiddleWare)
Egg 是基于 Koa 實作的,是以 Egg 的中間件形式和 Koa 的中間件形式是一樣的,都是基于洋蔥圈模型。每次我們編寫一個中間件,就相當于在洋蔥外面包了一層。類似于這個樣子:
image
Egg 約定一個中間件是一個放置在 app/middleware/ 下的獨立檔案,并會 exports 一個函數。函數接收兩個參數:
- options: 中間件的配置項,架構會将 app.config[${middlewareName}] 傳遞進來。
- app: 目前應用 Application 的執行個體。
例如,我們寫了一個驗證請求中是否攜帶 token 的中間件:
// 一個中間件 ( app/middleware/xtoken.ts )
import { Context } from 'egg';
export default (options) => {
return async (ctx: Context, next: Function) => {
// 排除登入路徑, 其他路徑需通過 token 校驗
const { url } = ctx.request;
if (!options.exclude[url]) {
return await next();
}
// 檢查 token 有效性...
};
}
中間件編寫完成之後,我們需要在配置檔案中,配置該中間件,使其生效:
// 配置檔案 (config.default.ts)
import { EggAppConfig, EggAppInfo, PowerPartial } from 'egg';
export default (appInfo: EggAppInfo) => {
const config = {} as PowerPartial;
// 配置中間件
config.middleware = ['xtoken', 'otherMiddleWare'];
// 為中間件添加動态配置
config.xtoken = {
exclude: { '/access': true }
};
// 其他的配置内容...
return {
...config
};
}
屆時,我們通過該中間件,描述了所有的請求必須經過 token 校驗,除了排除清單中的請求。當然,這是應用中使用中間件的方式,還可以在架構、插件,乃至于在 router 中明确哪個請求才會由中間件進行處理。
此時的目錄結構如下:
// 這是一個 egg 項目的目錄結構
├─ app
│ ├─ controller
│ │ └─ home.ts
│ ├─ service
│ │ └─ home.ts
│ ├─ model
│ │ └─ user.ts
│ ├─ middleware
│ │ └─ xtoken.ts
│ └─ router.ts
├─ config
│ ├─ config.default.ts
│ ├─ config.prod.ts
│ └─ config.local.ts
多個中間件時
當應用中包含有多個中間件,則中間件的加載順序以 config 中聲明中間件的數組順序而定,假設我們在中間件定義中聲明:config.middleware = ['mw1', 'mw2', 'mw3']; ,則中間件的加載順序為:mw1 -> mw2 -> mw3,在請求攔截進行中的嵌套關系為:
image
由此可見,最後被加載的中間件,将置于請求過程中的最内層進行攔截。
更簡單的攔截處理
在上述示例中,我們在 config 配置檔案中,在聲明中間件結束時,為 xtoken 設定了自定義屬性 exclude 作為攔截條件,在中間件的定義檔案 app/middleware/xtoken.ts 中以參數 options 擷取了攔截條件并執行相應的邏輯。而在實際開發應用時,中間件已配備了幾個通用參數,用以更簡便的設定中間件的狀态:
屬性名
類型
注釋
enable
boolean
控制中間件是否開啟。
match
string、stringp[]、RegEx、function
設定隻有符合某些規則的請求字首才會經過這個中間件。
ignore
string、stringp[]、RegEx、function
設定符合某些規則的請求字首不經過這個中間件。
是以,我們在 config 中的攔截規則便可以簡單的改造為:
// 配置檔案 (config.default.ts)
import { EggAppConfig, EggAppInfo, PowerPartial } from 'egg';
export default (appInfo: EggAppInfo) => {
const config = {} as PowerPartial;
// 配置中間件
config.middleware = ['xtoken', 'otherMiddleWare'];
// 為中間件添加動态配置
config.xtoken = {
// 配置所有的字首為 /access 或 /morepath 的 url 不經過該中間件
ignore: [ '/access', '/morepath' ]
};
// 其他的配置内容...
return {
...config
};
}
而在中間件檔案中,便可以省去了對于攔截條件的校驗 -