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