控制器(Controller)
控制器
在传统的MVC
架构中,C可以理解为控制器Controller
,而在Egg
框架中服务是在控制器的下发中分层抽离出来的,因此这里一起总结一下。
控制器
Egg
框架的router将用户的请求基于 method
和 URL
分发到了对应的 Controller
上,Controller
负责解析用户的输入,处理后返回相应的结果。
- 在
RESTful
接口中,Controller
接受用户的参数,从数据库中查找内容返回给用户或者将用户的请求更新到数据库中。 - 在
HTML
页面请求中,Controller
根据用户访问不同的 URL,渲染不同的模板得到 HTML 返回给用户。 - 在代理服务器中,
Controller
将用户的请求转发到其他服务器上,并将其他服务器的处理结果返回给用户。
框架推荐 Controller
层主要对用户的请求参数进行处理(校验、转换),然后调用对应的 service 方法处理业务, 得到业务结果后封装并返回
- 获取用户通过
HTTP
传递过来的请求参数。 - 校验、组装参数。
- 调用
Service
进行业务处理,必要时处理转换Service
的返回结果,让它适应用户的需求。 - 通过
HTTP
将结果响应给用户。
编写Controller
所有的 Controller
文件都必须放在 app/controller
目录下,可以支持多级目录,访问的时候可以通过目录名级联访问。 Controller
支持多种形式进行编写,可以根据不同的项目场景和开发习惯来选择
Controller 类(推荐)
// 继承基类,编写自定义Controller逻辑
// app/controller/post.js
const Controller = require('egg').Controller
class PostController extends Controller {
async create() {
const { ctx, service } = this
const createRule = {
title: { type: 'string' },
content: { type: 'string' },
}
// 校验参数
ctx.validate(createRule)
// 组装参数
const author = ctx.session.userId
const req = Object.assign(ctx.request.body, { author })
// 调用 Service 进行业务处理
const res = await service.post.create(req)
// 设置响应内容和响应状态码
ctx.body = { id: res.id }
ctx.status = 201
}
}
module.exports = PostController
定义了一个 PostController
的类,类里面的每一个方法都可以作为一个 Controller
在 Router
中引用到,我们可以从 app.controller
根据文件名和方法名定位到它
// app/router.js
module.exports = (app) => {
const { router, controller } = app
router.post('createPost', '/api/posts', controller.post.create)
}
Controller
支持多级目录,例如如果将上面的 Controller
代码放到 app/controller/sub/post.js
中,则可以在 router
中这样使用:
// app/router.js
module.exports = (app) => {
app.router.post('createPost', '/api/posts', app.controller.sub.post.create)
}
定义的 Controller
类,会在每一个请求访问到 server
时实例化一个全新的对象,而项目中的 Controller
类继承于 egg.Controller
, this对象属性有:
this.ctx
: 当前请求的上下文Context
对象的实例,通过它可以拿到框架封装好的处理当前请求的各种便捷属性和方法。this.app
: 当前应用Application
对象的实例,通过它可以拿到框架提供的全局对象和方法。this.service
:应用定义的Service
,通过它可以访问到抽象出的业务层,等价于this.ctx.service
。this.config
:应用运行时的配置项。this.logger
:logger
对象,上面有四个方法(debug
,info
,warn
,error
),分别代表打印四个不同级别的日志,使用方法和效果与context logger
中介绍的一样,但是通过这个logger
对象记录的日志,在日志前面会加上打印该日志的文件路径,以便快速定位日志打印位置。
自定义 Controller
基类
按照类的方式编写 Controller,不仅可以更好的对 Controller 层代码进行抽象(例如将一些统一的处理抽象成一些私有方法),还可以通过自定义 Controller 基类的方式封装应用中常用的方法。
// app/core/base_controller.js
const { Controller } = require('egg')
class BaseController extends Controller {
get user() {
return this.ctx.session.user
}
success(data) {
this.ctx.body = {
success: true,
data,
}
}
notFound(msg) {
msg = msg || 'not found'
this.ctx.throw(404, msg)
}
}
module.exports = BaseController
此时在编写应用的 Controller
时,可以继承 BaseController
,直接使用基类上的方法:
// 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)
}
}
Controller 方法(不推荐使用,只是为了兼容)
每一个 Controller
都是一个 async function
,它的入参为请求的上下文 Context
对象的实例,通过它可以拿到框架封装好的各种便捷属性和方法。
// app/controller/post.js
exports.create = async (ctx) => {
const createRule = {
title: { type: 'string' },
content: { type: 'string' },
}
// 校验参数
ctx.validate(createRule)
// 组装参数
const author = ctx.session.userId
const req = Object.assign(ctx.request.body, { author })
// 调用 service 进行业务处理
const res = await ctx.service.post.create(req)
// 设置响应内容和响应状态码
ctx.body = { id: res.id }
ctx.status = 201
}
获取请求参数
框架通过在 Controller
上绑定的 Context
实例,提供了许多便捷方法和属性获取用户通过 HTTP
请求发送过来的参数。
Query
在 URL 中 ?
后面的部分是一个 Query String
,这一部分经常用于 GET
类型的请求中传递参数。例如: GET /posts?category=egg&language=node
中 category=egg&language=node
就是用户传递过来的参数。我们可以通过 ctx.query
拿到解析过后的这个参数体
class PostController extends Controller {
async listPosts() {
const query = this.ctx.query
// {
// category: 'egg',
// language: 'node',
// }
}
}
当 Query String
中的 key
重复时,ctx.query
只取 key 第一次出现时的值, 后面再出现的都会被忽略。
GET /posts?category=egg&category=koa
通过 ctx.query
拿到的值是:
{
// 第一次出现
category:'egg'
}
这样处理的原因是为了保持统一性,由于通常情况下我们都不会设计让用户传递 key
相同的 Query String
,所以经常会写类似下面的代码:
const key = ctx.query.key || ''
if (key.startsWith('egg')) {
// do something code
}
而如果有人故意发起请求在 Query String
中带上重复的 key
来请求时就会引发系统异常。因此框架保证了从 ctx.query
上获取的参数一旦存在,一定是字符串类型。
Queries
有时候系统会设计成让用户传递相同的 key
,例如 GET /posts?category=egg&id=1&id=2&id=3
。针对此类情况,* *框架提供了 ctx.queries
对象,这个对象也解析了 Query String
,但是它不会丢弃任何一个重复的数据,而是将他们都放到一个数组中 **:
// GET /posts?category=egg&id=1&id=2&id=3
class PostController extends Controller {
async listPosts() {
console.log(this.ctx.queries)
// {
// category: [ 'egg' ],
// id: [ '1', '2', '3' ],
// }
}
}
ctx.queries
上所有的 key
如果有值,也一定会是数组类型。
params
Router
上也可以申明参数,这些参数都可以通过 ctx.params
获取到。
// app.get('/projects/:projectId/app/:appId', 'app.listApp');
// GET /projects/1/app/2
class AppController extends Controller {
async listApp() {
assert.equal(this.ctx.params.projectId, '1')
assert.equal(this.ctx.params.appId, '2')
}
}
Body
虽然可以通过 URL 传递参数,但是还是有很多限制:
浏览器中会对 URL 的长度有所限制,如果需要传递的参数过多就会无法传递。
服务端经常会将访问的完整 URL 记录到日志文件中,有一些敏感数据通过 URL 传递会不安全。
一般请求中有 body
的时候,客户端(浏览器)会同时发送 Content-Type
告诉服务端这次请求的 body
是什么格式的。Web
开发中数据传递最常用的两类格式分别是 JSON
和 Form
。
框架内置了 bodyParser
中间件来对这两类格式的请求 body
解析成 object
挂载到 ctx.request.body
上。HTTP
协议中并不建议在通过 GET
、HEAD
方法访问时传递 body
,所以无法在 GET
、HEAD
方法中按照此方法获取到内容。
// POST /api/posts HTTP/1.1
// Host: localhost:3000
// Content-Type: application/json; charset=UTF-8
//
// {"title": "controller", "content": "what is controller"}
class PostController extends Controller {
async listPosts() {
// 断言比对
assert.equal(this.ctx.request.body.title, 'controller')
assert.equal(this.ctx.request.body.content, 'what is controller')
}
}
框架对 bodyParser 设置了一些默认参数,配置好之后拥有以下特性:
- 当请求的
Content-Type
为application/json
,application/json-patch+json
,application/vnd.api+json
和application/csp-report
时,会按照json
格式对请求 body 进行解析,并限制body
最大长度为100kb
。 - 当请求的
Content-Type
为application/x-www-form-urlencoded
时,会按照form
格式对请求body
进行解析,并限制body
最大长度为100kb
。 - 如果解析成功,
body
一定会是一个Object
(可能是一个数组)。
可以在 config/config.default.js
中覆盖框架的默认值,变更解析时允许的最大长度:
module.exports = {
bodyParser: {
// json字符串大小限制
jsonLimit: '1mb',
// 表单大小限制
formLimit: '1mb',
},
}
如果用户的请求 body
超过了我配置的解析最大长度,会抛出一个状态码为 413
的异常,如果用户请求的 body
解析失败(错误的 JSON
),会抛出一个状态码为 400
的异常。
另外特别需要注意的是:在调整 bodyParser
支持的 body
长度时,如果我们应用前面还有一层反向代理(Nginx),可能也需要调整它的配置,确保反向代理也支持同样长度的请求 body
。
常见的错误是把 ctx.request.body 和 ctx.body 混淆,后者其实是 ctx.response.body 的简写。
文件上传
请求 body 除了可以带参数之外,还可以发送文件。一般来说,浏览器上都是通过 Multipart/form-data
格式发送文件的,框架通过内置 Multipart
插件来支持获取用户上传的文件,提供两种方式:
- File模式
- Stream模式
File模式
和Stream模式相比,File模式非常简单且适合新手,可以按照如下流程来:
- 在 config 文件中启用 file 模式:
// config/config.default.js
exports.multipart = {
mode: 'file',
}
- 文件上传/接收
关于文件的上传,File模式下有对单个文件或者多个文件上传进行支持。具体分为:前端上传
+后端接收
前端上传代码示例:
<form
method="POST"
action="/upload?_csrf={{ ctx.csrf | safe }}"
enctype="multipart/form-data"
>
title: <input name="title" /> file: <input name="file" type="file" />
<button type="submit">Upload</button>
</form>
后端接收代码示例:
// app/controller/upload.js
const Controller = require('egg').Controller
const fs = require('mz/fs')
module.exports = class extends Controller {
async upload() {
const { ctx } = this
const file = ctx.request.files[0]
const name = `egg-multipart-test/${path.basename(file.filename)}`
let result
try {
// 处理文件,比如上传到云端
result = await ctx.oss.put(name, file.filepath)
}
finally {
// 需要删除临时文件
await fs.unlink(file.filepath)
}
ctx.body = {
url: result.url,
// 获取所有的字段值
requestBody: ctx.request.body,
}
}
}
上面的是基于最简单的单个文件上传进行的代码示例,借助ctx.request.files
数组,取0号元素完成的。而在实际的应用中,多个文件的上传的场景是非常常见的,此时也是需要借助ctx.request.files
数组,* *不过需要多注意一步——数组遍历**
前端多文件上传:
<form
method="POST"
action="/upload?_csrf={{ ctx.csrf | safe }}"
enctype="multipart/form-data"
>
title: <input name="title" /> file1: <input name="file1" type="file" /> file2:
<input name="file2" type="file" />
<button type="submit">Upload</button>
</form>
后端多文件上传逻辑:
// app/controller/upload.js
const Controller = require('egg').Controller
const fs = require('mz/fs')
module.exports = class extends Controller {
async upload() {
const { ctx } = this
console.log(ctx.request.body)
console.log('got %d files', ctx.request.files.length)
// 遍历文件
for (const file of ctx.request.files) {
console.log(`field: ${file.fieldname}`)
console.log(`filename: ${file.filename}`)
console.log(`encoding: ${file.encoding}`)
console.log(`mime: ${file.mime}`)
console.log(`tmp filepath: ${file.filepath}`)
let result
try {
// 处理文件,比如上传到云端
result = await ctx.oss.put(`egg-multipart-test/${file.filename}`, file.filepath)
}
finally {
// 需要删除临时文件
await fs.unlink(file.filepath)
}
console.log(result)
}
}
}
Stream模式
如果对于 Node
中的 Stream
模式非常熟悉,可以选择Stream
模式。在 Controller
中,我们可以通过 ctx.getFileStream()
接口能获取到上传的文件流。
上传 / 接受单个文件
前端示例:
<form
method="POST"
action="/upload?_csrf={{ ctx.csrf | safe }}"
enctype="multipart/form-data"
>
title: <input name="title" /> file: <input name="file" type="file" />
<button type="submit">Upload</button>
</form>
后端示例:
const path = require('node:path')
const sendToWormhole = require('stream-wormhole')
const Controller = require('egg').Controller
class UploaderController extends Controller {
async upload() {
const ctx = this.ctx
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
}
ctx.body = {
url: result.url,
// 所有表单字段都能通过 `stream.fields` 获取到
fields: stream.fields,
}
}
}
module.exports = UploaderController
另外,要通过 ctx.getFileStream 便捷的获取到用户上传的文件,需要满足两个条件:
- 只支持上传一个文件。
- 上传文件必须在所有其他的 fields 后面,否则在拿到文件流时可能还获取不到 fields。
上传 / 接受多个文件
如果要获取同时上传的多个文件,不能通过 ctx.getFileStream()
来获取,只能通过下面这种方式
const sendToWormhole = require('stream-wormhole')
const Controller = require('egg').Controller
class UploaderController extends Controller {
async upload() {
const ctx = this.ctx
const parts = ctx.multipart()
let part
// parts() 返回 promise 对象
while ((part = await parts()) != null) {
if (part.length) {
// 这是 busboy 的字段
console.log(`field: ${part[0]}`)
console.log(`value: ${part[1]}`)
console.log(`valueTruncated: ${part[2]}`)
console.log(`fieldnameTruncated: ${part[3]}`)
}
else {
if (!part.filename) {
// 这时是用户没有选择文件就点击了上传(part 是 file stream,但是 part.filename 为空)
// 需要做出处理,例如给出错误提示消息
return
}
// part 是上传的文件流
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(`egg-multipart-test/${part.filename}`, part)
}
catch (err) {
// 必须将上传的文件流消费掉,要不然浏览器响应会卡死
await sendToWormhole(part)
throw err
}
console.log(result)
}
}
console.log('and we are done parsing the form!')
}
}
module.exports = UploaderController
为了保证文件上传的安全,框架限制了支持的的文件格式,框架默认支持白名单
// images
'.jpg', '.jpeg', // image/jpeg
'.png', // image/png, image/x-png
'.gif', // image/gif
'.bmp', // image/bmp
'.wbmp', // image/vnd.wap.wbmp
'.webp',
'.tif',
'.psd',
// text
'.svg',
'.js', '.jsx',
'.json',
'.css', '.less',
'.html', '.htm',
'.xml',
// tar
'.zip',
'.gz', '.tgz', '.gzip',
// video
'.mp3',
'.mp4',
'.avi',
用户可以通过在 config/config.default.js
中配置来新增支持的文件扩展名,或者重写整个白名单
- 新增支持的文件扩展名
// config/config.default.js
module.exports = {
multipart: {
// 增加对 apk 扩展名的文件支持
fileExtensions: ['.apk']
},
}
- 覆盖整个白名单
module.exports = {
multipart: {
// 覆盖整个白名单,只允许上传 '.png' 格式
whitelist: ['.png'],
},
}
注意:当重写了 whitelist
时,fileExtensions
不生效。
header
除了从 URL 和请求 body 上获取参数之外,还有许多参数是通过请求 header 传递的。
ctx.headers
,ctx.header
,ctx.request.headers
,ctx.request.header
:这几个方法是等价的,都是获取整个 header 对象。ctx.get(name)
,ctx.request.get(name)
:获取请求 header 中的一个字段的值,如果这个字段不存在,会返回空字符串。- 建议用
ctx.get(name)
而不是ctx.headers['name']
,因为ctx.get(name)
会自动处理大小写。
由于 header
比较特殊,有一些是 HTTP
协议规定了具体含义的(例如 Content-Type
,Accept
),有些是反向代理设置的,已经约定俗成(X-Forwarded-For
),框架也会对他们增加一些便捷的 getter
, 相关api参考:https://eggjs.org/api/
**特别是如果通过 config.proxy = true
设置了应用部署在反向代理(Nginx)之后,有一些 Getter 的内部处理会发生改变。 **(config.proxy
配置前置代理模式)
ctx.host
优先读通过 config.hostHeaders
中配置的 header
的值,读不到时再尝试获取 host
这个 header
的值,**如果都获取不到,返回空字符串。 **
config.hostHeaders
默认配置为 x-forwarded-host
。
ctx.protocol
通过这个 Getter
获取 protocol
时,首先会判断当前连接是否是加密连接,如果是加密连接,返回 https
。
如果处于非加密连接时,优先读通过 config.protocolHeaders
中配置的 header 的值来判断是 HTTP 还是 https
,* *如果读取不到,可以在配置中通过 config.protocol 来设置兜底值,默认为 HTTP。**
config.protocolHeaders
默认配置为 x-forwarded-proto
。
ctx.ips
通过 ctx.ips
获取请求经过所有的中间设备 IP
地址列表,只有在 config.proxy = true
时,才会通过读取 config.ipHeaders
中配置的 header
的值来获取,获取不到时为空数组。
config.ipHeaders
默认配置为 x-forwarded-for
。
ctx.ip
通过 ctx.ip
获取请求发起方的 IP
地址,优先从 ctx.ips
中获取,ctx.ips
为空时使用连接上发起方的 IP
地址。
注意:ip
和 ips
不同,ip
当 config.proxy = false
时会返回当前连接发起者的 ip
地址,ips
此时会为空数组。
Cookie
HTTP 请求都是无状态的,但是 Web
应用通常都需要知道发起请求的人是谁。为了解决这个问题,HTTP 协议设计了一个特殊的请求头:Cookie
。服务端可以通过响应头(set-cookie)将少量数据响应给客户端 ,浏览器会遵循协议将数据保存,并在下次请求同一个服务的时候带上(浏览器也会遵循协议,只在访问符合 Cookie
指定规则的网站时带上对应的 Cookie
来保证安全性)
通过 ctx.cookies
,可以在 Controller
中便捷、安全的设置和读取 Cookie
。
class CookieController extends Controller {
// 添加cookies
async add() {
const { ctx } = this
let count = ctx.cookies.get('count')
count = count ? Number(count) : 0
ctx.cookies.set('count', ++count)
ctx.body = count
}
// 删除cookies
async remove() {
const { ctx } = this
const count = ctx.cookies.set('count', null)
ctx.status = 204
}
}
Cookie 虽然在 HTTP 中只是一个头,但是通过 foo=bar;foo1=bar1; 的格式可以设置多个键值对。
Cookie 在 Web 应用中经常承担了传递客户端身份信息的作用,因此有许多安全相关的配置,不可忽视。
Session
通过 Cookie,可以给每一个用户设置一个 Session,用来存储用户身份相关的信息,这份信息会加密后存储在 Cookie 中,实现跨请求的用户身份保持。
框架内置了 Session 插件,提供了 ctx.session 来访问或者修改当前用户 Session 。
class PostController extends Controller {
async fetchPosts() {
const { ctx } = this
// 获取 Session 上的内容
const userId = ctx.session.userId
const posts = await ctx.service.post.fetch(userId)
// 修改 Session 的值
ctx.session.visited = ctx.session.visited ? ++ctx.session.visited : 1
ctx.body = {
success: true,
posts,
}
}
}
Session 的使用方法非常直观,直接读取它或者修改它就可以了,如果要删除它,直接将它赋值为 null:
class SessionController extends Controller {
async deleteSession() {
this.ctx.session = null
}
};
参数校验
在获取到用户请求的参数后,不可避免的要对参数进行一些校验。借助 egg-validate
插件提供便捷的参数校验机制,帮助完成各种复杂的参数校验。
// config/plugin.js
exports.validate = {
enable: true,
package: 'egg-validate',
}
通过 ctx.validate(rule, [body])
直接对参数进行校验, 如果不传第二个参数会自动校验 ctx.request.body
class PostController extends Controller {
async create() {
// 校验参数
// 如果不传第二个参数会自动校验 `ctx.request.body`
this.ctx.validate({
title: { type: 'string' },
content: { type: 'string' },
})
}
}
当校验异常时,会直接抛出一个异常,异常的状态码为 422,errors 字段包含了详细的验证不通过信息。如果想要自己处理检查的异常,可以通过 try catch 来自行捕获。
class PostController extends Controller {
async create() {
const ctx = this.ctx
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 中直接使用这条规则来进行参数校验了
class PostController extends Controller {
async handler() {
const { ctx } = this
// query.test 字段必须是 json 字符串
const rule = { test: 'json' }
ctx.validate(rule, ctx.query)
}
};
调用 Service
基于分层结构的思想,并不需要在 Controller
中实现太多业务逻辑,所以提供了一个 Service
层进行业务逻辑的封装,这不仅能提高代码的复用性, 同时可以让业务逻辑更好测试。
**在 Controller
中可以调用任何一个 Service
上的任何方法,同时 Service
是懒加载的,只有当访问到它的时候框架才会去实例化它。 **
class PostController extends Controller {
async create() {
const { ctx } = this
const author = ctx.session.userId
// Object.assign() 方法用于将所有可枚举属性的值从一个或多个源对象复制到目标对象。它将返回目标对象
const req = Object.assign(ctx.request.body, { author })
// 调用 service 进行业务处理
const res = await ctx.service.post.create(req)
ctx.body = { id: res.id }
ctx.status = 201
}
}
发送 HTTP 响应
当业务逻辑完成之后,Controller 的最后一个职责就是将业务逻辑的处理结果通过 HTTP 响应发送给用户。
设置 status
HTTP 设计了非常多的状态码,每一个状态码都代表了一个特定的含义,通过设置正确的状态码,可以让响应更符合语义。
// 框架提供了一个便捷的 Setter 来进行状态码的设置
class PostController extends Controller {
async create() {
// 设置状态码为 201
this.ctx.status = 201
}
};
设置 body
绝大多数的数据都是通过 body 发送给请求方的,和请求中的 body 一样,在响应中发送的 body,也需要有配套的 Content-Type 告知客户端如何对数据进行解析。
- 作为一个
RESTful
的API
接口controller
,通常会返回Content-Type
为application/json
格式的body
,内容是一个JSON
字符串。 - 作为一个
html
页面的controller
,通常会返回Content-Type
为text/html
格式的body
,内容是html
代码段。
注意:ctx.body
是 ctx.response.body
的简写,和ctx.request.body
不一致;
class ViewController extends Controller {
async show() {
const { ctx } = this
ctx.body = {
name: 'egg',
category: 'framework',
language: 'Node.js',
}
}
// 渲染页面
async page() {
this.ctx.body = '<html><h1>Hello</h1></html>'
}
}
由于 Node.js 的流式特性,还有很多场景需要通过 Stream 返回响应,例如返回一个大文件,代理服务器直接返回上游的内容,框架也支持直接将 body 设置成一个 Stream,并会同时处理好这个 Stream 上的错误事件。
class ProxyController extends Controller {
async proxy() {
const { ctx } = this
const result = await ctx.curl(url, {
streaming: true,
})
ctx.set(result.header)
// result.res 是一个 stream
ctx.body = result.res
}
};
渲染模板
通常来说,不会去手写 HTML
页面,而是会通过模板引擎进行生成。 框架自身没有集成任何一个模板引擎,但是约定了 View
插件的规范,通过接入的模板引擎,可以直接使用 ctx.render(template)
来渲染模板生成 html
。
class HomeController extends Controller {
async index() {
const ctx = this.ctx
await ctx.render('home.tpl', { name: 'egg' })
// ctx.body = await ctx.renderString('hi, {{ name }}', { name: 'egg' });
}
};
JSONP
有时需要给非本域的页面提供接口服务,又由于一些历史原因无法通过 CORS 实现,可以通过 JSONP 来进行响应。
由于 JSONP 如果使用不当会导致非常多的安全问题,所以框架中提供了便捷的响应 JSONP 格式数据的方法,封装了 JSONP XSS 相关的安全防范,并支持进行 CSRF 校验和 referrer 校验。
- 通过
app.jsonp()
提供的中间件来让一个controller
支持响应JSONP
格式的数据。在路由中,我们给需要支持jsonp
的路由加上这个中间件:
// app/router.js
module.exports = (app) => {
const jsonp = app.jsonp()
app.router.get('/api/posts/:id', jsonp, app.controller.posts.show)
app.router.get('/api/posts', jsonp, app.controller.posts.list)
}
- 在 Controller 中,只需要正常编写即可:
// app/controller/posts.js
class PostController extends Controller {
async show() {
this.ctx.body = {
name: 'egg',
category: 'framework',
language: 'Node.js',
}
}
}
用户请求对应的 URL 访问到这个 controller
的时候,如果 query
中有 _callback=fn
参数,将会返回 JSONP
格式的数据,否则返回 JSON
格式的数据。
JSONP 配置
框架默认通过 query
中的 _callback
参数作为识别是否返回 JSONP 格式数据的依据,并且 _callback
中设置的方法名长度最多只允许 50 个字符。应用可以在 config/config.default.js
全局覆盖默认的配置:
// config/config.default.js
exports.jsonp = {
callback: 'callback', // 识别 query 中的 `callback` 参数
limit: 100, // 函数名最长为 100 个字符
}
通过上面的方式配置之后,如果用户请求 /api/posts/1?callback=fn
,响应为 JSONP
格式,如果用户请求 /api/posts/1
,响应格式为 JSON
。
同样可以在 app.jsonp()
创建中间件时覆盖默认的配置,以达到不同路由使用不同配置的目的:
// app/router.js
module.exports = (app) => {
const { router, controller, jsonp } = app
router.get('/api/posts/:id', jsonp({ callback: 'callback' }), controller.posts.show)
router.get('/api/posts', jsonp({ callback: 'cb' }), controller.posts.list)
}
跨站防御配置
默认配置下,响应 JSONP 时不会进行任何跨站攻击的防范,在某些情况下,这是很危险的。初略将 JSONP 接口分为三种类型:
- 查询非敏感数据,例如获取一个论坛的公开文章列表。
- 查询敏感数据,例如获取一个用户的交易记录。
- 提交数据并修改数据库,例如给某一个用户创建一笔订单。
如果 JSONP 接口提供下面两类服务,在不做任何跨站防御的情况下,可能泄露用户敏感数据甚至导致用户被钓鱼。因此框架给 JSONP 默认提供了 CSRF 校验支持和 referrer 校验支持。
CSRF配置
在 JSONP
配置中,我们只需要打开 csrf: true
,即可对 JSONP
接口开启 CSRF
校验。
// config/config.default.js
module.exports = {
jsonp: {
csrf: true,
},
}
CSRF 校验依赖于 security 插件提供的基于 Cookie 的 CSRF 校验。
在开启 CSRF
校验时,客户端在发起 JSONP
请求时,也要带上 CSRF token
,如果发起 JSONP
的请求方所在的页面和我们的服务在同一个主域名之下的话,可以读取到 Cookie
中的 CSRF token
(在 CSRF token
缺失时也可以自行设置 CSRF token
到 Cookie
中),并在请求时带上该 token。
referrer 校验
如果在同一个主域之下,可以通过开启 CSRF 的方式来校验 JSONP 请求的来源,如果想对其他域名的网页提供 JSONP 服务,我们可以通过配置 referrer 白名单的方式来限制 JSONP 的请求方在可控范围之内。
// config/config.default.js
exports.jsonp = {
whiteList: /^https?:\/\/test.com\//,
// whiteList: '.test.com',
// whiteList: 'sub.test.com',
// whiteList: [ 'sub.test.com', 'sub2.test.com' ],
}
whiteList
可以配置为正则表达式、字符串或者数组:
- 正则表达式:此时只有请求的
Referrer
匹配该正则时才允许访问 JSONP 接口。在设置正则表达式的时候,注意开头的 ^ 以及结尾的 /,保证匹配到完整的域名。
exports.jsonp = {
whiteList: /^https?:\/\/test.com\//,
}
// matches referrer:
// https://test.com/hello
// http://test.com/
- 字符串:设置字符串形式的白名单时分为两种,当字符串以 . 开头,例如
.test.com
时,代表referrer
白名单为test.com
的所有子域名,包括test.com
自身。当字符串不以 . 开头,例如sub.test.com
,代表referrer
白名单为sub.test.com
这一个域名。(同时支持HTTP
和HTTPS
)。
exports.jsonp = {
whiteList: '.test.com',
}
// matches domain test.com:
// https://test.com/hello
// http://test.com/
// matches subdomain
// https://sub.test.com/hello
// http://sub.sub.test.com/
exports.jsonp = {
whiteList: 'sub.test.com',
}
// only matches domain sub.test.com:
// https://sub.test.com/hello
// http://sub.test.com/
- 数组:当设置的白名单为数组时,代表只要满足数组中任意一个元素的条件即可通过
referrer
校验。
exports.jsonp = {
whiteList: ['sub.test.com', 'sub2.test.com'],
}
// matches domain sub.test.com and sub2.test.com:
// https://sub.test.com/hello
// http://sub2.test.com/
当 CSRF 和 referrer 校验同时开启时,请求发起方只需要满足任意一个条件即可通过 JSONP 的安全校验。
设置 Header
通过状态码标识请求成功与否、状态如何,在 body
中设置响应的内容。而通过响应的 Header
,还可以设置一些扩展信息。
通过 ctx.set(key, value)
方法可以设置一个响应头,ctx.set(headers)
设置多个 Header
// app/controller/api.js
class ProxyController extends Controller {
async show() {
const ctx = this.ctx
const start = Date.now()
ctx.body = await ctx.service.post.get()
const used = Date.now() - start
// 设置一个响应头
ctx.set('show-response-time', used.toString())
}
};
重定向
框架通过 security
插件覆盖了 koa
原生的 ctx.redirect
实现,以提供更加安全的重定向。
ctx.redirect(url)
如果不在配置的白名单域名内,则禁止跳转。ctx.unsafeRedirect(url)
不判断域名,直接跳转,一般不建议使用,明确了解可能带来的风险后使用。
用户如果使用ctx.redirect
方法,需要在应用的配置文件中做如下配置:
// config/config.default.js
exports.security = {
domainWhiteList: ['.domain.com'], // 安全白名单,以 . 开头
}
若用户没有配置 domainWhiteList
或者 domainWhiteList
数组内为空,则默认会对所有跳转请求放行,即等同于ctx.unsafeRedirect(url)