愿你坚持不懈,努力进步,进阶成自己理想的人

—— 2017.09, 写给3年后的自己

Egg学习笔记(二):环境/中间件/Router/Controller

一、运行环境与配置

在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.json
  • appInfo.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 符合相应规则,中间价则失效

对于matchignore,它们的值可以为:字符串正则函数,其中字符串/正则都是基于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 给路由设置的别名,可通过辅助函数pathForurlFor生成URL
  • path-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 日志记录对象,分为四个级别(debuginfowarnerror

另一种编写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=Jackctx.query.name返回的是Tom
  • ctx.queries 则支持重复的key,重复的key会合并成一个数组,即:name=Tom&name=Jackctx.queries.name返回['Tom', 'Jack']

2)获取body
由于浏览器对URL的长度有所限制,且一些敏感数据也不宜通过URL传递,那么这种情况下,选择使用body传递数据是一种好的选择。在HTTP中,通常是在POSTPUTDELETE方法中才使用body传递数据。框架内置了bodyParser中间件,会帮助进行以下解析操作:

  • 根据请求的Content-Type进行解析。值为application/jsonapplication/json-patch+jsonapplication/vnd.api+jsonapplication/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()作为参数,实现更灵活的配置