對上篇文章回顧下,上篇講到了javascript
經過 ctx.cookies
,咱們能夠在 controller 中便捷、安全的設置和讀取 Cookie。html
class HomeController 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;
ctx.cookies.set('count', null);
ctx.status = 204;
}
}
複製代碼
設置 Cookie 實際上是經過在 HTTP 響應中設置 set-cookie 頭完成的,每個 set-cookie 都會讓瀏覽器在 Cookie 中存一個鍵值對。在設置 Cookie 值的同時,協議還支持許多參數來配置這個 Cookie 的傳輸、存儲和權限。前端
{Number} maxAge
: 設置這個鍵值對在瀏覽器的最長保存時間。是一個從服務器當前時刻開始的毫秒數。{Date} expires
: 設置這個鍵值對的失效時間,若是設置了 maxAge,expires 將會被覆蓋。若是 maxAge 和 expires 都沒設置,Cookie 將會在瀏覽器的會話失效(通常是關閉瀏覽器時)的時候失效。{String} path
: 設置鍵值對生效的 URL 路徑,默認設置在根路徑上(/),也就是當前域名下的全部 URL 均可以訪問這個 Cookie。{String} domain
: 設置鍵值對生效的域名,默認沒有配置,能夠配置成只在指定域名才能訪問。{Boolean} httpOnly
: 設置鍵值對是否能夠被 js 訪問,默認爲 true,不容許被 js 訪問。{Boolean} secure
: 設置鍵值對只在 HTTPS 鏈接上傳輸,框架會幫咱們判斷當前是否在 HTTPS 鏈接上自動設置 secure 的值。除了這些屬性以外,框架另外擴展了 3 個參數的支持:java
{Boolean} overwrite
:設置 key 相同的鍵值對如何處理,若是設置爲 true
,則後設置的值會覆蓋前面設置的,不然將會發送兩個 set-cookie 響應頭。{Boolean} signed
:設置是否對 Cookie 進行簽名,若是設置爲 true,則設置鍵值對的時候會同時對這個鍵值對的值進行簽名,後面取的時候作校驗,能夠防止前端對這個值進行篡改。默認爲 true。{Boolean} encrypt
:設置是否對 Cookie 進行加密,若是設置爲 true,則在發送 Cookie 前會對這個鍵值對的值進行加密,客戶端沒法讀取到 Cookie 的明文值。默認爲 false。Cookie 是加簽不加密的,瀏覽器能夠看到明文,js 不能訪問,不能被客戶端(手工)篡改。mysql
若是想要 Cookie 在瀏覽器端能夠被 js 訪問並修改:linux
ctx.cookies.set(key, value, {
httpOnly: false,
signed: false,
});
複製代碼
上面在設置 Cookie 的時候,咱們能夠設置 options.signed 和 options.encrypt 來對 Cookie 進行簽名或加密,所以對應的在獲取 Cookie 的時候也要傳相匹配的選項。git
因爲咱們在 Cookie 中須要用到加解密和驗籤,因此須要配置一個祕鑰供加密使用。在 config/config.default.js
中github
module.exports = {
keys: 'key1,key2',
};
複製代碼
keys 配置成一個字符串,能夠按照逗號分隔配置多個 key。Cookie 在使用這個配置進行加解密時:redis
若是咱們想要更新 Cookie 的祕鑰,可是又不但願以前設置到用戶瀏覽器上的 Cookie 失效,能夠將新的祕鑰配置到 keys 最前面,等過一段時間以後再刪去不須要的祕鑰便可。sql
Cookie 在 Web 應用中常常承擔標識請求方身份的功能,因此 Web 應用在 Cookie 的基礎上封裝了 Session 的概念,專門用作用戶身份識別。
框架內置了 Session 插件,給咱們提供了 ctx.session
來訪問或者修改當前用戶 Session 。
class HomeController 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) : 1;
ctx.body = {
success: true,
posts,
};
}
}
複製代碼
Session 的使用方法很是直觀,直接讀取它或者修改它就能夠了,若是要刪除它,直接將它賦值爲 null:
ctx.session = null;
複製代碼
須要 特別注意 的是:設置 session 屬性時須要避免如下幾種狀況(會形成字段丟失,詳見 koa-session 源碼)
_
開頭isNew
// ❌ 錯誤的用法
ctx.session._visited = 1; // --> 該字段會在下一次請求時丟失
ctx.session.isNew = 'HeHe'; // --> 爲內部關鍵字, 不該該去更改
// ✔️ 正確的用法
ctx.session.visited = 1; // --> 此處沒有問題
複製代碼
Session 的實現是基於 Cookie 的,默認配置下,用戶 Session 的內容加密後直接存儲在 Cookie 中的一個字段中,用戶每次請求咱們網站的時候都會帶上這個 Cookie,咱們在服務端解密後使用。Session 的默認配置以下:
exports.session = {
key: 'EGG_SESS',
maxAge: 24 * 3600 * 1000, // 1 天
httpOnly: true,
encrypt: true,
};
複製代碼
能夠看到這些參數除了 key
都是 Cookie 的參數,key
表明了存儲 Session 的 Cookie 鍵值對的 key 是什麼。在默認的配置下,存放 Session 的 Cookie 將會加密存儲、不可被前端 js 訪問,這樣能夠保證用戶的 Session 是安全的。
Session 默認存放在 Cookie 中,可是若是咱們的 Session 對象過於龐大,就會帶來一些額外的問題:
咱們只須要設置 app.sessionStore
便可將 Session 存儲到指定的存儲中。
// app.js
module.exports = app => {
app.sessionStore = {
// support promise / async
async get (key) {
// return value;
},
async set (key, value, maxAge) {
// set key to store
},
async destroy (key) {
// destroy key
},
};
};
複製代碼
sessionStore
的實現咱們也能夠封裝到插件中,例如 egg-session-redis 就提供了將 Session 存儲到 redis 中的能力,在應用層,咱們只須要引入 egg-redis 和 egg-session-redis 插件便可。
// plugin.js
exports.redis = {
enable: true,
package: 'egg-redis',
};
exports.sessionRedis = {
enable: true,
package: 'egg-session-redis',
};
複製代碼
一旦選擇了將 Session 存入到外部存儲中,就意味着系統將強依賴於這個外部存儲,當它掛了的時候,咱們就徹底沒法使用 Session 相關的功能了。所以咱們更推薦你們只將必要的信息存儲在 Session 中,保持 Session 的精簡併使用默認的 Cookie 存儲,用戶級別的緩存不要存儲在 Session 中。
雖然在 Session 的配置中有一項是 maxAge,可是它只能全局設置 Session 的有效期,咱們常常能夠在一些網站的登錄頁上看到有 記住我 的選項框,勾選以後可讓登錄用戶的 Session 有效期更長。這種針對特定用戶的 Session 有效時間設置咱們能夠經過 ctx.session.maxAge=
來實現。
const ms = require('ms');
class UserController extends Controller {
async login() {
const ctx = this.ctx;
const { username, password, rememberMe } = ctx.request.body;
const user = await ctx.loginAndGetUser(username, password);
// 設置 Session
ctx.session.user = user;
// 若是用戶勾選了 `記住我`,設置 30 天的過時時間
if (rememberMe) ctx.session.maxAge = ms('30d');
}
}
複製代碼
默認狀況下,當用戶請求沒有致使 Session 被修改時,框架都不會延長 Session 的有效期,可是在有些場景下,咱們但願用戶若是長時間都在訪問咱們的站點,則延長他們的 Session 有效期,不讓用戶退出登陸態。
// config/config.default.js
module.exports = {
session: {
renew: true,
},
};
複製代碼
onerror 插件的配置中支持 errorPageUrl 屬性,當配置了 errorPageUrl 時,一旦用戶請求線上應用的 HTML 頁面異常,就會重定向到這個地址。
在 config/config.default.js
中 先配置靜態文件地址
// config/config.default.js
module.exports = {
static: {
prefix: '/',
dir: path.join(appInfo.baseDir, 'app/public'),
},
};
複製代碼
// config/config.default.js
module.exports = {
onerror: {
// 線上頁面發生異常時,重定向到這個頁面上
errorPageUrl: '/50x.html',
},
};
複製代碼
// config/config.default.js
module.exports = {
onerror: {
all(err, ctx) {
// 在此處定義針對全部響應類型的錯誤處理方法
// 注意,定義了 config.all 以後,其餘錯誤處理方法不會再生效
ctx.body = 'error';
ctx.status = 500;
},
html(err, ctx) {
// html hander
ctx.body = '<h3>error</h3>';
ctx.status = 500;
},
json(err, ctx) {
// json hander
ctx.body = { message: 'error' };
ctx.status = 500;
},
jsonp(err, ctx) {
// 通常來講,不須要特殊針對 jsonp 進行錯誤定義,jsonp 的錯誤處理會自動調用 json 錯誤處理,幷包裝成 jsonp 的響應格式
},
},
};
複製代碼
框架並不會將服務端返回的 404 狀態當作異常來處理,可是框架提供了當響應爲 404 且沒有返回 body 時的默認響應。
{ "message": "Not Found" }
複製代碼
<h1>404 Not Found</h1>
複製代碼
框架支持經過配置,將默認的 HTML 請求的 404 響應重定向到指定的頁面。
// config/config.default.js
module.exports = {
notfound: {
pageUrl: '/404.html',
},
};
複製代碼
在一些場景下,咱們須要自定義服務器 404 時的響應,和自定義異常處理同樣,咱們也只須要加入一箇中間件便可對 404 作統一處理:
// app/middleware/notfound_handler.js
module.exports = () => {
return async function notFoundHandler(ctx, next) {
await next();
if (ctx.status === 404 && !ctx.body) {
if (ctx.acceptJSON) {
ctx.body = { error: 'Not Found' };
} else {
ctx.body = '<h1>Page Not Found</h1>';
}
}
};
};
複製代碼
在配置中引入中間件:
// config/config.default.js
module.exports = {
middleware: [ 'notfoundHandler' ],
};
複製代碼
框架提供了 egg-mysql 插件來訪問 MySQL 數據庫。這個插件既能夠訪問普通的 MySQL 數據庫,也能夠訪問基於 MySQL 協議的在線數據庫服務
npm i --save egg-mysql
複製代碼
開啓插件:
// config/plugin.js
exports.mysql = {
enable: true,
package: 'egg-mysql',
};
複製代碼
在 config/config.${env}.js
配置各個環境的數據庫鏈接信息。
若是咱們的應用只須要訪問一個 MySQL 數據庫實例,能夠以下配置:
// config/config.${env}.js
exports.mysql = {
// 單數據庫信息配置
client: {
// host
host: 'mysql.com',
// 端口號
port: '3306',
// 用戶名
user: 'test_user',
// 密碼
password: 'test_password',
// 數據庫名
database: 'test',
},
// 是否加載到 app 上,默認開啓
app: true,
// 是否加載到 agent 上,默認關閉
agent: false,
};
複製代碼
使用方式:
await app.mysql.query(sql, values); // 單實例能夠直接經過 app.mysql 訪問
複製代碼
exports.mysql = {
clients: {
// clientId, 獲取client實例,須要經過 app.mysql.get('clientId') 獲取
db1: {
// host
host: 'mysql.com',
// 端口號
port: '3306',
// 用戶名
user: 'test_user',
// 密碼
password: 'test_password',
// 數據庫名
database: 'test',
},
db2: {
// host
host: 'mysql2.com',
// 端口號
port: '3307',
// 用戶名
user: 'test_user',
// 密碼
password: 'test_password',
// 數據庫名
database: 'test',
},
// ...
},
// 全部數據庫配置的默認值
default: {
},
// 是否加載到 app 上,默認開啓
app: true,
// 是否加載到 agent 上,默認關閉
agent: false,
};
複製代碼
使用方式:
const client1 = app.mysql.get('db1');
await client1.query(sql, values);
const client2 = app.mysql.get('db2');
await client2.query(sql, values);
複製代碼
因爲對 MySQL 數據庫的訪問操做屬於 Web 層中的數據處理層,所以咱們強烈建議將這部分代碼放在 Service 層中維護。
// app/service/user.js
class UserService extends Service {
async find(uid) {
// 假如 咱們拿到用戶 id 從數據庫獲取用戶詳細信息
const user = await this.app.mysql.get('users', { id: 11 });
return { user };
}
}
// app/controller/user.js
class UserController extends Controller {
async info() {
const ctx = this.ctx;
const userId = ctx.params.id;
const user = await ctx.service.user.find(userId);
ctx.body = user;
}
}
複製代碼
// 插入
const result = await this.app.mysql.insert('posts', { title: 'Hello World' }); // 在 post 表中,插入 title 爲 Hello World 的記錄
=> INSERT INTO `posts`(`title`) VALUES('Hello World');
console.log(result);
=>
{
fieldCount: 0,
affectedRows: 1,
insertId: 3710,
serverStatus: 2,
warningCount: 2,
message: '',
protocol41: true,
changedRows: 0
}
// 判斷插入成功
const insertSuccess = result.affectedRows === 1;
複製代碼
能夠直接使用 get
方法或 select
方法獲取一條或多條記錄。select
方法支持條件查詢與結果的定製。
const post = await this.app.mysql.get('posts', { id: 12 });
=> SELECT * FROM `posts` WHERE `id` = 12 LIMIT 0, 1;
複製代碼
const results = await this.app.mysql.select('posts');
=> SELECT * FROM `posts`;
複製代碼
where
查詢條件 { status: 'draft', author: ['author1', 'author2'] }
columns
查詢的列名 ['author', 'title']
orders
排序方式 [['created_at','desc'], ['id','desc']]
limit
10
查詢條數offset
0
偏移量const results = await this.app.mysql.select('posts', { // 搜索 post 表
where: { status: 'draft', author: ['author1', 'author2'] }, // WHERE 條件
columns: ['author', 'title'], // 要查詢的表字段
orders: [['created_at','desc'], ['id','desc']], // 排序方式
limit: 10, // 返回數據量
offset: 0, // 數據偏移量
});
=> SELECT `author`, `title` FROM `posts`
WHERE `status` = 'draft' AND `author` IN('author1','author2')
ORDER BY `created_at` DESC, `id` DESC LIMIT 0, 10;
複製代碼
// 修改數據,將會根據主鍵 ID 查找,並更新
const row = {
id: 123,
name: 'fengmk2',
otherField: 'other field value', // any other fields u want to update
modifiedAt: this.app.mysql.literals.now, // `now()` on db server
};
const result = await this.app.mysql.update('posts', row); // 更新 posts 表中的記錄
=> UPDATE `posts` SET `name` = 'fengmk2', `modifiedAt` = NOW() WHERE id = 123 ;
// 判斷更新成功
const updateSuccess = result.affectedRows === 1;
// 若是主鍵是自定義的 ID 名稱,如 custom_id,則須要在 `where` 裏面配置
const row = {
name: 'fengmk2',
otherField: 'other field value', // any other fields u want to update
modifiedAt: this.app.mysql.literals.now, // `now()` on db server
};
const options = {
where: {
custom_id: 456
}
};
const result = await this.app.mysql.update('posts', row, options); // 更新 posts 表中的記錄
=> UPDATE `posts` SET `name` = 'fengmk2', `modifiedAt` = NOW() WHERE custom_id = 456 ;
// 判斷更新成功
const updateSuccess = result.affectedRows === 1;
複製代碼
const result = await this.app.mysql.delete('posts', {
author: 'fengmk2',
});
=> DELETE FROM `posts` WHERE `author` = 'fengmk2';
複製代碼
使用 query 能夠執行合法的 sql 語句。
咱們極其不建議開發者拼接 sql 語句,這樣很容易引發 sql 注入!!
若是必需要本身拼接 sql 語句,請使用 mysql.escape
方法。
const postId = 1;
const results = await this.app.mysql.query('update posts set hits = (hits + ?) where id = ?', [1, postId]);
=> update posts set hits = (hits + 1) where id = 1;
複製代碼
通常來講,事務是必須知足4個條件(ACID): Atomicity(原子性)、Consistency(一致性)、Isolation(隔離性)、Durability(可靠性)
所以,對於一個事務來說,必定伴隨着 beginTransaction、commit 或 rollback,分別表明事務的開始,成功和失敗回滾。
beginTransaction
, commit
或 rollback
都由開發者來徹底控制,能夠作到很是細粒度的控制。const conn = await app.mysql.beginTransaction(); // 初始化事務
try {
await conn.insert(table, row1); // 第一步操做
await conn.update(table, row2); // 第二步操做
await conn.commit(); // 提交事務
} catch (err) {
// error, rollback
await conn.rollback(); // 必定記得捕獲異常後回滾事務!!
throw err;
}
複製代碼
beginTransactionScope(scope, ctx)
scope
: 一個 generatorFunction,在這個函數裏面執行此次事務的全部 sql 語句。ctx
: 當前請求的上下文對象,傳入 ctx 能夠保證即使在出現事務嵌套的狀況下,一次請求中同時只有一個激活狀態的事務。const result = await app.mysql.beginTransactionScope(async conn => {
// don't commit or rollback by yourself
await conn.insert(table, row1);
await conn.update(table, row2);
return { success: true };
}, ctx); // ctx 是當前請求的上下文,若是是在 service 文件中,能夠從 `this.ctx` 獲取到
// if error throw on scope, will auto rollback
複製代碼
若是須要調用 MySQL 內置的函數(或表達式),可使用 Literal
。
NOW()
:數據庫當前系統時間,經過 app.mysql.literals.now
獲取。await this.app.mysql.insert(table, {
create_time: this.app.mysql.literals.now,
});
=> INSERT INTO `$table`(`create_time`) VALUES(NOW())
複製代碼
下例展現瞭如何調用 MySQL 內置的 CONCAT(s1, ...sn)
函數,作字符串拼接。
const Literal = this.app.mysql.literals.Literal;
const first = 'James';
const last = 'Bond';
await this.app.mysql.insert(table, {
id: 123,
fullname: new Literal(`CONCAT("${first}", "${last}"`),
});
=> INSERT INTO `$table`(`id`, `fullname`) VALUES(123, CONCAT("James", "Bond"))
複製代碼
啓動的時候
windows
set DEBUG=ali-rds* && npm run dev
複製代碼
linux、 mac
DEBUG=ali-rds* npm run dev
複製代碼
在 Node.js 社區中,sequelize 是一個普遍使用的 ORM 框架,它支持 MySQL、PostgreSQL、SQLite 和 MSSQL 等多個數據源。
安裝依賴
npm install --save egg-sequelize mysql2
複製代碼
在 config/plugin.js
中引入 egg-sequelize 插件
exports.sequelize = {
enable: true,
package: 'egg-sequelize',
};
複製代碼
在 config/config.default.js
中編寫 sequelize 配置
config.sequelize = {
dialect: 'mysql',
host: '127.0.0.1',
port: 3306,
database: 'egg-sequelize-doc-default',
};
複製代碼
咱們能夠在不一樣的環境配置中配置不一樣的數據源地址,用於區分不一樣環境使用的數據庫,例如咱們能夠新建一個 config/config.unittest.js
配置文件,寫入以下配置,將單測時鏈接的數據庫指向 egg-sequelize-doc-unittest。
exports.sequelize = {
dialect: 'mysql',
host: '127.0.0.1',
port: 3306,
database: 'egg-sequelize-doc-unittest',
};
複製代碼
在項目的演進過程當中,每個迭代都有可能對數據庫數據結構作變動,怎樣跟蹤每個迭代的數據變動,並在不一樣的環境(開發、測試、CI)和迭代切換中,快速變動數據結構呢?這時候咱們就須要 Migrations 來幫咱們管理數據結構的變動了。
sequelize 提供了 sequelize-cli 工具來實現 Migrations,咱們也能夠在 egg 項目中引入 sequelize-cli。
npm install --save-dev sequelize-cli
複製代碼
在 egg 項目中,咱們但願將全部數據庫 Migrations 相關的內容都放在 database
目錄下,因此咱們在項目根目錄下新建一個 .sequelizer
配置文件:
'use strict';
const path = require('path');
module.exports = {
config: path.join(__dirname, 'database/config.json'),
'migrations-path': path.join(__dirname, 'database/migrations'),
'seeders-path': path.join(__dirname, 'database/seeders'),
'models-path': path.join(__dirname, 'app/model'),
};
複製代碼
npx sequelize init:config
npx sequelize init:migrations
複製代碼
執行完後會生成 database/config.json
文件和 database/migrations
目錄,咱們修改一下 database/config.json
中的內容,將其改爲咱們項目中使用的數據庫配置:
{
"development": {
"username": "root",
"password": null,
"database": "egg-sequelize-doc-default",
"host": "127.0.0.1",
"dialect": "mysql"
},
"test": {
"username": "root",
"password": null,
"database": "egg-sequelize-doc-unittest",
"host": "127.0.0.1",
"dialect": "mysql"
}
}
複製代碼
時 sequelize-cli 和相關的配置也都初始化好了,咱們能夠開始編寫項目的第一個 Migration 文件來建立咱們的一個 users 表了。
npx sequelize migration:generate --name=init-users
複製代碼
執行完後會在 database/migrations
目錄下生成一個 migration 文件(${timestamp}-init-users.js
),咱們修改它來處理初始化 users 表:
'use strict';
module.exports = {
// 在執行數據庫升級時調用的函數,建立 users 表
up: async (queryInterface, Sequelize) => {
const { INTEGER, DATE, STRING } = Sequelize;
await queryInterface.createTable('users', {
id: { type: INTEGER, primaryKey: true, autoIncrement: true },
name: STRING(30),
age: INTEGER,
created_at: DATE,
updated_at: DATE,
});
},
// 在執行數據庫降級時調用的函數,刪除 users 表
down: async queryInterface => {
await queryInterface.dropTable('users');
},
};
複製代碼
# 升級數據庫
npx sequelize db:migrate
# 若是有問題須要回滾,能夠經過 `db:migrate:undo` 回退一個變動
# npx sequelize db:migrate:undo
# 能夠經過 `db:migrate:undo:all` 回退到初始狀態
# npx sequelize db:migrate:undo:all
複製代碼
首先咱們來在 app/model/
目錄下編寫 user 這個 Model:
'use strict';
module.exports = app => {
const { STRING, INTEGER, DATE } = app.Sequelize;
const User = app.model.define('user', {
id: { type: INTEGER, primaryKey: true, autoIncrement: true },
name: STRING(30),
age: INTEGER,
created_at: DATE,
updated_at: DATE,
});
return User;
};
複製代碼
這個 Model 就能夠在 Controller 和 Service 中經過 app.model.User
或者 ctx.model.User
訪問到了,例如咱們編寫 app/controller/users.js
:
// app/controller/users.js
const Controller = require('egg').Controller;
function toInt(str) {
if (typeof str === 'number') return str;
if (!str) return str;
return parseInt(str, 10) || 0;
}
class UserController extends Controller {
async index() {
const ctx = this.ctx;
const query = { limit: toInt(ctx.query.limit), offset: toInt(ctx.query.offset) };
ctx.body = await ctx.model.User.findAll(query);
}
async show() {
const ctx = this.ctx;
ctx.body = await ctx.model.User.findByPk(toInt(ctx.params.id));
}
async create() {
const ctx = this.ctx;
const { name, age } = ctx.request.body;
const user = await ctx.model.User.create({ name, age });
ctx.status = 201;
ctx.body = user;
}
async update() {
const ctx = this.ctx;
const id = toInt(ctx.params.id);
const user = await ctx.model.User.findByPk(id);
if (!user) {
ctx.status = 404;
return;
}
const { name, age } = ctx.request.body;
await user.update({ name, age });
ctx.body = user;
}
async destroy() {
const ctx = this.ctx;
const id = toInt(ctx.params.id);
const user = await ctx.model.User.findByPk(id);
if (!user) {
ctx.status = 404;
return;
}
await user.destroy();
ctx.status = 200;
}
}
module.exports = UserController;
複製代碼
工具總結