Egg框架
由阿里巴巴团队开源的一套基于koa的应用框架,已经在集团内部服务了大量的nodejs系统。
约定优于配置
特点
基于koa封装的一个企业应用框架
- 可以写插件
- 可以根据业务去封装自己的framework
- 约定优于配置
egg与koa的关系
- egg继承自koa
- egg可以基于koa去扩展
- 中间件
- extend 可以在ctx上挂载一些属性或者方法
- config
运行环境
//设置运行环境
EGG_SERVER_ENV=prod npm start
//prod 生产环境
//local 开发环境
//sit 集成环境
//unittest 测试环境
//获取运行环境
app.config.env
获取运行环境
框架提供了变量 app.config.env 来表示应用当前的运行环境。
很多 Node.js 应用会使用
NODE_ENV来区分运行环境,但EGG_SERVER_ENV区分得更加精细。一般的项目开发流程包括本地开发环境、测试环境、生产环境等
框架默认支持的运行环境及映射关系(如果未指定 EGG_SERVER_ENV 会根据 NODE_ENV 来匹配)
| NODE_ENV | EGG_SERVER_ENV | 说明 |
|---|---|---|
| development | local | 本地开发环境 |
| test | unittest | 单元测试 |
| production | prod | 生产环境 |
例如,当 NODE_ENV 为 production 而 EGG_SERVER_ENV 未指定时,框架会将 EGG_SERVER_ENV 设置成 prod。
比如,要为开发流程增加集成测试环境 SIT。将
EGG_SERVER_ENV设置成sit(并建议设置NODE_ENV = production),启动时会加载config/config.sit.js,运行环境变量app.config.env会被设置成sit。
快速入门
脚手架工具
mkdir egg-example && cd egg-example
npm init egg --type=simple
npm i
//启动项目
npm run dev
目录结构
egg-project
├── package.json
├── app.js (可选) //用于自定义启动时的初始化工作,可选
├── agent.js (可选) //同上
├── app
| ├── router.js //用于配置 URL 路由规则
│ ├── 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
在egg中使用插件
基本使用
插件一般通过 npm 模块的方式进行复用如:
npm i egg-mysql --save
1.在/config/plugin.js中配置,告诉egg加入xx插件
module.exports = {
nunjucks: {
enable: true,
package: 'egg-view-nunjucks',
},
//...
};
2.在/config/config.default.js中配置
module.exports = appInfo => {
/**
* built-in config
* @type {Egg.EggAppConfig}
**/
const config = exports = {};
// use for cookie sign key, should change to your own and keep security
config.keys = appInfo.name + '_1586073102728_7493';
// add your middleware config here
config.middleware = [];
#添加的插件配置
config.view = {
defaultViewEngine: 'nunjucks', //默认的模板引擎
mapping: {
'.nj': 'nunjucks',
},
};
// add your user config here
const userConfig = {
// myAppName: 'egg',
};
return {
...config,
...userConfig,
};
};
引入方式
1.可以通过npm安装
2.path 是绝对路径引入,如应用内部抽了一个插件,但还没达到开源发布独立 npm 的阶段,或者是应用自己覆盖了框架的一些插件
// config/plugin.js
const path = require('path');
exports.mysql = {
enable: true,
path: path.join(__dirname, '../lib/plugin/egg-mysql'),
};
为什么要插件
使用 Koa 中间件过程中发现了下面一些问题:
- 中间件加载其实是有先后顺序的,但是中间件自身却无法管理这种顺序,只能交给使用者。这样其实非常不友好,一旦顺序不对,结果可能有天壤之别。
- 中间件的定位是拦截用户请求,并在它前后做一些事情,例如:鉴权、安全检查、访问日志等等。但实际情况是,有些功能是和请求无关的,例如:定时任务、消息订阅、后台逻辑等等。
- 有些功能包含非常复杂的初始化逻辑,需要在应用启动的时候完成。这显然也不适合放到中间件中去实现。
中间件、插件、应用的关系
一个插件其实就是一个『迷你的应用』,和应用(app)几乎一样:
- 它包含了 Service、中间件、配置、框架扩展等等。
- 它没有独立的 Router 和 Controller。(插件一般不写业务逻辑)
- 它没有 plugin.js,只能声明跟其他插件的依赖,而不能决定其他插件的开启与否。
如何写一个插件
脚手架初始化
mkdir egg-hello && cd egg-hello
npm init egg --type=plugin
npm i
目录结构
在项目的/projectname/app/lib/plugin/egg-hello/
. 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
在egg中编写扩展
编写helper(工具类) js文件
在/app/extend/下面编写规定的js文件,如果没有extend创建一个,此文件夹下的js可全局使用
│ └── extend (可选)
│ ├── helper.js (可选) //工具类文件
│ ├── request.js (可选)
│ ├── response.js (可选)
│ ├── context.js (可选) //给ctx对象扩展属性或者方法
│ ├── application.js (可选)
│ └── agent.js (可选)
编写
'use strict';
exports.getData = () => {
return '我是处理后的时间';
};
exports.getStr = () => {
return '我是字符串';
};
调用(无需引入)
//在ctx中调用
ctx.helper.getStr()
//在模板中调用
<h1>hello world {{helper.getStr()}}</h1>
在egg中编写中间件
在/app/middleware/下写中间件,每次请求都在执行
编写log.js
'use strict';
module.exports = (options, app) => {
return async function log(ctx, next) {
console.log('我是日志!!!',options.cont);
await next();
};
};
在/config/config.default.js 中配置
config.middleware = [ 'log' ];
log:{
cont:'打印日志'
}
在框架和插件中使用中间件
可以通过app的config对象去个框架添加中间件,因为可能中间件执行顺序有一些要求。
// 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 上。
单个路由生效
中间件对象也挂载在app下面
module.exports = app => {
const gzip = app.middleware.gzip({ threshold: 1024 });
app.router.get('/needgzip', gzip, app.controller.handler);
};
框架默认中间件
除了应用层加载中间件之外,框架自身和其他的插件也会加载许多中间件。所有的这些自带中间件的配置项都通过在配置中修改中间件同名配置项进行修改,例如框架自带的中间件中有一个 bodyParser 中间件(框架的加载器会将文件名中的各种分隔符都修改成驼峰形式的变量名),我们想要修改 bodyParser 的配置,只需要在
config/config.default.js中编写
module.exports = {
bodyParser: {
jsonLimit: '10mb',
},
};
使用koa的中间件
我们按照框架的规范来在应用中加载这个 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 中间件不符合入参规范,则可以自行处理下:
// 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);
}
通用配置
无论是应用层加载的中间件还是框架自带中间件,都支持几个通用的配置项:
- enable:控制中间件是否开启。
- match:设置只有符合某些规则的请求才会经过这个中间件。
- ignore:设置符合某些规则的请求不经过这个中间件。
如果我们的应用并不需要默认的 bodyParser 中间件来进行请求体的解析,此时我们可以通过配置 enable 为 false 来关闭它
module.exports = {
bodyParser: {
enable: false,
},
};
如果我们想让 gzip 只针对 /static 前缀开头的 url 请求开启,我们可以配置 match 选项
module.exports = {
gzip: {
match: '/static',
},
};
match 和 ignore 支持多种类型的配置方式
- 字符串:当参数为字符串类型时,配置的是一个 url 的路径前缀,所有以配置的字符串作为前缀的 url 都会匹配上。 当然,你也可以直接使用字符串数组。
- 正则:当参数为正则时,直接匹配满足正则验证的 url 的路径。
- 函数:当参数为一个函数时,会将请求上下文传递给这个函数,最终取函数返回的结果(true/false)来判断是否匹配。
module.exports = {
gzip: {
match(ctx) {
// 只有 ios 设备才开启
const reg = /iphone|ipad|ipod/i;
return reg.test(ctx.get('user-agent'));
},
},
};
路由
Router 主要用来描述请求 URL 和具体承担执行动作的 Controller 的对应关系, 框架约定了
app/router.js文件用于统一所有路由规则。通过统一的配置,我们可以避免路由规则逻辑散落在多个地方,从而出现未知的冲突,集中在一起我们可以更方便的来查看全局的路由规则。
如果想通过 RESTful 的方式来定义路由, 我们提供了 app.resources('routerName', 'pathMatch', controller) 快速在一个路径上生成 CRUD 路由结构。
| Method | Path | Route Name | Controller.Action | 说明 |
|---|---|---|---|---|
| GET | /posts | posts | app.controllers.posts.index | 全部查询 |
| GET | /posts/new | new_post | app.controllers.posts.new | |
| GET | /posts/:id | post | app.controllers.posts.show | 单条查询 |
| GET | /posts/:id/edit | edit_post | app.controllers.posts.edit | |
| POST | /posts | posts | app.controllers.posts.create | 新增 |
| PUT | /posts/:id | post | app.controllers.posts.update | 更新 |
| DELETE | /posts/:id | post | app.controllers.posts.destroy | 删除 |
// app/router.js
module.exports = app => {
const { router, controller } = app;
router.resources('/api/user', controller.posts);
};
获取参数
// 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
params
获取post请求
query
获取get请求
渐进式开发
渐进式开发是egg里面的一种非常重要的设计思想。
1.需要封装一些方法,最早起的需求雏形。
在/app/extend/context.js中编写,给ctx对象扩展属性或者方法
'use strict';
module.exports = {
get isIOS() {
return '我不是ios';
},
};
#注: 只能在ctx中调用
2.插件的雏形
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'),
};
3.抽成独立的插件, 通过npm包的形式引入
egg-ua
├── app
│ └── extend
│ └── context.js
├── test
│ ├── fixtures
│ │ └── test-app
│ │ ├── app
│ │ │ └── router.js
│ │ └── package.json
│ └── ua.test.js
└── package.json
4.沉淀到框架
Egg内置对象
Application, Context, Request, Response继承至koa。
Application
全局应用对象,在一个应用中,只会实例化一个,它继承自 Koa.Application,在它上面我们可以挂载一些全局的方法和对象。我们可以轻松的在插件或者应用中扩展 Application 对象。
事件: server
该事件一个 worker 进程只会触发一次,在 HTTP 服务完成启动后,会将 HTTP server 通过这个事件暴露出来给开发者。
事件:error
运行时有任何的异常被 onerror 插件捕获后,都会触发
error事件,将错误对象和关联的上下文(如果有)暴露给开发者,可以进行自定义的日志记录上报等处理。
事件:request&response
应用收到请求和响应请求时,分别会触发
request和response事件,并将当前请求上下文暴露出来,开发者可以监听这两个事件来进行日志记录。
在根目录下的app.js中编写
'use strict';
module.exports = app => {
app.testapp = 'testapp'
app.once('server', server => {
// websocket
console.log('服务启动完毕');
});
app.on('error', (err, ctx) => {
// report error
});
app.on('request', ctx => {
// log receive request
console.log('request');
});
app.on('response', ctx => {
// ctx.starttime is set by framework
console.log('response');
// log total cost
});
};
获取方式
//controller.js
class UserController extends Controller {
async fetch() {
this.ctx.body = this.app.testapp;//或者this.ctx.app.testApplication
}
}
Context
Context 是一个请求级别的对象,继承自 Koa.Context。在每一次收到用户请求时,框架会实例化一个 Context 对象,这个对象封装了这次用户请求的信息,并提供了许多便捷的方法来获取请求参数或者设置响应信息。框架会将所有的 Service挂载到 Context 实例上,一些插件也会将一些其他的方法和对象挂载到它上面(egg-sequelize会将所有的 model 挂载在 Context 上)。
获取方式
最常见的 Context 实例获取方式是在 Middleware, Controller 以及 Service 中。Controller 中的获取方式在上面的例子中已经展示过了,在 Service 中获取和 Controller 中获取的方式一样,在 Middleware 中获取 Context 实例则和 Koa 框架在中间件中获取 Context 对象的方式一致。
除了在请求时可以获取 Context 实例之外, 在有些非用户请求的场景下我们需要访问 service / model 等 Context 实例上的对象,我们可以通过 Application.createAnonymousContext() 方法创建一个匿名 Context 实例:
// app.js
module.exports = app => {
app.beforeStart(async () => {
const ctx = app.createAnonymousContext();
// preload before app start
await ctx.service.posts.load();
});
}
在定时任务中的每一个 task 都接受一个 Context 实例作为参数,以便我们更方便的执行一些定时的业务逻辑:
// app/schedule/refresh.js
exports.task = async ctx => {
await ctx.service.posts.refresh();
};
Request
Request 是一个请求级别的对象,继承自 Koa.Request。封装了 Node.js 原生的 HTTP Request 对象,提供了一系列辅助方法获取 HTTP 请求常用参数。
获取request参数
- query 地址栏参数, 会丢弃重复的参数
- params route参数
- queries 地址栏参数,不会丢弃重复的参数
- request.body的参数 框架内置了 bodyParser 中间件来对这两类格式的请求 body 解析成 object 挂载到
ctx.request.body上
一个常见的错误是把 ctx.request.body 和 ctx.body 混淆,后者其实是 ctx.response.body 的简写。
Response
Response 是一个请求级别的对象,继承自 Koa.Response。封装了 Node.js 原生的 HTTP Response 对象,提供了一系列辅助方法设置 HTTP 响应。
// 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);
}
}
csrf防范
egg会默认带上csrf防护,不加上token是取不到post中的参数的。
- 从ctx.csrf中读取token
- 通过header的x-csrf-token字段携带过来
///config/config.default.js 可以在开发阶段关闭
config.security = {
csrf: {
enable: false,
},
};
重定向
框架通过 security 插件覆盖了 koa 原生的 ctx.redirect 实现,以提供更加安全的重定向。
ctx.redirect(url)如果不在配置的白名单域名内,则禁止跳转。ctx.unsafeRedirect(url)不判断域名,直接跳转,一般不建议使用,明确了解可能带来的风险后使用。
// config/config.default.js
exports.security = {
domainWhiteList:['.domain.com'], // 安全白名单,以 . 开头
};
Controller
框架提供了一个 Controller 基类,并推荐所有的 Controller 都继承于该基类实现。这个 Controller 基类有下列属性:
ctx- 当前请求的 Context 实例。app- 应用的 Application 实例。config- 应用的配置。service- 应用所有的 service。logger- 为当前 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
};
};
自定义Controller基类
按照类的方式编写 Controller,不仅可以让我们更好的对 Controller 层代码进行抽象(例如将一些统一的处理抽象成一些私有方法),还可以通过自定义 Controller 基类的方式封装应用中常用的方法。
// 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);
}
}
Service
框架提供了一个 Service 基类,并推荐所有的 Service 都继承于该基类实现。
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 函数。它的作用在于我们可以将一些常用的动作抽离在 helper.js 里面成为一个独立的函数,这样可以用 JavaScript 来写复杂的逻辑,避免逻辑分散各处,同时可以更好的编写测试用例。
Helper 自身是一个类,有和 Controller 基类一样的属性,它也会在每次请求时进行实例化,因此 Helper 上的所有函数也能获取到当前请求相关的上下文信息。
获取方式
可以在 Context 的实例上获取到当前请求的 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 对象。
Logger
Subscription
所有的定时任务都统一存放在
app/schedule目录下,每一个文件都是一个独立的定时任务,可以配置定时任务的属性和要执行的方法。
const Subscription = require('egg').Subscription;
class LogSubscription extends Subscription {
// 通过 schedule 属性来设置定时任务的执行间隔等配置
static get schedule() {
return {
interval: '1s', // 1 分钟间隔
type: 'worker', // 指定所有的 worker 都需要执行
};
}
// subscribe 是真正定时任务执行时被运行的函数
async subscribe() {
console.log('我是定时任务')
}
}
module.exports = LogSubscription;
另一种写法:
module.exports = {
schedule: {
interval: '1s', // 1 分钟间隔
type: 'all', // 指定所有的 worker 都需要执行
},
async task(ctx) {
console.log('我是定时任务')
},
};
参数
- interval
- 数字类型,单位为毫秒数,例如
5000。 - 字符类型,会通过 ms 转换成毫秒数,例如
5s。
- 数字类型,单位为毫秒数,例如
- type
worker类型:每台机器上只有一个 worker 会执行这个定时任务,每次执行定时任务的 worker 的选择是随机的。all类型:每台机器上的每个 worker 都会执行这个定时任务。
Config配置
框架提供了强大且可扩展的配置功能,可以自动合并应用、插件、框架的配置,按顺序覆盖,且可以根据环境维护不同的配置。合并后的配置可直接从
app.config获取。
多环境配置
框架支持根据环境来加载配置,定义多个环境的配置文件。
config
|- config.default.js
|- config.prod.js
|- config.unittest.js
|- config.local.js
config.default.js 为默认的配置文件,所有环境都会加载这个配置文件,一般也会作为开发环境的默认配置文件。
当指定 env 时会同时加载对应的配置文件,并覆盖默认配置文件的同名配置。如 prod 环境会加载 config.prod.js 和 config.default.js 文件,config.prod.js 会覆盖 config.default.js 的同名配置。
配置写法
配置文件返回的是一个 object 对象,可以覆盖框架的一些配置,应用也可以将自己业务的配置放到这里方便管理。
// 配置 logger 文件的目录,logger 默认配置由框架提供
module.exports = {
logger: {
dir: '/home/admin/logs/demoapp',
},
};
配置文件也可以简化的写成 exports.key = value 形式
exports.keys = 'my-cookie-secret-key';
exports.logger = {
level: 'DEBUG',
};
配置文件也可以返回一个 function,可以接受 appInfo 参数
// 将 logger 目录放到代码目录下
const path = require('path');
module.exports = appInfo => {
return {
logger: {
dir: path.join(appInfo.baseDir, 'logs'),
},
};
};
内置的 appInfo 有:
| appInfo | 说明 |
|---|---|
| pkg | package.json |
| name | 应用名,同 pkg.name |
| baseDir | 应用代码的目录 |
| HOME | 用户目录,如 admin 账户为 /home/admin |
| root | 应用根目录,只有在 local 和 unittest 环境下为 baseDir,其他都为 HOME。 |
appInfo.root 是一个优雅的适配,比如在服务器环境我们会使用 /home/admin/logs 作为日志目录,而本地开发时又不想污染用户目录,这样的适配就很好解决这个问题。
配置加载顺序
应用、插件、框架都可以定义这些配置,而且目录结构都是一致的,但存在优先级(应用 > 框架 > 插件),相对于此运行环境的优先级会更高。
比如在 prod 环境加载一个配置的加载顺序如下,后加载的会覆盖前面的同名配置。
-> 插件 config.default.js
-> 框架 config.default.js
-> 应用 config.default.js
-> 插件 config.prod.js
-> 框架 config.prod.js
-> 应用 config.prod.js
合并规则
const a = {
arr: [ 1, 2 ],
};
const b = {
arr: [ 3 ],
};
extend(true, a, b);
// [3]
配置结果
框架在启动时会把合并后的最终配置 dump 到 run/application_config.json(worker 进程)和 run/agent_config.json(agent 进程)中,可以用来分析问题。
配置文件中会隐藏一些字段,主要包括两类:
- 如密码、密钥等安全字段。
- 如函数、Buffer 等类型,
JSON.stringify后的内容特别大
还会生成 run/application_config_meta.json(worker 进程)和 run/agent_config_meta.json(agent 进程)文件,用来排查属性的来源,如:
{
"logger": {
"dir": "/path/to/config/config.default.js"
}
}
自定义启动
生命周期函数
// app.js
class AppBootHook {
constructor(app) {
this.app = app;
}
configWillLoad() {
// 此时 config 文件已经被读取并合并,但是还并未生效
// 这是应用层修改配置的最后时机
// 注意:此函数只支持同步调用
// 例如:参数中的密码是加密的,在此处进行解密
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() {
// 所有的配置已经加载完毕
// 可以用来加载应用自定义的文件,启动自定义的服务
// 例如:创建自定义应用的示例
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() {
// 所有的插件都已启动完毕,但是应用整体还未 ready
// 可以做一些数据初始化等操作,这些操作成功才会启动应用
// 例如:从数据库加载数据到内存缓存
this.app.cacheData = await this.app.model.query(QUERY_CACHE_SQL);
}
async didReady() {
// 应用已经启动完毕
const ctx = await this.app.createAnonymousContext();
await ctx.service.Biz.request();
}
async serverDidReady() {
// http / https server 已启动,开始接受外部请求
// 此时可以从 app.server 拿到 server 的实例
this.app.server.on('timeout', socket => {
// handle socket timeout
});
}
}
module.exports = AppBootHook;
执行顺序
configWillLoad() //配置文件即将加载,这是最后动态修改配置的时机
configDidLoad() //配置文件加载完成
didLoad() //文件加载完成
willReady() //插件启动完毕
didReady() //worker 准备就绪
serverDidReady() //应用启动完成
beforeClose() //应用即将关闭
Agent
你可以在应用或插件根目录下的
agent.js中实现你自己的逻辑(和启动自定义用法类似,只是入口参数是 agent 对象)
Agent基本使用
// agent.js
module.exports = agent => {
// 在这里写你的初始化逻辑
// 也可以通过 messenger 对象发送消息给 App Worker
// 但需要等待 App Worker 启动成功后才能发送,不然很可能丢失
agent.messenger.on('egg-ready', () => {
const data = { ... };
agent.messenger.sendToApp('xxx_action', data);
});
};
// app.js
module.exports = app => {
app.messenger.on('xxx_action', data => {
// ...
});
};
Master VS Agent VS Worker
当一个应用启动时,会同时启动这三类进程。
| 类型 | 进程数量 | 作用 | 稳定性 | 是否运行业务代码 |
|---|---|---|---|---|
| Master | 1 | 进程管理,进程间消息转发 | 非常高 | 否 |
| Agent | 1 | 后台运行工作(长连接客户端) | 高 | 少量 |
| Worker | 一般设置为 CPU 核数 | 执行业务代码 | 一般 | 是 |
Master
在这个模型下,Master 进程承担了进程管理的工作(类似 pm2),不运行任何业务代码,我们只需要运行起一个 Master 进程它就会帮我们搞定所有的 Worker、Agent 进程的初始化以及重启等工作了。
Master 进程的稳定性是极高的,线上运行时我们只需要通过 egg-scripts 后台运行通过 egg.startCluster 启动的 Master 进程就可以了,不再需要使用 pm2 等进程守护模块。
$ egg-scripts start --daemon
Agent
长连接
在大部分情况下,我们在写业务代码的时候完全不用考虑 Agent 进程的存在,但是当我们遇到一些场景,只想让代码运行在一个进程上的时候,Agent 进程就到了发挥作用的时候了。
由于 Agent 只有一个,而且会负责许多维持连接的脏活累活,因此它不能轻易挂掉和重启,所以 Agent 进程在监听到未捕获异常时不会退出,但是会打印出错误日志,我们需要对日志中的未捕获异常提高警惕。
Worker
Worker 进程负责处理真正的用户请求和定时任务的处理。而 Egg 的定时任务也提供了只让一个 Worker 进程运行的能力,所以能够通过定时任务解决的问题就不要放到 Agent 上执行。
Worker 运行的是业务代码,相对会比 Agent 和 Master 进程上运行的代码复杂度更高,稳定性也低一点,当 Worker 进程异常退出时,Master 进程会重启一个 Worker 进程。
进程间通讯(IPC)
虽然每个 Worker 进程是相对独立的,但是它们之间始终还是需要通讯的,叫进程间通讯(IPC)。下面是 Node.js 官方提供的一段示例代码
'use strict';
const cluster = require('cluster');
if (cluster.isMaster) {
const worker = cluster.fork();
worker.send('hi there');
worker.on('message', msg => {
console.log(`msg: ${msg} from worker#${worker.id}`);
});
} else if (cluster.isWorker) {
process.on('message', (msg) => {
process.send(msg);
});
}
细心的你可能已经发现 cluster 的 IPC 通道只存在于 Master 和 Worker/Agent 之间,Worker 与 Agent 进程互相间是没有的。那么 Worker 之间想通讯该怎么办呢?是的,通过 Master 来转发。
广播消息: agent => all workers
+--------+ +-------+
| Master |<---------| Agent |
+--------+ +-------+
/ | \
/ | \
/ | \
/ | \
v v v
+----------+ +----------+ +----------+
| Worker 1 | | Worker 2 | | Worker 3 |
+----------+ +----------+ +----------+
指定接收方: one worker => another worker
+--------+ +-------+
| Master |----------| Agent |
+--------+ +-------+
^ |
send to / |
worker 2 / |
/ |
/ v
+----------+ +----------+ +----------+
| Worker 1 | | Worker 2 | | Worker 3 |
+----------+ +----------+ +----------+
为了方便调用,我们封装了一个 messenger 对象挂在 app / agent 实例上,提供一系列友好的 API。
发送
- app.messenger.broadcast(action, data):发送给所有的 agent / app 进程(包括自己)
- app.messenger.sendToApp(action, data): 发送给所有的 app 进程
- 在 app 上调用该方法会发送给自己和其他的 app 进程
- 在 agent 上调用该方法会发送给所有的 app 进程
- app.messenger.sendToAgent(action, data) : 发送给 agent 进程
- 在 app 上调用该方法会发送给 agent 进程
- 在 agent 上调用该方法会发送给 agent 自己
- agent.messenger.sendRandom(action, data) :
- app 上没有该方法(现在 Egg 的实现是等同于 sentToAgent)
- agent 会随机发送消息给一个 app 进程(由 master 来控制发送给谁)
- app.messenger.sendTo(pid, action, data): 发送给指定进程
// app.js
module.exports = app => {
// 注意,只有在 egg-ready 事件拿到之后才能发送消息
app.messenger.once('egg-ready', () => {
app.messenger.sendToAgent('agent-event', { foo: 'bar' });
app.messenger.sendToApp('app-event', { foo: 'bar' });
});
}
上面所有 app.messenger 上的方法都可以在 agent.messenger 上使用。
egg-ready
上面的示例中提到,需要等 egg-ready 消息之后才能发送消息。只有在 Master 确认所有的 Agent 进程和 Worker 进程都已经成功启动(并 ready)之后,才会通过 messenger 发送 egg-ready 消息给所有的 Agent 和 Worker,告知一切准备就绪,IPC 通道可以开始使用了。
接收
在 messenger 上监听对应的 action 事件,就可以收到其他进程发送来的信息了。
app.messenger.on(action, data => {
// process data
});
app.messenger.once(action, data => {
// process data
});
agent 上的 messenger 接收消息的用法和 app 上一致。
异常处理
ctx.runInBackground(scope)
class HomeController extends Controller {
async buy () {
const request = {};
const config = await ctx.service.trade.buy(request);
// 下单后需要进行一次核对,且不阻塞当前请求
ctx.runInBackground(async () => {
// 这里面的异常都会统统被 Backgroud 捕获掉,并打印错误日志
await ctx.service.trade.check(request);
});
}
}
框架层统一异常处理
//config/config.default.js
module.exports = {
onerror: {
// 线上页面发生异常时,重定向到这个页面上
errorPageUrl: '/50x.html',
},
};
自定义统一异常处理
// config/config.default.js
module.exports = {
onerror: {
all(err, ctx) {
// 在此处定义针对所有响应类型的错误处理方法
// 注意,定义了 config.all 之后,其他错误处理方法不会再生效
ctx.body = 'error';
ctx.status = 500;
},
html(err, ctx) {
// html hander
ctx.body = '<h3>error</h3>';
ctx.status = 500;
},
json(err, ctx) {
// json hander
ctx.body = { message: 'error' };
ctx.status = 500;
},
jsonp(err, ctx) {
// 一般来说,不需要特殊针对 jsonp 进行错误定义,jsonp 的错误处理会自动调用 json 错误处理,并包装成 jsonp 的响应格式
},
},
};
多实例插件
许多插件的目的都是将一些已有的服务引入到框架中,如 egg-mysql, egg-oss。他们都需要在 app 上创建对应的实例。而在开发这一类的插件时,我们发现存在一些普遍性的问题:
- 在一个应用中同时使用同一个服务的不同实例(连接到两个不同的 MySQL 数据库)。
- 从其他服务获取配置后动态初始化连接(从配置中心获取到 MySQL 服务地址后再建立连接)。
如果让插件各自实现,可能会出现各种奇怪的配置方式和初始化方式,所以框架提供了 app.addSingleton(name, creator) 方法来统一这一类服务的创建。需要注意的是在使用 app.addSingleton(name, creator) 方法时,配置文件中一定要有 client 或者 clients 为 key 的配置作为传入 creator 函数 的 config。
插件写法
我们将 egg-mysql 的实现简化之后来看看如何编写此类插件:
// egg-mysql/app.js
module.exports = app => {
// 第一个参数 mysql 指定了挂载到 app 上的字段,我们可以通过 `app.mysql` 访问到 MySQL singleton 实例
// 第二个参数 createMysql 接受两个参数(config, app),并返回一个 MySQL 的实例
app.addSingleton('mysql', createMysql);
}
/**
* @param {Object} config 框架处理之后的配置项,如果应用配置了多个 MySQL 实例,会将每一个配置项分别传入并调用多次 createMysql
* @param {Application} app 当前的应用
* @return {Object} 返回创建的 MySQL 实例
*/
function createMysql(config, app) {
// 省略。。。通过config,创建一个mysql实例
return client;
}
初始化方法也支持 Async function,便于有些特殊的插件需要异步化获取一些配置文件。
单实例
- 在配置文件中声明 MySQL 的配置。
// config/config.default.js
module.exports = {
mysql: {
client: {
host: 'mysql.com',
port: '3306',
user: 'test_user',
password: 'test_password',
database: 'test',
},
},
};
- 直接通过
app.mysql访问数据库。
// app/controller/post.js
class PostController extends Controller {
async list() {
const posts = await this.app.mysql.query(sql, values);
},
}
多实例
- 同样需要在配置文件中声明 MySQL 的配置,不过和单实例时不同,配置项中需要有一个
clients字段,分别申明不同实例的配置,同时可以通过default字段来配置多个实例中共享的配置(如 host 和 port)。需要注意的是在这种情况下要用get方法指定相应的实例。(例如:使用app.mysql.get('db1').query(),而不是直接使用app.mysql.query()得到一个undefined)。
// config/config.default.js
exports.mysql = {
clients: {
// clientId, access the client instance by app.mysql.get('clientId')
db1: {
user: 'user1',
password: 'upassword1',
database: 'db1',
},
db2: {
user: 'user2',
password: 'upassword2',
database: 'db2',
},
},
// default configuration for all databases
default: {
host: 'mysql.com',
port: '3306',
},
};
- 通过
app.mysql.get('db1')来获取对应的实例并使用。
// app/controller/post.js
class PostController extends Controller {
async list() {
const posts = await this.app.mysql.get('db1').query(sql, values);
},
}
动态创建实例
// app.js
module.exports = app => {
app.beforeStart(async () => {
// 从配置中心获取 MySQL 的配置 { host, post, password, ... }
const mysqlConfig = await app.configCenter.fetch('mysql');
// 动态创建 MySQL 实例
app.database = await app.mysql.createInstanceAsync(mysqlConfig);
});
};
通过 app.database 来使用这个实例。
// app/controller/post.js
class PostController extends Controller {
async list() {
const posts = await this.app.database.query(sql, values);
},
}
注意,在动态创建实例的时候,框架也会读取配置中 default 字段内的配置项作为默认配置。
多进程增强版
在前面讲解 的多进程模型中, 其中适合使用 Agent 进程的有一类常见的场景:一些中间件客户端需要和服务器建立长连接,理论上一台服务器最好只建立一个长连接,但多进程模型会导致 n 倍(n = Worker 进程数)连接被创建。
+--------+ +--------+
| Client | | Client | ... n
+--------+ +--------+
| \ / |
| \ / | n * m 个链接
| / \ |
| / \ |
+--------+ +--------+
| Server | | Server | ... m
+--------+ +--------+
为了尽可能的复用长连接(因为它们对于服务端来说是非常宝贵的资源),我们会把它放到 Agent 进程里维护,然后通过 messenger 将数据传递给各个 Worker。这种做法是可行的,但是往往需要写大量代码去封装接口和实现数据的传递,非常麻烦。
另外,通过 messenger 传递数据效率是比较低的,因为它会通过 Master 来做中转;万一 IPC 通道出现问题还可能将 Master 进程搞挂。
我们提供一种新的模式来降低这类客户端封装的复杂度。通过建立 Agent 和 Worker 的 socket 直连跳过 Master 的中转。Agent 作为对外的门面维持多个 Worker 进程的共享连接。
核心思想
- 受到 Leader/Follower 模式的启发。
- 客户端会被区分为两种角色:
- Leader: 负责和远程服务端维持连接,对于同一类的客户端只有一个 Leader。
- Follower: 会将具体的操作委托给 Leader,常见的是订阅模型(让 Leader 和远程服务端交互,并等待其返回)。
- 如何确定谁是 Leader,谁是 Follower 呢?有两种模式:
- 自由竞争模式:客户端启动的时候通过本地端口的争夺来确定 Leader。例如:大家都尝试监听 7777 端口,最后只会有一个实例抢占到,那它就变成 Leader,其余的都是 Follower。
- 强制指定模式:框架指定某一个 Leader,其余的就是 Follower。
- 框架里面我们采用的是强制指定模式,Leader 只能在 Agent 里面创建,这也符合我们对 Agent 的定位
- 框架启动的时候 Master 会随机选择一个可用的端口作为 Cluster Client 监听的通讯端口,并将它通过参数传递给 Agent 和 App Worker。
- Leader 和 Follower 之间通过 socket 直连(通过通讯端口),不再需要 Master 中转。
新的模式下,客户端的通信方式如下:
+-------+
| start |
+---+---+
|
+--------+---------+
__| port competition |__
win / +------------------+ \ lose
/ \
+---------------+ tcp conn +-------------------+
| Leader(Agent) |<---------------->| Follower(Worker1) |
+---------------+ +-------------------+
| \ tcp conn
| \
+--------+ +-------------------+
| Client | | Follower(Worker2) |
+--------+ +-------------------+
客户端接口类型抽象
抽象是类的描述
我们将客户端接口抽象为下面两大类,这也是对客户端接口的一个规范,对于符合规范的客户端,我们可以自动将其包装为 Leader/Follower 模式。
- 订阅、发布类(subscribe / publish):
subscribe(info, listener)接口包含两个参数,第一个是订阅的信息,第二个是订阅的回调函数。publish(info)接口包含一个参数,就是订阅的信息。
- 调用类 (invoke),支持 callback, promise 和 generator function 三种风格的接口,但是推荐使用 generator function。
客户端示例
const Base = require('sdk-base');
class Client extends Base {
constructor(options) {
super(options);
// 在初始化成功以后记得 ready
this.ready(true);
}
/**
* 订阅
*
* @param {Object} info - 订阅的信息(一个 JSON 对象,注意尽量不要包含 Function, Buffer, Date 这类属性)
* @param {Function} listener - 监听的回调函数,接收一个参数就是监听到的结果对象
*/
subscribe(info, listener) {
// ...
}
/**
* 发布
*
* @param {Object} info - 发布的信息,和上面 subscribe 的 info 类似
*/
publish(info) {
// ...
}
/**
* 获取数据 (invoke)
*
* @param {String} id - id
* @return {Object} result
*/
async getData(id) {
// ...
}
}
异常处理
- Leader 如果“死掉”会触发新一轮的端口争夺,争夺到端口的那个实例被推选为新的 Leader。
- 为保证 Leader 和 Follower 之间的通道健康,需要引入定时心跳检查机制,如果 Follower 在固定时间内没有发送心跳包,那么 Leader 会将 Follower 主动断开,从而触发 Follower 的重新初始化。
具体使用方法
下面我用一个简单的例子,介绍在框架里面如何让一个客户端支持 Leader/Follower 模式:
- 第一步,我们的客户端最好是符合上面提到过的接口约定,例如:
// registry_client.js 就是进行socket的基础类
const URL = require('url');
const Base = require('sdk-base');
class RegistryClient extends Base {
constructor(options) {
super({
// 指定异步启动的方法
initMethod: 'init',
});
this._options = options;
this._registered = new Map();
}
/**
* 启动逻辑
*/
async init() {
this.ready(true);
}
/**
* 获取配置
* @param {String} dataId - the dataId
* @return {Object} 配置
*/
async getConfig(dataId) {
return this._registered.get(dataId);
}
/**
* 订阅
* @param {Object} reg
* - {String} dataId - the dataId
* @param {Function} listener - the listener
*/
subscribe(reg, listener) {
const key = reg.dataId; // 时间名称
this.on(key, listener);
const data = this._registered.get(key);
if (data) {
process.nextTick(() => listener(data));
}
}
/**
* 发布
* @param {Object} reg
* - {String} dataId - the dataId
* - {String} publishData - the publish data
*/
publish(reg) {
const key = reg.dataId;
let changed = false;
if (this._registered.has(key)) {
const arr = this._registered.get(key);
if (arr.indexOf(reg.publishData) === -1) {
changed = true;
arr.push(reg.publishData);
}
} else {
changed = true;
this._registered.set(key, [reg.publishData]);
}
if (changed) {
this.emit(key, this._registered.get(key).map(url => URL.parse(url, true)));
}
}
}
module.exports = RegistryClient;
- 第二步,使用
agent.cluster接口对RegistryClient进行封装:
// agent.js
const RegistryClient = require('registry_client');
module.exports = agent => {
// 对 RegistryClient 进行封装和实例化
agent.registryClient = agent.cluster(RegistryClient)
// create 方法的参数就是 RegistryClient 构造函数的参数
.create({});
agent.beforeStart(async () => {
await agent.registryClient.ready();
agent.coreLogger.info('registry client is ready');
});
};
- 第三步,使用
app.cluster接口对RegistryClient进行封装:
const RegistryClient = require('registry_client');
module.exports = app => {
app.registryClient = app.cluster(RegistryClient).create({});
app.beforeStart(async () => {
await app.registryClient.ready();
app.coreLogger.info('registry client is ready');
// 调用 subscribe 进行订阅
app.registryClient.subscribe({
dataId: 'demo.DemoService',
}, val => {
// ...
});
// 调用 publish 发布数据
app.registryClient.publish({
dataId: 'demo.DemoService',
publishData: 'xxx',
});
// 调用 getConfig 接口
const res = await app.registryClient.getConfig('demo.DemoService');
console.log(res);
});
};
插件
egg-view-nunjucks
npm i egg-view-nunjucks --save
基本使用
在/app/view/ 下面创建 index.ng,如果项目没有view目录创建一个
<html>
<head>
<title>Hacker News</title>
</head>
<body>
<ul class="news-view view">
hello world
</ul>
<h1>hello world</h1>
</body>
</html>
/app/controller/home.js
'use strict';
const Controller = require('egg').Controller;
class HomeController extends Controller {
async index() {
const { ctx } = this;
ctx.body = 'hi, egg';
}
async hello() {
const { ctx } = this;
await ctx.render('hello.nj');
}
}
module.exports = HomeController;
/app/router.js
'use strict';
/**
* @param {Egg.Application} app - egg application
*/
module.exports = app => {
const { router, controller } = app;
router.get('/', controller.home.index);
router.get('/hello', controller.home.hello);
};
egg-mongoose
npm i egg-mongoose --save
使用 api
find() //查询全部
findOne(_id) //查询单条
save() //新增
remove(_id) //删除
update(_id,{$set:body}) //更新
egg-validate
egg校验插件
npm i egg-validate --save
校验规则
const createRule = {
title: 'string',
content: 'string',
};
ctx.validate(createRule);
egg-graphql
GraphQL使用 Schema 来描述数据,并通过制定和实现 GraphQL 规范定义了支持 Schema 查询的 DSQL (Domain Specific Query Language,领域特定查询语言,由 FACEBOOK 提出。
npm i --save egg-graphql
egg-snowflake(UUID)
npm install egg-snowflake
小案例
1.插件引入
//config/plugin.js
'use strict';
/** @type Egg.EggPlugin */
module.exports = {
// had enabled by egg
// static: {
// enable: true,
// }
mongoose: {
enable: true,
package: 'egg-mongoose',
},
validate: {
enable: true,
package: 'egg-validate',
},
};
2.配置插件
//config/config.default.js
config.mongoose = {
url: 'mongodb://127.0.0.1/blog',
options: {},
};
/* config.security = {
csrf: {
enable: false,
},
}; */
3.创建实体类
//model/posts.js
'use strict';
module.exports = app => {
const mongoose = app.mongoose;
const Schema = mongoose.Schema({
title: {
type: String,
unique: true,
},
content: String,
});
return mongoose.model('posts', Schema);
};
4.创建基础规范类
//controller/base/responseBase.js
'use strict';
const Controller = require('egg').Controller;
class HttpController extends Controller {
async success(data) {
this.ctx.body = {
msg: data && data.msg || 'ok',
code: 0,
data,
};
}
async fail(data) {
this.logger.error(data);
this.ctx.body = {
msg: data && data.msg || 'fail',
code: data && data.code || 1,
data,
};
}
}
module.exports = HttpController;
5.路由
'use strict';
/**
* @param {Egg.Application} app - egg application
*/
module.exports = app => {
const { router, controller } = app;
router.resources('posts', '/api/posts', controller.home);
};
6.控制层
//controlller/home.js
//引入并且继承基础规范类
'use strict';
// const Controller = require('egg').Controller;
const HttpController = require('./base/responseBase');
const createRule = {
title: 'string',
content: 'string',
};
class HomeController extends HttpController {
/*
GET /posts posts app.controllers.posts.index
GET /posts/new new_post app.controllers.posts.new
GET /posts/:id post app.controllers.posts.show
GET /posts/:id/edit edit_post app.controllers.posts.edit
POST /posts posts app.controllers.posts.create
PUT /posts/:id post app.controllers.posts.update
DELETE /posts/:id post app.controllers.posts.destroy
*/
// 新增
async create() {
const { ctx } = this;
try {
const requestBody = ctx.request.body;
await ctx.validate(createRule);
const res = await ctx.service.posts.create(requestBody);
await this.success(res);
} catch (error) {
await this.fail(error);
}
}
// 查询全部
async index() {
const { ctx } = this;
try {
const res = await ctx.service.posts.index();
await this.success(res);
} catch (error) {
await this.fail(error);
}
}
// 查询单条
async show() {
const { ctx } = this;
try {
const postsId = ctx.params.id;
const res = await ctx.service.posts.show(postsId);
await this.success(res);
} catch (error) {
await this.fail(error);
}
}
// 删除
async destroy() {
const { ctx } = this;
try {
const postsId = ctx.params.id;
const res = await ctx.service.posts.destroy(postsId);
await this.success(res);
} catch (error) {
await this.fail(error);
}
}
// 更新
async update() {
const { ctx } = this;
try {
const postsId = ctx.params.id;
const requestBody = ctx.request.body;
const res = await ctx.service.posts.update(postsId, requestBody);
await this.success(res);
} catch (error) {
await this.fail(error);
}
}
}
module.exports = HomeController;
7.服务层
'use strict';
const Service = require('egg').Service;
class PostsService extends Service {
// 新增
async create(data) {
const { ctx } = this;
// 写入数据库
const postsInstance = new ctx.model.Posts({
title: data.title,
content: data.content,
});
return await postsInstance.save();
}
// 查询全部
async index() {
const { ctx } = this;
return await ctx.model.Posts.find();
}
// 查询单条
async show(id) {
const { ctx } = this;
return await ctx.model.Posts.findOne({ _id: id });
}
// 删除
async destroy(id) {
const { ctx } = this;
return await ctx.model.Posts.remove({ _id: id });
}
// 更新
async update(id, data) {
const { ctx } = this;
return await ctx.model.Posts.update({ _id: id }, { $set: { ...data } });
}
}
module.exports = PostsService;