什么是 Controller 前面章節(jié) 寫(xiě)到,我們通過(guò) Router 將用戶的請(qǐng)求基于 method 和 URL 分發(fā)到了對(duì)應(yīng)的 Controller 上,那 Controller 負(fù)責(zé)做什么?
簡(jiǎn)單的說(shuō) Controller 負(fù)責(zé)解析用戶的輸入,處理后返回相應(yīng)的結(jié)果,例如
在 RESTful 接口中,Controller 接受用戶的參數(shù),從數(shù)據(jù)庫(kù)中查找內(nèi)容返回給用戶或者將用戶的請(qǐng)求更新到數(shù)據(jù)庫(kù)中。 在 HTML 頁(yè)面請(qǐng)求中,Controller 根據(jù)用戶訪問(wèn)不同的 URL,渲染不同的模板得到 HTML 返回給用戶。 在代理服務(wù)器中,Controller 將用戶的請(qǐng)求轉(zhuǎn)發(fā)到其他服務(wù)器上,并將其他服務(wù)器的處理結(jié)果返回給用戶。 框架推薦 Controller 層主要對(duì)用戶的請(qǐng)求參數(shù)進(jìn)行處理(校驗(yàn)、轉(zhuǎn)換),然后調(diào)用對(duì)應(yīng)的 service 方法處理業(yè)務(wù),得到業(yè)務(wù)結(jié)果后封裝并返回:
獲取用戶通過(guò) HTTP 傳遞過(guò)來(lái)的請(qǐng)求參數(shù)。 校驗(yàn)、組裝參數(shù)。 調(diào)用 Service 進(jìn)行業(yè)務(wù)處理,必要時(shí)處理轉(zhuǎn)換 Service 的返回結(jié)果,讓它適應(yīng)用戶的需求。 通過(guò) HTTP 將結(jié)果響應(yīng)給用戶。 如何編寫(xiě) Controller所有的 Controller 文件都必須放在 app/controller 目錄下,可以支持多級(jí)目錄,訪問(wèn)的時(shí)候可以通過(guò)目錄名級(jí)聯(lián)訪問(wèn)。Controller 支持多種形式進(jìn)行編寫(xiě),可以根據(jù)不同的項(xiàng)目場(chǎng)景和開(kāi)發(fā)習(xí)慣來(lái)選擇。
Controller 類(lèi)(推薦)我們可以通過(guò)定義 Controller 類(lèi)的方式來(lái)編寫(xiě)代碼:
// 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' }, }; // 校驗(yàn)參數(shù) ctx.validate(createRule); // 組裝參數(shù) const author = ctx.session.userId; const req = Object.assign(ctx.request.body, { author }); // 調(diào)用 Service 進(jìn)行業(yè)務(wù)處理 const res = await service.post.create(req); // 設(shè)置響應(yīng)內(nèi)容和響應(yīng)狀態(tài)碼 ctx.body = { id: res.id }; ctx.status = 201; } } module.exports = PostController;
我們通過(guò)上面的代碼定義了一個(gè) PostController 的類(lèi),類(lèi)里面的每一個(gè)方法都可以作為一個(gè) Controller 在 Router 中引用到,我們可以從 app.controller 根據(jù)文件名和方法名定位到它。
// app/router.js module.exports = app => { const { router, controller } = app; router.post('createPost', '/api/posts', controller.post.create); }
Controller 支持多級(jí)目錄,例如如果我們將上面的 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 類(lèi),會(huì)在每一個(gè)請(qǐng)求訪問(wèn)到 server 時(shí)實(shí)例化一個(gè)全新的對(duì)象,而項(xiàng)目中的 Controller 類(lèi)繼承于 egg.Controller,會(huì)有下面幾個(gè)屬性掛在 this 上。
this.ctx: 當(dāng)前請(qǐng)求的上下文 Context 對(duì)象的實(shí)例,通過(guò)它我們可以拿到框架封裝好的處理當(dāng)前請(qǐng)求的各種便捷屬性和方法。 this.app: 當(dāng)前應(yīng)用 Application 對(duì)象的實(shí)例,通過(guò)它我們可以拿到框架提供的全局對(duì)象和方法。 this.service:應(yīng)用定義的 Service ,通過(guò)它我們可以訪問(wèn)到抽象出的業(yè)務(wù)層,等價(jià)于 this.ctx.service 。 this.config:應(yīng)用運(yùn)行時(shí)的配置項(xiàng) 。 this.logger:logger 對(duì)象,上面有四個(gè)方法(debug,info,warn,error),分別代表打印四個(gè)不同級(jí)別的日志,使用方法和效果與 context logger 中介紹的一樣,但是通過(guò)這個(gè) logger 對(duì)象記錄的日志,在日志前面會(huì)加上打印該日志的文件路徑,以便快速定位日志打印位置。 自定義 Controller 基類(lèi)按照類(lèi)的方式編寫(xiě) Controller,不僅可以讓我們更好的對(duì) Controller 層代碼進(jìn)行抽象(例如將一些統(tǒng)一的處理抽象成一些私有方法),還可以通過(guò)自定義 Controller 基類(lèi)的方式封裝應(yīng)用中常用的方法。
// 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;
此時(shí)在編寫(xiě)應(yīng)用的 Controller 時(shí),可以繼承 BaseController,直接使用基類(lèi)上的方法:
//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 方法(不推薦使用,只是為了兼容)每一個(gè) Controller 都是一個(gè) async function,它的入?yún)檎?qǐng)求的上下文 Context 對(duì)象的實(shí)例,通過(guò)它我們可以拿到框架封裝好的各種便捷屬性和方法。
例如我們寫(xiě)一個(gè)對(duì)應(yīng)到 POST /api/posts 接口的 Controller,我們會(huì)在 app/controller 目錄下創(chuàng)建一個(gè) post.js 文件
// app/controller/post.js exports.create = async ctx => { const createRule = { title: { type: 'string' }, content: { type: 'string' }, }; // 校驗(yàn)參數(shù) ctx.validate(createRule); // 組裝參數(shù) const author = ctx.session.userId; const req = Object.assign(ctx.request.body, { author }); // 調(diào)用 service 進(jìn)行業(yè)務(wù)處理 const res = await ctx.service.post.create(req); // 設(shè)置響應(yīng)內(nèi)容和響應(yīng)狀態(tài)碼 ctx.body = { id: res.id }; ctx.status = 201; };
在上面的例子中我們引入了許多新的概念,但還是比較直觀,容易理解的,我們會(huì)在下面對(duì)它們進(jìn)行更詳細(xì)的介紹。
HTTP 基礎(chǔ)由于 Controller 基本上是業(yè)務(wù)開(kāi)發(fā)中唯一和 HTTP 協(xié)議打交道的地方,在繼續(xù)往下了解之前,我們首先簡(jiǎn)單的看一下 HTTP 協(xié)議是怎樣的。
如果我們發(fā)起一個(gè) HTTP 請(qǐng)求來(lái)訪問(wèn)前面例子中提到的 Controller:
curl -X POST http://localhost:3000/api/posts --data '{"title":"controller", "content": "what is controller"}' --header 'Content-Type:application/json; charset=UTF-8'
通過(guò) curl 發(fā)出的 HTTP 請(qǐng)求的內(nèi)容就會(huì)是下面這樣的:
POST /api/posts HTTP/1.1 Host: localhost:3000 Content-Type: application/json; charset=UTF-8 {"title": "controller", "content": "what is controller"}
請(qǐng)求的第一行包含了三個(gè)信息,我們比較常用的是前面兩個(gè):
method:這個(gè)請(qǐng)求中 method 的值是 POST。 path:值為 /api/posts,如果用戶的請(qǐng)求中包含 query,也會(huì)在這里出現(xiàn) 從第二行開(kāi)始直到遇到的第一個(gè)空行位置,都是請(qǐng)求的 Headers 部分,這一部分中有許多常用的屬性,包括這里看到的 Host,Content-Type,還有 Cookie,User-Agent 等等。在這個(gè)請(qǐng)求中有兩個(gè)頭:
Host:我們?cè)跒g覽器發(fā)起請(qǐng)求的時(shí)候,域名會(huì)用來(lái)通過(guò) DNS 解析找到服務(wù)的 IP 地址,但是瀏覽器也會(huì)將域名和端口號(hào)放在 Host 頭中一并發(fā)送給服務(wù)端。 Content-Type:當(dāng)我們的請(qǐng)求有 body 的時(shí)候,都會(huì)有 Content-Type 來(lái)標(biāo)明我們的請(qǐng)求體是什么格式的。 之后的內(nèi)容全部都是請(qǐng)求的 body,當(dāng)請(qǐng)求是 POST, PUT, DELETE 等方法的時(shí)候,可以帶上請(qǐng)求體,服務(wù)端會(huì)根據(jù) Content-Type 來(lái)解析請(qǐng)求體。
在服務(wù)端處理完這個(gè)請(qǐng)求后,會(huì)發(fā)送一個(gè) HTTP 響應(yīng)給客戶端
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}
第一行中也包含了三段,其中我們常用的主要是響應(yīng)狀態(tài)碼 ,這個(gè)例子中它的值是 201,它的含義是在服務(wù)端成功創(chuàng)建了一條資源。
和請(qǐng)求一樣,從第二行開(kāi)始到下一個(gè)空行之間都是響應(yīng)頭,這里的 Content-Type, Content-Length 表示這個(gè)響應(yīng)的格式是 JSON,長(zhǎng)度為 8 個(gè)字節(jié)。
最后剩下的部分就是這次響應(yīng)真正的內(nèi)容。
獲取 HTTP 請(qǐng)求參數(shù)從上面的 HTTP 請(qǐng)求示例中可以看到,有好多地方可以放用戶的請(qǐng)求數(shù)據(jù),框架通過(guò)在 Controller 上綁定的 Context 實(shí)例,提供了許多便捷方法和屬性獲取用戶通過(guò) HTTP 請(qǐng)求發(fā)送過(guò)來(lái)的參數(shù)。
query在 URL 中 ? 后面的部分是一個(gè) Query String,這一部分經(jīng)常用于 GET 類(lèi)型的請(qǐng)求中傳遞參數(shù)。例如 GET /posts?category=egg&language=node 中 category=egg&language=node 就是用戶傳遞過(guò)來(lái)的參數(shù)。我們可以通過(guò) ctx.query 拿到解析過(guò)后的這個(gè)參數(shù)體
class PostController extends Controller { async listPosts() { const query = this.ctx.query; // { // category: 'egg', // language: 'node', // } } }
當(dāng) Query String 中的 key 重復(fù)時(shí),ctx.query 只取 key 第一次出現(xiàn)時(shí)的值,后面再出現(xiàn)的都會(huì)被忽略。GET /posts?category=egg&category=koa 通過(guò) ctx.query 拿到的值是 { category: 'egg' }。
這樣處理的原因是為了保持統(tǒng)一性,由于通常情況下我們都不會(huì)設(shè)計(jì)讓用戶傳遞 key 相同的 Query String,所以我們經(jīng)常會(huì)寫(xiě)類(lèi)似下面的代碼:
const key = ctx.query.key || ''; if (key.startsWith('egg')) { // do something }
而如果有人故意發(fā)起請(qǐng)求在 Query String 中帶上重復(fù)的 key 來(lái)請(qǐng)求時(shí)就會(huì)引發(fā)系統(tǒng)異常。因此框架保證了從 ctx.query 上獲取的參數(shù)一旦存在,一定是字符串類(lèi)型。
queries有時(shí)候我們的系統(tǒng)會(huì)設(shè)計(jì)成讓用戶傳遞相同的 key,例如 GET /posts?category=egg&id=1&id=2&id=3。針對(duì)此類(lèi)情況,框架提供了 ctx.queries 對(duì)象,這個(gè)對(duì)象也解析了 Query String,但是它不會(huì)丟棄任何一個(gè)重復(fù)的數(shù)據(jù),而是將他們都放到一個(gè)數(shù)組中:
// 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 如果有值,也一定會(huì)是數(shù)組類(lèi)型。
Router params在 Router 中,我們介紹了 Router 上也可以申明參數(shù),這些參數(shù)都可以通過(guò) 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雖然我們可以通過(guò) URL 傳遞參數(shù),但是還是有諸多限制:
在前面的 HTTP 請(qǐng)求報(bào)文示例中,我們看到在 header 之后還有一個(gè) body 部分,我們通常會(huì)在這個(gè)部分傳遞 POST、PUT 和 DELETE 等方法的參數(shù)。一般請(qǐng)求中有 body 的時(shí)候,客戶端(瀏覽器)會(huì)同時(shí)發(fā)送 Content-Type 告訴服務(wù)端這次請(qǐng)求的 body 是什么格式的。Web 開(kāi)發(fā)中數(shù)據(jù)傳遞最常用的兩類(lèi)格式分別是 JSON 和 Form。
框架內(nèi)置了 bodyParser 中間件來(lái)對(duì)這兩類(lèi)格式的請(qǐng)求 body 解析成 object 掛載到 ctx.request.body 上。HTTP 協(xié)議中并不建議在通過(guò) GET、HEAD 方法訪問(wèn)時(shí)傳遞 body,所以我們無(wú)法在 GET、HEAD 方法中按照此方法獲取到內(nèi)容。
// 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'); } }
框架對(duì) bodyParser 設(shè)置了一些默認(rèn)參數(shù),配置好之后擁有以下特性:
當(dāng)請(qǐng)求的 Content-Type 為 application/json,application/json-patch+json,application/vnd.api+json 和 application/csp-report 時(shí),會(huì)按照 json 格式對(duì)請(qǐng)求 body 進(jìn)行解析,并限制 body 最大長(zhǎng)度為 100kb。 當(dāng)請(qǐng)求的 Content-Type 為 application/x-www-form-urlencoded 時(shí),會(huì)按照 form 格式對(duì)請(qǐng)求 body 進(jìn)行解析,并限制 body 最大長(zhǎng)度為 100kb。 如果解析成功,body 一定會(huì)是一個(gè) Object(可能是一個(gè)數(shù)組)。 一般來(lái)說(shuō)我們最經(jīng)常調(diào)整的配置項(xiàng)就是變更解析時(shí)允許的最大長(zhǎng)度,可以在 config/config.default.js 中覆蓋框架的默認(rèn)值。
module.exports = { bodyParser: { jsonLimit: '1mb', formLimit: '1mb', }, };
如果用戶的請(qǐng)求 body 超過(guò)了我們配置的解析最大長(zhǎng)度,會(huì)拋出一個(gè)狀態(tài)碼為 413 的異常,如果用戶請(qǐng)求的 body 解析失?。ㄥe(cuò)誤的 JSON),會(huì)拋出一個(gè)狀態(tài)碼為 400 的異常。
注意:在調(diào)整 bodyParser 支持的 body 長(zhǎng)度時(shí),如果我們應(yīng)用前面還有一層反向代理(Nginx),可能也需要調(diào)整它的配置,確保反向代理也支持同樣長(zhǎng)度的請(qǐng)求 body。
一個(gè)常見(jiàn)的錯(cuò)誤是把 ctx.request.body 和 ctx.body 混淆,后者其實(shí)是 ctx.response.body 的簡(jiǎn)寫(xiě)。
獲取上傳的文件請(qǐng)求 body 除了可以帶參數(shù)之外,還可以發(fā)送文件,一般來(lái)說(shuō),瀏覽器上都是通過(guò) Multipart/form-data 格式發(fā)送文件的,框架通過(guò)內(nèi)置 Multipart 插件來(lái)支持獲取用戶上傳的文件,我們?yōu)槟闾峁┝藘煞N方式:
如果你完全不知道 Nodejs 中的 Stream 用法,那么 File 模式非常合適你:
1)在 config 文件中啟用 file 模式:
// config/config.default.js exports.multipart = { mode: 'file', };
2)上傳 / 接收文件:
上傳 / 接收單個(gè)文件: 你的前端靜態(tài)頁(yè)面代碼應(yīng)該看上去如下樣子:
<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>
對(duì)應(yīng)的后端代碼如下:
// 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 { // 需要?jiǎng)h除臨時(shí)文件 await fs.unlink(file.filepath); } ctx.body = { url: result.url, // 獲取所有的字段值 requestBody: ctx.request.body, }; } };
上傳 / 接收多個(gè)文件: 對(duì)于多個(gè)文件,我們借助 ctx.request.files 屬性進(jìn)行遍歷,然后分別進(jìn)行處理:
你的前端靜態(tài)頁(yè)面代碼應(yīng)該看上去如下樣子:
<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>
對(duì)應(yīng)的后端代碼:
// 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 { // 需要?jiǎng)h除臨時(shí)文件 await fs.unlink(file.filepath); } console.log(result); } } };
如果你對(duì)于 Node 中的 Stream 模式非常熟悉,那么你可以選擇此模式。在 Controller 中,我們可以通過(guò) ctx.getFileStream() 接口能獲取到上傳的文件流。
上傳 / 接受單個(gè)文件: <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('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); // 文件處理,上傳到云存儲(chǔ)等等 let result; try { result = await ctx.oss.put(name, stream); } catch (err) { // 必須將上傳的文件流消費(fèi)掉,要不然瀏覽器響應(yīng)會(huì)卡死 await sendToWormhole(stream); throw err; } ctx.body = { url: result.url, // 所有表單字段都能通過(guò) `stream.fields` 獲取到 fields: stream.fields, }; } } module.exports = UploaderController;
要通過(guò) ctx.getFileStream 便捷的獲取到用戶上傳的文件,需要滿足兩個(gè)條件:
只支持上傳一個(gè)文件。 上傳文件必須在所有其他的 fields 后面,否則在拿到文件流時(shí)可能還獲取不到 fields。 上傳 / 接受多個(gè)文件: 如果要獲取同時(shí)上傳的多個(gè)文件,不能通過(guò) ctx.getFileStream() 來(lái)獲取,只能通過(guò)下面這種方式:
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 對(duì)象 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) { // 這時(shí)是用戶沒(méi)有選擇文件就點(diǎn)擊了上傳(part 是 file stream,但是 part.filename 為空) // 需要做出處理,例如給出錯(cuò)誤提示消息 return; } // part 是上傳的文件流 console.log('field: ' + part.fieldname); console.log('filename: ' + part.filename); console.log('encoding: ' + part.encoding); console.log('mime: ' + part.mime); // 文件處理,上傳到云存儲(chǔ)等等 let result; try { result = await ctx.oss.put('egg-multipart-test/' + part.filename, part); } catch (err) { // 必須將上傳的文件流消費(fèi)掉,要不然瀏覽器響應(yīng)會(huì)卡死 await sendToWormhole(part); throw err; } console.log(result); } } console.log('and we are done parsing the form!'); } } module.exports = UploaderController;
為了保證文件上傳的安全,框架限制了支持的的文件格式,框架默認(rèn)支持白名單如下:
// 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',
用戶可以通過(guò)在 config/config.default.js 中配置來(lái)新增支持的文件擴(kuò)展名,或者重寫(xiě)整個(gè)白名單
module.exports = { multipart: { fileExtensions: [ '.apk' ] // 增加對(duì) apk 擴(kuò)展名的文件支持 }, };
module.exports = { multipart: { whitelist: [ '.png' ], // 覆蓋整個(gè)白名單,只允許上傳 '.png' 格式 }, };
注意:當(dāng)重寫(xiě)了 whitelist 時(shí),fileExtensions 不生效。
欲了解更多相關(guān)此技術(shù)細(xì)節(jié)和詳情,請(qǐng)參閱 Egg-Multipart 。
header除了從 URL 和請(qǐng)求 body 上獲取參數(shù)之外,還有許多參數(shù)是通過(guò)請(qǐng)求 header 傳遞的??蚣芴峁┝艘恍┹o助屬性和方法來(lái)獲取。
ctx.headers,ctx.header,ctx.request.headers,ctx.request.header:這幾個(gè)方法是等價(jià)的,都是獲取整個(gè) header 對(duì)象。 ctx.get(name),ctx.request.get(name):獲取請(qǐng)求 header 中的一個(gè)字段的值,如果這個(gè)字段不存在,會(huì)返回空字符串。 我們建議用 ctx.get(name) 而不是 ctx.headers['name'],因?yàn)榍罢邥?huì)自動(dòng)處理大小寫(xiě)。 由于 header 比較特殊,有一些是 HTTP 協(xié)議規(guī)定了具體含義的(例如 Content-Type,Accept),有些是反向代理設(shè)置的,已經(jīng)約定俗成(X-Forwarded-For),框架也會(huì)對(duì)他們?cè)黾右恍┍憬莸?getter,詳細(xì)的 getter 可以查看 API 文檔。
特別是如果我們通過(guò) config.proxy = true 設(shè)置了應(yīng)用部署在反向代理(Nginx)之后,有一些 Getter 的內(nèi)部處理會(huì)發(fā)生改變。
ctx.host優(yōu)先讀通過(guò) config.hostHeaders 中配置的 header 的值,讀不到時(shí)再嘗試獲取 host 這個(gè) header 的值,如果都獲取不到,返回空字符串。
config.hostHeaders 默認(rèn)配置為 x-forwarded-host。
ctx.protocol通過(guò)這個(gè) Getter 獲取 protocol 時(shí),首先會(huì)判斷當(dāng)前連接是否是加密連接,如果是加密連接,返回 https。
如果處于非加密連接時(shí),優(yōu)先讀通過(guò) config.protocolHeaders 中配置的 header 的值來(lái)判斷是 HTTP 還是 https,如果讀取不到,我們可以在配置中通過(guò) config.protocol 來(lái)設(shè)置兜底值,默認(rèn)為 HTTP。
config.protocolHeaders 默認(rèn)配置為 x-forwarded-proto。
ctx.ips通過(guò) ctx.ips 獲取請(qǐng)求經(jīng)過(guò)所有的中間設(shè)備 IP 地址列表,只有在 config.proxy = true 時(shí),才會(huì)通過(guò)讀取 config.ipHeaders 中配置的 header 的值來(lái)獲取,獲取不到時(shí)為空數(shù)組。
config.ipHeaders 默認(rèn)配置為 x-forwarded-for。
ctx.ip通過(guò) ctx.ip 獲取請(qǐng)求發(fā)起方的 IP 地址,優(yōu)先從 ctx.ips 中獲取,ctx.ips 為空時(shí)使用連接上發(fā)起方的 IP 地址。
注意:ip 和 ips 不同,ip 當(dāng) config.proxy = false 時(shí)會(huì)返回當(dāng)前連接發(fā)起者的 ip 地址,ips 此時(shí)會(huì)為空數(shù)組。
CookieHTTP 請(qǐng)求都是無(wú)狀態(tài)的,但是我們的 Web 應(yīng)用通常都需要知道發(fā)起請(qǐng)求的人是誰(shuí)。為了解決這個(gè)問(wèn)題,HTTP 協(xié)議設(shè)計(jì)了一個(gè)特殊的請(qǐng)求頭:Cookie 。服務(wù)端可以通過(guò)響應(yīng)頭(set-cookie)將少量數(shù)據(jù)響應(yīng)給客戶端,瀏覽器會(huì)遵循協(xié)議將數(shù)據(jù)保存,并在下次請(qǐng)求同一個(gè)服務(wù)的時(shí)候帶上(瀏覽器也會(huì)遵循協(xié)議,只在訪問(wèn)符合 Cookie 指定規(guī)則的網(wǎng)站時(shí)帶上對(duì)應(yīng)的 Cookie 來(lái)保證安全性)。
通過(guò) ctx.cookies,我們可以在 Controller 中便捷、安全的設(shè)置和讀取 Cookie。
class CookieController extends Controller { async add() { const ctx = this.ctx; let count = ctx.cookies.get('count'); count = count ? Number(count) : 0; ctx.cookies.set('count', ++count); ctx.body = count; } async remove() { const ctx = this.ctx; const count = ctx.cookies.set('count', null); ctx.status = 204; } }
Cookie 雖然在 HTTP 中只是一個(gè)頭,但是通過(guò) foo=bar;foo1=bar1; 的格式可以設(shè)置多個(gè)鍵值對(duì)。
Cookie 在 Web 應(yīng)用中經(jīng)常承擔(dān)了傳遞客戶端身份信息的作用,因此有許多安全相關(guān)的配置,不可忽視,Cookie 文檔中詳細(xì)介紹了 Cookie 的用法和安全相關(guān)的配置項(xiàng),可以深入閱讀了解。
配置對(duì)于 Cookie 來(lái)說(shuō),主要有下面幾個(gè)屬性可以在 config.default.js 中進(jìn)行配置:
module.exports = { cookies: { // httpOnly: true | false, // sameSite: 'none|lax|strict', }, };
舉例: 配置應(yīng)用級(jí)別的 Cookie SameSite 屬性等于 Lax。
module.exports = { cookies: { sameSite: 'lax', }, };
Session通過(guò) Cookie,我們可以給每一個(gè)用戶設(shè)置一個(gè) Session,用來(lái)存儲(chǔ)用戶身份相關(guān)的信息,這份信息會(huì)加密后存儲(chǔ)在 Cookie 中,實(shí)現(xiàn)跨請(qǐng)求的用戶身份保持。
框架內(nèi)置了 Session 插件,給我們提供了 ctx.session 來(lái)訪問(wèn)或者修改當(dāng)前用戶 Session 。
class PostController extends Controller { async fetchPosts() { const ctx = this.ctx; // 獲取 Session 上的內(nèi)容 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 的使用方法非常直觀,直接讀取它或者修改它就可以了,如果要?jiǎng)h除它,直接將它賦值為 null:
class SessionController extends Controller { async deleteSession() { this.ctx.session = null; } };
和 Cookie 一樣,Session 也有許多安全等選項(xiàng)和功能,在使用之前也最好閱讀 Session 文檔深入了解。
配置對(duì)于 Session 來(lái)說(shuō),主要有下面幾個(gè)屬性可以在 config.default.js 中進(jìn)行配置:
module.exports = { key: 'EGG_SESS', // 承載 Session 的 Cookie 鍵值對(duì)名字 maxAge: 86400000, // Session 的最大有效時(shí)間 };
參數(shù)校驗(yàn)在獲取到用戶請(qǐng)求的參數(shù)后,不可避免的要對(duì)參數(shù)進(jìn)行一些校驗(yàn)。
借助 Validate 插件提供便捷的參數(shù)校驗(yàn)機(jī)制,幫助我們完成各種復(fù)雜的參數(shù)校驗(yàn)。
// config/plugin.js exports.validate = { enable: true, package: 'egg-validate', };
通過(guò) ctx.validate(rule, [body]) 直接對(duì)參數(shù)進(jìn)行校驗(yàn):
class PostController extends Controller { async create() { // 校驗(yàn)參數(shù) // 如果不傳第二個(gè)參數(shù)會(huì)自動(dòng)校驗(yàn) `ctx.request.body` this.ctx.validate({ title: { type: 'string' }, content: { type: 'string' }, }); } }
當(dāng)校驗(yàn)異常時(shí),會(huì)直接拋出一個(gè)異常,異常的狀態(tài)碼為 422,errors 字段包含了詳細(xì)的驗(yàn)證不通過(guò)信息。如果想要自己處理檢查的異常,可以通過(guò) try catch 來(lái)自行捕獲。
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 }; return; } } };
校驗(yàn)規(guī)則參數(shù)校驗(yàn)通過(guò) Parameter 完成,支持的校驗(yàn)規(guī)則可以在該模塊的文檔中查閱到。
自定義校驗(yàn)規(guī)則除了上一節(jié)介紹的內(nèi)置檢驗(yàn)類(lèi)型外,有時(shí)候我們希望自定義一些校驗(yàn)規(guī)則,讓開(kāi)發(fā)時(shí)更便捷,此時(shí)可以通過(guò) app.validator.addRule(type, check) 的方式新增自定義規(guī)則。
// app.js app.validator.addRule('json', (rule, value) => { try { JSON.parse(value); } catch (err) { return 'must be json string'; } });
添加完自定義規(guī)則之后,就可以在 Controller 中直接使用這條規(guī)則來(lái)進(jìn)行參數(shù)校驗(yàn)了
class PostController extends Controller { async handler() { const ctx = this.ctx; // query.test 字段必須是 json 字符串 const rule = { test: 'json' }; ctx.validate(rule, ctx.query); } };
調(diào)用 Service我們并不想在 Controller 中實(shí)現(xiàn)太多業(yè)務(wù)邏輯,所以提供了一個(gè) Service 層進(jìn)行業(yè)務(wù)邏輯的封裝,這不僅能提高代碼的復(fù)用性,同時(shí)可以讓我們的業(yè)務(wù)邏輯更好測(cè)試。
在 Controller 中可以調(diào)用任何一個(gè) Service 上的任何方法,同時(shí) Service 是懶加載的,只有當(dāng)訪問(wèn)到它的時(shí)候框架才會(huì)去實(shí)例化它。
class PostController extends Controller { async create() { const ctx = this.ctx; const author = ctx.session.userId; const req = Object.assign(ctx.request.body, { author }); // 調(diào)用 service 進(jìn)行業(yè)務(wù)處理 const res = await ctx.service.post.create(req); ctx.body = { id: res.id }; ctx.status = 201; } }
Service 的具體寫(xiě)法,請(qǐng)查看 Service 章節(jié)。
發(fā)送 HTTP 響應(yīng)當(dāng)業(yè)務(wù)邏輯完成之后,Controller 的最后一個(gè)職責(zé)就是將業(yè)務(wù)邏輯的處理結(jié)果通過(guò) HTTP 響應(yīng)發(fā)送給用戶。
設(shè)置 statusHTTP 設(shè)計(jì)了非常多的狀態(tài)碼 ,每一個(gè)狀態(tài)碼都代表了一個(gè)特定的含義,通過(guò)設(shè)置正確的狀態(tài)碼,可以讓響應(yīng)更符合語(yǔ)義。
框架提供了一個(gè)便捷的 Setter 來(lái)進(jìn)行狀態(tài)碼的設(shè)置
class PostController extends Controller { async create() { // 設(shè)置狀態(tài)碼為 201 this.ctx.status = 201; } };
具體什么場(chǎng)景設(shè)置什么樣的狀態(tài)碼,可以參考 List of HTTP status codes 中各個(gè)狀態(tài)碼的含義。
設(shè)置 body絕大多數(shù)的數(shù)據(jù)都是通過(guò) body 發(fā)送給請(qǐng)求方的,和請(qǐng)求中的 body 一樣,在響應(yīng)中發(fā)送的 body,也需要有配套的 Content-Type 告知客戶端如何對(duì)數(shù)據(jù)進(jìn)行解析。
作為一個(gè) RESTful 的 API 接口 controller,我們通常會(huì)返回 Content-Type 為 application/json 格式的 body,內(nèi)容是一個(gè) JSON 字符串。 作為一個(gè) html 頁(yè)面的 controller,我們通常會(huì)返回 Content-Type 為 text/html 格式的 body,內(nèi)容是 html 代碼段。 注意:ctx.body 是 ctx.response.body 的簡(jiǎn)寫(xiě),不要和 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>'; } }
由于 Node.js 的流式特性,我們還有很多場(chǎng)景需要通過(guò) Stream 返回響應(yīng),例如返回一個(gè)大文件,代理服務(wù)器直接返回上游的內(nèi)容,框架也支持直接將 body 設(shè)置成一個(gè) Stream,并會(huì)同時(shí)處理好這個(gè) Stream 上的錯(cuò)誤事件。
class ProxyController extends Controller { async proxy() { const ctx = this.ctx; const result = await ctx.curl(url, { streaming: true, }); ctx.set(result.header); // result.res 是一個(gè) stream ctx.body = result.res; } };
渲染模板通常來(lái)說(shuō),我們不會(huì)手寫(xiě) HTML 頁(yè)面,而是會(huì)通過(guò)模板引擎進(jìn)行生成。 框架自身沒(méi)有集成任何一個(gè)模板引擎,但是約定了 View 插件的規(guī)范 ,通過(guò)接入的模板引擎,可以直接使用 ctx.render(template) 來(lái)渲染模板生成 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有時(shí)我們需要給非本域的頁(yè)面提供接口服務(wù),又由于一些歷史原因無(wú)法通過(guò) CORS 實(shí)現(xiàn),可以通過(guò) JSONP 來(lái)進(jìn)行響應(yīng)。
由于 JSONP 如果使用不當(dāng)會(huì)導(dǎo)致非常多的安全問(wèn)題,所以框架中提供了便捷的響應(yīng) JSONP 格式數(shù)據(jù)的方法,封裝了 JSONP XSS 相關(guān)的安全防范 ,并支持進(jìn)行 CSRF 校驗(yàn)和 referrer 校驗(yàn)。
通過(guò) app.jsonp() 提供的中間件來(lái)讓一個(gè) controller 支持響應(yīng) JSONP 格式的數(shù)據(jù)。在路由中,我們給需要支持 jsonp 的路由加上這個(gè)中間件: // 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 中,只需要正常編寫(xiě)即可: // app/controller/posts.js class PostController extends Controller { async show() { this.ctx.body = { name: 'egg', category: 'framework', language: 'Node.js', }; } }
用戶請(qǐng)求對(duì)應(yīng)的 URL 訪問(wèn)到這個(gè) controller 的時(shí)候,如果 query 中有 _callback=fn 參數(shù),將會(huì)返回 JSONP 格式的數(shù)據(jù),否則返回 JSON 格式的數(shù)據(jù)。
JSONP 配置框架默認(rèn)通過(guò) query 中的 _callback 參數(shù)作為識(shí)別是否返回 JSONP 格式數(shù)據(jù)的依據(jù),并且 _callback 中設(shè)置的方法名長(zhǎng)度最多只允許 50 個(gè)字符。應(yīng)用可以在 config/config.default.js 全局覆蓋默認(rèn)的配置:
// config/config.default.js exports.jsonp = { callback: 'callback', // 識(shí)別 query 中的 `callback` 參數(shù) limit: 100, // 函數(shù)名最長(zhǎng)為 100 個(gè)字符 };
通過(guò)上面的方式配置之后,如果用戶請(qǐng)求 /api/posts/1?callback=fn,響應(yīng)為 JSONP 格式,如果用戶請(qǐng)求 /api/posts/1,響應(yīng)格式為 JSON。
我們同樣可以在 app.jsonp() 創(chuàng)建中間件時(shí)覆蓋默認(rèn)的配置,以達(dá)到不同路由使用不同配置的目的:
// 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); };
跨站防御配置默認(rèn)配置下,響應(yīng) JSONP 時(shí)不會(huì)進(jìn)行任何跨站攻擊的防范,在某些情況下,這是很危險(xiǎn)的。我們初略將 JSONP 接口分為三種類(lèi)型:
查詢非敏感數(shù)據(jù),例如獲取一個(gè)論壇的公開(kāi)文章列表。 查詢敏感數(shù)據(jù),例如獲取一個(gè)用戶的交易記錄。 提交數(shù)據(jù)并修改數(shù)據(jù)庫(kù),例如給某一個(gè)用戶創(chuàng)建一筆訂單。 如果我們的 JSONP 接口提供下面兩類(lèi)服務(wù),在不做任何跨站防御的情況下,可能泄露用戶敏感數(shù)據(jù)甚至導(dǎo)致用戶被釣魚(yú)。因此框架給 JSONP 默認(rèn)提供了 CSRF 校驗(yàn)支持和 referrer 校驗(yàn)支持。
CSRF在 JSONP 配置中,我們只需要打開(kāi) csrf: true,即可對(duì) JSONP 接口開(kāi)啟 CSRF 校驗(yàn)。
// config/config.default.js module.exports = { jsonp: { csrf: true, }, };
注意,CSRF 校驗(yàn)依賴于 security 插件提供的基于 Cookie 的 CSRF 校驗(yàn)。
在開(kāi)啟 CSRF 校驗(yàn)時(shí),客戶端在發(fā)起 JSONP 請(qǐng)求時(shí),也要帶上 CSRF token,如果發(fā)起 JSONP 的請(qǐng)求方所在的頁(yè)面和我們的服務(wù)在同一個(gè)主域名之下的話,可以讀取到 Cookie 中的 CSRF token(在 CSRF token 缺失時(shí)也可以自行設(shè)置 CSRF token 到 Cookie 中),并在請(qǐng)求時(shí)帶上該 token。
referrer 校驗(yàn)如果在同一個(gè)主域之下,可以通過(guò)開(kāi)啟 CSRF 的方式來(lái)校驗(yàn) JSONP 請(qǐng)求的來(lái)源,而如果想對(duì)其他域名的網(wǎng)頁(yè)提供 JSONP 服務(wù),我們可以通過(guò)配置 referrer 白名單的方式來(lái)限制 JSONP 的請(qǐng)求方在可控范圍之內(nèi)。
//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 可以配置為正則表達(dá)式、字符串或者數(shù)組:
正則表達(dá)式:此時(shí)只有請(qǐng)求的 Referrer 匹配該正則時(shí)才允許訪問(wèn) JSONP 接口。在設(shè)置正則表達(dá)式的時(shí)候,注意開(kāi)頭的 ^ 以及結(jié)尾的 \/,保證匹配到完整的域名。 exports.jsonp = { whiteList: /^https?:\/\/test.com\//, }; // matches referrer: // https://test.com/hello // http://test.com/
字符串:設(shè)置字符串形式的白名單時(shí)分為兩種,當(dāng)字符串以 . 開(kāi)頭,例如 .test.com 時(shí),代表 referrer 白名單為 test.com 的所有子域名,包括 test.com 自身。當(dāng)字符串不以 . 開(kāi)頭,例如 sub.test.com,代表 referrer 白名單為 sub.test.com 這一個(gè)域名。(同時(shí)支持 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/
數(shù)組:當(dāng)設(shè)置的白名單為數(shù)組時(shí),代表只要滿足數(shù)組中任意一個(gè)元素的條件即可通過(guò) referrer 校驗(yàn)。 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/
當(dāng) CSRF 和 referrer 校驗(yàn)同時(shí)開(kāi)啟時(shí),請(qǐng)求發(fā)起方只需要滿足任意一個(gè)條件即可通過(guò) JSONP 的安全校驗(yàn)。
設(shè)置 Header我們通過(guò)狀態(tài)碼標(biāo)識(shí)請(qǐng)求成功與否、狀態(tài)如何,在 body 中設(shè)置響應(yīng)的內(nèi)容。而通過(guò)響應(yīng)的 Header,還可以設(shè)置一些擴(kuò)展信息。
通過(guò) ctx.set(key, value) 方法可以設(shè)置一個(gè)響應(yīng)頭,ctx.set(headers) 設(shè)置多個(gè) 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; // 設(shè)置一個(gè)響應(yīng)頭 ctx.set('show-response-time', used.toString()); } };
重定向框架通過(guò) security 插件覆蓋了 koa 原生的 ctx.redirect 實(shí)現(xiàn),以提供更加安全的重定向。
ctx.redirect(url) 如果不在配置的白名單域名內(nèi),則禁止跳轉(zhuǎn)。 ctx.unsafeRedirect(url) 不判斷域名,直接跳轉(zhuǎn),一般不建議使用,明確了解可能帶來(lái)的風(fēng)險(xiǎn)后使用。 用戶如果使用ctx.redirect方法,需要在應(yīng)用的配置文件中做如下配置:
// config/config.default.js exports.security = { domainWhiteList:['.domain.com'], // 安全白名單,以 . 開(kāi)頭 };
若用戶沒(méi)有配置 domainWhiteList 或者 domainWhiteList數(shù)組內(nèi)為空,則默認(rèn)會(huì)對(duì)所有跳轉(zhuǎn)請(qǐng)求放行,即等同于ctx.unsafeRedirect(url)
更多建議: