一、运行环境与配置
在Egg中,指定运行环境的方式有两种:
- 通过
config/env
文件指定,文件的内容即为运行环境 - 通过
EGG_SERVER_ENV
环境变量,这是比较常用的方式,如在生产环境启应用,可以这么启:
EGG_SERVER_ENV=prod npm start
1、在应用内获取环境
在应用内,可以使用app.config.env
来获取当前的运行环境
注意点:
1)在Egg中,细分了运行环境,它使用的是EGG_SERVER_ENV
这一环境变量,其可细分为:local
/unittest
/prod
等模式
2)当未指定EGG_SERVER_ENV
时,框架也会自动获取NODE_ENV
并转成EGG_SERVER_ENV
3)Koa中区分环境使用的是app.env
(而app.env
值取决于process.env.NODE_ENV
),所以在Egg中,不再使用app.env
区分环境
4)EGG_SERVER_ENV
可以支持更多的自定义环境变量,应用启动时,便会自动加载config/config.[EGG_SERVER_ENV].js
文件
2、配置
Egg中可以同时有多份配置文件,这些配置文件会根据具体运行环境进行自动合并、整合,如一个Egg应用可以有如下的配置文件:
config
├── config.default.js
├── config.test.js
├── config.prod.js
├── config.unittest.js
└── config.local.js
其中,config.default.js
为默认配置文件,会被任何环境下加载,而指定了EGG_SERVER_ENV
后,则会自动加载对应的配置文件,然后覆盖默认配置文件中的同名配置
编写配置文件的方式如下:
// config/config.local.js
module.exports = {
// ...
}
// 也可以返回一个函数
module.exports = appInfo => {
return {
// ...
}
}
// 也可以使用exports快捷方式,但是exports不能赋值一个新的引用
exports.keys = '...'
exports.logger = {
// ...
}
其中,当配置文件返回一个函数时,调用时会被自动注入参数appInfo
,而appInfo
拥有如下属性:
appInfo.pkg
package.jsonappInfo.name
应用名称,相当于appInfo.pkg.name
appInfo.baseDir
应用代码的目录appInfo.HOME
用户目录,如admin账户为/home/admin
appInfo.root
应用根目录,在local
/unittest
下相当于baseDir
,其他情况下则为HOME
3、配置加载的优先级与合并规则
配置的加载,遵循优先级:应用 > 框架 > 插件,运行环境 > 默认配置,如下:
插件 config.default.js
< 框架 config.default.js
< 应用 config.default.js
< 插件 config.prod.js
< 框架 config.prod.js
< 应用 config.prod.js
而在合并上,则使用extend2
模块进行深度拷贝,extend2
虽然继承自extend
,但在数组拷贝行为上是直接覆盖而非合并:
extend(true, {
arr: [1, 2]
}, {
arr: [3]
})
// 结果为:{ arr: [3] }
如果要对合并后的最终结果进行分析,则可以查看run
目录下的文件,其中worker进程下对应application_config.json
文件,而agent进程下对应agent_config.json
文件
但是文件中会隐藏密码、密钥、函数、Buffer等类型的字段
此外,还可以通过application_config_meta.json
/agent_config_meta.json
文件来排查属性的来源
二、中间件
Egg中的中间件与Koa中的一致,都是基于洋葱圈模型
的,在Egg中编写中间件的写法如下:
// app/middleware/gzip.js
const isJSON = require('koa-is-json')
const zlib = require('zlib')
module.exports = (options, app) => {
return async function(ctx, next) {
await next()
let body = ctx.body
if (!body) return
if (options.threshold && ctx.length < options.threshold) return
if (isJSON(body)) body = JSON.stringify(body)
const stream = zlib.createGzip()
stream.end(body)
ctx.body = stream
ctx.set('Content-Encoding', 'gzip')
}
}
当中间件被调用时,配置文件中的中间件对应的配置,就会被作为options
参数(即为app.config[${middlewareName}]
)传入中间件,而Application
实例,会作为第二个参数app
传入
使用中间件,则需要手动进行配置,步骤为:
- 在
config.default.js
里的middlewares
数组里加入中间件名称 - 在
config.default.js
里加入中间件对应的配置
如以上gzip
中间件,使用方法如下:
// config/config.default.js
module.exports = {
middleware: ['gzip'],
gzip: {
threshold: 1024
}
}
配置最后会在应用启动时合并到app.config.appMiddleware
里
但是若需要在框架和插件中使用中间件,则不支持通过config.default.js
进行配置,而是需要手动访问app.config.appMiddleware
,如下:
// app/middleware/report.js
module.exports = () => {
return async function(ctx, next) {
const startTime = Date.now()
await next()
reportTime(Date.now() - startTime)
}
}
// app.js
module.exports = app => {
app.config.coreMiddleware.unshift('report')
}
应用层定义的中间件(app.config.appMiddleware
)和框架默认中间件(app.config.coreMiddleware
)都会被加载器加载,并挂载到app.middleware
上
由于应用级和框架级的中间件,都是全局性的,会被应用到每一次请求。若需要对特定路由生效,则可在app/router.js
中挂载,如下:
module.exports = app => {
const gzip = app.middleware.gzip({
threshold: 1024
})
app.router.get('/needgzip', gzip, app.controller.handler)
}
此外,若需要修改框架自带中间件
中的中间件配置,则只需要在config/config.default.js
中编写覆盖:
module.exports = {
bodyParser: {
jsonLimit: '10mb'
}
}
但是框架默认的中间件,不能被应用层中间件覆盖,若应用层中间件有同名中间价,则启动时会报错。
由于Egg基于Koa,所以Egg也可以很方便地使用Koa的中间件,若Koa中间件符合(options) => middleware
这种形式,那么则可以直接使用,如下:
// app/middleware/compress.js
module.exports = require('koa-compress')
而若不符合入参规范,则可以自行包装:
// app/middleware/webpack.js
const webpackMiddleware = require('some-koa-middleware')
module.exports = (options, app) => {
return webpackMiddleware(options.compiler, options.others)
}
// config/config.default.js
module.exports = {
webpack: {
compiler: {},
others: {}
}
}
对于中间件本身的配置,应用层加载的、框架自带的,都支持以下几个通用的配置:
enable
控制是否开启一个中间件,为false
时,中间件不起作用match
只有符合相应规则,中间件才能生效ignore
符合相应规则,中间价则失效
对于match
和ignore
,它们的值可以为:字符串
、正则
、函数
,其中字符串
/正则
都是基于url
匹配,而match(ctx)
则是传入ctx
参数,自行定义匹配规则
三、Router
Router声明了URL与Controller的对应关系,在Egg中,Router规则都写在app/router.js
里,例如:
// app/controller/user.js
class UserController extends Controller {
async info() {
const { ctx } = this
ctx.body = {
name: `Hello, ${ctx.params.id}`
}
}
}
可以添加如下的路由规则进行关联:
// app/router.js
module.exports = app => {
const { router, controller } = app
router.get('/user/:id', controller.user.info)
}
如此,GET /user/123
,会映射到UserController
里的info
方法,从而info
方法得到执行
1、Router详细定义
声明一个Router,具有如下几种形式:
router.verb('path-match', app.controller.action)
router.verb('router-name', 'path-match', app.controller.action)
router.verb('path-match', middleware1, ..., middlewareN, app.controller.action)
router.verb('router-name', 'path-match', middleware1, ..., middlewareN, app.controller.action)
说明如下:
verb
在上面只是一个占位符,实际上verb
表示的是触发的动作(即HTTP的请求方法),即可有head
/options
/get
/put
/post
/patch
/delete
这些值,还有del
(由于delete
是保留字,所以这个是delete
方法的别名)和redirect
这些值router-name
给路由设置的别名,可通过辅助函数pathFor
和urlFor
生成URLpath-match
路由的URL路径middleware
路由里相应加载的中间件controller
映射的控制器,可以通过app.controller
对象获取,也可以是一个字符串,如'user.fetch'
(相当于app.controller.user.fetch
)
注意:
1)Router中可支持多个middleware串联执行
2)Controller必须定义在app/controller
中,但一个文件其实可以支持多个Controller(路由中可以通过${fileName}.${functionName}
的方式指定),同时Controller也支持子目录(通过${directoryName}.${fileName}.${functionName}
指定)
范例如下:
// 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)
}
2、RESTful
Egg中,也对RESTful
进行了支持,其关键在于使用router.resources('routerName', 'pathMatch', controller)
方法生成可以支持CRUD的路由结构,如下:
// app/router.js
module.exports = app => {
const { router, controller } = app
router.resources('posts', '/api/posts', controller.posts)
router.resources('users', '/api/v1/users', controller.v1.users)
}
由于router.resources()
帮我们自动生成了CRUD路径结构,那么我们只要在Controller里实现相应方法,如router.resources('posts', '/api/posts', controller.posts)
,会生成以下的结构:
GET /posts -> app.controller.posts.index
GET /posts/new -> app.controller.posts.new
GET /posts/:id -> app.controller.posts.show
GET /posts/:id/edit -> app.controller.posts.edit
POST /posts -> app.controller.posts.create
PUT /posts/:id -> app.controller.posts.update
DELETE /posts/:id -> app.controller.posts.destory
若不需要某些方法,则可以不用实现,并且对应的路由也不会注册到Router
里
3、router实战
1)获取参数
要获取参数,有三种方法:查询字符串
、命名参数
和正则
,其中,查询字符串方式可以使用ctx.query
对象获取,命名参数则通过ctx.params
对象获取。如下:
// app/router.js
module.exports = app => {
app.router.get('/user/:id/:name', app.controller.user.info) // id和name都是命名参数,使用ctx.params.id方式获取
app.router.get('/search', app.controller.search.index) // 对于search?name=xxx,使用ctx.query.name获取
}
在正则方式中,捕获的参数则会存放在ctx.params
里
2)表单参数
表单参数的获取,可以通过ctx.request.body
获取,如:
// app/controller/form.js
exports.post = async ctx => {
ctx.body = `Body: ${JSON.stringify(ctx.request.body)}`
}
3)表单校验
表单的校验,可以使用ctx.validate()
方法校验,当校验出错时,会抛出错误,实例如下:
// app/router.js
module.exports = app => {
app.router.post('/user', app.controller.user)
}
// app/controller/user.js
const createRule = {
username: {
type: 'email'
},
password: {
type: 'password',
compare: 're-password'
}
}
exports.create = async ctx => {
ctx.validate(createRule)
ctx.body = ctx.request.body
}
4)重定向
重定向,分为内部重定向
(使用router.redirect(fromPath, toPath, httpCode)
)和外部重定向(在应用内使用ctx.redirect(url)
进行重定向)
四、Controller
Controller负责解析用户的输入、处理后返回相应的结果,一般情况下有:
1)在RESTful中,Controller接受用户的参数,从数据库中查找内容返回给用户、把用户请求更新到数据库中
2)在HTML页面请求中,Controller根据用户访问不同的URL,渲染不同的模板给用户
3)在代理服务器中,Controller将用户请求转发到其他服务器上,并将其他服务器的处理结果返回给用户
在Egg中,一般Controller层主要做的事情是对用户的请求参数进行处理,然后调用Service处理业务,得到结果后处理返回
1、编写Controller
在Egg中,编写Controller方式有两种:class
方式和导出方法
方式,其中主要推荐使用class
方式。controller文件,都放置于app/controller
下,可以支持多级目录:
const { Controller } = require('egg')
class SomeController extends Controller {
async someMethod() {
// ...
}
}
module.exports = SomeController
编写完Controller后,router
中便可通过app.controller
对象进行访问。此外,在每一个新请求达到server时,便会实例化一个全新的Controller对象,会有如下的属性挂载在this
上:
this.ctx
当前请求上下文中的Context
实例this.app
当前应用的Application
实例,可以拿到框架提供的全局对象、方法this.service
访问Service
的接口,等价于this.ctx.service
this.config
运行配置this.logger
日志记录对象,分为四个级别(debug
、info
、warn
、error
)
另一种编写controller的方法是导出Controller方法,导出的每个方法都是async
函数,如:
// app/controller/posts.js
exports.create = async ctx => {
// ...
}
2、编写Controller基类
可以针对特定业务场景对Controller进行进一步抽象,编写Controller基类,如下:
// app/core/base-controller.js
class BaseController extends Controller {
// ...
}
3、常用操作
1)获取请求参数
获取参数主要有两种:查询字符串
和路由参数
,这两种方法在router
里已经进行了介绍。在查询字符串
这种方式中,Egg中支持以下两种方法获取:
ctx.query
这种方式只取key第一次出现的值,不会进行合并,即:name=Tom&name=Jack
中ctx.query.name
返回的是Tom
ctx.queries
则支持重复的key,重复的key会合并成一个数组,即:name=Tom&name=Jack
中ctx.queries.name
返回['Tom', 'Jack']
2)获取body
由于浏览器对URL的长度有所限制,且一些敏感数据也不宜通过URL传递,那么这种情况下,选择使用body传递数据是一种好的选择。在HTTP中,通常是在POST
、PUT
、DELETE
方法中才使用body传递数据。框架内置了bodyParser
中间件,会帮助进行以下解析操作:
- 根据请求的
Content-Type
进行解析。值为application/json
、application/json-patch+json
、application/vnd.api+json
、application/csp-report
时,按照JSON进行解析,默认情况下限制最大长度为100kb
;而值为application/x-www-form-urlencoded
时,按照Form格式进行解析,默认情况下限制body最大长度为100kb
- 若解析成功,body一定会是一个Object/Array(解析失败则抛出
400
异常) - 若要调整默认的最大长度限制(超过时用户请求会返回
413
状态码),则可在config/config.default.js
里进行覆盖修改:
module.exports = {
bodyParser: {
jsonLimit: '1mb',
formLimit: '1mb'
}
}
注意:获取请求的body,是用ctx.request.body
3)获取上传的文件
框架内置Multipart
插件,可支持获取用户上传的文件(multipart/form-data
请求),实例如下:
<form method="POST" action="/upload?_csrf={{ ctx.csrf | safe }}" enctype="multipart/form-data">
<p>文件名称:<input name="title"/></p>
<p>文件:<input name="file" type="file" /></p>
<button type="submit">上传</button>
</form>
const path = require('path')
const sendToWormhole = require('stream-wormhole')
const { Controller } = require('egg')
class UploaderController extends Controller {
async upload() {
const { ctx } = this
const stream = await ctx.getFileStream()
const name = 'egg-multipart-test/' + path.basename(stream.filename)
// 文件处理、传到云存储等
let result
try {
result = await ctx.oss.put(name, stream)
} catch(err) {
// 将上传的文件流消费掉,避免浏览器卡死
await sendToWormhole(stream)
throw err
}
// 获取表单字段,则可通过`stream.fields`对象
}
}
module.exports = UploaderController
然而,通过ctx.getFileStream()
获取文件有两个局限性:
- 只支持上传一个文件
- 上传文件必须在所有其他
fields
后面,否则在拿文件流时获取不到fields
若要上传多个文件,可以用以下的方式:
const sendToWormhole = require('stream-warmhole')
const { Controller } = require('egg')
class UploaderController extends Controller {
async upload() {
const { ctx } = this
const parts = ctx.multipart() // 返回的是Promise
let part
while ((part = await parts()) !== null) {
// 如果是数组,是filed
if (part.length) {
console.log(`field: ${part[0]}`)
console.log(`value: ${part[1]}`)
console.log(`valueTruncated: ${part[2]}`)
console.log(`filedNameTruncated: ${part[3]}`)
} else {
// 若用户不选择文件就上传,那么part是file stream,但part.filename为空
if (!part.filename) return
// 获取信息
console.log(`field: ${part.fieldname}`)
console.log(`filename: ${part.filename}`)
console.log(`encoding: ${part.encoding}`)
console.log(`mime: ${part.mime}`)
// 文件处理、传到云存储等
let result
try {
result = await ctx.oss.put(name, stream)
} catch(err) {
// 将上传的文件流消费掉,避免浏览器卡死
await sendToWormhole(stream)
throw err
}
}
}
}
}
module.exports = UploaderController
框架默认支持了一系列文件扩展名,若需要新增扩展名,则可以通过在config/config.default.js
中配置进行支持:
- 新增支持的文件扩展名
module.exports = {
multipart: {
fileExtensions: ['.apk']
}
}
- 覆盖整个白名单
module.exports = {
multipart: {
whitelist: ['.png'] // 只支持`.png`文件上传
}
}
4)获取Header
除了从URL和body上获取参数,还有一些参数是从请求header上获取的,可以通过如下方式获取header:
ctx.headers
/ctx.header
/ctx.request.headers
/ctx.request.header
,这几个方法都是等价的,获取整个header对象ctx.get(name)
/ctx.request.get(name)
,获取特定头部字段,头部字段不存在时返回空字符串ctx.get(name)
和ctx.headers[name]
的区别在于,前者会自动处理大小写
此外,有一些header是HTTP协议规定了具体含义的,有些是反向代理设置的约定俗成的,故框架对这些header进行了一些特殊处理:
ctx.host
先读取config.hostHeaders
中配置的值,读取不到再获取header
中的host
值ctx.protocol
判断当前连接是否为加密的,是则返回https
,而处于非加密连接时,则先读取通过config.protocolHeaders
中配置的值,如果还读取不到,则读取config.protocol
ctx.ips
获取请求经过的所有中间设备的IP地址列表,若config.proxy = true
时,会读取config.ipHeaders
中配置的值,读取不到则为[]
ctx.ip
获取请求发起方的IP地址,优先从ctx.ips
中获取,为空时使用连接上发起方的IP地址
5)Cookie
- 读取cookie:
ctx.cookies.get(cookieName)
- 创建/修改cookie:
ctx.cookies.set(cookieName, value)
- 删除cookie:
ctx.cookies.set(cookieName, null)
6)Session
- 读取session:通过
ctx.session
- 设置session:通过对
ctx.session[sessionName]
赋值 - 删除session:设置
ctx.session[sessionName] = null
- 在框架中,对session进行配置,可以修改
config/config.default.js
:
module.exports = {
key: 'EGG_SESS',
maxAge: 86400000
}
7)参数校验
框架提供了Validate
插件用于参数校验:
// config/plugin.js
exports.validate = {
enable: true,
package: 'egg-validate'
}
使用方法则为调用ctx.validate(rule, [body])
,如:
class PostController extends Controller {
async create() {
this.ctx.validate({
title: { type: 'string' },
content: { type: 'string' }
})
}
}
校验异常时,会抛出异常,异常状态码为422,若需自己处理异常,则可用try catch
捕获:
class PostController extends Controller {
async create() {
const { ctx } = this
try {
ctx.validate(createRule)
} catch (err) {
ctx.logger.warn(err.errors)
ctx.body = { success: false }
}
}
}
若需自定义校验规则,则可用app.validator.addRule(type, check)
的方式:
// app.js
app.validator.addRule('json', (rule, value) => {
try {
JSON.parse(value)
} catch (err) {
return 'Must be JSON string'
}
})
// 在controller中使用:
ctx.validate({ test: 'json' }, ctx.query)
校验参数采用的是Parameter
模块,具体规则可查看该模块的文档
8)调用Service
在Controller中可以调用任何一个Service上的任何方法,通过ctx.service
对象即可调用,此外:Service是懒加载的,只有在访问到它时,框架才会实例化该对象
9)发送HTTP响应
- 使用
ctx.status
设置响应状态码 - 使用
ctx.body
设置响应主体 - 可调用
ctx.render()
渲染模板,如:await ctx.render('home.tpl', { name: 'egg' })
- 可使用
ctx.set(key, value)
来设置一个响应头,或者用ctx.set(headers)
设置多个header - 若要支持JSONP,则可以在router里通过
app.jsonp()
引入JSONP中间件,如下:
// app/router.js
module.exports = app => {
const jsonp = app.jsonp()
app.router.get('/api/posts/:id', jsonp, app.controller.posts.show)
}
如此,当用户请求对应的URL的query中带有_callback=fn
参数时,就会返回JSONP格式的数据,否则返回JSON格式的数据
框架默认情况下通过query里的_callback
参数识别是否返回JSONP格式的数据,且这个值最多只能为50个字符,若需要修改这些默认配置,可以修改config/config.default.js
:
exports.jsonp = {
callback: 'callback',
limit: 100
}
或者,也可以在router
中,将配置传入app.jsonp()
作为参数,实现更灵活的配置