1. 程式人生 > >Egg框架知識點1.目錄結構和具體內容

Egg框架知識點1.目錄結構和具體內容

在進行專案之前,最應該瞭解的就是專案結構,瞭解每一個檔案存放的地方,為後續的修改做準備。
在這篇文章中,結合專案中的經歷和EGG框架的目錄結構進行詳細整理。

目錄結構:

server(egg-project)
├── app
| ├── router.js
│ ├── controller
│ | └── home.js
│ ├── service (可選)
│ | └── user.js
│ ├── middleware (可選)
│ | └── accesslog.js
│ └── extend

(可選)
│ ├── helper.js
│ ├── request.js
│ ├── response.js
│ ├── context.js
│ ├── application.js
│ └── agent.js
├── config
| ├── plugin.js
| ├── config.default.js(預設環境)
| ├── config.local.js(本地開發環境)
│ ├── config.prod.js(線上環境)
| ├── config.test.js
(測試環境)
| └── config.stage.js (預發環境)
├── logs
├── node_modules
├── jcloud
├── run
├── test
| ├── app
| | └── response_time.test.js
| └── config
| └── config.default.js
| └── config.default.js
├── package.json
├── app.js
├── build.js

├── sever.js
目錄結構說明:
1. app/router.js :用於配置 URL 路由規則
2. app/controller/** :用於解析使用者的輸入,處理後返回相應的結果
3. app/service/**:用於編寫業務邏輯層,可選,建議使用
4. app/middleware/**: 用於編寫中介軟體
5. app/public/** :用於放置靜態資源,可選
6. app/extend/** :用於框架的擴充套件,可選
7. config/config.{ENV}.js:用於編寫配置檔案
8. config/plugin.js: 用於配置需要載入的外掛
9. test/**:用於單元測試
10. app.js 和 agent.js:用於自定義啟動時的初始化工作,可選
11. app/schedule/** 用於定時任務,可選

具體內容

1.app/router.js :

Router 主要用來描述請求 URL 和具體承擔執行動作的 Controller 的對應關係, 框架約定了 app/router.js 檔案用於統一所有路由規則。
定義router方法:
app/router.js 裡面定義 URL 路由規則

// app/router.js
module.exports = app => {
  const { router, controller } = app
  router.get(`/user/:id`, controller.home.index)
}

app/controller 目錄下面實現 Controller

// app/controller/home.js
const Controller = require(`egg`).Controller
class HomeController extends Controller {
  async index(ctx) {
    ctx.body = 'hello buddy'
  }
}

module.exports = HomeController

這樣就完成了一個最簡單的 Router 定義,當用戶執行 GET /user/123,home.js 這個裡面的 index 方法就會執行
備註:params.id中params在路由表示為/id,若改為qurey.id在路由表示為? id
下面是一些路由定義的方式:

// app/router.js
module.exports = app => {
 const { router, controller } = app;
router.get('/home', controller.home);
router.get('/user/:id', controller.user.page);
router.post('/admin', isAdmin, controller.admin);
router.post('/user', isLoginUser, hasAdminPermission, controller.user.create);
router.post('/api/v1/comments',controller.v1.comments.create); 
  // app/controller/v1/comments.js
};

如上訴所示,路由完整定義主要包括5個主要部分:

router.verb(‘router-name’, ‘path-match’, middleware1, …,middlewareN,
app.controller.action);

1.verb - 使用者觸發動作,支援 get,post 等所有 HTTP 方法
2.router-name 給路由設定一個別名,可以通過 Helper 提供的輔助函式 pathFor 和 urlFor 來生成 URL
3.path-match - 路由 URL 路徑
4.middleware1 - 在 Router 裡面可以配置多個 Middleware
5.controller - 指定路由對映到的具體的 controller 上,controller 可以有兩種寫法:
其中:app.controller.user.fetch - 直接指定一個具體的 controller
‘user.fetch’ - 可以簡寫為字串形式
(但在我的專案中,主要的路由配置在Vue的框架裡)

2.app/controller/ :**

Controller 負責解析使用者的輸入,處理後返回相應的結果,例如:
a.在 RESTful 介面中,Controller 接受使用者的引數,從資料庫中查詢內容返回給使用者或者將使用者的請求更新到資料庫中。
b.在 HTML 頁面請求中,Controller 根據使用者訪問不同的 URL,渲染不同的模板得到 HTML 返回給使用者。
c.在代理伺服器中,Controller 將使用者的請求轉發到其他伺服器上,並將其他伺服器的處理結果返回給使用者。

3.app/service/:**

在 Controller 中不想實現太多業務邏輯,便可以在 Service 層進行業務邏輯的封裝,這不僅能提高程式碼的複用性,同時可以讓我們的業務邏輯更好測試。
在 Controller 中可以呼叫任何一個 Service 上的任何方法,同時 Service 是懶載入的,只有當訪問到它的時候框架才會去例項化它。
使用service有以下幾個好處:
1.保持 Controller 中的邏輯更加簡潔。
2.保持業務邏輯獨立性,抽象出來的 Service 可以被多個 Controller 重複呼叫
現在通過一組程式碼,詳細展現router、controller、service直接三者的關係:

// app/router.js
module.exports = app => {
  app.router.get('/user/:id', app.controller.user.info);
};

// app/controller/user.js
const Controller = require('egg').Controller;
class UserController extends Controller {
  async info() {
    const userId = ctx.params.id;
    const userInfo = await ctx.service.user.find(userId);
    ctx.body = userInfo;
  }
}
module.exports = UserController;

// app/service/user.js
const Service = require('egg').Service;
class UserService extends Service {
  // 預設不需要提供建構函式。
  // constructor(ctx) {
  //   super(ctx); 如果需要在建構函式做一些處理,一定要有這句話,才能保證後面 `this.ctx`的使用。
  //   // 就可以直接通過 this.ctx 獲取 ctx 了
  //   // 還可以直接通過 this.app 獲取 app 了
  // }
  async find(uid) {
    // 假如 我們拿到使用者 id 從資料庫獲取使用者詳細資訊
    const user = await this.ctx.db.query('select * from user where uid = ?', uid);

    // 假定這裡還有一些複雜的計算,然後返回需要的資訊。
    const picture = await this.getPicture(uid);

    return {
      name: user.user_name,
      age: user.age,
      picture,
    };
  }

  async getPicture(uid) {
    const result = await this.ctx.curl(`http://photoserver/uid=${uid}`, { dataType: 'json' });
    return result.data;
  }
}
module.exports = UserService;

// curl http://127.0.0.1:7001/user/1234

4.app/middleware/**

Egg 的中介軟體形式和 Koa 的中介軟體形式是一樣的,都是基於洋蔥圈模型。
配置:
一般來說中介軟體也有自己的配置。在框架中,一個完整的中介軟體是包含了配置處理的。約定一箇中間件是一個放置在 app/middleware 目錄下的單獨檔案,它需要 exports 一個普通的 function,接受兩個引數:
options: 中介軟體的配置項,框架會將 app.config[${middlewareName}] 傳遞進來。
app: 當前應用 Application 的例項
使用中介軟體
中介軟體編寫完成後,我們還需要手動掛載,支援以下方式:

在應用中使用中介軟體
在應用中,可以完全通過配置來載入自定義的中介軟體,並決定它們的順序。如果我們載入中介軟體,在 config.default.js 中加入配置就可以完成中介軟體的開啟和配置:

//app/middleware/accesslog.js

const util = require(`util`)

module.exports = (options, app) => {
  return async (ctx, next) => {
    const startTime = Date.now()
    const accessLogger = app.getLogger('accessLogger')
    // todo test
    const data = {
      xForwardedFor: ctx.header[app.config.ipHeaders] || ``,
      ip: ctx.ip || ``,
      method: ctx.method,
      url: ctx.url,
      host: ctx.host || `-`
    }
    if (ctx.acceptJSON) {
      data.body = JSON.stringify(ctx.request.body)
    }
  }
}
 // config.default.js 

module.exports = appInfo => {
  const config = {}
  // use for cookie sign key, should change to your own and keep security
  config.keys = `${appInfo.name}_1623503412105_8112`

  // 運營後臺
  config.consoleBack = true

  // add your config here
  config.middleware = ['accesslog']
    config.cluster = {
    listen: {
      port: 3001,
      hostname: '0.0.0.0'
    }
  }
    return config
}

在框架和外掛中使用中介軟體
框架和外掛不支援在 config.default.js 中匹配 middleware,需要通過以下方式:

// app.js
module.exports = app => {
  // 在中介軟體最前面統計請求時間
  app.config.coreMiddleware.unshift('report');
};

// app/middleware/report.js
module.exports = () => {
  return async function (ctx, next) {
    const startTime = Date.now();
    await next();
    // 上報請求時間
    reportTime(Date.now() - startTime);
  }
};

應用層定義的中介軟體(app.config.appMiddleware)和框架預設中介軟體(app.config.coreMiddleware)都會被載入器載入,並掛載到 app.middleware 上

中介軟體過程中發現了下面一些問題

 1.中介軟體載入其實是有先後順序的,但是中介軟體自身卻無法管理這種順序,只能交給使用者。這樣其實非常不友好,一旦順序不對,結果可能有天壤之別。
 2.中介軟體的定位是攔截使用者請求,並在它前後做一些事情,例如:鑑權、安全檢查、訪問日誌等等。但實際情況是,有些功能是和請求無關的,例如:定時任務、訊息訂閱、後臺邏輯等等。
 3.有些功能包含非常複雜的初始化邏輯,需要在應用啟動的時候完成。這顯然也不適合放到中介軟體中去實現

正是由於以上問題,引出之後的外掛

5.app/public/**

6.app/extend/**

框架擴充套件:
框架提供了多種擴充套件點擴充套件自身的功能:
Application
Context
Request
Response
Helper
在開發中,我們既可以使用已有的擴充套件 API 來方便開發,也可以對以上物件進行自定義擴充套件,進一步加強框架的功能
在專案中,是對Context進行擴充套件,便以此為例進行說明

Context
Context 指的是 Koa 的請求上下文,這是 請求級別 的物件,每次請求生成一個 Context 例項,通常也簡寫成 ctx。在所有的文件中,Context 和 ctx 都是指 Koa 的上下文物件。

訪問方式
middleware 中 this 就是 ctx,例如 this.cookies.get(‘foo’)。
controller 有兩種寫法:
類的寫法通過 this.ctx
方法的寫法直接通過 ctx 入參。
helper,service 中的 this 指向 helper,service 物件本身,使用 this.ctx 訪問 context 物件,例如 this.ctx.cookies.get(‘foo’)。
擴充套件方式
框架會把 app/extend/context.js 中定義的物件與 Koa Context 的 prototype 物件進行合併,在處理請求時會基於擴充套件後的 prototype 生成 ctx 物件
方法擴充套件:
要給Context擴充套件一個ctx.bizCurl()方法:

// app/extend/context.js
const querystring = require(`querystring`)
module.exports = {
  async bizCurl({ url, param, opts = {} }) {
    opts.contentType = `json`
    opts.dataType = `json`
    opts.method = this.method
    if (param) {
      url += `?${querystring.stringfy(param)}`
    }

    try {
      this.app.logger.debug(`開始請求中間層`, url, opts)
      const result = await this.curl(url, opts)
      this.app.logger.debug(`中間層返回結果`, result.data)
      return result
    } catch (e) {
      this.app.logger.error(e)
      throw e
    }
  }
}

屬性擴充套件
一般來說屬性的計算在同一次請求中只需要進行一次,那麼一定要實現快取,否則在同一次請求中多次訪問屬性時會計算多次,這樣會降低應用效能。

推薦的方式是使用 Symbol + Getter 的模式。
例如,增加一個 ctx.bar 屬性 Getter:

// app/extend/context.js
const BAR = Symbol('Context#bar');

module.exports = {
  get bar() {
    // this 就是 ctx 物件,在其中可以呼叫 ctx 上的其他方法,或訪問屬性
    if (!this[BAR]) {
      // 例如,從 header 中獲取,實際情況肯定更復雜
      this[BAR] = this.get('x-bar');
    }
    return this[BAR];
  }
}

同樣的思路也適合application,Request,Response,Helper

application
訪問方式
ctx.app
Controller,Middleware,Helper,Service 中都可以通過 this.app 訪問到 Application 物件,例如 this.app.config 訪問配置物件。
在 app.js 中 app 物件會作為第一個引數注入到入口函式中

擴充套件方式
框架會把 app/extend/application.js 中定義的物件與 Koa Application 的 prototype 物件進行合併,在應用啟動時會基於擴充套件後的 prototype 生成 app 物件

request
Request 物件和 Koa 的 Request 物件相同,是 請求級別 的物件,它提供了大量請求相關的屬性和方法供使用。

訪問方式
ctx.request
ctx 上的很多屬性和方法都被代理到 request 物件上,對於這些屬性和方法使用 ctx 和使用 request 去訪問它們是等價的,例如 ctx.url === ctx.request.url。
擴充套件方式
框架會把 app/extend/request.js 中定義的物件與內建 request 的 prototype 物件進行合併,在處理請求時會基於擴充套件後的 prototype 生成 request 物件

Response
Response 物件和 Koa 的 Response 物件相同,是 請求級別 的物件,它提供了大量響應相關的屬性和方法供使用。

訪問方式
ctx.response
ctx 上的很多屬性和方法都被代理到 response 物件上,對於這些屬性和方法使用 ctx 和使用 response 去訪問它們是等價的,例如 ctx.status = 404 和 ctx.response.status = 404 是等價的。

擴充套件方式
框架會把 app/extend/response.js 中定義的物件與內建 response 的 prototype 物件進行合併,在處理請求時會基於擴充套件後的 prototype 生成 response 物件。

7.config/config.{ENV}.js:

 config配置:egg框架提供了強大且可擴充套件的配置功能,可以自動合併應用、外掛、框架的配置,**按順序覆蓋**,且可以**根據環境維護不同的配置**。(正是這個按順序覆蓋和根據環境維護不同配置,才使得我們在egg框架下可以配置多種環境,如預設環境,本地開發環境,測試環境,預發環境和線上環境)
 egg框架的配置管理方法是使用程式碼管理配置,也就是**配置即程式碼**,在程式碼中新增多個環境的配置,在啟動時傳入當前環境的引數即可。但無法全域性配置,必須修改程式碼。
  **多環境配置**

框架支援根據環境來載入配置,定義多個環境的配置檔案,例如我現在專案中的結構:
├── config
├── plugin.js
├── config.default.js(預設環境)
├── config.local.js(本地開發環境)
├── config.prod.js(線上環境)
├── config.test.js (測試環境)
└── config.stage.js (可選)

config.default.js 為預設的配置檔案,所有環境都會載入這個配置檔案,一般也會作為開發環境的預設配置檔案。
當指定 env 時會同時載入對應的配置檔案,並覆蓋預設配置檔案的同名配置。如 prod 環境會載入 config.prod.js 和 config.default.js 檔案,config.prod.js 會覆蓋 config.default.js 的同名配置。
(備註:在配置環境時候,在webstrom中可以在dev編輯欄進行編輯。)
在我這個專案中,在config.default.js裡放入專案執行的路由埠,在config.local.js裡放入專案的基本配置,包括網管介面等,在執行時候,直接執行local檔案,在後期按照需要進行環境切換。
配置寫法:
在官網中提及的配置寫法有三種,我們採用第三種寫法,
即配置檔案也可以返回一個 function,可以接受 appInfo 引數:

// 將 logger 目錄放到程式碼目錄下
const path = require('path');
module.exports = appInfo => {
  return {
    logger: {
      dir: path.join(appInfo.baseDir, 'logs'),
    },
  };
};

配置結果
框架在啟動時會把合併後的最終配置 dump 到 run/application_config.json(worker 程序)和 run/agent_config.json(agent 程序)中,可以用來分析問題。
配置檔案中會隱藏一些欄位,主要包括兩類:
如密碼、金鑰等安全欄位,這裡可以通過 config.dump.ignore 配置,必須是 Set 型別。
如函式、Buffer 等型別,JSON.stringify 後的內容特別大,還會生成
run/application_config_meta.json(worker程序)和 run/agent_config_meta.json(agent 程序)檔案,用來排查屬性的來源,如

{
  "logger": {
    "dir": "/path/to/config/config.default.js"
  }
}

8.config/plugin.js:用於配置需要載入的外掛

中介軟體、外掛、應用的關係
一個外掛其實就是一個『迷你的應用』,和應用(app)幾乎一樣:
它包含了 Service、中介軟體、配置、框架擴充套件等等。
它沒有獨立的 Router 和 Controller。
它沒有 plugin.js,只能宣告跟其他外掛的依賴,而不能決定其他外掛的開啟與否。

他們的關係是:
應用可以直接引入 Koa 的中介軟體。
當遇到上一節提到的場景時,則應用需引入外掛。
外掛本身可以包含中介軟體。
多個外掛可以包裝為一個上層框架。

簡而言之,外掛就是一個小型應用,在主介面裡通過新增外掛可以實現一個完整的且邏輯複雜的功能,這樣可以確保中介軟體簡單易讀。

9.test/**:用於單元測試

測試框架:Mocha
Egg選擇和推薦大家使用 Mocha,功能非常豐富,支援執行在 Node.js 和瀏覽器中, 對非同步測試支援非常友好。
斷言庫:power-assert
因為『No API is the best API』, 最終我們重新迴歸原始的 assert 作為預設的斷言庫
測試目錄結構
約定 test 目錄為存放所有測試指令碼的目錄,測試所使用到的 fixtures 和相關輔助指令碼都應該放在此目錄下。
測試指令碼檔案統一按 ${filename}.test.js 命名,必須以 .test.js 作為檔案字尾。
執行工具
只需要在 package.json 上配置好 scripts.test 即可。

{
“scripts”: {
“test”: “egg-bin test”
}
}
然後就可以按標準的 npm test 來執行測試了

10.app.js 和 agent.js:用於自定義啟動時的初始化工作,可選

我們常常需要在應用啟動期間進行一些初始化工作,等初始化完成後應用才可以啟動成功,並開始對外提供服務。

框架提供了統一的入口檔案(app.js)進行啟動過程自定義,這個檔案只返回一個函式。

const webpackMiddleware = require('koa-webpack')

module.exports = app => {
  app.beforeStart(async () => {
    if (app.config.webpack) {
      const md = await webpackMiddleware(app.config.webpack)
      app.use(md)
    }
  })
}

11.app/schedule/** 用於定時任務,可選

雖然我們通過框架開發的 HTTP Server 是請求響應模型的,但是仍然還會有許多場景需要執行一些定時任務,例如:

定時上報應用狀態。
定時從遠端介面更新本地快取。
定時進行檔案切割、臨時檔案刪除。
框架提供了一套機制來讓定時任務的編寫和維護更加優雅。

編寫定時任務
所有的定時任務都統一存放在 app/schedule 目錄下,每一個檔案都是一個獨立的定時任務,可以配置定時任務的屬性和要執行的方法。
(在專案中沒有使用過這個功能,之後用到再補充)

12.build.js

console.log(`node got EGG_SERVER_ENV is ${process.env.EGG_SERVER_ENV}`)
console.log(`node got NODE_ENV is ${process.env.NODE_ENV}`)
const assert = require('assert')
const mm = require('egg-mock')
const fs = require('fs')

mm.env(process.env.EGG_SERVER_ENV || 'prod')
mm.consoleLevel('DEBUG')

let app = mm.app() // 建立當前應用的 app 例項
app.ready().then(  // 等待 app 啟動成功,才能執行測試用例
  function() {
    const config = JSON.parse(
      fs.readFileSync(app.config.rundir + '/webpack_config_alias.json', 'utf8')
    )
    app.close().then(function() {
      console.log('app closed')
      process.nextTick(function() {
        assert.ok(!!config.bizUrl)
        process.exit(0)
      })
    })
  },
  function(error) {
    console.log(error.message)
    process.exit(-1)
  }
)

13.sever.js

const startCluster = require('egg').startCluster
startCluster(
  {
    baseDir: __dirname
  },
  () => {
    console.log('app started')
  }
)