Controller Controller
所有的 Controller 文件都必须放在 app/controller 目录下,
可以支持多级目录,访问的时候可以通过目录名级联访问
Controller defined
// 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.post('createPost', '/api/posts', 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)分别代表打印不同级别的日志
HTTP basic
Controller 基本上是业务开发中唯一和 HTTP 协议打交道的地方
如果发起一个post请求访问Controller,
axios({
url: '/home',
method: 'post',
data: {name: 'jack', age:18},
headers: {'Content-Type':'application/json; charset=UTF-8'}
})
// 发出的HTTP请求的内容就是下面这样
POST /home HTTP/1.1
Host: localhost:3000
Content-Type: application/json; charset=UTF-8
{name: 'jack', age:18}
1. 请求行,第一行包含三个信息,我们比较常用的是前面两个
method: 请求方式为post
path: 值为/home,如果用户请求包含query也会显示在这里
2. 请求头,第二行开始直到遇到第一个空行位置,都是请求headers的部分
Host: 浏览器会将域名和端口号放在host头中一并发给服务端
Content-Type: 当请求有body的时候,都会有content-type来标明我们的请求体时什么格式的
还有Cookie,User-agent等等,都在这个请求头中
3. 空行,发送回车符和换行符,通知服务器以下不再有请求头,它的作用是通过一个空行,告诉服务器请求头部到此为止。
4. 最后一行,请求体/请求数据
如果请求方式为post,请求参数和值就会放在这里,会把数据以key: value的形式发送请求
如果请求方式为post,请求参数和值就会包含在资源路径(URL)上
// 服务端处理完这个请求后,会返送一个HTTP响应给客户端
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Content-Length: 8
Date: Mon, 09 Jan 2017 08:40:28 GMT
Connection: keep-alive
{"id": 1}
1. 状态行,第一行,响应状态码,这个例子中的值为201,含义是在服务端成功创建一条资源
2. 响应头,第二行开始到第一个空行,
Content-Type: 表示响应的格式为json格式
Content-Length: 表示长度为8个字节
3. 空行,响应结束
4. 返回响应的内容
Gets HTTP request parameters
框架通过在Comtroller上绑定Context实例,提供许多方法和属性来获取用户通过HTTP发送过来的参数
1.query
在URL中?后面的部分是一个Query String,经常用域get类型的请求中传递参数
// 例如 http://localhost:7001/home?name=jack&age=18
name=jack&age=18就是用户传递过来的参数,我们通过ctx.query获取解析过后的参数体
class PostController extends Controller {
async listPosts() {
const query = this.ctx.query; // => {name: 'jack', age: 18}
}
}
当Query String中的key值重复,ctx.query只获取key第一次出现的值,后面的忽略
有时候我们的系统会设计成让用户传递相同的 key,例如 GET /posts?category=egg&id=1&id=2&id=3。
针对此类情况,框架提供了 ctx.queries 对象, 但是会将每一个数据都放进一个数组中
// 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']} 就算没有重复的值,也会保存到数组中
}
}
2. Router params
在router动态路由中,也可以申明参数,这些参数都可以通过ctx.params获取到
// router.get('/projects/:projectId/app/:appId', controller.home.listApp)
class AppController extends Controller {
async listApp() {
this.ctx.response.body = `projectid:${this.ctx.params.projectId}, appid:${this.ctx.params.appId}`;
}
}
// http://localhost:7001/projects/zhangsan/app/18 => 'projectid:zhangsan, appid:18'
3. body
虽然可以通过url传递参数,但是有许多限制
浏览器中会对url的长度有所限制,如果参数过多就会无法传递
服务端经常会将访问的完整的url暴露在请求信息中,一些敏感数据通过url传递不安全
前面HTTP请求报文实例中,header之后还有一个body部分,通常会在这个部分传递post,put等方法的参数
一般请求中有body的时候,客户端(浏览器)会同时发送Content-Type告诉服务端这次请求的body什么格式的
web开发数据传递最常用的格式json和Form,框架内置了bodyParse中间件来对这两类格式的请求body解析成obj
将obj对象挂载到全局ctx.request.body上,GET,HEAD方法无法获取到内容
let options = {
url: '/form',
method: 'post',
data: {name: 'jack', age: 18},
headers: {'Content-Type': 'application/json'}
}
axios(options).then(data=> {console.log(data)})
class PostController extends Controller {
async listPosts() {
console.log(this.ctx.request.body.name) // => 'jack'
console.log(this.ctx.request.body.age) // => 18
}
}
// 框架对bodyParse设置了一些默认参数:
1. Content-Type为application/json,application/json-patch+json,
application/vnd.api+json和application/csp-report时,
会按照json格式对请求body进行解析,并限制最大长度为100kb
2. Content-Type为application/x-www-form-urlencoded时,
会按照form格式对请求body进行解析,限制最大长度为100kb
3. 如果解析成功,body一定会是一个object(可能时一个数组)
// 配置解析允许的最大长度,可以在config/config-default.js中覆盖框架默认值
module.exports = {
bodyParser: {
jsonLimit: '1mb',
formLimit: '1mb',
},
};
如果超出最大长度,抛出状态码413的异常。请求body解析失败,抛出错误状态码400的异常
// 一个常见的错误是把ctx.request.body和ctx.body混淆,后者是ctx.response.body的简写
Get upload files
请求body除了可以带参数以外,还可以发送文件,一般浏览器上都是通过Multipart/form-data格式发送文件
框架通过内置Multipart插件来支持获取用户上传的文件
1. 在config文件中启用file模式
// config/config.default.js
exports.multipart = {
mode: 'file',
};
2. 上传单个文件/前端静态页面form标签
<form method="POST" action="http://localhost:7001/upload" enctype="multipart/form-data">
// mtehod: 请求方式
// action: 请求地址
// enctype: 请求类型
title: <input name="title" />
file: <input name="file" type="file" />
<button type="submit">Upload</button>
</form>
3. 对应的后端代码
// app/controller/upload.js
const Controller = require('egg').Controller;
const fs = require('fs');
module.exports = class extends Controller {
async upload() {
const { ctx } = this;
const file = ctx.request.files[0]; // 返回一个数组,保存的文件对象
let result;
try {
// 处理文件,将上传的文件从缓存移动到本地服务器upload文件夹下
// app/public/upload/file.filename
fs.rename(file.filepath, `${__dirname}/../public/upload/${file.filename}`)
result = 'success'
} catch(e){
console.log(e)
}
ctx.response.body = result;
}
};
// 对于多个文件,我们借助 ctx.request.files 属性进行遍历,然后分别进行处理:
<form method="POST" action="http:localhost:7001/upload" 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('fs');
module.exports = class extends Controller {
async upload() {
const { ctx } = this;
console.log(ctx.request.body) //form表单其他字段
for (const file of ctx.request.files) {
console.log('field: ' + file.fieldname); //字段名等于file.field
console.log('filename: ' + file.filename); //文件名
console.log('encoding: ' + file.encoding); //编码
console.log('mime: ' + file.mime); // mime类型
console.log('tmp filepath: ' + file.filepath); //文件临时缓存目录
try {
do something....
} catch (e) {
....
}
}
}
}
// 为了保证文件上传的安全,框架限制了支持的的文件格式,框架默认支持白名单
jpg,png,gif,svg,js,json,zip,mp3,mp4.......
用户可以通过在 config/config.default.js 中配置来新增支持的文件扩展名,或者重写整个白名单
module.exports = {
multipart: {
// 增加对 apk 扩展名的文件支持
fileExtensions: [ '.apk' ]
// 覆盖整个白名单,只允许上传 '.png' 格式,当whitelist重写时,fileExtensions不生效
// whitelist: [ '.png' ],
},
};
Cookie
HTTP 请求都是无状态的,但是我们的 Web 应用通常都需要知道发起请求的人是谁
为了解决这个问题,HTTP 协议设计了一个特殊的请求头:Cookie
通过 ctx.cookies,我们可以在 Controller 中便捷、安全的设置和读取 Cookie。
'use strict';
const Controller = require('egg').Controller;
class CookieController extends Controller {
async add() {
const ctx = this.ctx;
let count = ctx.cookies.get('count');
count = count ? Number(count) : 0;
// 每请求一次count加1,在没有调用remove以前,页面刷新后,cookie记录着上一次的值
ctx.cookies.set('count', ++count);
ctx.body = count;
}
async remove() {
const ctx = this.ctx;
//将cookie的count属性清空
ctx.cookies.set('count', null);
ctx.status = 204;
}
}
module.exports = CookieController;
// Cookie 在 Web 应用中经常承担了传递客户端身份信息的作用,因此有许多安全相关的配置
// 详情: https://eggjs.org/zh-cn/core/cookie-and-session.html#cookie
// config/config.default.js
module.exports = {
cookies: {
// httpOnly: true | false, // true: 不能被修改, false,可以修改
// sameSite: 'none|lax|strict', //配置应用级别的 Cookie SameSite 属性等于 Lax
// signed: false, //可以被访问
// encrypt: true, // 加密传输,不能看到明文
},
};
Session
通过 Cookie,我们可以给每一个用户设置一个 Session,用来存储用户身份相关的信息,
这份信息会加密后存储在 Cookie 中,实现跨请求的用户身份保持,
Cookie 在 Web 应用中经常承担标识请求方身份的功能,
所以 Web 应用在 Cookie 的基础上封装了 Session 的概念,专门用做用户身份识别。
// 框架内置了 Session 插件,给我们提供了 ctx.session 来访问或者修改当前用户 Session
class PostController extends Controller {
async fetchPosts() {
const ctx = this.ctx;
// 获取 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;
// Session 的使用方法非常直观,直接读取它或者修改它就可以了,如果要删除它,直接将它赋值为 null
// this.ctx.session = null;
ctx.body = {
success: true,
posts,
};
}
}
// 设置session属性时,不是以_开头,不能时关键字,
ctx.session._visited = 1 // ×
ctx.session.get = 'haha' // ×
ctx.session.visited = 1 // √
// Session的实现时基于Cookie的,默认配置下,用户Session的内容加密后直接存储在Cookie的一个字段中,
// 用户每次请求网站的时候都会带上这个Cookie,我们在服务端解密后使用
// config/config.default.js
exports.session = {
key: 'EGG_SESS',
maxAge: 24 * 3600 * 1000, // session保存的时间,1 天
httpOnly: true,
encrypt: true,
renew: true, // 它会在发现当用户 Session 的有效期仅剩下最大有效期一半的时候,重置 Session 的有效期
};
// 可以看到这些参数除了 key 都是 Cookie 的参数,key 代表了存储 Session 的 Cookie 键值对的 key 是什么。在默认的配置下,存放 Session 的 Cookie 将会加密存储、不可被前端 js 访问,这样可以保证用户的 Session 是安全的。
Parameter check
在获取到用户请求的参数后,不可避免的要对参数进行一些校验。
借助 Validate 插件提供便捷的参数校验机制,帮助我们完成各种复杂的参数校验。
下载安装插件 npm install egg-validata --save
// 配置插件 config/plugin.js
exports.validate = {
enable: true, // 启用插件
package: 'egg-validate'
}
// 通过 ctx.validate(rule, [body]) 直接对参数进行校验:
rule: 校验规则,
body: 可选,不传递该参数会自动校验 ctx.request.body
// app/controller/home.js
class PostController extends Controller {
async create() {
const ctx = this.ctx;
const createRule = {
title: { type: 'string' },
content: { type: 'string' },
};
try {
//当校验异常,抛出异常,状态码422,errors字段包含详细验证不通过的信息
ctx.validate(createRule);
} catch (err) {
ctx.logger.warn(err.errors);
ctx.body = { success: false };
return;
}
}
};
Call Service
我们并不想在 Controller 中实现太多业务逻辑,所以提供了一个 Service 层进行业务逻辑的封装,
这不仅能提高代码的复用性,同时可以让我们的业务逻辑更好测试。
在 Controller 中可以调用任何一个 Service 上的任何方法,
同时 Service 是懒加载的,只有当访问到它的时候框架才会去实例化它。
class PostController extends Controller {
async create() {
const ctx = this.ctx;
// 进行参数处理
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;
}
}
Sending an HTTP response
当业务逻辑完成之后,Controller 的最后一个职责就是将业务逻辑的处理结果通过 HTTP 响应发送给用户。
1. Set status
HTTP 设计了非常多的状态码,每一个状态码都代表了一个特定的含义,通过设置正确的状态码,可以让响应更符合语义。
class PostController extends Controller {
async create() {
// 设置状态码为 201
this.ctx.status = 201;
}
};
2. Set up 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() {
this.ctx.body = {
name: 'egg',
category: 'framework',
language: 'Node.js',
};
}
async page() {
this.ctx.body = '<html><h1>Hello</h1></html>';
}
}
我们通过状态码标识请求成功与否、状态如何,
在 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());
}
};
JSONP
有时我们需要给非本域的页面提供接口服务,又由于一些历史原因无法通过 CORS(跨域) 实现,可以通过 JSONP 来进行响应。
1. 通过 app.jsonp() 提供的中间件来让一个 controller 支持响应 JSONP 格式的数据。在路由中,我们给需要支持 jsonp 的路由加上这个中间件:
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);
};
2. 在Controller中,正常编写
// app/controller/posts.js
class PostController extends Controller {
async show() {
this.ctx.body = {
name: 'egg',
category: 'framework',
language: 'Node.js',
};
}
}
3. JSONP配置
// 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 配置中,我们只需要打开 csrf: true,即可对 JSONP 接口开启 CSRF 校验。
// config/config.default.js
module.exports = {
jsonp: {
csrf: true,
},
};
// 如果在同一个主域之下,可以通过开启 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 可以配置为正则表达式、字符串或者数组:
1. 正则表达式: 此时只有请求的 Referrer 匹配该正则时才允许访问 JSONP 接口
2. 字符串:当字符串以 . 开头,例如 .test.com 时,代表 referrer 白名单为 test.com 的所有子域名,包括 test.com 自身
当字符串不以 . 开头,例如 sub.test.com,代表 referrer 白名单为 sub.test.com 这一个域名。
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/
3. 数组: 当设置的白名单为数组时,满足数组中任意一个
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 的安全校验
Redirect
框架通过 security 插件覆盖了 koa 原生的 ctx.redirect 实现,以提供更加安全的重定向。
1. ctx.redirect(url) 如果不在配置的白名单域名内,则禁止跳转。
2. ctx.unsafeRedirect(url) 不判断域名,直接跳转,一般不建议使用,明确了解可能带来的风险后使用。
//使用ctx.redirect(url),需要在应用配置文件配置:
// config/config.default.js
exports.security = {
domainWhiteList:['.domain.com'], // 安全白名单,以 . 开头
};
若用户没有配置 domainWhiteList 或者 domainWhiteList数组内为空,则默认会对所有跳转请求放行,即等同于ctx.unsafeRedirect(url)