国产gaysexchina男同gay,japanrcep老熟妇乱子伦视频,吃奶呻吟打开双腿做受动态图,成人色网站,国产av一区二区三区最新精品

Egg 控制器(Controller)

2020-02-06 14:10 更新

什么是 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é)果后封裝并返回:

  1. 獲取用戶通過(guò) HTTP 傳遞過(guò)來(lái)的請(qǐng)求參數(shù)。
  2. 校驗(yàn)、組裝參數(shù)。
  3. 調(diào)用 Service 進(jìn)行業(yè)務(wù)處理,必要時(shí)處理轉(zhuǎn)換 Service 的返回結(jié)果,讓它適應(yīng)用戶的需求。
  4. 通過(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方式:

  • File 模式:

如果你完全不知道 Nodejs 中的 Stream 用法,那么 File 模式非常合適你:

1)在 config 文件中啟用 file 模式:

// config/config.default.js
exports.multipart = {
mode: 'file',
};

2)上傳 / 接收文件:

  1. 上傳 / 接收單個(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,
};
}
};
  1. 上傳 / 接收多個(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);
}
}
};
  • Stream 模式:

如果你對(duì)于 Node 中的 Stream 模式非常熟悉,那么你可以選擇此模式。在 Controller 中,我們可以通過(guò) ctx.getFileStream() 接口能獲取到上傳的文件流。

  1. 上傳 / 接受單個(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。
  1. 上傳 / 接受多個(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è)白名單

  • 新增支持的文件擴(kuò)展名
module.exports = {
multipart: {
fileExtensions: [ '.apk' ] // 增加對(duì) apk 擴(kuò)展名的文件支持
},
};
  • 覆蓋整個(gè)白名單
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í)再?lài)L試獲取 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ù)組。

Cookie

HTTP 請(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è)置 status

HTTP 設(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)型:

  1. 查詢非敏感數(shù)據(jù),例如獲取一個(gè)論壇的公開(kāi)文章列表。
  2. 查詢敏感數(shù)據(jù),例如獲取一個(gè)用戶的交易記錄。
  3. 提交數(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)依賴(lài)于 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)


以上內(nèi)容是否對(duì)您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號(hào)
微信公眾號(hào)

編程獅公眾號(hào)