控制器Controller
所有的 Controller 文件都必須放在 app/controller 目錄下,
可以支持多級目錄,訪問的時候可以通過目錄名級聯訪問
Controller定義
// app/controller/post.js
const Controller = require('egg').Controller;
class PostController extends Controller {
async create() {
const { ctx, service } = this;
const createRule = {
title: { type: 'string' },
content: { type: 'string' },
};
// 校驗參數
ctx.validate(createRule);
// 組裝參數
const author = ctx.session.userId;
const req = Object.assign(ctx.request.body, { author });
// 調用 Service 進行業務處理
const res = await service.post.create(req);
// 設置響應內容和響應狀態碼
ctx.body = { id: res.id };
ctx.status = 201;
}
}
module.exports = PostController;
我們通過上面的代碼定義了一個 PostController 的類,類里面的每一個方法都可以作為一個 Controller 在 Router 中引用到,
我們可以從 app.controller 根據文件名和方法名定位到它。
// app/router.js
module.exports = app => {
const { router, controller } = app;
router.post('createPost', '/api/posts', controller.post.create);
// Controller 支持多級目錄,例如如果我們將上面的 Controller 代碼放到 app/controller/sub/post.js 中
router.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 基礎
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. 返回響應的內容
獲取HTTP請求參數
框架通過在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的簡寫
獲取上傳文件
請求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, // 加密傳輸,不能看到明文
// maxAge: 1000 * 60 * 60 * 24, // cookie保存一天的時間
},
};
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 是安全的。
參數校驗
在獲取到用戶請求的參數后,不可避免的要對參數進行一些校驗。
借助 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;
}
}
};
調用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;
}
}
發送HTTP響應
當業務邏輯完成之后,Controller 的最后一個職責就是將業務邏輯的處理結果通過 HTTP 響應發送給用戶。
1. 設置status
HTTP 設計了非常多的狀態碼,每一個狀態碼都代表了一個特定的含義,通過設置正確的狀態碼,可以讓響應更符合語義。
class PostController extends Controller {
async create() {
// 設置狀態碼為 201
this.ctx.status = 201;
}
};
2. 設置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 的安全校驗
重定向
框架通過 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)