回顾一下上篇讲到的内容,上篇讲了:
- 运行环境
- Config 配置
- 中间件(Middleware)
- 路由
- 控制器(Controller)
服务(Service)
Service 就是在复杂业务场景下用于做业务逻辑封装的一个抽象层
使用场景
- 复杂数据的处理,比如要展现的信息需要从数据库获取,还要经过一定的规则计算,才能返回用户显示。或者计算完成后,更新到数据库。
- 第三方服务的调用,比如 GitHub 信息获取等。
定义 Service
// app/service/user.js
const Service = require('egg').Service;
class UserService extends Service {
async find(uid) {
const user = await this.ctx.db.query('select from user where uid = ?', uid);
return user;
}
}
module.exports = UserService;
复制代码
属性
*每一次用户请求,框架都会实例化对应的 Service 实例,由于它继承于 egg.Service
,故拥有下列属性方便我们进行开发:
-
this.ctx
: 当前请求的上下文 Context 对象的实例 -
this.app
: 当前应用 Application 对象的实例 -
this.service
:应用定义的 Service -
this.config
:应用运行时的配置项 -
this.logger
:logger 对象,上面有四个方法(debug
,info
,warn
,error
),分别代表打印四个不同级别的日志,使用方法和效果与 context logger 中介绍的一样,但是通过这个 logger 对象记录的日志,在日志前面会加上打印该日志的文件路径,以便快速定位日志打印位置。
Service ctx 详解
-
this.ctx.curl
发起网络调用。 -
this.ctx.service.otherService
调用其他 Service。 -
this.ctx.db
发起数据库调用等, db 可能是其他插件提前挂载到 app 上的模块。
注意事项
- Service 文件必须放在
app/service
目录,可以支持多级目录,访问的时候可以通过目录名级联访问。
app/service/biz/user.js => ctx.service.biz.user // 多级目录,依据目录名级联访问
app/service/sync_user.js => ctx.service.syncUser // 下划线自动转换为自动驼峰
app/service/HackerNews.js => ctx.service.hackerNews // 大写自动转换为驼峰
复制代码
- 一个 Service 文件只能包含一个类, 这个类需要通过 module.exports 的方式返回。
- Service 需要通过 Class 的方式定义,父类必须是 egg.Service。
- Service 不是单例,是 请求级别 的对象,框架在每次请求中首次访问
ctx.service.xx
时延迟实例化,所以 Service 中可以通过 this.ctx 获取到当前请求的上下文。
使用 Service*
*`// app/controller/user.js
const Controller = require('egg').Controller;
class UserController extends Controller {
async info() {
const { ctx } = this;
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;
复制代码`
插件
为什么要插件
在使用 Koa 中间件过程中发现了下面一些问题:
- 中间件加载其实是有先后顺序的,但是中间件自身却无法管理这种顺序,只能交给使用者。这样其实非常不友好,一旦顺序不对,结果可能有天壤之别。
- 中间件的定位是拦截用户请求,并在它前后做一些事情,例如:鉴权、安全检查、访问日志等等。但实际情况是,有些功能是和请求无关的,例如:定时任务、消息订阅、后台逻辑等等。
- 有些功能包含非常复杂的初始化逻辑,需要在应用启动的时候完成。这显然也不适合放到中间件中去实现。
中间件、插件、应用的关系
一个插件其实就是一个『迷你的应用』,和应用(app)几乎一样:+
- 它包含了 Service、中间件、配置、框架扩展等等。
- 它没有独立的 Router 和 Controller。
- 它没有
plugin.js
,只能声明跟其他插件的依赖,而不能决定其他插件的开启与否。
他们的关系是:
- 应用可以直接引入 Koa 的中间件。
- 插件本身可以包含中间件。
- 多个插件可以包装为一个上层框架。
使用插件
插件一般通过 npm 模块的方式进行复用:
npm i egg-mysql --save
复制代码
建议通过 ^ 的方式引入依赖,并且强烈不建议锁定版本。
{
"dependencies": {
"egg-mysql": "^3.0.0"
}
}
复制代码
然后需要在应用或框架的 config/plugin.js
中声明:
// config/plugin.js
// 使用 mysql 插件
exports.mysql = {
enable: true,
package: 'egg-mysql',
};
复制代码
就可以直接使用插件提供的功能:app.mysql.query(sql, values);
egg-mysql 插件文档
参数介绍
plugin.js
中的每个配置项支持:
-
{Boolean} enable
- 是否开启此插件,默认为 true -
{String} package
- npm 模块名称,通过 npm 模块形式引入插件 -
{String} path
- 插件绝对路径,跟 package 配置互斥 -
{Array} env
- 只有在指定运行环境才能开启,会覆盖插件自身 package.json 中的配置
开启和关闭
在上层框架内部内置的插件,应用在使用时就不用配置 package 或者 path,只需要指定 enable 与否:
// 对于内置插件,可以用下面的简洁方式开启或关闭
exports.onerror = false;
复制代码
根据环境配置
同时,我们还支持 plugin.{env}.js
这种模式,会根据运行环境加载插件配置。
比如定义了一个开发环境使用的插件 egg-dev
,只希望在本地环境加载,可以安装到 devDependencies
。
// npm i egg-dev --save-dev
// package.json
{
"devDependencies": {
"egg-dev": ""
}
}
复制代码
然后在 plugin.local.js
中声明:
// config/plugin.local.js
exports.dev = {
enable: true,
package: 'egg-dev',
};
复制代码
这样在生产环境可以 npm i --production
不需要下载 egg-dev
的包了。
注意:
- 不存在
plugin.default.js
- 只能在应用层使用,在框架层请勿使用。
package 和 path
-
package
是npm
方式引入,也是最常见的引入方式 -
path
是绝对路径引入,如应用内部抽了一个插件,但还没达到开源发布独立 npm 的阶段,或者是应用自己覆盖了框架的一些插件
// config/plugin.js
const path = require('path');
exports.mysql = {
enable: true,
path: path.join(__dirname, '../lib/plugin/egg-mysql'),
};
复制代码
插件配置
插件一般会包含自己的默认配置,应用开发者可以在 config.default.js
覆盖对应的配置:
// config/config.default.js
exports.mysql = {
client: {
host: 'mysql.com',
port: '3306',
user: 'test_user',
password: 'test_password',
database: 'test',
},
};
复制代码
插件列表
框架默认内置了企业级应用常用的插件:
-
onerror
统一异常处理 -
Session
Session 实现 - i18n 多语言
- watcher 文件和文件夹监控
- multipart 文件流式上传
- security 安全
- development 开发环境配置
- logrotator 日志切分
- schedule 定时任务
- static 静态服务器
- jsonp jsonp 支持
- view 模板引擎
更多社区的插件可以 GitHub 搜索 egg-plugin
。
插件开发详情见 插件开发
定时任务
虽然我们通过框架开发的 HTTP Server 是请求响应模型的,但是仍然还会有许多场景需要执行一些定时任务
- 定时上报应用状态。
- 定时从远程接口更新本地缓存。
- 定时进行文件切割、临时文件删除。
编写定时任务
所有的定时任务都统一存放在 app/schedule
目录下,每一个文件都是一个独立的定时任务,可以配置定时任务的属性和要执行的方法。
在 app/schedule
目录下创建一个 update_cache.js
文件
const Subscription = require('egg').Subscription;
class UpdateCache extends Subscription {
// 通过 schedule 属性来设置定时任务的执行间隔等配置
static get schedule() {
return {
interval: '1m', // 1 分钟间隔
type: 'all', // 指定所有的 worker 都需要执行
};
}
// subscribe 是真正定时任务执行时被运行的函数
async subscribe() {
const res = await this.ctx.curl('http://www.api.com/cache', {
dataType: 'json',
});
this.ctx.app.cache = res.data;
}
}
module.exports = UpdateCache;
复制代码
还可以简写为
module.exports = {
schedule: {
interval: '1m', // 1 分钟间隔
type: 'all', // 指定所有的 worker 都需要执行
},
async task(ctx) {
const res = await ctx.curl('http://www.api.com/cache', {
dataType: 'json',
});
ctx.app.cache = res.data;
},
};
复制代码
这个定时任务会在每一个 Worker 进程上每 1 分钟执行一次,将远程数据请求回来挂载到 app.cache
上。
任务
-
task
或subscribe
同时支持generator functio
和async function
。 -
task
的入参为ctx
,匿名的 Context 实例,可以通过它调用service
等。
定时方式
定时任务可以指定 interval 或者 cron 两种不同的定时方式。
interval
通过 schedule.interval
参数来配置定时任务的执行时机,定时任务将会每间隔指定的时间执行一次。interval 可以配置成
- 数字类型,单位为毫秒数,例如 5000
- 字符类型,会通过 ms 转换成毫秒数,例如 5s。
module.exports = {
schedule: {
// 每 10 秒执行一次
interval: '10s',
},
};
复制代码
cron
通过 schedule.cron 参数来配置定时任务的执行时机,定时任务将会按照 cron 表达式在特定的时间点执行。cron 表达式通过 cron-parser
进行解析。
注意:cron-parser 支持可选的秒(linux crontab 不支持)。*
┬ ┬ ┬ ┬ ┬ ┬ │ │ │ │ │ | │ │ │ │ │ └ day of week (0 - 7) (0 or 7 is Sun) │ │ │ │ └───── month (1 - 12) │ │ │ └────────── day of month (1 - 31) │ │ └─────────────── hour (0 - 23) │ └──────────────────── minute (0 - 59) └───────────────────────── second (0 - 59, optional)
module.exports = { schedule: { // 每三小时准点执行一次 cron: '0 0
* /3 *', }, };
类型 type
*worker 和 all。worker 和 all 都支持上面的两种定时方式,只是当到执行时机时,会执行定时任务的 worker 不同:
-
worker
类型:每台机器上只有一个 worker 会执行这个定时任务,每次执行定时任务的 worker 的选择是随机的。 -
all
类型:每台机器上的每个 worker 都会执行这个定时任务。
其他参数
除了刚才介绍到的几个参数之外,定时任务还支持这些参数:
-
cronOptions
: 配置 cron 的时区等,参见 cron-parser 文档 -
immediate
:配置了该参数为 true 时,这个定时任务会在应用启动并 ready 后立刻执行一次这个定时任务。 -
disable
:配置该参数为 true 时,这个定时任务不会被启动。 -
env
:数组,仅在指定的环境下才启动该定时任务。
执行日志
执行日志会输出到 ${appInfo.root}/logs/{app_name}/egg-schedule.log
,默认不会输出到控制台,可以通过 config.customLogger.scheduleLogger
来自定义。
// config/config.default.js
config.customLogger = {
scheduleLogger: {
// consoleLevel: 'NONE',
// file: path.join(appInfo.root, 'logs', appInfo.name, 'egg-schedule.log'),
},
};
复制代码
动态配置定时任务
module.exports = app => {
return {
schedule: {
interval: app.config.cacheTick,
type: 'all',
},
async task(ctx) {
const res = await ctx.curl('http://www.api.com/cache', {
contentType: 'json',
});
ctx.app.cache = res.data;
},
};
};
复制代码
手动执行定时任务
我们可以通过 app.runSchedule(schedulePath)
来运行一个定时任务。app.runSchedule
接受一个定时任务文件路径(app/schedule
目录下的相对路径或者完整的绝对路径),执行对应的定时任务,返回一个 Promise。
- 通过手动执行定时任务可以更优雅的编写对定时任务的单元测试。
const mm = require('egg-mock');
const assert = require('assert');
it('should schedule work fine', async () => {
const app = mm.app();
await app.ready();
await app.runSchedule('update_cache');
assert(app.cache);
});
复制代码
- 应用启动时,手动执行定时任务进行系统初始化,等初始化完毕后再启动应用。参见应用启动自定义章节,我们可以在
app.js
中编写初始化逻辑。
module.exports = app => {
app.beforeStart(async () => {
// 保证应用启动监听端口前数据已经准备好了
// 后续数据的更新由定时任务自动触发
await app.runSchedule('update_cache');
});
};
复制代码
框架扩展
框架提供了多种扩展点扩展自身的功能:Application、Context、Request、Response、Helper。
Application
访问方式
ctx.app
- Controller,Middleware,Helper,Service 中都可以通过
this.app
访问到 Application 对象,例如this.app.config
访问配置对象。 - 在 app.js 中 app 对象会作为第一个参数注入到入口函数中
// app.js
module.exports = app => {
// 使用 app 对象
};
复制代码
扩展方式
框架会把 app/extend/application.js
中定义的对象与 Koa Application 的 prototype 对象进行合并,在应用启动时会基于扩展后的 prototype 生成 app 对象。
// app/extend/application.js
module.exports = {
foo(param) {
// this 就是 app 对象,在其中可以调用 app 上的其他方法,或访问属性
},
};
复制代码
属性扩展
一般来说属性的计算只需要进行一次,那么一定要实现缓存,否则在多次访问属性时会计算多次,这样会降低应用性能。
推荐的方式是使用 Symbol + Getter 的模式。
// app/extend/application.js
const BAR = Symbol('Application#bar');
module.exports = {
get bar() {
// this 就是 app 对象,在其中可以调用 app 上的其他方法,或访问属性
if (!this[BAR]) {
// 实际情况肯定更复杂
this[BAR] = this.config.xx + this.config.yy;
}
return this[BAR];
},
};
复制代码
Context
Context 指的是 Koa 的请求上下文,这是 请求级别 的对象,每次请求生成一个 Context 实例,通常我们也简写成 ctx。在所有的文档中,Context 和 ctx 都是指 Koa 的上下文对象。
访问方式
- middleware 中返回函数的第一个参数就是 ctx,例如
ctx.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 对象。
// app/extend/context.js
module.exports = {
foo(param) {
// this 就是 ctx 对象,在其中可以调用 ctx 上的其他方法,或访问属性
},
};
复制代码
属性扩展同 Application
Request
Request 对象和 Koa 的 Request 对象相同,是 请求级别 的对象,它提供了大量请求相关的属性和方法供使用。
访问方式
ctx.request
ctx
上的很多属性和方法都被代理到 request
对象上,对于这些属性和方法使用 ctx
和使用 request
去访问它们是等价的,例如 ctx.url === ctx.request.url
。
扩展方式
框架会把 app/extend/request.js
中定义的对象与内置 request
的 prototype 对象进行合并,在处理请求时会基于扩展后的 prototype 生成 request
对象。
// app/extend/request.js
module.exports = {
get foo() {
return this.get('x-request-foo');
},
};
复制代码
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 对象。
// app/extend/response.js
module.exports = {
set foo(value) {
this.set('x-response-foo', value);
},
};
复制代码
就可以这样使用啦:this.response.foo = 'bar';
Helper
Helper 函数用来提供一些实用的 utility 函数。
它的作用在于我们可以将一些常用的动作抽离在 helper.js 里面成为一个独立的函数,这样可以用 JavaScript 来写复杂的逻辑,避免逻辑分散各处。另外还有一个好处是 Helper 这样一个简单的函数,可以让我们更容易编写测试用例。
框架内置了一些常用的 Helper 函数。我们也可以编写自定义的 Helper 函数。
访问方式
通过 ctx.helper
访问到 helper 对象,例如:
// 假设在 app/router.js 中定义了 home router
app.get('home', '/', 'home.index');
// 使用 helper 计算指定 url path
ctx.helper.pathFor('home', { by: 'recent', limit: 20 })
// => /?by=recent&limit=20
复制代码
扩展方式
框架会把 app/extend/helper.js
中定义的对象与内置 helper
的 prototype 对象进行合并,在处理请求时会基于扩展后的 prototype 生成 helper
对象。
// app/extend/helper.js
module.exports = {
foo(param) {
// this 是 helper 对象,在其中可以调用其他 helper 方法
// this.ctx => context 对象
// this.app => application 对象
},
};
复制代码
启动自定义
框架提供了统一的入口文件(app.js
)进行启动过程自定义,这个文件返回一个 Boot 类,我们可以通过定义 Boot 类中的生命周期方法来执行启动应用过程中的初始化工作。
框架提供了这些生命周期函数供开发人员处理:
- 配置文件即将加载,这是最后动态修改配置的时机(
configWillLoad
) - 配置文件加载完成(
configDidLoad
) - 文件加载完成(
didLoad
) - 插件启动完毕(
willReady
) - worker 准备就绪(
didReady
) - 应用启动完成(
serverDidReady
) - 应用即将关闭(
beforeClose
)
// 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;
复制代码
应用部署
在本地开发时,我们使用 egg-bin dev 来启动服务,但是在部署应用的时候不可以这样使用。因为 egg-bin dev 会针对本地开发做很多处理,而生产运行需要一个更加简单稳定的方式。
部署
服务器需要预装 Node.js,框架支持的 Node 版本为 >= 8.0.0
。
框架内置了 egg-cluster 来启动 Master 进程,Master 有足够的稳定性,不再需要使用 pm2 等进程守护模块。
同时,框架也提供了 egg-scripts 来支持线上环境的运行和停止。
npm i egg-scripts --save
复制代码
{
"scripts": {
"start": "egg-scripts start --daemon",
"stop": "egg-scripts stop"
}
}
复制代码
这样我们就可以通过 npm start 和 npm stop 命令启动或停止应用。
启动命令
egg-scripts start --port=7001 --daemon --title=egg-server-showcase
复制代码
支持以下参数:
-
--port=7001
端口号,默认会读取环境变量process.env.PORT
,如未传递将使用框架内置端口 7001。 -
--daemon
是否允许在后台模式,无需nohup
。若使用 Docker 建议直接前台运行。 -
--env=prod
框架运行环境,默认会读取环境变量process.env.EGG_SERVER_ENV
, 如未传递将使用框架内置环境 prod。 -
--workers=2
框架 worker 线程数,默认会创建和 CPU 核数相当的 app worker 数,可以充分的利用 CPU 资源。 -
--title=egg-server-showcase
用于方便 ps 进程时 grep 用,默认为egg-server-${appname}
。 -
--framework=yadan
如果应用使用了自定义框架,可以配置 package.json 的 egg.framework 或指定该参数。 -
--ignore-stderr
忽略启动期的报错。 -
--https.key
指定 HTTPS 所需密钥文件的完整路径。 -
--https.cert
指定 HTTPS 所需证书文件的完整路径。
更多参数可查看 egg-scripts 和 egg-cluster 文档。
启动配置项
// config/config.default.js
exports.cluster = {
listen: {
port: 7001,
hostname: '127.0.0.1',
// path: '/var/run/egg.sock',
}
}
复制代码
停止命令
egg-scripts stop [--title=egg-server]
复制代码
该命令将杀死 master 进程,并通知 worker 和 agent 优雅退出。
--title=egg-server
用于杀死指定的 egg 应用,未传递则会终止所有的 Egg 应用。
日志
框架内置了强大的企业级日志支持,由 egg-logger 模块提供。
- 日志分级
- 统一错误日志,所有 logger 中使用 .error() 打印的 ERROR 级别日志都会打印到统一的错误日志文件中,便于追踪
- 启动日志和运行日志分离
- 自定义日志
- 多进程日志
- 自动切割日志
- 高性能
日志路径
- 所有日志文件默认都放在
${appInfo.root}/logs/${appInfo.name}
路径下,例如/home/admin/logs/example-app
。 - 在本地开发环境 (env: local) 和单元测试环境 (env: unittest),为了避免冲突以及集中管理,日志会打印在项目目录下的 logs 目录,例如
/path/to/example-app/logs/example-app
。
如果想自定义日志路径:
// config/config.${env}.js
exports.logger = {
dir: '/path/to/your/custom/log/dir',
};
复制代码
日志分类
框架内置了几种日志,分别在不同的场景下使用:
- appLogger
${appInfo.name}-web.log
,例如example-app-web.log
,应用相关日志,供应用开发者使用的日志。我们在绝大数情况下都在使用它。 - coreLogger
egg-web.log
框架内核、插件日志。 - errorLogger
common-error.log
实际一般不会直接使用它,任何 logger 的 .error() 调用输出的日志都会重定向到这里,重点通过查看此日志定位异常。 - agentLogger
egg-agent.log
agent 进程日志,框架和使用到 agent 进程执行任务的插件会打印一些日志到这里。
如果想自定义以上日志文件名称,可以在 config 文件中覆盖默认值:
// config/config.${env}.js
module.exports = appInfo => {
return {
logger: {
appLogName: ${appInfo.name}-web.log,
coreLogName: 'egg-web.log',
agentLogName: 'egg-agent.log',
errorLogName: 'common-error.log',
},
};
};
复制代码
如何打印日志
Context Logger
用于记录 Web 行为相关的日志。
每行日志会自动记录上当前请求的一些基本信息, 如 [$userId/$ip/$traceId/${cost}ms $method $url]
。
ctx.logger.debug('debug info');
ctx.logger.info('some request data: %j', ctx.request.body);
ctx.logger.warn('WARNNING!!!!');
// 错误日志记录,直接会将错误日志完整堆栈信息记录下来,并且输出到 errorLog 中
// 为了保证异常可追踪,必须保证所有抛出的异常都是 Error 类型,因为只有 Error 类型才会带上堆栈信息,定位到问题。
ctx.logger.error(new Error('whoops'));
复制代码
对于框架开发者和插件开发者会使用到的 Context Logger
还有 ctx.coreLogger
。
App Logger
如果我们想做一些应用级别的日志记录,如记录启动阶段的一些数据信息,可以通过 App Logger 来完成。
// app.js
module.exports = app => {
app.logger.debug('debug info');
app.logger.info('启动耗时 %d ms', Date.now() - start);
app.logger.warn('warning!');
app.logger.error(someErrorObj);
};
复制代码
对于框架和插件开发者会使用到的 App Logger 还有 app.coreLogger
。
// app.js
module.exports = app => {
app.coreLogger.info('启动耗时 %d ms', Date.now() - start);
};
复制代码
Agent Logger
在开发框架和插件时有时会需要在 Agent 进程运行代码,这时使用 agent.coreLogger
。
// agent.js
module.exports = agent => {
agent.logger.debug('debug info');
agent.logger.info('启动耗时 %d ms', Date.now() - start);
agent.logger.warn('warning!');
agent.logger.error(someErrorObj);
};
复制代码
日志文件编码
默认编码为 utf-8
,可通过如下方式覆盖:
// config/config.${env}.js
exports.logger = {
encoding: 'gbk',
};
复制代码
日志文件格式
// config/config.${env}.js
exports.logger = {
outputJSON: true,
};
复制代码
日志级别
日志分为 NONE
,DEBUG
,INFO
,WARN
和 ERROR
5 个级别。
日志打印到文件中的同时,为了方便开发,也会同时打印到终端中。
文件日志级别
默认只会输出 INFO
及以上(WARN
和 ERROR
)的日志到文件中。
打印所有级别日志到文件中:
// config/config.${env}.js
exports.logger = {
level: 'DEBUG',
};
复制代码
关闭所有打印到文件的日志:
// config/config.${env}.js
exports.logger = {
level: 'NONE',
};
复制代码
生产环境打印 debug 日志
为了避免一些插件的调试日志在生产环境打印导致性能问题,生产环境默认禁止打印 DEBUG 级别的日志,如果确实有需求在生产环境打印 DEBUG 日志进行调试,需要打开 allowDebugAtProd
配置项。
// config/config.prod.js
exports.logger = {
level: 'DEBUG',
allowDebugAtProd: true,
};
复制代码
终端日志级别
默认只会输出 INFO
及以上(WARN
和 ERROR
)的日志到终端中。(注意:这些日志默认只在 local 和 unittest 环境下会打印到终端)
logger.consoleLevel
: 输出到终端日志的级别,默认为 INFO
打印所有级别日志到终端:
// config/config.${env}.js
exports.logger = {
consoleLevel: 'DEBUG',
};
复制代码
关闭所有打印到终端的日志:
// config/config.${env}.js
exports.logger = {
consoleLevel: 'NONE',
};
复制代码
基于性能的考虑,在正式环境下,默认会关闭终端日志输出。如有需要,你可以通过下面的配置开启。(不推荐)
// config/config.${env}.js
exports.logger = {
disableConsoleAfterReady: false,
};
复制代码
日志切割
框架对日志切割的支持由 egg-logrotator 插件提供。
按天切割
这是框架的默认日志切割方式,在每日 00:00
按照 .log.YYYY-MM-DD
文件名进行切割。
以 appLog 为例,当前写入的日志为 example-app-web.log
,当凌晨 00:00 时,会对日志进行切割,把过去一天的日志按 example-app-web.log.YYYY-MM-DD
的形式切割为单独的文件。
按照文件大小切割*
// config/config.${env}.js
const path = require('path');
module.exports = appInfo => {
return {
logrotator: {
filesRotateBySize: [
path.join(appInfo.root, 'logs', appInfo.name, 'egg-web.log'),
],
maxFileSize: 2` *` 1024 * 1024 * 1024,
},
};
};
按照小时切割
这和默认的按天切割非常类似,只是时间缩短到每小时。
// config/config.${env}.js
const path = require('path');
module.exports = appInfo => {
return {
logrotator: {
filesRotateByHour: [
path.join(appInfo.root, 'logs', appInfo.name, 'common-error.log'),
],
},
};
};
复制代码
性能
通常 Web 访问是高频访问,每次打印日志都写磁盘会造成频繁磁盘 IO,为了提高性能,我们采用的文件日志写入策略是:
日志同步写入内存,异步每隔一段时间(默认 1 秒)刷盘
更多详细请参考 egg-logger 和 egg-logrotator。
HttpClient
框架基于 urllib 内置实现了一个 HttpClient,应用可以非常便捷地完成任何 HTTP 请求。
通过 app 使用 HttpClient
架在应用初始化的时候,会自动将 HttpClient 初始化到 app.httpclient
。 同时增加了一个 app.curl(url, options)
方法,它等价于 app.httpclient.request(url, options)
。
// app.js
module.exports = app => {
app.beforeStart(async () => {
// 示例:启动的时候去读取 https://registry.npm.taobao.org/egg/latest 的版本信息
const result = await app.curl('https://registry.npm.taobao.org/egg/latest', {
dataType: 'json',
});
app.logger.info('Egg latest version: %s', result.data.version);
});
};
复制代码
通过 ctx 使用 HttpClient
框架在 Context 中同样提供了 ctx.curl(url, options)
和 ctx.httpclient
,保持跟 app 下的使用体验一致。 这样就可以在有 Context 的地方(如在 controller 中)非常方便地使用 ctx.curl()
方法完成一次 HTTP 请求。
// app/controller/npm.js
class NpmController extends Controller {
async index() {
const ctx = this.ctx;
// 示例:请求一个 npm 模块信息
const result = await ctx.curl('https://registry.npm.taobao.org/egg/latest', {
// 自动解析 JSON response
dataType: 'json',
// 3 秒超时
timeout: 3000,
});
ctx.body = {
status: result.status,
headers: result.headers,
package: result.data,
};
}
}
复制代码
基本 HTTP 请求
GET
// app/controller/npm.js
class NpmController extends Controller {
async get() {
const ctx = this.ctx;
const result = await ctx.curl('https://httpbin.org/get?foo=bar');
ctx.status = result.status;
ctx.set(result.headers);
ctx.body = result.data;
}
}
复制代码
POST
const result = await ctx.curl('https://httpbin.org/post', {
// 必须指定 method
method: 'POST',
// 通过 contentType 告诉 HttpClient 以 JSON 格式发送
contentType: 'json',
data: {
hello: 'world',
now: Date.now(),
},
// 明确告诉 HttpClient 以 JSON 格式处理返回的响应 body
dataType: 'json',
});
复制代码
PUT
const result = await ctx.curl('https://httpbin.org/put', {
// 必须指定 method
method: 'PUT',
// 通过 contentType 告诉 HttpClient 以 JSON 格式发送
contentType: 'json',
data: {
update: 'foo bar',
},
// 明确告诉 HttpClient 以 JSON 格式处理响应 body
dataType: 'json',
});
复制代码
DELETE
const result = await ctx.curl('https://httpbin.org/delete', {
// 必须指定 method
method: 'DELETE',
// 明确告诉 HttpClient 以 JSON 格式处理响应 body
dataType: 'json',
});
复制代码
options 参数详解
httpclient.request(url, options)
HttpClient 默认全局配置,应用可以通过 config/config.default.js 覆盖此配置。
常用
-
data: Object
需要发送的请求数据,根据 method 自动选择正确的数据处理方式。-
GET
,HEAD
:通过querystring.stringify(data)
处理后拼接到 url 的 query 参数上。 -
POST
,PUT
和DELETE
等:需要根据contentType
做进一步判断处理。-
contentType = json
:通过JSON.stringify(data)
处理,并设置为 body 发送。 - 其他:通过
querystring.stringify(data)
处理,并设置为 body 发送。
-
-
files: Mixed
-
method: String
设置请求方法,默认是 GET。 支持 GET、POST、PUT、DELETE、PATCH 等所有 HTTP 方法。 -
contentType: String
设置请求数据格式,默认是 undefined,HttpClient 会自动根据 data 和 content 参数自动设置。 data 是 object 的时候默认设置的是 form。支持 json 格式。 -
dataType: String
设置响应数据格式,默认不对响应数据做任何处理,直接返回原始的 buffer 格式数据。 支持 text 和 json 两种格式。 -
headers: Object
自定义请求头。 -
timeout: Number|Array
请求超时时间,默认是[ 5000, 5000 ]
,即创建连接超时是 5 秒,接收响应超时是 5 秒。
调试辅助(对 ctx.curl 进行抓包)
如果你需要对 HttpClient 的请求进行抓包调试,可以添加以下配置到 config.local.js
:
// config.local.js
module.exports = () => {
const config = {};
// add http_proxy to httpclient
if (process.env.http_proxy) {
config.httpclient = {
request: {
enableProxy: true,
rejectUnauthorized: false,
proxy: process.env.http_proxy,
},
};
}
return config;
}
复制代码
然后启动你的抓包工具,如 charles 或 fiddler。
最后通过以下指令启动应用:
http_proxy=http://127.0.0.1:8888 npm run dev
复制代码
windows 下可以用cmder 或者 git bash
set http_proxy=http://127.0.0.1:8888 && npm run dev
复制代码
然后就可以正常操作了,所有经过 HttpClient 的请求,都可以你的抓包工具中查看到。