Egg泛講

Egg是什么?

由阿里巴巴團(tuán)隊開源的一套基于koa的應(yīng)用框架,已經(jīng)在集團(tuán)內(nèi)部服務(wù)了大量的nodejs系統(tǒng)。

Egg.js 為企業(yè)級框架和應(yīng)用而生,我們希望由 Egg.js 孕育出更多上層框架,幫助開發(fā)團(tuán)隊和開發(fā)人員降低開發(fā)和維護(hù)成本。

注:Egg.js 縮寫為 Egg

設(shè)計原則

Egg 的插件機(jī)制有很高的可擴(kuò)展性,一個插件只做一件事。

(比如 Nunjucks 模板封裝成了 egg-view-nunjucks、MySQL 數(shù)據(jù)庫封裝成了 egg-mysql)。Egg 通過框架聚合這些插件,并根據(jù)自己的業(yè)務(wù)場景定制配置,這樣應(yīng)用的開發(fā)成本就變得很低。

Egg 奉行『約定優(yōu)于配置』,按照一套統(tǒng)一的約定進(jìn)行應(yīng)用開發(fā),團(tuán)隊內(nèi)部采用這種方式可以減少開發(fā)人員的學(xué)習(xí)成本,開發(fā)人員不再是『釘子』,可以流動起來。

沒有約定的團(tuán)隊,溝通成本是非常高的,比如有人會按目錄分棧而其他人按目錄分功能,開發(fā)者認(rèn)知不一致很容易犯錯。但約定不等于擴(kuò)展性差,相反 Egg 有很高的擴(kuò)展性,可以按照團(tuán)隊的約定定制框架。使用 Loader 可以讓框架根據(jù)不同環(huán)境定義默認(rèn)配置,還可以覆蓋 Egg 的默認(rèn)約定。

image-20190621115447824.png

特點

Egg與Koa的關(guān)系

Koa 是一個非常優(yōu)秀的框架,然而對于企業(yè)級應(yīng)用來說,它還比較基礎(chǔ)。

Egg 選擇了 Koa 作為其基礎(chǔ)框架,在它的模型基礎(chǔ)上,進(jìn)一步對它進(jìn)行了一些增強(qiáng)。

擴(kuò)展

// app/extend/context.js
module.exports = {
  get isIOS() {
    const iosReg = /iphone|ipad|ipod/i;
    return iosReg.test(this.get('user-agent'));
  },
};

在 Controller 中,我們就可以使用到剛才定義的這個便捷屬性了:

// app/controller/home.js
exports.handler = ctx => {
  ctx.body = ctx.isIOS
    ? 'Your operating system is iOS.'
    : 'Your operating system is not iOS.';
};

插件

Koa 中,經(jīng)常會引入許許多多的中間件來提供各種各樣的功能,例如引入 koa-bodyparser 來解析請求 body。而 Egg 提供了一個更加強(qiáng)大的插件機(jī)制,讓這些獨立領(lǐng)域的功能模塊可以更加容易編寫。

一個插件可以包含

  • extend:擴(kuò)展基礎(chǔ)對象的上下文,提供各種工具類、屬性。
  • middleware:增加一個或多個中間件,提供請求的前置、后置處理邏輯。
  • config:配置各個環(huán)境下插件自身的默認(rèn)配置項。

快速入門

我們推薦直接使用腳手架,只需幾條簡單指令,即可快速生成項目:

$ mkdir egg-example && cd egg-example
$ npm init egg --type=simple
$ npm i

編寫Controller

// app/controller/home.js
const Controller = require('egg').Controller;

class HomeController extends Controller {
  async index() {
    const { ctx } = this;
    ctx.body = 'hi, egg';
  }
}

module.exports = HomeController;

配置路由映射:

// app/router.js
module.exports = app => {
  const { router, controller } = app;
  router.get('/', controller.home.index);
};

靜態(tài)資源

Egg 內(nèi)置了 static 插件,線上環(huán)境建議部署到 CDN,無需該插件。

static 插件默認(rèn)映射 /public/* -> app/public/* 目錄

此處,我們把靜態(tài)資源都放到 app/public 目錄即可:

app/public
├── css
│   └── news.css
└── js
    ├── lib.js
    └── news.js

模板渲染

$ npm i egg-view-nunjucks --save

開啟插件:

// config/plugin.js
exports.nunjucks = {
  enable: true,
  package: 'egg-view-nunjucks'
};
// config/config.default.js
// 添加 view 配置
exports.view = {
  defaultViewEngine: 'nunjucks',
  mapping: {
    '.nj': 'nunjucks',
  },
};
<html>
  <head>
    <title>Hacker News</title>
  </head>
  <body>
    <ul class="news-view view">
      hello world
    </ul>
    <h1>hello world</h1>
  </body>
</html>

編寫helper擴(kuò)展

// app/extend/helper.js
exports.getData = () => {
    return '我是處理后的時間'
}

模板引擎和ctx都可以獲取helper對象

編寫Middleware

類似koa中的中間件

// app/middleware/log.js
module.exports = (options, app) => { // egg約定  1. 可以定制化配置, 2.傳入 app的實例
    return async function log(ctx, next) { // 和koa寫法完全一樣
        console.log('我是日志?。。。。?);
        await next();
    }
}
// config/config.default.js
config.middleware = [
    'log'
];
  1. 寫一個中間件
  2. 配置引入

漸進(jìn)式開發(fā)

漸進(jìn)式開發(fā)是egg里面的一種非常重要的設(shè)計思想。

  1. 需要封裝一些方法, 最早起的需求雛形
// app/extend/context.js   給ctx對象擴(kuò)展屬性或者方法
module.exports = {
    get isIOS() {
        return '我不是ios'
    },
};
  1. 插件的雛形
example-app
├── app
│   └── router.js
├── config
│   └── plugin.js
├── lib
│   └── plugin
│       └── egg-ua
│           ├── app
│           │   └── extend
│           │       └── context.js
│           └── package.json
├── test
│   └── index.test.js
└── package.json

lib/plugin/egg-ua/package.json 聲明插件。

{
  "eggPlugin": {
    "name": "ua"
  }
}

config/plugin.js 中通過 path 來掛載插件。

// config/plugin.js
const path = require('path');
exports.ua = {
  enable: true,
  path: path.join(__dirname, '../lib/plugin/egg-ua'),
};
  1. 抽成獨立的插件, 通過npm包的形式引入
egg-ua
├── app
│   └── extend
│       └── context.js
├── test
│   ├── fixtures
│   │   └── test-app
│   │       ├── app
│   │       │   └── router.js
│   │       └── package.json
│   └── ua.test.js
└── package.json
  1. 沉淀到框架
  • oa-egg
  • player-egg
  • music-egg

總結(jié)

  • 一般來說,當(dāng)應(yīng)用中有可能會復(fù)用到的代碼時,直接放到 lib/plugin 目錄去,如例子中的 egg-ua。
  • 當(dāng)該插件功能穩(wěn)定后,即可獨立出來作為一個 node module 。
  • 如此以往,應(yīng)用中相對復(fù)用性較強(qiáng)的代碼都會逐漸獨立為單獨的插件。
  • 當(dāng)你的應(yīng)用逐漸進(jìn)化到針對某類業(yè)務(wù)場景的解決方案時,將其抽象為獨立的 framework 進(jìn)行發(fā)布。
  • 當(dāng)在新項目中抽象出的插件,下沉集成到框架后,其他項目只需要簡單的重新 npm install 下就可以使用上,對整個團(tuán)隊的效率有極大的提升。

基礎(chǔ)功能

目錄結(jié)構(gòu)的約定

egg的原則: 約定大于配置

egg-project
├── package.json
├── app.js (可選)
├── agent.js (可選)  // 比較獨特
├── app
|   ├── router.js
│   ├── controller
│   |   └── home.js
│   ├── service (可選)
│   |   └── user.js
│   ├── middleware (可選)
│   |   └── response_time.js
│   ├── schedule (可選)
│   |   └── my_task.js
│   ├── public (可選)
│   |   └── reset.css
│   ├── view (可選)
│   |   └── home.tpl
│   └── extend (可選)
│       ├── helper.js (可選)
│       ├── request.js (可選)
│       ├── response.js (可選)
│       ├── context.js (可選)
│       ├── application.js (可選)
│       └── agent.js (可選)
├── config
|   ├── plugin.js
|   ├── config.default.js
│   ├── config.prod.js
|   ├── config.test.js (可選)
|   ├── config.local.js (可選)
|   └── config.unittest.js (可選)
└── test
    ├── middleware
    |   └── response_time.test.js
    └── controller
        └── home.test.js

框架規(guī)定的目錄
  • app/router.js 用于配置 URL 路由規(guī)則。
  • app/controller/** 用于解析用戶的輸入,處理后返回相應(yīng)的結(jié)果。
  • app/service/** 用于編寫業(yè)務(wù)邏輯層,可選。
  • app/middleware/** 用于編寫中間件,可選。
  • app/public/** 用于放置靜態(tài)資源,可選。
  • app/extend/** 用于框架的擴(kuò)展,可選。
  • config/config.{env}.js 用于編寫配置文件。
  • config/plugin.js 用于配置需要加載的插件。
  • test/** 用于單元測試。
  • app.jsagent.js 用于自定義啟動時的初始化工作,可選。
內(nèi)置插件約定的目錄
  • app/public/** 用于放置靜態(tài)資源,可選。
  • app/schedule/** 用于定時任務(wù),可選。

內(nèi)置對象

在本章,我們會初步介紹一下框架中內(nèi)置的一些基礎(chǔ)對象,包括從 Koa 繼承而來的 4 個對象(Application, Context, Request, Response) 以及框架擴(kuò)展的一些對象(Controller, Service, Helper, Config, Logger),在后續(xù)的課程中我們會經(jīng)常遇到它們。

Application

Application 是全局應(yīng)用對象,在一個應(yīng)用中,只會實例化一個,它繼承自 Koa.Application,在它上面我們可以掛載一些全局的方法和對象。我們可以輕松的在插件或者應(yīng)用中擴(kuò)展 Application 對象

事件

在框架運(yùn)行時,會在 Application 實例上觸發(fā)一些事件,應(yīng)用開發(fā)者或者插件開發(fā)者可以監(jiān)聽這些事件做一些操作。作為應(yīng)用開發(fā)者,我們一般會在啟動自定義腳本中進(jìn)行監(jiān)聽。

  • server: 該事件一個 worker 進(jìn)程只會觸發(fā)一次,在 HTTP 服務(wù)完成啟動后,會將 HTTP server 通過這個事件暴露出來給開發(fā)者。
  • error: 運(yùn)行時有任何的異常被 onerror 插件捕獲后,都會觸發(fā) error 事件,將錯誤對象和關(guān)聯(lián)的上下文(如果有)暴露給開發(fā)者,可以進(jìn)行自定義的日志記錄上報等處理。
  • requestresponse: 應(yīng)用收到請求和響應(yīng)請求時,分別會觸發(fā) requestresponse 事件,并將當(dāng)前請求上下文暴露出來,開發(fā)者可以監(jiān)聽這兩個事件來進(jìn)行日志記錄。
// app.js

module.exports = app => {
  app.once('server', server => {
    // websocket
  });
  app.on('error', (err, ctx) => {
    // report error
  });
  app.on('request', ctx => {
    // log receive request
  });
  app.on('response', ctx => {
    // ctx.starttime is set by framework
    const used = Date.now() - ctx.starttime;
    // log total cost
  });
};

獲取方式

// app.js
module.exports = app => {
  app.xxxx = 'xxxx';
};

controller文件


class UserController extends Controller {
  async fetch() {
    this.ctx.body = this.app.xxxx;
  }
}

Koa 一樣,在 Context 對象上,也可以通過 ctx.app 訪問到 Application 對象。

context

Context 是一個請求級別的對象,繼承自 Koa.Context。在每一次收到用戶請求時,框架會實例化一個 Context 對象,這個對象封裝了這次用戶請求的信息,并提供了許多便捷的方法來獲取請求參數(shù)或者設(shè)置響應(yīng)信息??蚣軙⑺械?Service 掛載到 Context 實例上,一些插件也會將一些其他的方法和對象掛載到它上面(egg-sequelize 會將所有的 model 掛載在 Context 上)。

獲取方式

最常見的 Context 實例獲取方式是在 Middleware, Controller 以及 Service 中。Controller 中的獲取方式在上面的例子中已經(jīng)展示過了,在 Service 中獲取和 Controller 中獲取的方式一樣,在 Middleware 中獲取 Context 實例則和 Koa 框架在中間件中獲取 Context 對象的方式一致。

除了在請求時可以獲取 Context 實例之外, 在有些非用戶請求的場景下我們需要訪問 service / model 等 Context 實例上的對象,我們可以通過 Application.createAnonymousContext() 方法創(chuàng)建一個匿名 Context 實例:

// app.js
module.exports = app => {
  app.beforeStart(async () => {
    const ctx = app.createAnonymousContext();
    // preload before app start
    await ctx.service.posts.load();
  });
}

定時任務(wù)中的每一個 task 都接受一個 Context 實例作為參數(shù),以便我們更方便的執(zhí)行一些定時的業(yè)務(wù)邏輯:

// app/schedule/refresh.js
exports.task = async ctx => {
  await ctx.service.posts.refresh();
};
Request & Response

Request 是一個請求級別的對象,繼承自 Koa.Request。封裝了 Node.js 原生的 HTTP Request 對象,提供了一系列輔助方法獲取 HTTP 請求常用參數(shù)。

Response 是一個請求級別的對象,繼承自 Koa.Response。封裝了 Node.js 原生的 HTTP Response 對象,提供了一系列輔助方法設(shè)置 HTTP 響應(yīng)。

// app/controller/user.js
class UserController extends Controller {
  async fetch() {
    const { app, ctx } = this;
    const id = ctx.request.query.id;
    ctx.response.body = app.cache.get(id);
  }
}
Controller

框架提供了一個 Controller 基類,并推薦所有的 Controller 都繼承于該基類實現(xiàn)。這個 Controller 基類有下列屬性:

  • ctx - 當(dāng)前請求的 Context 實例。
  • app - 應(yīng)用的 Application 實例。
  • config - 應(yīng)用的配置。
  • service - 應(yīng)用所有的 service。
  • logger - 為當(dāng)前 controller 封裝的 logger 對象。

在 Controller 文件中,可以通過兩種方式來引用 Controller 基類:

// app/controller/user.js

// 從 egg 上獲?。ㄍ扑])
const Controller = require('egg').Controller;
class UserController extends Controller {
  // implement
}
module.exports = UserController;

// 從 app 實例上獲取
module.exports = app => {
  return class UserController extends app.Controller {
    // implement
  };
};
Service

框架提供了一個 Service 基類,并推薦所有的 Service 都繼承于該基類實現(xiàn)。

Service 基類的屬性和 Controller 基類屬性一致,訪問方式也類似:

// app/service/user.js

// 從 egg 上獲取(推薦)
const Service = require('egg').Service;
class UserService extends Service {
  // implement
}
module.exports = UserService;

// 從 app 實例上獲取
module.exports = app => {
  return class UserService extends app.Service {
    // implement
  };
};
Helper

Helper 用來提供一些實用的 utility 函數(shù)。它的作用在于我們可以將一些常用的動作抽離在 helper.js 里面成為一個獨立的函數(shù),這樣可以用 JavaScript 來寫復(fù)雜的邏輯,避免邏輯分散各處,同時可以更好的編寫測試用例。

Helper 自身是一個類,有和 Controller 基類一樣的屬性,它也會在每次請求時進(jìn)行實例化,因此 Helper 上的所有函數(shù)也能獲取到當(dāng)前請求相關(guān)的上下文信息。

獲取方式

可以在 Context 的實例上獲取到當(dāng)前請求的 Helper(ctx.helper) 實例。

// app/controller/user.js
class UserController extends Controller {
  async fetch() {
    const { app, ctx } = this;
    const id = ctx.query.id;
    const user = app.cache.get(id);
    ctx.body = ctx.helper.formatUser(user);
  }
}

除此之外,Helper 的實例還可以在模板中獲取到。

config

我們可以通過 app.config 從 Application 實例上獲取到 config 對象,也可以在 Controller, Service, Helper 的實例上通過 this.config 獲取到 config 對象。

運(yùn)行環(huán)境

EGG_SERVER_ENV=prod npm start

獲取運(yùn)行環(huán)境:

框架提供了變量 app.config.env 來表示應(yīng)用當(dāng)前的運(yùn)行環(huán)境。

很多 Node.js 應(yīng)用會使用 NODE_ENV 來區(qū)分運(yùn)行環(huán)境,但 EGG_SERVER_ENV 區(qū)分得更加精細(xì)。一般的項目開發(fā)流程包括本地開發(fā)環(huán)境、測試環(huán)境、生產(chǎn)環(huán)境等

框架默認(rèn)支持的運(yùn)行環(huán)境及映射關(guān)系(如果未指定 EGG_SERVER_ENV 會根據(jù) NODE_ENV 來匹配)

image-20190629122051604.png

例如,當(dāng) NODE_ENVproductionEGG_SERVER_ENV 未指定時,框架會將 EGG_SERVER_ENV 設(shè)置成 prod。

比如,要為開發(fā)流程增加集成測試環(huán)境 SIT。將 EGG_SERVER_ENV 設(shè)置成 sit(并建議設(shè)置 NODE_ENV = production),啟動時會加載 config/config.sit.js,運(yùn)行環(huán)境變量 app.config.env 會被設(shè)置成 sit。

Config配置

框架提供了強(qiáng)大且可擴(kuò)展的配置功能,可以自動合并應(yīng)用、插件、框架的配置,按順序覆蓋,且可以根據(jù)環(huán)境維護(hù)不同的配置。合并后的配置可直接從 app.config 獲取。

多環(huán)境配置

框架支持根據(jù)環(huán)境來加載配置,定義多個環(huán)境的配置文件。

config
|- config.default.js
|- config.prod.js
|- config.unittest.js
`- config.local.js

config.default.js 為默認(rèn)的配置文件,所有環(huán)境都會加載這個配置文件,一般也會作為開發(fā)環(huán)境的默認(rèn)配置文件。

當(dāng)指定 env 時會同時加載對應(yīng)的配置文件,并覆蓋默認(rèn)配置文件的同名配置。如 prod 環(huán)境會加載 config.prod.jsconfig.default.js 文件,config.prod.js 會覆蓋 config.default.js 的同名配置。

配置寫法

配置文件返回的是一個 object 對象,可以覆蓋框架的一些配置,應(yīng)用也可以將自己業(yè)務(wù)的配置放到這里方便管理。

// 配置 logger 文件的目錄,logger 默認(rèn)配置由框架提供
module.exports = {
  logger: {
    dir: '/home/admin/logs/demoapp',
  },
};

配置文件也可以簡化的寫成 exports.key = value 形式

exports.keys = 'my-cookie-secret-key';
exports.logger = {
  level: 'DEBUG',
};

配置文件也可以返回一個 function,可以接受 appInfo 參數(shù)

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

內(nèi)置的 appInfo 有:

image-20190629124037397.png

appInfo.root 是一個優(yōu)雅的適配,比如在服務(wù)器環(huán)境我們會使用 /home/admin/logs 作為日志目錄,而本地開發(fā)時又不想污染用戶目錄,這樣的適配就很好解決這個問題。

配置加載順序

應(yīng)用、插件、框架都可以定義這些配置,而且目錄結(jié)構(gòu)都是一致的,但存在優(yōu)先級(應(yīng)用 > 框架 > 插件),相對于此運(yùn)行環(huán)境的優(yōu)先級會更高。

比如在 prod 環(huán)境加載一個配置的加載順序如下,后加載的會覆蓋前面的同名配置。

-> 插件 config.default.js
-> 框架 config.default.js
-> 應(yīng)用 config.default.js
-> 插件 config.prod.js
-> 框架 config.prod.js
-> 應(yīng)用 config.prod.js

合并規(guī)則

const a = {
  arr: [ 1, 2 ],
};
const b = {
  arr: [ 3 ],
};
extend(true, a, b);

// [3]

配置結(jié)果

框架在啟動時會把合并后的最終配置 dump 到 run/application_config.json(worker 進(jìn)程)和 run/agent_config.json(agent 進(jìn)程)中,可以用來分析問題。

配置文件中會隱藏一些字段,主要包括兩類:

  • 如密碼、密鑰等安全字段。
  • 如函數(shù)、Buffer 等類型,JSON.stringify 后的內(nèi)容特別大

還會生成 run/application_config_meta.json(worker 進(jìn)程)和 run/agent_config_meta.json(agent 進(jìn)程)文件,用來排查屬性的來源,如:

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

中間件

我們介紹了 Egg 是基于 Koa 實現(xiàn)的,所以 Egg 的中間件形式和 Koa 的中間件形式是一樣的,都是基于洋蔥圈模型。每次我們編寫一個中間件,就相當(dāng)于在洋蔥外面包了一層。

編寫中間件
// app/middleware/gzip.js
const isJSON = require('koa-is-json');
const zlib = require('zlib');

async function gzip(ctx, next) {
  await next();  // app.use過之后的下一個函數(shù)

  // 后續(xù)中間件執(zhí)行完成后將響應(yīng)體轉(zhuǎn)換成 gzip
  let body = ctx.body;
  if (!body) return;
  if (isJSON(body)) body = JSON.stringify(body);

  // 設(shè)置 gzip body,修正響應(yīng)頭
  const stream = zlib.createGzip();
  stream.end(body);
  ctx.body = stream;
  ctx.set('Content-Encoding', 'gzip');
}
配置

一般來說中間件也會有自己的配置。在框架中,一個完整的中間件是包含了配置處理的。我們約定一個中間件是一個放置在 app/middleware 目錄下的單獨文件,它需要 exports 一個普通的 function,接受兩個參數(shù):

  • options: 中間件的配置項,框架會將 app.config[${middlewareName}] 傳遞進(jìn)來。
  • app: 當(dāng)前應(yīng)用 Application 的實例。

我們將上面的 gzip 中間件做一個簡單的優(yōu)化,讓它支持指定只有當(dāng) body 大于配置的 threshold 時才進(jìn)行 gzip 壓縮,我們要在 app/middleware 目錄下新建一個文件 gzip.js

// app/middleware/gzip.js
const isJSON = require('koa-is-json');
const zlib = require('zlib');

module.exports = options => {
  return async function gzip(ctx, next) {
    await next();

    // 后續(xù)中間件執(zhí)行完成后將響應(yīng)體轉(zhuǎn)換成 gzip
    let body = ctx.body;
    if (!body) return;

    // 支持 options.threshold
    if (options.threshold && ctx.length < options.threshold) return;

    if (isJSON(body)) body = JSON.stringify(body);

    // 設(shè)置 gzip body,修正響應(yīng)頭
    const stream = zlib.createGzip();
    stream.end(body);
    ctx.body = stream;
    ctx.set('Content-Encoding', 'gzip');
  };
};
使用中間件

中間件編寫完成后,我們還需要手動掛載,支持以下方式:

module.exports = {
  // 配置需要的中間件,數(shù)組順序即為中間件的加載順序
  middleware: [ 'gzip' ],

  // 配置 gzip 中間件的配置
  gzip: {
    threshold: 1024, // 小于 1k 的響應(yīng)體不壓縮
  },
};
在框架和插件中使用中間件

可以通過app的config對象去個框架添加中間件,因為可能中間件執(zhí)行順序有一些要求。

// app.js
module.exports = app => {
  // 在中間件最前面統(tǒng)計請求時間
  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);
  }
};

應(yīng)用層定義的中間件(app.config.appMiddleware)和框架默認(rèn)中間件(app.config.coreMiddleware)都會被加載器加載,并掛載到 app.middleware 上。

單個路由生效

中間件對象也掛載在app下面

module.exports = app => {
  const gzip = app.middleware.gzip({ threshold: 1024 });
  app.router.get('/needgzip', gzip, app.controller.handler);
};
框架默認(rèn)中間件

除了應(yīng)用層加載中間件之外,框架自身和其他的插件也會加載許多中間件。所有的這些自帶中間件的配置項都通過在配置中修改中間件同名配置項進(jìn)行修改,例如框架自帶的中間件中有一個 bodyParser 中間件(框架的加載器會將文件名中的各種分隔符都修改成駝峰形式的變量名),我們想要修改 bodyParser 的配置,只需要在 config/config.default.js 中編寫

module.exports = {
  bodyParser: {
    jsonLimit: '10mb',
  },
};
使用koa的中間件

koa-compress 為例,在 Koa 中使用時:

const koa = require('koa');
const compress = require('koa-compress');

const app = koa();

const options = { threshold: 2048 };
app.use(compress(options));

我們按照框架的規(guī)范來在應(yīng)用中加載這個 Koa 的中間件:

// app/middleware/compress.js
// koa-compress 暴露的接口(`(options) => middleware`)和框架對中間件要求一致
module.exports = require('koa-compress');
// config/config.default.js
module.exports = {
  middleware: [ 'compress' ],
  compress: {
    threshold: 2048,
  },
};

如果使用到的 Koa 中間件不符合入?yún)⒁?guī)范,則可以自行處理下:

// config/config.default.js
module.exports = {
  webpack: {
    compiler: {},
    others: {},
  },
};

// app/middleware/webpack.js
const webpackMiddleware = require('some-koa-middleware');

module.exports = (options, app) => {
  return webpackMiddleware(options.compiler, options.others);
}
通用配置

無論是應(yīng)用層加載的中間件還是框架自帶中間件,都支持幾個通用的配置項:

  • enable:控制中間件是否開啟。
  • match:設(shè)置只有符合某些規(guī)則的請求才會經(jīng)過這個中間件。
  • ignore:設(shè)置符合某些規(guī)則的請求不經(jīng)過這個中間件。

如果我們的應(yīng)用并不需要默認(rèn)的 bodyParser 中間件來進(jìn)行請求體的解析,此時我們可以通過配置 enable 為 false 來關(guān)閉它

module.exports = {
  bodyParser: {
    enable: false,
  },
};

如果我們想讓 gzip 只針對 /static 前綴開頭的 url 請求開啟,我們可以配置 match 選項

module.exports = {
  gzip: {
    match: '/static',
  },
};

match 和 ignore 支持多種類型的配置方式

  1. 字符串:當(dāng)參數(shù)為字符串類型時,配置的是一個 url 的路徑前綴,所有以配置的字符串作為前綴的 url 都會匹配上。 當(dāng)然,你也可以直接使用字符串?dāng)?shù)組。
  2. 正則:當(dāng)參數(shù)為正則時,直接匹配滿足正則驗證的 url 的路徑。
  3. 函數(shù):當(dāng)參數(shù)為一個函數(shù)時,會將請求上下文傳遞給這個函數(shù),最終取函數(shù)返回的結(jié)果(true/false)來判斷是否匹配。
module.exports = {
  gzip: {
    match(ctx) {
      // 只有 ios 設(shè)備才開啟
      const reg = /iphone|ipad|ipod/i;
      return reg.test(ctx.get('user-agent'));
    },
  },
};

路由

Router 主要用來描述請求 URL 和具體承擔(dān)執(zhí)行動作的 Controller 的對應(yīng)關(guān)系, 框架約定了 app/router.js 文件用于統(tǒng)一所有路由規(guī)則。

通過統(tǒng)一的配置,我們可以避免路由規(guī)則邏輯散落在多個地方,從而出現(xiàn)未知的沖突,集中在一起我們可以更方便的來查看全局的路由規(guī)則。

如何定義Router
  • app/router.js 里面定義 URL 路由規(guī)則
// app/router.js
module.exports = app => {
  const { router, controller } = app;
  router.get('/user/:id', controller.user.info);
};
  • app/controller 目錄下面實現(xiàn) Controller
// app/controller/user.js
class UserController extends Controller {
  async info() {
    const { ctx } = this;
    ctx.body = {
      name: `hello ${ctx.params.id}`,
    };
  }
}

支持 get,post 等所有 HTTP 方法

  • router.get - GET
  • router.put - PUT
  • router.post - POST
  • router.patch - PATCH
  • router.delete - DELETE
restful風(fēng)格的URL定義

http://www.ruanyifeng.com/blog/2018/10/restful-api-best-practices.html

如果想通過 RESTful 的方式來定義路由, 我們提供了 app.resources('routerName', 'pathMatch', controller) 快速在一個路徑上生成 CRUD 路由結(jié)構(gòu)。

// app/router.js
module.exports = app => {
  const { router, controller } = app;
  router.resources('/api/user', controller.posts);
};

上面代碼就在 /posts 路徑上部署了一組 CRUD 路徑結(jié)構(gòu),對應(yīng)的 Controller 為 app/controller/posts.js 接下來, 你只需要在 posts.js 里面實現(xiàn)對應(yīng)的函數(shù)就可以了。

image-20190629164107094.png
獲取參數(shù)
// app/router.js
module.exports = app => {
  app.router.get('/search', app.controller.search.index);
};

// app/controller/search.js
exports.index = async ctx => {
  ctx.body = `search: ${ctx.query.name}`;
};
// app/router.js
module.exports = app => {
  app.router.get('/user/:id/:name', app.controller.user.info);
};

// app/controller/user.js
exports.info = async ctx => {
  ctx.body = `user: ${ctx.params.id}, ${ctx.params.name}`;
};

// curl http://127.0.0.1:7001/user/123/xiaoming

控制器

簡單的說 Controller 負(fù)責(zé)解析用戶的輸入,處理后返回相應(yīng)的結(jié)果

作用
  • RESTful 接口中,Controller 接受用戶的參數(shù),從數(shù)據(jù)庫中查找內(nèi)容返回給用戶或者將用戶的請求更新到數(shù)據(jù)庫中。
    • 返回json
  • 在 HTML 頁面請求中,Controller 根據(jù)用戶訪問不同的 URL,渲染不同的模板得到 HTML 返回給用戶。
  • 在代理服務(wù)器中,Controller 將用戶的請求轉(zhuǎn)發(fā)到其他服務(wù)器上,并將其他服務(wù)器的處理結(jié)果返回給用戶。
    • 調(diào)用第三方的接口
推薦用法(適合在controller中完成的邏輯)
  1. 獲取用戶通過 HTTP 傳遞過來的請求參數(shù)。(解析參數(shù))
  2. 校驗、組裝參數(shù)。(校驗)
  3. 調(diào)用 Service 進(jìn)行業(yè)務(wù)處理,必要時處理轉(zhuǎn)換 Service 的返回結(jié)果,讓它適應(yīng)用戶的需求。(數(shù)據(jù)庫查詢)
  4. 通過 HTTP 將結(jié)果響應(yīng)給用戶。(返回response)
如何編寫controller
'use strict';
// import HttpController from './base/http';
const Controller = require('egg').Controller;

class HomeController extends Controller {
  async index() {
    const { ctx } = this;
    ctx.body = await this.service.user.find() // ctx 來自koa
  }

  // 1. 需要在 controller定義怎么渲染
  async hello() {
    const { ctx } = this;
    await ctx.render('hello.nj'); // 自動的讀取view/hello.nj
  }

  async addUser() {
    const { ctx } = this;
    console.log(ctx.request.body);
    console.log(ctx.csrf)
    ctx.body = 'success';
  }

  async redirect() {
    const { ctx } = this;
    ctx.redirect('https://taobao.com');
  }
}

module.exports = HomeController;

router調(diào)用

// app/router.js
module.exports = app => {
  const { router, controller } = app;
  router.post('/api/posts', controller.post.create);
}

Controller 支持多級目錄,例如如果我們將上面的 Controller 代碼放到 app/controller/sub/post.js 中,則可以在 router 中這樣使用:

// app/router.js
module.exports = app => {
  app.router.post('/api/posts', app.controller.sub.post.create);
}
Controller的屬性

項目中的 Controller 類繼承于 egg.Controller,會有下面幾個屬性掛在 this 上。

  • this.ctx: 當(dāng)前請求的上下文 Context 對象的實例,通過它我們可以拿到框架封裝好的處理當(dāng)前請求的各種便捷屬性和方法。
  • this.app: 當(dāng)前應(yīng)用 Application 對象的實例,通過它我們可以拿到框架提供的全局對象和方法。
  • this.service:應(yīng)用定義的 Service,通過它我們可以訪問到抽象出的業(yè)務(wù)層,等價于 this.ctx.service 。
  • this.config:應(yīng)用運(yùn)行時的配置項。
  • this.logger:logger 對象,上面有四個方法(debug,infowarn,error),分別代表打印四個不同級別的日志,使用方法和效果與 context logger 中介紹的一樣,但是通過這個 logger 對象記錄的日志,在日志前面會加上打印該日志的文件路徑,以便快速定位日志打印位置。
自定義Controller基類

按照類的方式編寫 Controller,不僅可以讓我們更好的對 Controller 層代碼進(jìn)行抽象(例如將一些統(tǒng)一的處理抽象成一些私有方法),還可以通過自定義 Controller 基類的方式封裝應(yīng)用中常用的方法。

// app/controller/base/http.js

const Controller = require('egg').Controller;

class HttpController extends Controller {
  success(data) {
    this.ctx.body = {
        msg: 'success',
        code: 0,
        data
    }
  }
}

module.exports = HttpController;

//app/controller/post.js
const Controller = require('../core/base_controller');
class PostController extends Controller {
  async list() {
    const posts = await this.service.listByUser(this.user);
    this.success(posts);
  }
}
獲取request參數(shù)
  • query 地址欄參數(shù), 會丟棄重復(fù)的參數(shù)
  • params route參數(shù)
  • queries 地址欄參數(shù),不會丟棄重復(fù)的參數(shù)
  • request.body的參數(shù) 框架內(nèi)置了 bodyParser 中間件來對這兩類格式的請求 body 解析成 object 掛載到 ctx.request.body

一個常見的錯誤是把 ctx.request.body 和 ctx.body 混淆,后者其實是 ctx.response.body 的簡寫。**

csrf防范

egg會默認(rèn)帶上csrf防護(hù),不加上token是取不到post中的參數(shù)的。

  • 從ctx.csrf中讀取token

  • 通過header的x-csrf-token字段攜帶過來

重定向

框架通過 security 插件覆蓋了 koa 原生的 ctx.redirect 實現(xiàn),以提供更加安全的重定向。

  • ctx.redirect(url) 如果不在配置的白名單域名內(nèi),則禁止跳轉(zhuǎn)。
  • ctx.unsafeRedirect(url) 不判斷域名,直接跳轉(zhuǎn),一般不建議使用,明確了解可能帶來的風(fēng)險后使用。
// config/config.default.js
exports.security = {
  domainWhiteList:['.domain.com'],  // 安全白名單,以 . 開頭
};

服務(wù)(Service)

簡單來說,Service 就是專門請求和組裝數(shù)據(jù)的。

使用 場景
  • 復(fù)雜數(shù)據(jù)的處理,比如要展現(xiàn)的信息需要從數(shù)據(jù)庫獲取,還要經(jīng)過一定的規(guī)則計算,才能返回用戶顯示?;蛘哂嬎阃瓿珊?,更新到數(shù)據(jù)庫。
  • 第三方服務(wù)的調(diào)用,比如 GitHub 信息獲取等。
// app/service/user
const Service = require('egg').Service;

class UserService extends Service {
    async find(uid) {
        return [1, 2, 3, 4];
    }
}

module.exports = UserService;
'use strict';
// import HttpController from './base/http';
const Controller = require('egg').Controller;

class HomeController extends Controller {
  async index() {
    const { ctx } = this;
    ctx.body = await this.service.user.find() // ctx 來自koa
  }
}

module.exports = HomeController;

插件

插件機(jī)制是我們框架的一大特色。它不但可以保證框架核心的足夠精簡、穩(wěn)定、高效,還可以促進(jìn)業(yè)務(wù)邏輯的復(fù)用,生態(tài)圈的形成。

  • Koa 已經(jīng)有了中間件的機(jī)制,為啥還要插件呢?
  • 中間件、插件、應(yīng)用它們之間是什么關(guān)系,有什么區(qū)別?
為什么要插件

使用 Koa 中間件過程中發(fā)現(xiàn)了下面一些問題:

  1. 中間件加載其實是有先后順序的,但是中間件自身卻無法管理這種順序,只能交給使用者。這樣其實非常不友好,一旦順序不對,結(jié)果可能有天壤之別。
  2. 中間件的定位是攔截用戶請求,并在它前后做一些事情,例如:鑒權(quán)、安全檢查、訪問日志等等。但實際情況是,有些功能是和請求無關(guān)的,例如:定時任務(wù)、消息訂閱、后臺邏輯等等。
  3. 有些功能包含非常復(fù)雜的初始化邏輯,需要在應(yīng)用啟動的時候完成。這顯然也不適合放到中間件中去實現(xiàn)。
中間件、插件、應(yīng)用的關(guān)系

一個插件其實就是一個『迷你的應(yīng)用』,和應(yīng)用(app)幾乎一樣:

  • 它包含了 Service、中間件、配置、框架擴(kuò)展等等。
  • 它沒有獨立的 Router 和 Controller。(插件一般不寫業(yè)務(wù)邏輯)
  • 它沒有 plugin.js,只能聲明跟其他插件的依賴,而不能決定其他插件的開啟與否。
使用插件

插件一般通過 npm 模塊的方式進(jìn)行復(fù)用:

npm i egg-mysql --save

然后需要在應(yīng)用或框架的 config/plugin.js 中聲明:

// config/plugin.js
// 使用 mysql 插件
exports.mysql = {
  enable: true,
  package: 'egg-mysql',
};
根據(jù)環(huán)境配置

同時,我們還支持 plugin.{env}.js 這種模式,會根據(jù)運(yùn)行環(huán)境加載插件配置。

// config/plugin.local.js
exports.dev = {
  enable: true,
  package: 'egg-dev',
};
引入
  • packagenpm 方式引入,也是最常見的引入方式
  • path 是絕對路徑引入,如應(yīng)用內(nèi)部抽了一個插件,但還沒達(dá)到開源發(fā)布獨立 npm 的階段,或者是應(yīng)用自己覆蓋了框架的一些插件
// config/plugin.js
const path = require('path');
exports.mysql = {
  enable: true,
  path: path.join(__dirname, '../lib/plugin/egg-mysql'),
};
如何寫一個插件

你可以直接使用 egg-boilerplate-plugin 腳手架來快速上手。

$ mkdir egg-hello && cd egg-hello
$ npm init egg --type=plugin
$ npm i

一個插件其實就是一個『迷你的應(yīng)用』,下面展示的是一個插件的目錄結(jié)構(gòu),和應(yīng)用(app)幾乎一樣。

. egg-hello
├── package.json
├── app.js (可選)
├── agent.js (可選)
├── app
│   ├── extend (可選)
│   |   ├── helper.js (可選)
│   |   ├── request.js (可選)
│   |   ├── response.js (可選)
│   |   ├── context.js (可選)
│   |   ├── application.js (可選)
│   |   └── agent.js (可選)
│   ├── service (可選)
│   └── middleware (可選)
│       └── mw.js
├── config
|   ├── config.default.js
│   ├── config.prod.js
|   ├── config.test.js (可選)
|   ├── config.local.js (可選)
|   └── config.unittest.js (可選)
└── test
    └── middleware
        └── mw.test.js
  1. 插件沒有獨立的 router 和 controller。這主要出于幾點考慮:
  • 路由一般和應(yīng)用強(qiáng)綁定的,不具備通用性。
  • 一個應(yīng)用可能依賴很多個插件,如果插件支持路由可能導(dǎo)致路由沖突。
  • 如果確實有統(tǒng)一路由的需求,可以考慮在插件里通過中間件來實現(xiàn)。
  1. 插件需要在 package.json 中的 eggPlugin 節(jié)點指定插件特有的信息:
  • {String} name - 插件名(必須配置),具有唯一性,配置依賴關(guān)系時會指定依賴插件的 name。
  • {Array} dependencies - 當(dāng)前插件強(qiáng)依賴的插件列表(如果依賴的插件沒找到,應(yīng)用啟動失敗)。
  • {Array} optionalDependencies - 當(dāng)前插件的可選依賴插件列表(如果依賴的插件未開啟,只會 warning,不會影響應(yīng)用啟動)。
  • {Array} env - 只有在指定運(yùn)行環(huán)境才能開啟,具體有哪些環(huán)境可以參考運(yùn)行環(huán)境。此配置是可選的,一般情況下都不需要配置。
{
  "name": "egg-rpc",
  "eggPlugin": {
    "name": "rpc",
    "dependencies": [ "registry" ],
    "optionalDependencies": [ "vip" ],
    "env": [ "local", "test", "unittest", "prod" ]
  }
}
插件能做什么?
  • 擴(kuò)展內(nèi)置對象的接口
    • app/extend/request.js - 擴(kuò)展 Koa#Request 類
    • app/extend/response.js - 擴(kuò)展 Koa#Response 類
    • app/extend/context.js - 擴(kuò)展 Koa#Context 類
    • app/extend/helper.js - 擴(kuò)展 Helper 類
    • app/extend/application.js - 擴(kuò)展 Application 類
    • app/extend/agent.js - 擴(kuò)展 Agent 類
  • 插入自定義中間件
  • 在應(yīng)用啟動時做一些初始化工作
  • 設(shè)置定時任務(wù)

定時任務(wù)

會有許多場景需要執(zhí)行一些定時任務(wù),例如:

  1. 定時上報應(yīng)用狀態(tài)。
  2. 定時從遠(yuǎn)程接口更新本地緩存。
  3. 定時進(jìn)行文件切割、臨時文件刪除。

框架提供了一套機(jī)制來讓定時任務(wù)的編寫和維護(hù)更加優(yōu)雅。

編寫定時任務(wù)

所有的定時任務(wù)都統(tǒng)一存放在 app/schedule 目錄下,每一個文件都是一個獨立的定時任務(wù),可以配置定時任務(wù)的屬性和要執(zhí)行的方法。

const Subscription = require('egg').Subscription;

class LogSubscription extends Subscription {
  // 通過 schedule 屬性來設(shè)置定時任務(wù)的執(zhí)行間隔等配置
  static get schedule() {
    return {
      interval: '1s', // 1 分鐘間隔
      type: 'worker', // 指定所有的 worker 都需要執(zhí)行
    };
  }

  // subscribe 是真正定時任務(wù)執(zhí)行時被運(yùn)行的函數(shù)
  async subscribe() {
    console.log('我是定時任務(wù)')
  }
}

module.exports = LogSubscription;

另一種寫法:

module.exports = {
  schedule: {
    interval: '1s', // 1 分鐘間隔
    type: 'all', // 指定所有的 worker 都需要執(zhí)行
  },
  async task(ctx) {
    console.log('我是定時任務(wù)')
  },
};
參數(shù)
  • interval
    • 數(shù)字類型,單位為毫秒數(shù),例如 5000。
    • 字符類型,會通過 ms 轉(zhuǎn)換成毫秒數(shù),例如 5s。
  • type
    • worker 類型:每臺機(jī)器上只有一個 worker 會執(zhí)行這個定時任務(wù),每次執(zhí)行定時任務(wù)的 worker 的選擇是隨機(jī)的。
    • all 類型:每臺機(jī)器上的每個 worker 都會執(zhí)行這個定時任務(wù)。

自定義啟動

我們常常需要在應(yīng)用啟動期間進(jìn)行一些初始化工作,等初始化完成后應(yīng)用才可以啟動成功,并開始對外提供服務(wù)。

框架提供了統(tǒng)一的入口文件(app.js)進(jìn)行啟動過程自定義,這個文件返回一個 Boot 類,我們可以通過定義 Boot 類中的生命周期方法來執(zhí)行啟動應(yīng)用過程中的初始化工作。

框架提供了這些 生命周期函數(shù)供開發(fā)人員處理:

  • 配置文件即將加載,這是最后動態(tài)修改配置的時機(jī)(configWillLoad
  • 配置文件加載完成(configDidLoad
  • 文件加載完成(didLoad
  • 插件啟動完畢(willReady
  • worker 準(zhǔn)備就緒(didReady
  • 應(yīng)用啟動完成(serverDidReady
  • 應(yīng)用即將關(guān)閉(beforeClose
// app.js
class AppBootHook {
    constructor(app) {
      this.app = app;
    }
  
    configWillLoad() {
         // 此時 config 文件已經(jīng)被讀取并合并,但是還并未生效
         // 這是應(yīng)用層修改配置的最后時機(jī)
         // 注意:此函數(shù)只支持同步調(diào)用
        console.log('configWillLoad')
     
  
      // 例如:參數(shù)中的密碼是加密的,在此處進(jìn)行解密
    //   this.app.config.mysql.password = decrypt(this.app.config.mysql.password);
    //   // 例如:插入一個中間件到框架的 coreMiddleware 之間
    //   const statusIdx = this.app.config.coreMiddleware.indexOf('status');
    //   this.app.config.coreMiddleware.splice(statusIdx + 1, 0, 'limit');
    }
  
    async didLoad() {
        console.log('didLoad')
      // 所有的配置已經(jīng)加載完畢
      // 可以用來加載應(yīng)用自定義的文件,啟動自定義的服務(wù)
  
      // 例如:創(chuàng)建自定義應(yīng)用的示例
    //   this.app.queue = new Queue(this.app.config.queue);
    //   await this.app.queue.init();
  
    //   // 例如:加載自定義的目錄
    //   this.app.loader.loadToContext(path.join(__dirname, 'app/tasks'), 'tasks', {
    //     fieldClass: 'tasksClasses',
    //   });
    }
  
    async willReady() {
        console.log('willReady')
      // 所有的插件都已啟動完畢,但是應(yīng)用整體還未 ready
      // 可以做一些數(shù)據(jù)初始化等操作,這些操作成功才會啟動應(yīng)用
  
      // 例如:從數(shù)據(jù)庫加載數(shù)據(jù)到內(nèi)存緩存
    //   this.app.cacheData = await this.app.model.query(QUERY_CACHE_SQL);
    }
  
    async didReady() {
        console.log('didReady')
      // 應(yīng)用已經(jīng)啟動完畢
  
    //   const ctx = await this.app.createAnonymousContext();
    //   await ctx.service.Biz.request();
    }
  
    async serverDidReady() {
        console.log('serverDidReady')
      // http / https server 已啟動,開始接受外部請求
      // 此時可以從 app.server 拿到 server 的實例
  
    //   this.app.server.on('timeout', socket => {
    //     // handle socket timeout
    //   });
    }
  }
  
  module.exports = AppBootHook;

注意:在自定義生命周期函數(shù)中不建議做太耗時的操作,框架會有啟動的超時檢測。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容