0%

这是一个比较大的话题.包含了很多部分.总体来讲就是尽量快的加载页面,使用资源又尽量少.

从输入网址到页面渲染,发生了啥

这道面试题就比较宽范的触及到性能优化的各个点.需要比较系统的去理解.

阻塞渲染

想要首屏优化,就得先把页面渲染出来,什么会阻塞渲染呢?
HTML 和 CSS 会,因为他们要合成渲染树.所以就要降低要渲染的文件大小,扁平层级,优化选择器.
还有一个问题,script会暂停 DOM 渲染.所以一般script会放在 body 后面.
不想放底部就可以使用deferasync属性.两个都是异步下载,和 dom 并行.区别就是执行了.
下载是没区别的.
defer:要等到 DOM 执行完,再按照顺序执行.
async: 下载完就执行,不按顺序,谁先完事谁先跑.

为什么操作 DOM 慢

dom 属于渲染引擎,js 属于 js 引擎,通过 js 操作 dom 就涉及到两个线程之间的通信,就会带来性能损耗.还有可能带来回流重绘的情况,导致性能问题.

插入几万个 DOM,如何实现页面不卡顿

肯定不能一次性加载,需要分批次加载.
方法一: 通过requestAnimationFrame的方式循环插入 DOM.
方法二: 虚拟滚动.只渲染可视区域的内容.当用户滚动,实时替换渲染的内容.避免空白记得加上预渲染.

HTML 优化

就是在不考虑,网络和缓存的情况下,如何加快渲染?

  • 从文件大小考虑
  • 从 script 标签使用上来考虑
  • 从 CSS、HTML 的代码书写上来考虑
  • 从需要下载的内容是否需要在首屏使用上来考虑

图片懒加载

html 实现

给图片加上loading=‘lazy’

1
<img src'./a.png' loading='lazy' />

JS 实现懒加载

原理:拿到所有图片的 DOM,遍历每个图片是否到了可视区域,到了就设置 src 属性,绑定scroll事件进行事件监听。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<!DOCTYPE html>
<html lang="en">
<body>
<img src="./img/default.png" data-src="./img/1.png" />
<img src="./img/default.png" data-src="./img/2.png" />
<img src="./img/default.png" data-src="./img/3.png" />
</body>
<script>
function lazyLoad() {
// 可视区高度
let viewHeight =
window.innerHeight ||
document.documentElement.clientHeight ||
document.body.clientHeight;
// 所有图片
let imgs = document.querySelectorAll("img[data-src]");
// 循环
imgs.forEach((item, index) => {
if (item.dataset.src === "") return;
// 获取该元素在视口位置
let rect = item.getBoundingClientRect();
// 可视区域的高度 > 元素到顶部的高度 => 元素露头了
if (rect.top < viewHeight) {
item.src = item.dataset.src;
item.removeAttribute("data-src");
}
});
}
// 这里还需要使用节流函数
window.addEventListener("scroll", lazyLoad);
</script>
</html>

intersectionObserver

Intersection Observer 即重叠观察者,从这个命名就可以看出它用于判断两个元素是否重叠,因为不用进行事件的监听,性能方面相比 getBoundingClientRect 会好很多
目标元素与视口产生交叉区域。
IntersectionObserver 是浏览器原生提供的构造函数,接受两个参数:callback 是可见性变化时的回调函数,option 是配置对象(该参数可选)。
目标元素的可见性变化时,就会调用观察器的回调函数 callback。callback 一般会触发两次。一次是目标元素刚刚进入视口(开始可见),另一次是完全离开视口(开始不可见)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const imgs = document.querySelectorAll("img[data-src]");
const config = {
rootMargin: "0px",
threshold: 0,
};

const callback = (entries, self) => {
entries.forEach((entry) => {
let img = entry.target;
let src = img.dataset.src;
if (src) {
img.src = src;
img.removeAttribute("data-src");
}
// 解除观察
self.unobserve(entry.target);
});
};
// 传入回调
let observer = new IntersectionObserver(callback, config);
// 开始观察
imgs.forEach((img) => observer.observe(img));

JS 性能优化

CSS 性能优化

问题: css 层级设置过多导致性能减弱

1
2
3
4
5
6
7
8
9
10
11
<div>
<a> <span></span> </a>
</div>
<style>
span {
color: red;
}
div > a > span {
color: red;
}
</style>

明显第一种要比第二种效率高得多.减少了因查找递归的过程带来的性能损失.
防范: 尽量避免写过于具体的 CSS 选择器,对 HTML 尽量减少无意义的标签.

回流(reflow)和重绘(repaint)

  • 回流: 回的是文档流,都要快加载好了,又倒回去了.就是回流.布局或者几何属性发生的变化.
  • 重绘: 重新绘画,就是上色.就是外观发生的变化.
  • 回流肯定重绘,重绘不一定回流.

1、添加、删除元素(回流+重绘)
2、隐藏元素,display:none(回流+重绘),visibility:hidden(只重绘,不回流)
3、移动元素,如改变 top、left 或移动元素到另外 1 个父元素中(重绘+回流)
4、改变浏览器大小(回流+重绘)
5、改变浏览器的字体大小(回流+重绘)
6、改变元素的 padding、border、margin(回流+重绘)
7、改变浏览器的字体颜色(只重绘,不回流)
8、改变元素的背景颜色(只重绘,不回流)

减少

  • CSS 选择符从右往左匹配查找,避免节点层级过多
  • 不要使用 table 布局
  • 不要把节点的属性值放在一个循环里当成循环里的变量
  • 使用 visibility 替换 display: none

网络性能优化

框架性能优化

连接 mongo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/*
* @ use 数据库连接
*/
const mongoose = require("mongoose");
const config = require("../../config/common");

const dbConfig = config[process.env.NODE_ENV || "development"];

mongoose.connect(dbConfig.mongo.url, { useNewUrlParser: true });

mongoose.set("useCreateIndex", true); //加上这个

// 连接成功
mongoose.connection.on("connected", function () {
//console.log('连接成功 ' + dbConfig.mongo.url);
});

// 连接失败
mongoose.connection.on("error", function (err) {
console.log("连接失败 " + err);
});

// 断开连接
mongoose.connection.on("disconnected", function () {
console.log("断开连接");
});

定义 Schema

#Schema.Type

  • String
  • Number
  • Date
  • Buffer
  • Boolean
  • Mixed
  • Objectid
  • Array
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
var mongoose = require('mongoose');
var Schema = mongoose.Schema;

var UserSchema = new Schema({
name : { type: String, unique: true },
posts : [{ type: Schema.Types.ObjectId, ref: 'Post' }]
});
var User = mongoose.model('User', UserSchema);

var PostSchema = new Schema({
poster : { type: Schema.Types.ObjectId, ref: 'User' },
comments : [{ type: Schema.Types.ObjectId, ref: 'Comment' }],
title : String,
content : String
});
var Post = mongoose.model('Post', PostSchema);

var CommentSchema = new Schema({
post : { type: Schema.Types.ObjectId, ref: "Post" },
commenter : { type: Schema.Types.ObjectId, ref: 'User' },
content : {
main: String,
label: String
},
points: [
point: [{type: Schema.Types.ObjectId, ref: 'Point'}]
]
});
var Comment = mongoose.model('Comment', CommentSchema);

var PointSchema = new mongoose.Schema({
name: String,
parent: {type: Schema.Types.ObjectId, ref: 'point'},
children: [{type: Schema.Types.ObjectId, ref: 'point'}]
})
var Point = mongoose.model('Point', PointSchema);

查询条件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
$or    或关系
$nor    或关系取反
$gt    大于
$gte    大于等于
$lt     小于
$lte    小于等于
$ne 不等于
$in 在多个值范围内
$nin 不在多个值范围内
$all 匹配数组中多个值
$regex  正则,用于模糊查询
$size   匹配数组大小
$maxDistance  范围查询,距离(基于LBS
$mod   取模运算
$near   邻域查询,查询附近的位置(基于LBS
$exists   字段是否存在
$elemMatch  匹配内数组内的元素
$within  范围查询(基于LBS
$box    范围查询,矩形范围(基于LBS
$center 范围醒询,圆形范围(基于LBS
$centerSphere  范围查询,球形范围(基于LBS
$slice    查询字段集合中的元素(比如从第几个之后,第N到第M个元素)

mongodb 对数组中的所有元素进行一次性修改方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
name: 4,
list: [{
id: "a",
date: 1504195200000,
other: "c"
},{
id: "b",
date: 1504195200000,
other: "c"
}]
}
// 现在要把other全部更新为"a",方法如下
db.getCollection('test')
.update({'name': 4}, {$set: {'list.$[].other': 'a'}}, {multi: true})

暂无

核心思想: 约定大于配置

基础用法

建议直接去这里: 官方快速入门

目录结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
egg-project
├── package.json
├── app.js (可选)
├── agent.js (可选)
├── app
| ├── router.js
│ ├── controller
│ | └── home.js
│ ├── service (可选)
│ | └── user.js
│ ├── middleware (可选)
│ | └── response_time.js
│ ├── schedule (可选)
│ | └── my_task.js
│ ├── public (可选)
│ | └── reset.css
│ ├── view (可选)
│ | └── home.tpl
│ └── extend (可选)
│ ├── helper.js (可选)
│ ├── request.js (可选)
│ ├── response.js (可选)
│ ├── context.js (可选)
│ ├── application.js (可选)
│ └── agent.js (可选)
├── config
| ├── plugin.js
| ├── config.default.js
│ ├── config.prod.js
| ├── config.test.js (可选)
| ├── config.local.js (可选)
| └── config.unittest.js (可选)
└── test
├── middleware
| └── response_time.test.js
└── controller
└── home.test.js

初始化脚本

1
pnpm create egg --type=simple -r=https://registry.npmmirror.com/

模块设计

model 层: 放置一些要往数据库中存储的数据类型的模型.主要是Schema的实例.
service 层: 对 model 层中对应数据库的数据的操作,增删改查之类.
controller 层: 设计的业务逻辑,通过调用 service 的一些方法实现,返回给前端.
middleware: 在多处使用的方法可以抽离出,比如auth,error等模块.
config: 配置基础参数.
extend: 扩展方法,主要是一些和业务逻辑关联性不大的方法.比如md5加密.
schedule: 定时任务.

内置基础对象

Application,Context,Request,Response,Controller,Service,Helper,Config,Logger,Subscription.
其中前 4 个是从 Koa 继承而来.其他是框架自身的.

Application

Application 是全局应用对象。在一个应用中,每个进程只会实例化一个 Application 实例。在它上面我们可以挂载一些全局的方法和对象。

Controller

框架提供了一个 Controller 基类,并推荐所有的 Controller 都继承于该基类实现。这个 Controller 基类有下列属性:

  • ctx - 当前请求的 Context 实例。
  • app - 应用的 Application 实例。
  • config - 应用的配置。
  • service - 应用所有的 service。
  • logger - 为当前 controller 封装的 logger 对象。

路由相关

1. get 传值

1
2
3
4
5
6
7
8
9
10
// router.js
router.get('/admin/:id', controller.admin.index);

// controller
async index(ctx) {
// 获取路由get传值参数(路由:id)
ctx.params;
// 获取url的问号get传值参数
ctx.query;
}

2. 4 种配置方法

1
2
3
4
router.verb('path-match', app.controller.action);
router.verb('router-name', 'path-match', app.controller.action);// 第一个参数可以给name
router.verb('path-match', middleware1, ..., middlewareN, app.controller.action);
router.verb('router-name', 'path-match', middleware1, ..., middlewareN, app.controller.action);

重定向

1. ctx

1
2
3
4
async index() {
this.ctx.status = 301; // 把重定向改为301
this.ctx.redirect('/admin/add'); // 默认临时重定向 302
}

2. 路由重定向

1
app.router.redirect("/", "/home/index", 302);

3.路由分组

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// app/router.js
module.exports = (app) => {
require("./router/news")(app);
require("./router/admin")(app);
};

// app/router/news.js
module.exports = (app) => {
app.router.get("/news/list", app.controller.news.list);
app.router.get("/news/detail", app.controller.news.detail);
};

// app/router/admin.js
module.exports = (app) => {
app.router.get("/admin/user", app.controller.admin.user);
app.router.get("/admin/log", app.controller.admin.log);
};

控制器

自定义 Controller 基类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 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,直接使用基类上的方法:

1
2
3
4
5
6
7
8
//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);
}
}

模板引擎

1. 安装和使用 ejs

(1)安装:

1
npm i egg-view-ejs --save

(2)配置:/config

config/config.default.js

1
2
3
4
5
6
7
8
9
10
11
module.exports = appInfo => {
...

config.view = {
mapping: {
'.html': 'ejs',
},
};

...
};

config/plugin.js

1
2
3
4
5
6
7
module.exports = {
// 配置ejs
ejs: {
enable: true,
package: "egg-view-ejs",
},
};

(3)使用

app/controller

1
2
3
4
5
6
7
8
9
10
11
async index() {
const { ctx } = this;
// 渲染变量
let msg = "测试内容";
let list = [1, 2, 3, 4, 5, 6];
// 渲染模板(render需要加await)
await ctx.render('index', {
msg,
list
});
}

app/view/index.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>Document</title>
</head>

<body>
<!--渲染变量-->
<%=msg%>
<ul>
<% for(var i=0; i < list.length; i++){ %>
<li><%=list[i]%></li>
<% } %>
</ul>
<!--加载 app/public 下的资源文件-->
<img src="/public/images/find.png" />
</body>
</html>

模型和数据库

配置和创建迁移文件

配置

文档地址:https://github.com/demopark/sequelize-docs-Zh-CN/blob/v5/migrations.md

  1. 安装并配置egg-sequelize插件(它会辅助我们将定义好的 Model 对象加载到 app 和 ctx 上)和mysql2模块:
1
npm install --save egg-sequelize mysql2
  1. config/plugin.js中引入 egg-sequelize 插件
1
2
3
4
exports.sequelize = {
enable: true,
package: 'egg-sequelize',
};
  1. config/config.default.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
config.sequelize = {
dialect: "mysql",
host: "127.0.0.1",
username: "root",
password: "root",
port: 3306,
database: "friends",
// 中国时区
timezone: "+08:00",
define: {
// 取消数据表名复数
freezeTableName: true,
// 自动写入时间戳 created_at updated_at
timestamps: true,
// 字段生成软删除时间戳 deleted_at
paranoid: true,
createdAt: "created_at",
updatedAt: "updated_at",
deletedAt: "deleted_at",
// 所有驼峰命名格式化
underscored: true,
},
};
  1. sequelize 提供了sequelize-cli工具来实现Migrations,我们也可以在 egg 项目中引入 sequelize-cli。
1
npm install --save-dev sequelize-cli
  1. egg 项目中,我们希望将所有数据库 Migrations 相关的内容都放在database目录下,所以我们在项目根目录下新建一个.sequelizerc配置文件:
1
2
3
4
5
6
7
8
9
10
"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"),
};
  1. 初始化 Migrations 配置文件和目录
1
2
3
npx sequelize init:config
npx sequelize init:migrations
// npx sequelize init:models
  1. 行完后会生成database/config.json文件和database/migrations目录,我们修改一下database/config.json中的内容,将其改成我们项目中使用的数据库配置:
1
2
3
4
5
6
7
8
9
10
{
"development": {
"username": "root",
"password": null,
"database": "test",
"host": "127.0.0.1",
"dialect": "mysql",
"timezone": "+08:00"
}
}
  1. 创建数据库
1
npx sequelize db:create

创建数据迁移表

1
npx sequelize migration:generate --name=init-users

1.执行完命令后,会在 database / migrations / 目录下生成数据表迁移文件,然后定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
"use strict";

module.exports = {
up: async (queryInterface, Sequelize) => {
const { INTEGER, STRING, DATE, ENUM } = Sequelize;
// 创建表
await queryInterface.createTable(
"users",
{
id: {
type: INTEGER(20).UNSIGNED,
primaryKey: true,
autoIncrement: true,
},
username: {
type: STRING(30),
allowNull: false,
defaultValue: "",
comment: "用户名称",
unique: true,
},
email: {
type: STRING(160),
allowNull: false,
defaultValue: "",
comment: "用户邮箱",
unique: true,
},
password: { type: STRING(200), allowNull: false, defaultValue: "" },
avatar_url: { type: STRING(200), allowNull: true, defaultValue: "" },
mobile: {
type: STRING(20),
allowNull: false,
defaultValue: "",
comment: "用户手机",
unique: true,
},
prifix: { type: STRING(32), allowNull: false, defaultValue: "" },
abstract: { type: STRING(255), allowNull: true, defaultValue: "" },
role_id: {
type: INTEGER,
// 定义外键(重要)
references: {
model: "users", // 对应表名称(数据表名称)
key: "id", // 对应表的主键
},
onUpdate: "restrict", // 更新时操作
onDelete: "cascade", // 删除时操作
},
gender: {
type: ENUM,
values: ["男", "女", "保密"],
allowNull: true,
defaultValue: "男",
comment: "用户性别",
},
created_at: DATE,
updated_at: DATE,
},
{ engine: "MYISAM" }
);
// 添加索引
queryInterface.addIndex("users", ["gender"]);
// 添加唯一索引
queryInterface.addIndex("users", {
name: "name", // 索引名称
unique: true, // 唯一索引
fields: ["name"], // 索引对应字段
});
},

down: async (queryInterface) => {
await queryInterface.dropTable("users");
},
};
  • 执行 migrate 进行数据库变更
1
2
3
4
5
6
# 升级数据库
npx sequelize db:migrate
# 如果有问题需要回滚,可以通过 `db:migrate:undo` 回退一个变更
# npx sequelize db:migrate:undo
# 可以通过 `db:migrate:undo:all` 回退到初始状态
# npx sequelize db:migrate:undo:all

已创建新增字段

1.创建迁移文件:

1
npx sequelize migration:generate --name=user-addcolumn

2.执行完命令后,会在 database / migrations / 目录下生成数据表迁移文件,然后定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
"use strict";

module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.sequelize.transaction((t) => {
return Promise.all([
queryInterface.addColumn(
"user",
"role_id",
{
type: Sequelize.INTEGER,
},
{ transaction: t }
),
queryInterface.addColumn(
"user",
"ceshi",
{
type: Sequelize.STRING,
},
{ transaction: t }
),
]);
});
},

down: (queryInterface, Sequelize) => {
return queryInterface.sequelize.transaction((t) => {
return Promise.all([
queryInterface.removeColumn("user", "role_id", { transaction: t }),
queryInterface.removeColumn("user", "ceshi", { transaction: t }),
]);
});
},
};

3.执行 migrate 进行数据库变更

1
npx sequelize db:migrate

创建模型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// app / model / user.js

"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,
},
{
timestamps: true, // 是否自动写入时间戳
tableName: "users", // 自定义数据表名称
}
);

return User;
};

这个 Model 就可以在 Controller 和 Service 中通过 app.model.User 或者 ctx.model.User 访问到了,例如我们编写 app/controller/users.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
// 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;

最后我们将这个 controller 挂载到路由上:

1
2
3
4
5
// app/router.js
module.exports = (app) => {
const { router, controller } = app;
router.resources("users", "/users", controller.users);
};

针对 users 表的 CURD 操作的接口就开发完了

模型其他参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 配置(重要)
const User = app.model.define(
"user",
{
id: { type: INTEGER, primaryKey: true, autoIncrement: true },
name: STRING(30),
age: INTEGER,
created_at: DATE,
updated_at: DATE,
},
{
// 自定义表名
freezeTableName: true,
tableName: "xyz_users",

// 是否需要增加createdAt、updatedAt、deletedAt字段
timestamps: true,

// 不需要createdAt字段
createdAt: false,

// 将updatedAt字段改个名
updatedAt: "utime",

// 将deletedAt字段改名
// 同时需要设置paranoid为true(此种模式下,删除数据时不会进行物理删除,而是设置deletedAt为当前时间
deletedAt: "dtime",
paranoid: true,
}
);

sequelize 命令

命令 含义
sequelize db:migrate 运行迁移文件
sequelize db:migrate:status 列出所有迁移的状态
sequelize db:migrate:undo 隔离数据库:迁移:撤消
sequelize db:migrate:undo:all 还原所有运行的迁移
sequelize db:create 创建由配置指定的数据库
sequelize db:drop 删除由配置指定的数据库

外键约束(重要)

1
2
3
4
5
6
7
8
9
10
11
12
// 迁移文件
queryInterface.addConstraint("tableName", ["user_id"], {
type: "foreign key",
name: "user_id",
references: {
//Required field
table: "users",
field: "id",
},
onDelete: "cascade",
onUpdate: "cascade",
});

创建第一个种子

假设我们希望在默认情况下将一些数据插入到几个表中. 如果我们跟进前面的例子,我们可以考虑为 User 表创建演示用户.

要管理所有数据迁移,你可以使用 seeders. 种子文件是数据的一些变化,可用于使用样本数据或测试数据填充数据库表.

让我们创建一个种子文件,它会将一个演示用户添加到我们的 User 表中.

1
npx sequelize seed:generate --name demo-user

这个命令将会在 seeders 文件夹中创建一个种子文件.文件名看起来像是 XXXXXXXXXXXXXX-demo-user.js,它遵循相同的 up/down 语义,如迁移文件.

现在我们应该编辑这个文件,将演示用户插入User表.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
"use strict";

module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.bulkInsert(
"Users",
[
{
firstName: "John",
lastName: "Doe",
email: "demo@demo.com",
createdAt: new Date(),
updatedAt: new Date(),
},
],
{}
);
},

down: (queryInterface, Sequelize) => {
return queryInterface.bulkDelete("Users", null, {});
},
};

运行种子

在上一步中,你创建了一个种子文件. 但它还没有保存到数据库. 为此,我们需要运行一个简单的命令.

1
npx sequelize db:seed:all

这将执行该种子文件,你将有一个演示用户插入 User 表.

注意: _ \_seeders_ 执行不会存储在任何使用 _SequelizeMeta_ 表的迁移的地方. 如果你想覆盖这个,请阅读 _存储_ 部分_

撤销种子

Seeders 如果使用了任何存储那么就可以被撤消. 有两个可用的命令

如果你想撤消最近的种子

1
npx sequelize db:seed:undo

如果你想撤消特定的种子

1
npx sequelize db:seed:undo --seed name-of-seed-as-in-data

如果你想撤消所有的种子

1
npx sequelize db:seed:undo:all

关联操作

一对一

模型层:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// 一个用户对应一个用户资料

// app/model/user.js
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,
});
// 关联关系
User.associate = function (models) {
// 关联用户资料 一对一
User.hasOne(app.model.Userinfo);
};
return User;
};

// app/model/userinfo.js
module.exports = (app) => {
const { STRING, INTEGER, DATE } = app.Sequelize;
const userinfo = app.model.define(
"userinfo",
{
nickname: STRING,
user_id: INTEGER,
},
{}
);
// 关联用户表
userinfo.associate = function (models) {
app.model.Userinfo.belongsTo(app.model.User);
};
return userinfo;
};

控制器调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// app/controller/users.js
// 显示单条
async show() {
// 根据主键查询 查询一条用findOne
this.ctx.body = await this.ctx.model.User.findOne({
// 主表查询字段限制
attributes:['name'],
// 关联查询
include: [{
// 需要查询的模型
model: this.app.model.Userinfo,
// 副表查询的字段
attributes: ['nickname']
}],
// 主表条件
where: {
id: 3
}
});
}

一对多

1
2
3
4
5
6
7
8
9
10
11
class City extends Model {}
City.init({ countryCode: Sequelize.STRING }, { sequelize, modelName: "city" });
class Country extends Model {}
Country.init(
{ isoCode: Sequelize.STRING },
{ sequelize, modelName: "country" }
);

// 在这里,我们可以根据国家代码连接国家和城市
Country.hasMany(City, { foreignKey: "countryCode", sourceKey: "isoCode" });
City.belongsTo(Country, { foreignKey: "countryCode", targetKey: "isoCode" });

多对多

1
2
3
4
5
6
7
8
9
10
User.belongsToMany(Project, {
as: "Tasks",
through: "worker_tasks",
foreignKey: "userId",
});
Project.belongsToMany(User, {
as: "Workers",
through: "worker_tasks",
foreignKey: "projectId",
});

关联常用操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
// 获取关联模型对象,n对一不需要加s
let userinfo = await user.getUserinfo();
// n对多需要加s
await user.getPosts({
attributes: ["title"],
where: {
id: 3,
},
});
// 关联操作
// 1:用户创建文章(一对多)
await this.ctx.model.Post.create({
title: "第一篇文章",
user_id: user.id,
});

// 2.获取当前用户所有文章
await user.getPosts();
await user.getPosts({
attributes: ["id"],
where: {
title: "测试",
},
});

// 3:用户删除文章(一对多)
// (1) 获取当前用户的所有文章
let posts = await user.getPosts({
attributes: ["id"],
});
posts = posts.map((v) => v.id);
await this.ctx.model.Post.destroy({
where: {
id: posts,
},
});

// 场景三:用户关注话题(多对多)
await this.ctx.model.TopicUser.bulkCreate([
{
user_id: user.id,
topic_id: 1,
},
{
user_id: user.id,
topic_id: 2,
},
]);

// 用户关注话题(多对多)
await this.ctx.model.TopicUser.destroy({
where: {
user_id: user.id,
topic_id: [1, 2],
},
});

获取器和修改器

模型层

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// app/model/user.js

module.exports = (app) => {
const { STRING, INTEGER, DATE } = app.Sequelize;

const User = app.model.define("user", {
id: { type: INTEGER, primaryKey: true, autoIncrement: true },
name: {
type: STRING(30),
// 单独字段的getter,查询时都会调用
// this.getDataValue('name') 获取原始值
get() {
const age = this.getDataValue("age");
return this.getDataValue("name") + "年龄:" + age;
},
},
age: {
type: INTEGER,
// 单独字段的setter,新增和更新时调用
// this.setDataValue('name') 设置原始值
set(val) {
this.setDataValue("age", val * 10);
},
},
created_at: DATE,
updated_at: DATE,
});

// 关联用户资料
User.associate = function (models) {
app.model.User.hasOne(app.model.Userinfo);
};
return User;
};

控制器层

1
2
3
4
5
6
7
8
9
10
async show() {
// 根据主键查询
let user = await this.ctx.model.User.findOne({
where: {
id: 3
}
});
// 获取原始值 user.getDataValue('name')
this.ctx.body = user.getDataValue('name')
}

模型钩子

模型层

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
module.exports = app => {

...

// 钩子
// 查询前
User.beforeFind((user, option) => {
console.log('查询前');
});
// 查询后
User.afterFind((user, option) => {
console.log('查询后');
});
// 新增前
User.beforeCreate((user, option) => {
console.log('新增前');
});
// 新增后
User.afterCreate((user, option) => {
console.log('新增后');
});
// 修改前
User.beforeUpdate((user, option) => {
console.log('修改前');
});
// 修改后
User.afterUpdate((user, option) => {
console.log('修改后'); // 真正修改才会触发,数据相同不会触发
});
// 删除前
User.beforeDestroy((user, option) => {
console.log('删除前');
});
// 删除后
User.afterDestroy((user, option) => {
console.log('删除后');
});

// 批量删除前
User.beforeBulkDestroy((user, option) => {
console.log('批量删除前');
});
// 批量删除后
User.afterBulkDestroy((user, option) => {
console.log('批量删除后');
});

return User;
};

查询

主键查询

1
Model.findByPk(1);

查找不存在则创建

方法 findOrCreate 可用于检查数据库中是否已存在某个元素. 如果是这种情况,则该方法将生成相应的实例. 如果元素不存在,将会被创建.

如果是这种情况,则该方法将导致相应的实例. 如果元素不存在,将会被创建.

假设我们有一个空的数据库,一个 User 模型有一个 usernamejob.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
User.findOrCreate({
where: {
username: "sdepold",
},
defaults: {
job: "Technical Lead JavaScript",
},
}).then(([user, created]) => {
console.log(
user.get({
plain: true,
})
);
console.log(created);

/*
findOrCreate 返回一个包含已找到或创建的对象的数组,找到或创建的对象和一个布尔值,如果创建一个新对象将为true,否则为false,像这样:

[ {
username: 'sdepold',
job: 'Technical Lead JavaScript',
id: 1,
createdAt: Fri Mar 22 2013 21: 28: 34 GMT + 0100(CET),
updatedAt: Fri Mar 22 2013 21: 28: 34 GMT + 0100(CET)
},
true ]

在上面的例子中,第三行的数组将分成2部分,并将它们作为参数传递给回调函数,在这种情况下将它们视为 "user" 和 "created" .(所以“user”将是返回数组的索引0的对象,并且 "created" 将等于 "true".)
*/
});

代码创建了一个新的实例. 所以当我们已经有一个实例了 …

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
User.create({ username: "fnord", job: "omnomnom" })
.then(() =>
User.findOrCreate({
where: {
username: "fnord",
},
defaults: {
job: "something else",
},
})
)
.then(([user, created]) => {
console.log(
user.get({
plain: true,
})
);
console.log(created);

/*
在这个例子中,findOrCreate 返回一个如下的数组:
[ {
username: 'fnord',
job: 'omnomnom',
id: 2,
createdAt: Fri Mar 22 2013 21: 28: 34 GMT + 0100(CET),
updatedAt: Fri Mar 22 2013 21: 28: 34 GMT + 0100(CET)
},
false
]
由findOrCreate返回的数组通过第三行的数组扩展为两部分,并且这些部分将作为2个参数传递给回调函数,在这种情况下将其视为 "user" 和 "created" .(所以“user”将是返回数组的索引0的对象,并且 "created" 将等于 "false".)
*/
});

…现有条目将不会更改. 看到第二个用户的 “job”,并且实际上创建操作是假的.

查找并计数

findAndCountAll - 在数据库中搜索多个元素,返回数据和总计数

这是一个方便的方法,它结合了 findAllcount(见下文),当处理与分页相关的查询时,这是有用的,你想用 limitoffset 检索数据,但也需要知道总数与查询匹配的记录数:

处理程序成功将始终接收具有两个属性的对象:

  • count - 一个整数,总数记录匹配 where 语句和关联的其它过滤器
  • rows - 一个数组对象,记录在 limit 和 offset 范围内匹配 where 语句和关联的其它过滤器,
1
2
3
4
5
6
7
8
9
10
11
12
Project.findAndCountAll({
where: {
title: {
[Op.like]: "foo%",
},
},
offset: 10,
limit: 2,
}).then((result) => {
console.log(result.count);
console.log(result.rows);
});

它支持 include. 只有标记为 required 的 include 将被添加到计数部分:

假设你想查找附有个人资料的所有用户:

1
2
3
4
User.findAndCountAll({
include: [{ model: Profile, required: true }],
limit: 3,
});

因为 Profile 的 include 有 required 设置,这将导致内部连接,并且只有具有 profile 的用户将被计数. 如果我们从 include 中删除required,那么有和没有 profile 的用户都将被计数. 在 include 中添加一个 where 语句会自动使它成为 required:

1
2
3
4
User.findAndCountAll({
include: [{ model: Profile, where: { active: true } }],
limit: 3,
});

上面的查询只会对具有 active profile 的用户进行计数,因为在将 where 语句添加到 include 时,required 被隐式设置为 true.

传递给 findAndCountAll 的 options 对象与 findAll 相同(如下所述).

查询多个(常用)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// 找到多个条目
Project.findAll().then((projects) => {
// projects 将是所有 Project 实例的数组
});

// 搜索特定属性 - 使用哈希
Project.findAll({ where: { name: "A Project" } }).then((projects) => {
// projects将是一个具有指定 name 的 Project 实例数组
});

// 在特定范围内进行搜索
Project.findAll({ where: { id: [1, 2, 3] } }).then((projects) => {
// projects将是一系列具有 id 1,2 或 3 的项目
// 这实际上是在做一个 IN 查询
});

Project.findAll({
where: {
id: {
[Op.and]: { a: 5 }, // 且 (a = 5)
[Op.or]: [{ a: 5 }, { a: 6 }], // (a = 5 或 a = 6)
[Op.gt]: 6, // id > 6
[Op.gte]: 6, // id >= 6
[Op.lt]: 10, // id < 10
[Op.lte]: 10, // id <= 10
[Op.ne]: 20, // id != 20
[Op.between]: [6, 10], // 在 6 和 10 之间
[Op.notBetween]: [11, 15], // 不在 11 和 15 之间
[Op.in]: [1, 2], // 在 [1, 2] 之中
[Op.notIn]: [1, 2], // 不在 [1, 2] 之中
[Op.like]: "%hat", // 包含 '%hat'
[Op.notLike]: "%hat", // 不包含 '%hat'
[Op.iLike]: "%hat", // 包含 '%hat' (不区分大小写) (仅限 PG)
[Op.notILike]: "%hat", // 不包含 '%hat' (仅限 PG)
[Op.overlap]: [1, 2], // && [1, 2] (PG数组重叠运算符)
[Op.contains]: [1, 2], // @> [1, 2] (PG数组包含运算符)
[Op.contained]: [1, 2], // <@ [1, 2] (PG数组包含于运算符)
[Op.any]: [2, 3], // 任何数组[2, 3]::INTEGER (仅限 PG)
},
status: {
[Op.not]: false, // status 不为 FALSE
},
},
});

复合过滤 / OR / NOT 查询

你可以使用多层嵌套的 AND,OR 和 NOT 条件进行一个复合的 where 查询. 为了做到这一点,你可以使用 or , andnot 运算符:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Project.findOne({
where: {
name: "a project",
[Op.or]: [{ id: [1, 2, 3] }, { id: { [Op.gt]: 10 } }],
},
});

Project.findOne({
where: {
name: "a project",
id: {
[Op.or]: [[1, 2, 3], { [Op.gt]: 10 }],
},
},
});

这两段代码将生成以下内容:

1
2
3
4
5
6
7
SELECT *
FROM `Projects`
WHERE (
`Projects`.`name` = 'a project'
AND (`Projects`.`id` IN (1,2,3) OR `Projects`.`id` > 10)
)
LIMIT 1;

not 示例:

1
2
3
4
5
6
Project.findOne({
where: {
name: "a project",
[Op.not]: [{ id: [1, 2, 3] }, { array: { [Op.contains]: [3, 4, 5] } }],
},
});

将生成:

1
2
3
4
5
6
7
SELECT *
FROM `Projects`
WHERE (
`Projects`.`name` = 'a project'
AND NOT (`Projects`.`id` IN (1,2,3) OR `Projects`.`array` @> ARRAY[3,4,5]::INTEGER[])
)
LIMIT 1;

用限制,偏移,顺序和分组操作数据集

要获取更多相关数据,可以使用限制,偏移,顺序和分组:

1
2
3
4
5
6
7
8
// 限制查询的结果
Project.findAll({ limit: 10 });

// 跳过前10个元素
Project.findAll({ offset: 10 });

// 跳过前10个元素,并获取2个
Project.findAll({ offset: 10, limit: 2 });

分组和排序的语法是相同的,所以下面只用一个单独的例子来解释分组,而其余的则是排序. 你下面看到的所有内容也可以对分组进行

1
2
3
4
5
Project.findAll({ order: [["title", "DESC"]] });
// 生成 ORDER BY title DESC

Project.findAll({ group: "name" });
// 生成 GROUP BY name

请注意,在上述两个示例中,提供的字符串逐字插入到查询中,所以不会转义列名称. 当你向 order / group 提供字符串时,将始终如此. 如果要转义列名,你应该提供一个参数数组,即使你只想通过单个列进行 order / group

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
something.findOne({
order: [
// 将返回 `name`
["name"],
// 将返回 `username` DESC
["username", "DESC"],
// 将返回 max(`age`)
sequelize.fn("max", sequelize.col("age")),
// 将返回 max(`age`) DESC
[sequelize.fn("max", sequelize.col("age")), "DESC"],
// 将返回 otherfunction(`col1`, 12, 'lalala') DESC
[
sequelize.fn("otherfunction", sequelize.col("col1"), 12, "lalala"),
"DESC",
],
// 将返回 otherfunction(awesomefunction(`col`)) DESC,这个嵌套是可以无限的!
[
sequelize.fn(
"otherfunction",
sequelize.fn("awesomefunction", sequelize.col("col"))
),
"DESC",
],
],
});

回顾一下,order / group 数组的元素可以是以下内容:

  • String - 将被引用
  • Array - 第一个元素将被引用,第二个将被逐字地追加
  • Object -
    • raw 将被添加逐字引用
    • 如果未设置 raw,一切都被忽略,查询将失败
  • Sequelize.fn 和 Sequelize.col 返回函数和引用的列名

字段过滤

想要只选择某些属性,可以使用 attributes 选项. 通常是传递一个数组:

1
2
3
Model.findAll({
attributes: ["foo", "bar"],
});

SELECT foo, bar …

属性可以使用嵌套数组来重命名:

1
2
3
Model.findAll({
attributes: ["foo", ["bar", "baz"]],
});

SELECT foo, bar AS baz …

也可以使用 sequelize.fn 来进行聚合:

1
2
3
Model.findAll({
attributes: [[sequelize.fn("COUNT", sequelize.col("hats")), "no_hats"]],
});

SELECT COUNT(hats) AS no_hats …

使用聚合功能时,必须给它一个别名,以便能够从模型中访问它. 在上面的例子中,你可以使用 instance.get('no_hats') 获得帽子数量.

有时,如果你只想添加聚合,则列出模型的所有属性可能令人厌烦:

1
2
3
4
5
6
7
8
9
10
// This is a tiresome way of getting the number of hats...
Model.findAll({
attributes: ['id', 'foo', 'bar', 'baz', 'quz', [sequelize.fn('COUNT', sequelize.col('hats')), 'no_hats']]
});

// This is shorter, and less error prone because it still works if you add / remove attributes
Model.findAll({
attributes: { include: [[sequelize.fn('COUNT', sequelize.col('hats')), 'no_hats']] }
});
SELECT id, foo, bar, baz, quz, COUNT(hats) AS no_hats ...

同样,它也可以排除一些指定的表字段:

1
2
3
4
Model.findAll({
attributes: { exclude: ['baz'] }
});
SELECT id, foo, bar, quz ...

Where

无论你是通过 findAll/find 或批量 updates/destroys 进行查询,都可以传递一个 where 对象来过滤查询.

where 通常用 attribute:value 键值对获取一个对象,其中 value 可以是匹配等式的数据或其他运算符的键值对象.

也可以通过嵌套 orand 运算符 的集合来生成复杂的 AND/OR 条件.

基础

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
const Op = Sequelize.Op;

Post.findAll({
where: {
authorId: 2,
},
});
// SELECT * FROM post WHERE authorId = 2

Post.findAll({
where: {
authorId: 12,
status: "active",
},
});
// SELECT * FROM post WHERE authorId = 12 AND status = 'active';

Post.findAll({
where: {
[Op.or]: [{ authorId: 12 }, { authorId: 13 }],
},
});
// SELECT * FROM post WHERE authorId = 12 OR authorId = 13;

Post.findAll({
where: {
authorId: {
[Op.or]: [12, 13],
},
},
});
// SELECT * FROM post WHERE authorId = 12 OR authorId = 13;

Post.destroy({
where: {
status: "inactive",
},
});
// DELETE FROM post WHERE status = 'inactive';

Post.update(
{
updatedAt: null,
},
{
where: {
deletedAt: {
[Op.ne]: null,
},
},
}
);
// UPDATE post SET updatedAt = null WHERE deletedAt NOT NULL;

Post.findAll({
where: sequelize.where(
sequelize.fn("char_length", sequelize.col("status")),
6
),
});
// SELECT * FROM post WHERE char_length(status) = 6;

操作符

Sequelize 可用于创建更复杂比较的符号运算符 -

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
const Op = Sequelize.Op

[Op.and]: {a: 5} // 且 (a = 5)
[Op.or]: [{a: 5}, {a: 6}] // (a = 5 或 a = 6)
[Op.gt]: 6, // id > 6
[Op.gte]: 6, // id >= 6
[Op.lt]: 10, // id < 10
[Op.lte]: 10, // id <= 10
[Op.ne]: 20, // id != 20
[Op.eq]: 3, // = 3
[Op.not]: true, // 不是 TRUE
[Op.between]: [6, 10], // 在 6 和 10 之间
[Op.notBetween]: [11, 15], // 不在 11 和 15 之间
[Op.in]: [1, 2], // 在 [1, 2] 之中
[Op.notIn]: [1, 2], // 不在 [1, 2] 之中
[Op.like]: '%hat', // 包含 '%hat'
[Op.notLike]: '%hat' // 不包含 '%hat'
[Op.iLike]: '%hat' // 包含 '%hat' (不区分大小写) (仅限 PG)
[Op.notILike]: '%hat' // 不包含 '%hat' (仅限 PG)
[Op.startsWith]: 'hat' // 类似 'hat%'
[Op.endsWith]: 'hat' // 类似 '%hat'
[Op.substring]: 'hat' // 类似 '%hat%'
[Op.regexp]: '^[h|a|t]' // 匹配正则表达式/~ '^[h|a|t]' (仅限 MySQL/PG)
[Op.notRegexp]: '^[h|a|t]' // 不匹配正则表达式/!~ '^[h|a|t]' (仅限 MySQL/PG)
[Op.iRegexp]: '^[h|a|t]' // ~* '^[h|a|t]' (仅限 PG)
[Op.notIRegexp]: '^[h|a|t]' // !~* '^[h|a|t]' (仅限 PG)
[Op.like]: { [Op.any]: ['cat', 'hat']} // 包含任何数组['cat', 'hat'] - 同样适用于 iLike 和 notLike
[Op.overlap]: [1, 2] // && [1, 2] (PG数组重叠运算符)
[Op.contains]: [1, 2] // @> [1, 2] (PG数组包含运算符)
[Op.contained]: [1, 2] // <@ [1, 2] (PG数组包含于运算符)
[Op.any]: [2,3] // 任何数组[2, 3]::INTEGER (仅限PG)

[Op.col]: 'user.organization_id' // = 'user'.'organization_id', 使用数据库语言特定的列标识符, 本例使用 PG

范围选项

所有操作符都支持支持的范围类型查询.

请记住,提供的范围值也可以定义绑定的 inclusion/exclusion.

1
2
3
4
5
6
7
8
9
10
11
// 所有上述相等和不相等的操作符加上以下内容:

[Op.contains]: 2 // @> '2'::integer (PG range contains element operator)
[Op.contains]: [1, 2] // @> [1, 2) (PG range contains range operator)
[Op.contained]: [1, 2] // <@ [1, 2) (PG range is contained by operator)
[Op.overlap]: [1, 2] // && [1, 2) (PG range overlap (have points in common) operator)
[Op.adjacent]: [1, 2] // -|- [1, 2) (PG range is adjacent to operator)
[Op.strictLeft]: [1, 2] // << [1, 2) (PG range strictly left of operator)
[Op.strictRight]: [1, 2] // >> [1, 2) (PG range strictly right of operator)
[Op.noExtendRight]: [1, 2] // &< [1, 2) (PG range does not extend to the right of operator)
[Op.noExtendLeft]: [1, 2] // &> [1, 2) (PG range does not extend to the left of operator)

组合

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
{
rank: {
[Op.or]: {
[Op.lt]: 1000,
[Op.eq]: null
}
}
}
// rank < 1000 OR rank IS NULL

{
createdAt: {
[Op.lt]: new Date(),
[Op.gt]: new Date(new Date() - 24 * 60 * 60 * 1000)
}
}
// createdAt < [timestamp] AND createdAt > [timestamp]

{
[Op.or]: [
{
title: {
[Op.like]: 'Boat%'
}
},
{
description: {
[Op.like]: '%boat%'
}
}
]
}
// title LIKE 'Boat%' OR description LIKE '%boat%'

关系 / 关联

1
2
3
4
5
6
7
8
9
// 找到所有具有至少一个 task 的  project,其中 task.state === project.state
Project.findAll({
include: [
{
model: Task,
where: { state: Sequelize.col("project.state") },
},
],
});

分页 / 限制

1
2
3
4
5
6
7
8
// 获取10个实例/行
Project.findAll({ limit: 10 });

// 跳过8个实例/行
Project.findAll({ offset: 8 });

// 跳过5个实例,然后取5个
Project.findAll({ offset: 5, limit: 5 });

排序

order 需要一个条目的数组来排序查询或者一个 sequelize 方法.一般来说,你将要使用任一属性的 tuple/array,并确定排序的正反方向.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
Subtask.findAll({
order: [
// 将转义标题,并根据有效的方向参数列表验证DESC
['title', 'DESC'],

// 将按最大值排序(age)
sequelize.fn('max', sequelize.col('age')),

// 将按最大顺序(age) DESC
[sequelize.fn('max', sequelize.col('age')), 'DESC'],

// 将按 otherfunction 排序(`col1`, 12, 'lalala') DESC
[sequelize.fn('otherfunction', sequelize.col('col1'), 12, 'lalala'), 'DESC'],

// 将使用模型名称作为关联的名称排序关联模型的 created_at.
[Task, 'createdAt', 'DESC'],

// Will order through an associated model's created_at using the model names as the associations' names.
[Task, Project, 'createdAt', 'DESC'],

// 将使用关联的名称由关联模型的created_at排序.
['Task', 'createdAt', 'DESC'],

// Will order by a nested associated model's created_at using the names of the associations.
['Task', 'Project', 'createdAt', 'DESC'],

// Will order by an associated model's created_at using an association object. (优选方法)
[Subtask.associations.Task, 'createdAt', 'DESC'],

// Will order by a nested associated model's created_at using association objects. (优选方法)
[Subtask.associations.Task, Task.associations.Project, 'createdAt', 'DESC'],

// Will order by an associated model's created_at using a simple association object.
[{model: Task, as: 'Task'}, 'createdAt', 'DESC'],

// 嵌套关联模型的 created_at 简单关联对象排序
[{model: Task, as: 'Task'}, {model: Project, as: 'Project'}, 'createdAt', 'DESC']
]

// 将按年龄最大值降序排列
order: sequelize.literal('max(age) DESC')

// 按最年龄大值升序排列,当省略排序条件时默认是升序排列
order: sequelize.fn('max', sequelize.col('age'))

// 按升序排列是省略排序条件的默认顺序
order: sequelize.col('age')

// 将根据方言随机排序 (而不是 fn('RAND') 或 fn('RANDOM'))
order: sequelize.random()
})

count - 计算数据库中元素的出现次数

还有一种数据库对象计数的方法:

1
2
3
4
5
6
7
Project.count().then((c) => {
console.log("There are " + c + " projects!");
});

Project.count({ where: { id: { [Op.gt]: 25 } } }).then((c) => {
console.log("There are " + c + " projects with an id greater than 25.");
});

max - 获取特定表中特定属性的最大值

这里是获取属性的最大值的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
/*
我们假设3个具有属性年龄的对象.
第一个是10岁,
第二个是5岁,
第三个是40岁.
*/
Project.max("age").then((max) => {
// 将返回 40
});

Project.max("age", { where: { age: { [Op.lt]: 20 } } }).then((max) => {
// 将会是 10
});

min - 获取特定表中特定属性的最小值

这里是获取属性的最小值的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
/*
我们假设3个具有属性年龄的对象.
第一个是10岁,
第二个是5岁,
第三个是40岁.
*/
Project.min("age").then((min) => {
// 将返回 5
});

Project.min("age", { where: { age: { [Op.gt]: 5 } } }).then((min) => {
// 将会是 10
});

sum - 特定属性的值求和

为了计算表的特定列的总和,可以使用“sum”方法.

1
2
3
4
5
6
7
8
9
10
11
12
13
/*
我们假设3个具有属性年龄的对象.
第一个是10岁,
第二个是5岁,
第三个是40岁.
*/
Project.sum("age").then((sum) => {
// 将返回 55
});

Project.sum("age", { where: { age: { [Op.gt]: 5 } } }).then((sum) => {
// 将会是 50
});

预加载

当你从数据库检索数据时,也想同时获得与之相关联的查询,这被称为预加载.这个基本思路就是当你调用 findfindAll 时使用 include 属性.让我们假设以下设置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class User extends Model {}
User.init({ name: Sequelize.STRING }, { sequelize, modelName: "user" });
class Task extends Model {}
Task.init({ name: Sequelize.STRING }, { sequelize, modelName: "task" });
class Tool extends Model {}
Tool.init({ name: Sequelize.STRING }, { sequelize, modelName: "tool" });

Task.belongsTo(User);
User.hasMany(Task);
User.hasMany(Tool, { as: "Instruments" });

sequelize.sync().then(() => {
// 这是我们继续的地方 ...
});

首先,让我们用它们的关联 user 加载所有的 task.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Task.findAll({ include: [User] }).then((tasks) => {
console.log(JSON.stringify(tasks));

/*
[{
"name": "A Task",
"id": 1,
"createdAt": "2013-03-20T20:31:40.000Z",
"updatedAt": "2013-03-20T20:31:40.000Z",
"userId": 1,
"user": {
"name": "John Doe",
"id": 1,
"createdAt": "2013-03-20T20:31:45.000Z",
"updatedAt": "2013-03-20T20:31:45.000Z"
}
}]
*/
});

请注意,访问者(结果实例中的 User 属性)是单数形式,因为关联是一对一的.

接下来的事情:用多对一的关联加载数据!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
User.findAll({ include: [Task] }).then((users) => {
console.log(JSON.stringify(users));

/*
[{
"name": "John Doe",
"id": 1,
"createdAt": "2013-03-20T20:31:45.000Z",
"updatedAt": "2013-03-20T20:31:45.000Z",
"tasks": [{
"name": "A Task",
"id": 1,
"createdAt": "2013-03-20T20:31:40.000Z",
"updatedAt": "2013-03-20T20:31:40.000Z",
"userId": 1
}]
}]
*/
});

请注意,访问者(结果实例中的 Tasks 属性)是复数形式,因为关联是多对一的.

如果关联是别名的(使用 as 参数),则在包含模型时必须指定此别名. 注意用户的 Tool 如何被别名为 Instruments. 为了获得正确的权限,你必须指定要加载的模型以及别名:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
User.findAll({ include: [{ model: Tool, as: "Instruments" }] }).then(
(users) => {
console.log(JSON.stringify(users));

/*
[{
"name": "John Doe",
"id": 1,
"createdAt": "2013-03-20T20:31:45.000Z",
"updatedAt": "2013-03-20T20:31:45.000Z",
"Instruments": [{
"name": "Toothpick",
"id": 1,
"createdAt": null,
"updatedAt": null,
"userId": 1
}]
}]
*/
}
);

你还可以通过指定与关联别名匹配的字符串来包含别名:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
User.findAll({ include: ["Instruments"] }).then((users) => {
console.log(JSON.stringify(users));

/*
[{
"name": "John Doe",
"id": 1,
"createdAt": "2013-03-20T20:31:45.000Z",
"updatedAt": "2013-03-20T20:31:45.000Z",
"Instruments": [{
"name": "Toothpick",
"id": 1,
"createdAt": null,
"updatedAt": null,
"userId": 1
}]
}]
*/
});

User.findAll({ include: [{ association: "Instruments" }] }).then((users) => {
console.log(JSON.stringify(users));

/*
[{
"name": "John Doe",
"id": 1,
"createdAt": "2013-03-20T20:31:45.000Z",
"updatedAt": "2013-03-20T20:31:45.000Z",
"Instruments": [{
"name": "Toothpick",
"id": 1,
"createdAt": null,
"updatedAt": null,
"userId": 1
}]
}]
*/
});

当预加载时,我们也可以使用 where 过滤关联的模型. 这将返回 Tool 模型中所有与 where 语句匹配的行的User.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
User.findAll({
include: [
{
model: Tool,
as: "Instruments",
where: { name: { [Op.like]: "%ooth%" } },
},
],
}).then((users) => {
console.log(JSON.stringify(users));

/*
[{
"name": "John Doe",
"id": 1,
"createdAt": "2013-03-20T20:31:45.000Z",
"updatedAt": "2013-03-20T20:31:45.000Z",
"Instruments": [{
"name": "Toothpick",
"id": 1,
"createdAt": null,
"updatedAt": null,
"userId": 1
}]
}],

[{
"name": "John Smith",
"id": 2,
"createdAt": "2013-03-20T20:31:45.000Z",
"updatedAt": "2013-03-20T20:31:45.000Z",
"Instruments": [{
"name": "Toothpick",
"id": 1,
"createdAt": null,
"updatedAt": null,
"userId": 1
}]
}],
*/
});

当使用 include.where 过滤一个预加载的模型时,include.required 被隐式设置为 true. 这意味着内部联接完成返回具有任何匹配子项的父模型.

使用预加载模型的顶层 WHERE

将模型的 WHERE 条件从 ON 条件的 include 模式移动到顶层,你可以使用 '$nested.column$' 语法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
User.findAll({
where: {
'$Instruments.name$': { [Op.iLike]: '%ooth%' }
},
include: [{
model: Tool,
as: 'Instruments'
}]
}).then(users => {
console.log(JSON.stringify(users));

/*
[{
"name": "John Doe",
"id": 1,
"createdAt": "2013-03-20T20:31:45.000Z",
"updatedAt": "2013-03-20T20:31:45.000Z",
"Instruments": [{
"name": "Toothpick",
"id": 1,
"createdAt": null,
"updatedAt": null,
"userId": 1
}]
}],

[{
"name": "John Smith",
"id": 2,
"createdAt": "2013-03-20T20:31:45.000Z",
"updatedAt": "2013-03-20T20:31:45.000Z",
"Instruments": [{
"name": "Toothpick",
"id": 1,
"createdAt": null,
"updatedAt": null,
"userId": 1
}]
}],
*/

包括所有

要包含所有属性,你可以使用 all:true 传递单个对象:

1
User.findAll({ include: [{ all: true }] });

包括软删除的记录

如果想要加载软删除的记录,可以通过将 include.paranoid 设置为 false 来实现

1
2
3
4
5
6
7
8
9
User.findAll({
include: [
{
model: Tool,
where: { name: { [Op.like]: "%ooth%" } },
paranoid: false, // query and loads the soft deleted records
},
],
});

排序预加载关联

在一对多关系的情况下.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Company.findAll({ include: [Division], order: [[Division, "name"]] });
Company.findAll({ include: [Division], order: [[Division, "name", "DESC"]] });
Company.findAll({
include: [{ model: Division, as: "Div" }],
order: [[{ model: Division, as: "Div" }, "name"]],
});
Company.findAll({
include: [{ model: Division, as: "Div" }],
order: [[{ model: Division, as: "Div" }, "name", "DESC"]],
});
Company.findAll({
include: [{ model: Division, include: [Department] }],
order: [[Division, Department, "name"]],
});

在多对多关系的情况下,你还可以通过表中的属性进行排序.

1
2
3
4
Company.findAll({
include: [{ model: Division, include: [Department] }],
order: [[Division, DepartmentDivision, "name"]],
});

嵌套预加载

你可以使用嵌套的预加载来加载相关模型的所有相关模型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
User.findAll({
include: [
{
model: Tool,
as: "Instruments",
include: [
{
model: Teacher,
include: [
/* etc */
],
},
],
},
],
}).then((users) => {
console.log(JSON.stringify(users));

/*
[{
"name": "John Doe",
"id": 1,
"createdAt": "2013-03-20T20:31:45.000Z",
"updatedAt": "2013-03-20T20:31:45.000Z",
"Instruments": [{ // 1:M and N:M association
"name": "Toothpick",
"id": 1,
"createdAt": null,
"updatedAt": null,
"userId": 1,
"Teacher": { // 1:1 association
"name": "Jimi Hendrix"
}
}]
}]
*/
});

这将产生一个外连接. 但是,相关模型上的 where 语句将创建一个内部连接,并仅返回具有匹配子模型的实例. 要返回所有父实例,你应该添加 required: false.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
User.findAll({
include: [
{
model: Tool,
as: "Instruments",
include: [
{
model: Teacher,
where: {
school: "Woodstock Music School",
},
required: false,
},
],
},
],
}).then((users) => {
/* ... */
});

以上查询将返回所有用户及其所有乐器,但只会返回与 Woodstock Music School 相关的老师.

包括所有也支持嵌套加载:

1
User.findAll({ include: [{ all: true, nested: true }] });

新增

字段限制

1
2
3
4
5
6
7
8
9
10
11
await User.create(
{ username: "barfooz", isAdmin: true },
{ fields: ["username"] }
);
// 只有username有效

User.bulkCreate([{ username: "foo" }, { username: "bar", admin: true }], {
fields: ["username"],
}).then(() => {
// admin 将不会被构建
});

新增单个

1
2
3
4
5
// create
this.ctx.body = await this.ctx.model.User.create({
name: "哈哈哈",
age: 12,
});

批量新增

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 批量新增 bulkCreate
this.ctx.body = await this.ctx.model.User.bulkCreate([
{
name: "第一个",
age: 15,
},
{
name: "第二个",
age: 15,
},
{
name: "第三个",
age: 15,
},
]);

修改

字段限制

1
2
3
4
5
6
7
8
9
10
11
task.title = "foooo";
task.description = "baaaaaar";
await task.save({ fields: ["title"] });
// title 现在将是 “foooo”,而 description 与以前一样

// 使用等效的 update 调用如下所示:
await task.update(
{ title: "foooo", description: "baaaaaar" },
{ fields: ["title"] }
);
// title 现在将是 “foooo”,而 description 与以前一样

单个修改

1
2
3
4
5
6
// 找出当前记录
const user = await this.ctx.model.User.findByPk(1);
await user.update({
name: "我被修改了",
age: 30,
});

批量修改

1
2
3
4
5
6
7
8
9
10
11
12
// 批量修改
await this.ctx.model.User.update(
{
name: "批量修改",
},
{
// 条件
where: {
name: "第一个",
},
}
);

递增

1
2
3
4
5
6
// 找出当前记录 increment
const user = await this.ctx.model.User.findByPk(2);
this.ctx.body = await user.increment({
age: 3, // age每次递增3
other: 2, // other每次递增2
});

递减

1
2
3
4
5
6
// 找出当前记录 decrement
const user = await this.ctx.model.User.findByPk(2);
this.ctx.body = await user.decrement({
age: 3, // age每次递减3
other: 2, // other每次递减2
});

删除

软删除

模型中配置

1
2
3
4
5
6
7
8
9
10
11
// 配置(重要)
const User = app.model.define(
"user",
{
/* bla */
},
{
// 同时需要设置paranoid为true(此种模式下,删除数据时不会进行物理删除,而是设置deletedAt为当前时间
paranoid: true,
}
);

查询包括软删除内容

1
2
3
4
5
6
7
8
9
10
11
12
let user = await ctx.model.User.findOne({
include: {
model: ctx.model.Video,
// 包括软删除
paranoid: false,
},
where: {
id: 33,
},
// 包括软删除
paranoid: false,
});

彻底删除

如果 paranoid 选项为 true,则不会删除该对象,而将 deletedAt 列设置为当前时间戳. 要强制删除,可以将 force: true 传递给 destroy 调用:

1
task.destroy({ force: true });

paranoid 模式下对象被软删除后,在强制删除旧实例之前,你将无法使用相同的主键创建新实例.

恢复软删除的实例

如果你使用 paranoid:true 软删除了模型的实例,之后想要撤消删除,请使用 restore 方法:

1
2
3
4
// 进行软删除...
task.destroy();
// 恢复软删除...
task.restore();

条件删除

1
2
3
4
5
await this.ctx.model.User.destroy({
where: {
name: "批量修改",
},
});

批量删除

1
2
3
4
5
await this.ctx.model.Post.destroy({
where: {
id: posts,
},
});

重载实例

如果你需要让你的实例同步,你可以使用 reload 方法. 它将从数据库中获取当前数据,并覆盖调用该方法的模型的属性.

1
2
3
4
5
6
7
8
Person.findOne({ where: { name: "john" } }).then((person) => {
person.name = "jane";
console.log(person.name); // 'jane'

person.reload().then(() => {
console.log(person.name); // 'john'
});
});

模型自定义方法

1
2
3
4
5
6
7
8
9
10
// 模型
// 模型自定义方法
topic_user.ceshi = (param) => {
console.log("模型自定义方法");
console.log(param);
return param;
};

// 控制器
await this.ctx.model.TopicUser.ceshi(123);

Scopes - 作用域(重点)

作用域允许你定义常用查询,以便以后轻松使用. 作用域可以包括与常规查找器 where, include, limit 等所有相同的属性.

定义

作用域在模型定义中定义,可以是 finder 对象或返回 finder 对象的函数,除了默认作用域,该作用域只能是一个对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
class Project extends Model {}
Project.init({
// 属性
}, {
defaultScope: {
where: {
active: true
}
},
scopes: {
deleted: {
where: {
deleted: true
}
},
activeUsers: {
include: [
{ model: User, where: { active: true }}
]
},
random () {
return {
where: {
someNumber: Math.random()
}
}
},
accessLevel (value) {
return {
where: {
accessLevel: {
[Op.gte]: value
}
}
}
}
sequelize,
modelName: 'project'
}
});

通过调用 addScope 定义模型后,还可以添加作用域. 这对于具有包含的作用域特别有用,其中在定义其他模型时可能不会定义 include 中的模型.

始终应用默认作用域. 这意味着,通过上面的模型定义,Project.findAll() 将创建以下查询:

1
SELECT * FROM projects WHERE active = true

可以通过调用 .unscoped(), .scope(null) 或通过调用另一个作用域来删除默认作用域:

1
2
Project.scope('deleted').findAll(); // 删除默认作用域
SELECT * FROM projects WHERE deleted = true

还可以在作用域定义中包含作用域模型. 这让你避免重复 include,attributeswhere 定义.

使用上面的例子,并在包含的用户模型中调用 active 作用域(而不是直接在该 include 对象中指定条件):

1
2
3
activeUsers: {
include: [{ model: User.scope("active") }];
}

使用

通过在模型定义上调用 .scope 来应用作用域,传递一个或多个作用域的名称. .scope 返回一个全功能的模型实例,它具有所有常规的方法:.findAll,.update,.count,.destroy等等.你可以保存这个模型实例并稍后再次使用:

1
2
3
4
5
6
7
const DeletedProjects = Project.scope("deleted");

DeletedProjects.findAll();
// 过一段时间

// 让我们再次寻找被删除的项目!
DeletedProjects.findAll();

作用域适用于 .find, .findAll, .count, .update, .increment.destroy.

可以通过两种方式调用作为函数的作用域. 如果作用域没有任何参数,它可以正常调用. 如果作用域采用参数,则传递一个对象:

1
2
Project.scope('random', { method: ['accessLevel', 19]}).findAll();
SELECT * FROM projects WHERE someNumber = 42 AND accessLevel >= 19

合并

通过将作用域数组传递到 .scope 或通过将作用域作为连续参数传递,可以同时应用多个作用域.

1
2
3
4
5
6
7
// 这两个是等价的
Project.scope('deleted', 'activeUsers').findAll();
Project.scope(['deleted', 'activeUsers']).findAll();
SELECT * FROM projects
INNER JOIN users ON projects.userId = users.id
WHERE projects.deleted = true
AND users.active = true

如果要将其他作用域与默认作用域一起应用,请将键 defaultScope 传递给 .scope:

1
2
Project.scope('defaultScope', 'deleted').findAll();
SELECT * FROM projects WHERE active = true AND deleted = true

当调用多个作用域时,后续作用域的键将覆盖以前的作用域(类似于 Object.assign),除了whereinclude,它们将被合并. 考虑两个作用域:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
scope1: {
where: {
firstName: 'bob',
age: {
[Op.gt]: 20
}
},
limit: 2
},
scope2: {
where: {
age: {
[Op.gt]: 30
}
},
limit: 10
}
}

调用 .scope('scope1', 'scope2') 将产生以下查询

1
WHERE firstName = 'bob' AND age > 30 LIMIT 10

注意 scope2 将覆盖 limitage,而 firstName 被保留. limit,offset,order,paranoid,lockraw字段被覆盖,而where被浅层合并(意味着相同的键将被覆盖). include 的合并策略将在后面讨论.

请注意,多个应用作用域的 attributes 键以这样的方式合并,即始终保留 attributes.exclude. 这允许合并多个作用域,并且永远不会泄漏最终作用域内的敏感字段.

将查找对象直接传递给作用域模型上的findAll(和类似的查找程序)时,适用相同的合并逻辑:

1
2
3
4
5
6
Project.scope('deleted').findAll({
where: {
firstName: 'john'
}
})
WHERE deleted = true AND firstName = 'john'

这里的 deleted 作用域与 finder 合并. 如果我们要将 where: { firstName: 'john', deleted: false } 传递给 finder,那么 deleted 作用域将被覆盖.

合并 include

Include 是根据包含的模型递归合并的. 这是一个非常强大的合并,在 v5 上添加,并通过示例更好地理解.

考虑四种模型:Foo,Bar,Baz 和 Qux,具有如下多种关联:

1
2
3
4
5
6
7
8
9
10
11
class Foo extends Model {}
class Bar extends Model {}
class Baz extends Model {}
class Qux extends Model {}
Foo.init({ name: Sequelize.STRING }, { sequelize });
Bar.init({ name: Sequelize.STRING }, { sequelize });
Baz.init({ name: Sequelize.STRING }, { sequelize });
Qux.init({ name: Sequelize.STRING }, { sequelize });
Foo.hasMany(Bar, { foreignKey: "fooId" });
Bar.hasMany(Baz, { foreignKey: "barId" });
Baz.hasMany(Qux, { foreignKey: "bazId" });

现在,考虑 Foo 上定义的以下四个作用域:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
{
includeEverything: {
include: {
model: this.Bar,
include: [{
model: this.Baz,
include: this.Qux
}]
}
},
limitedBars: {
include: [{
model: this.Bar,
limit: 2
}]
},
limitedBazs: {
include: [{
model: this.Bar,
include: [{
model: this.Baz,
limit: 2
}]
}]
},
excludeBazName: {
include: [{
model: this.Bar,
include: [{
model: this.Baz,
attributes: {
exclude: ['name']
}
}]
}]
}
}

这四个作用域可以很容易地深度合并,例如通过调用 Foo.scope('includeEverything', 'limitedBars', 'limitedBazs', 'excludeBazName').findAll(),这完全等同于调用以下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Foo.findAll({
include: {
model: this.Bar,
limit: 2,
include: [
{
model: this.Baz,
limit: 2,
attributes: {
exclude: ["name"],
},
include: this.Qux,
},
],
},
});

观察四个作用域如何合并为一个. 根据所包含的模型合并作用域的 include. 如果一个作用域包括模型 A 而另一个作用域包括模型 B,则合并结果将包括模型 A 和 B.另一方面,如果两个作用域包括相同的模型 A,但具有不同的参数(例如嵌套 include 或其他属性) ,这些将以递归方式合并,如上所示.

无论应用于作用域的顺序如何,上面说明的合并都以完全相同的方式工作. 如果某个参数由两个不同的作用域设置,那么只会该顺序产生差异 - 这不是上述示例的情况,因为每个作用域都做了不同的事情.

这种合并策略的工作方式与传递给.findAll,.findOne等的参数完全相同.

关联

Sequelize 与关联有两个不同但相关的作用域概念. 差异是微妙但重要的:

  • 关联作用域 允许你在获取和设置关联时指定默认属性 - 在实现多态关联时很有用. 当使用get,set,addcreate相关联的模型函数时,这个作用域仅在两个模型之间的关联上被调用
  • 关联模型上的作用域 允许你在获取关联时应用默认和其他作用域,并允许你在创建关联时传递作用域模型. 这些作用域都适用于模型上的常规查找和通过关联查找.

举个例子,思考模型 Post 和 Comment. Comment 与其他几个模型(图像,视频等)相关联,Comment 和其他模型之间的关联是多态的,这意味着除了外键 commentable_id 之外,注释还存储一个commentable列.

可以使用 association scope 来实现多态关联:

1
2
3
4
5
6
this.Post.hasMany(this.Comment, {
foreignKey: "commentable_id",
scope: {
commentable: "post",
},
});

当调用 post.getComments() 时,这将自动添加 WHERE commentable = 'post'. 类似地,当向帖子添加新的注释时,commentable 会自动设置为 'post'. 关联作用域是为了存活于后台,没有程序员不必担心 - 它不能被禁用. 有关更完整的多态性示例,请参阅 关联作用域

那么考虑那个 Post 的默认作用域只显示活动的帖子:where: { active: true }. 该作用域存在于相关联的模型(Post)上,而不是像commentable 作用域那样在关联上. 就像在调用Post.findAll() 时一样应用默认作用域,当调用 User.getPosts() 时,它也会被应用 - 这只会返回该用户的活动帖子.

要禁用默认作用域,将 scope: null 传递给 getter: User.getPosts({ scope: null }). 同样,如果要应用其他作用域,请像这样:

1
User.getPosts({ scope: ["scope1", "scope2"] });

如果要为关联模型上的作用域创建快捷方式,可以将作用域模型传递给关联. 考虑一个快捷方式来获取用户所有已删除的帖子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Post extends Model {}
Post.init(attributes, {
defaultScope: {
where: {
active: true,
},
},
scopes: {
deleted: {
where: {
deleted: true,
},
},
},
sequelize,
});

User.hasMany(Post); // 常规 getPosts 关联
User.hasMany(Post.scope("deleted"), { as: "deletedPosts" });
User.getPosts(); // WHERE active = true
User.getDeletedPosts(); // WHERE deleted = true

扩展

extend/helper.js

1
2
3
4
5
6
7
8
// app/extend/helper.js
module.exports = {
// 扩展一个格式日期的方法
formatTime(val) {
let d = new Date(val * 1000);
return d.getFullYear();
},
};

模板中调用

1
<%=helper.formatTime(dateline)%>

其他地方调用

1
this.ctx.helper.formatTime(dateline);

中间件

1. 定义

app/middleware/getIp.js

1
2
3
4
5
6
7
8
9
10
11
12
13
/*
options: 中间件的配置项,框架会将 app.config[${middlewareName}] 传递进来。
app: 当前应用 Application 的实例。
*/
module.exports = (option, app) => {
// 返回一个异步的方法
return async function (ctx, next) {
// 通过option传入额外参数
console.log(option);
console.log(ctx.request.ip);
await next();
};
};

2. 配置

config/config.default.js(配置全局中间件,所有路由都会调用)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
module.exports = appInfo => {
...

// 配置全局中间件
config.middleware = ['getIp']; // 注意驼峰式写法,如果中间件是a_bc.js,则要写成aBc

// 配置中间件参数
config.getIp = {
ceshi: 123,
// 通用配置(以下是重点)
enable:true, // 控制中间件是否开启。
match:'/news', // 设置只有符合某些规则的请求才会经过这个中间件(匹配路由)
ignore:'/shop' // 设置符合某些规则的请求不经过这个中间件。

/**
注意:
1. match 和 ignore 不允许同时配置
2. 例如:match:'/news',只要包含/news的任何页面都生效
**/

// match 和 ignore 支持多种类型的配置方式:字符串、正则、函数(推荐)
match(ctx) {
// 只有 ios 设备才开启
const reg = /iphone|ipad|ipod/i;
return reg.test(ctx.get('user-agent'));
},
};

...
};

3. 使用

路由中使用

app/router.js

1
2
3
4
5
6
7
8
9
10
module.exports = (app) => {
// 局部中间件(如果只需要局部调用,则不需要在config.default.js中配置)
router.get(
"/admin/:id",
app.middleware.getIp({
ceshi: "我是admin",
}),
controller.admin.index
);
};

使用 Koa 的中间件(gzip 压缩)

大大提高网站的访问速度(非常有效)

koa-compress 为例,在 Koa 中使用时:

1
2
3
4
5
6
7
const koa = require("koa");
const compress = require("koa-compress");

const app = koa();

const options = { threshold: 2048 };
app.use(compress(options));

我们按照框架的规范来在应用中加载这个 Koa 的中间件:

1
2
3
4
5
6
7
8
9
10
11
// app/middleware/compress.js
// koa-compress 暴露的接口(`(options) => middleware`)和框架对中间件要求一致
module.exports = require("koa-compress");

// config/config.default.js
module.exports = {
middleware: ["compress"],
compress: {
threshold: 2048,
},
};

表单提交

post

app/controller/home.js

1
2
3
4
5
6
7
8
async addInput(ctx) {
await ctx.render('post');
}

async add(ctx) {
// 通过ctx.request.body获取post提交数据
console.log(ctx.request.body);
}

app/view/post.html

1
2
3
4
5
6
7
8
<!--
需要定义:?_csrf=<%=ctx.csrf%>
-->
<form action="/add?_csrf=<%=ctx.csrf%>" method="post">
<input type="text" name="username" id="username" />
<input type="password" name="password" id="password" />
<input type="submit" value="提交" />
</form>

app/router.js

1
2
router.get("/post", controller.home.addInput);
router.post("/add", controller.home.add);

cookie

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 1.设置
ctx.cookies.set("username", "ceshi");
// 2.获取
ctx.cookies.get("username");

// 3.设置中文(加密操作 encrypt: true)

// 4.设置(其他参数配置)
ctx.cookies.set("username", "ceshi", {
maxAge: 1000 * 3600 * 24, // 存储24小时,单位毫秒,关闭浏览器cookie还存在
httpOnly: true, // 设置键值对是否可以被 js 访问,默认为 true,不允许被 js 访问。
signed: true, // 签名,防止用户前台修改
encrypt: true, // 加密,注意:get获取时需要解密
});
// 5.获取时解密
ctx.cookies.get("username", {
encrypt: true,
});

// 6.清除cookie
ctx.cookies.set("username", null);

session

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 1.设置
ctx.session.username = "测试";
// 2.获取
ctx.session.username;
// 3.默认配置(全局配置,config/config.default.js)
exports.session = {
key: "EGG_SESS", // 设置cookies的key值
maxAge: 24 * 3600 * 1000, // 1 天,过期时间
httpOnly: true, // 设置键值对是否可以被 js 访问,默认为 true,不允许被 js 访问。
encrypt: true, // 加密
renew: true, // 每次刷新页面都会被延期
};
// 4.动态配置
ctx.session.maxAge = 5000; // 5秒的过期时间
ctx.session.username = "测试";
// 5.清空session
ctx.session.username = null;

定时任务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// app/schedule/ceshi.js
var i = 1;
module.exports = {
// 设置定时任务的执行间隔等配置
schedule: {
interval: "5s", // 每5秒执行一次
type: "all", // 指定所有的 worker 都需要执行
},
// 任务
async task(ctx) {
++i;
console.log(i);
},
};

API

1. context

curl

1
2
3
4
5
6
7
async ceshi() {
// 通过ctx中的curl方法获取数据
let r = await this.ctx.curl('http://www.phonegap100.com/appapi.php?a=getPortalList&catid=20&page=1');
// 将buffer类型数据转为json类型
let { result } = JSON.parse(r.data)
return result;
}

常用插件

缓存

https://www.npmjs.com/package/egg-cache
https://www.npmjs.com/package/egg-redis

验证

https://github.com/temool/egg-validate-plus

加密

https://www.npmjs.com/package/egg-jwt

前端访问:header 头添加:

1
2
// Authorization:"Bearer token值"
Authorization: "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6MTIzLCJpYXQiOjE1NzkxOTQxNTN9.Ml5B02ZPfYo78QwJic-Jdp2LUi2_AU0RGNgPhhJH--o";

通过利用 async 函数,Koa 帮你丢弃回调函数,并有力地增强错误处理。 Koa 并没有捆绑任何中间件, 而是提供了一套优雅的方法,帮助您快速而愉快地编写服务端应用程序。

基本使用

  • 需先建对象与 express 不同:const app = new Koa()
  • ctx(context)封装了 res 和 req
    • 返回使用 ctx.body,可使用 async 和 await
      • ctx.body 最终会转换为 res.end
      • 执行时机:洋葱模型的最外层,第一个中间件 promise 有结果后,将 ctx.body 转换为 res.end()
      • 异步使用时,不可放置异步函数中,需放置在 await 的 promise 中
    • 也可直接使用 ctx.res 和 ctx.req
    • 常用属性:req、res、request、response
  • ctx 属性:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
const Koa = require("koa"); // ⼀个类
const app = new Koa();

// 中间件,可以使⽤async await
app.use(async function (ctx) {
// throw new Error('出错了')
// ctx封装了req和res的功能,这⾥相当于res.end
// 1、有输出,ctx.body在promise结果后
ctx.body = await Promise.resolve("zhuwa");
// 2、无输出
setTimeout(() => {
ctx.body = "zhuwa";
}, 3000);
});

// 错误事件
app.on("error", function (err, ctx) {
console.log(err);
ctx.res.end(`500 错误`);
});

app.listen(3002, () => {
console.log("koa server started at 3002");
});

// req request res response的区别
app.use(async function (ctx) {
console.log(ctx.req.path); // 原⽣的req
console.log(ctx.request.path); // koa封装的request
console.log(ctx.request.req.path);
console.log(ctx.path); // 代理到request
ctx.body = await Promise.resolve("zhuwa"); // ctx封装了req和res的功能,这⾥相当于res.end
//ctx.throw(401, "err msg");
});

中间件(洋葱模型)

使用 app.use,回调函数只能传一个,多个需多次注册
异步流程会等待后续中间件有结果时返回
在 koa 中,中间件被 next() 方法分成了两部分。next() 方法上面部分会先执行,下面部门会在后续中间件执行全部结束之后再执行。

  • 多个中间件会形成一个栈结构(middlestack),以"先进后出"(first-in-last- out)的顺序执行。
  • 最外层的中间件首先执行。
  • 调用 next 函数,把执行权交给下一个中间件。
  • 最内层的中间件最后执行。
  • 执行结束后,把执行权交回上一层的中间件。
  • 最外层的中间件收回执行权之后,执行 next 函数后面的代码.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
app.use(async function (ctx, next) {
console.log("1-1");
await next();
console.log("1-2");
});
app.use(async function (ctx, next) {
console.log("2-1");
await next();
console.log("2-2");
});
app.use(async function (ctx, next) {
console.log("3-1");
await next();
console.log("3-2");
});
//输出:1-1、2-1、3-1、3-2、2-2、1-2

与 express 比较

框架 express koa
模型 线性模型 洋葱模型
表现 同步表现为洋葱模型,但遇到异步,即使使⽤了 await next()也不会等待,会继续向下执⾏,因为 next()返回 undefined ⽆论同步,还是异步,都是洋葱模型,可以使⽤ await 和 next() 来等待下⼀个中间件执⾏完成
原理 使⽤回调, next(),返回 undefined next(),返回 promise,会把中间件⽤ promise 包裹起来
错误 错误处理是在⼀个特殊签名的中间件中完成的,它必须被添加到链的后⾯才能捕获到前⾯的错误 可以使⽤ try catch 捕获中间件错误
注意 await next() 也不会等待下⼀个中间件异步完成 要使⽤ await 来等待异步操作;注意 res.end 的时机,等最外层的中间件(也就是第⼀个中间件)的 promise 有结果后,响应就会被返回 ctx.body ->res.end(ctx.body)

中间件编写

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//编写⼀个计算请求的时⻓的中间件
// koa 中间件
app.use(async function (ctx, next) {
console.time("serviceTime");
await next();
console.timeEnd("serviceTime");
});

// express 中间件
app.use(function (req, res, next) {
console.time("service");
next();
res.once("close", () => {
console.timeEnd("service");
});
});

错误处理

在最顶层捕获未处理的错误
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
// 可以使⽤try catch 捕获中间件错误
// 在最顶层捕获未处理的错误
app.use(async (ctx, next) => {
try {
// await 可以让异步函数的错误得到捕获
await next();
} catch (err) {
const status = err.status || 500;
ctx.status = status;
ctx.type = "html";
ctx.body = `
<b>${status}</b> ${err}
`;
// emmit
ctx.app.emit("error", err, ctx);
}
});

app.use(async (ctx, next) => {
try {
await next()
} catch (err) {
ctx.status = 400
ctx.body = `Uh-oh: ${err.message}`
console.log('Error handler:', err.message)
}
})

app.use(async (ctx) => {
if (ctx.query.greet !== 'world') {
throw new Error('can only greet "world"')
}
console.log('Sending response')
ctx.status = 200
ctx.body = `Hello ${ctx.query.greet} from Koa`
})

// 也可以使⽤总的错误事件来统⼀处理
// 错误事件
app.on('error', function(err) {
console.log(err)
})

````

## 常用中间件

- koa-router:路由
- koa-static:静态资源
- koa-mounut:子路由
- koa-body:body解析
- koa-parameter:参数校验
```javascript
//1、koa-router
const KoaRouter = require('koa-router')
router.get('/page', async ctx => {
ctx.body = await new Promise((resolve, reject) => {
resolve('ok')
})
})
// 注册中间件
app.use(router.routes())
// 添加前缀
const router = new Router({ prefix: '/user' })
// 重定向
router.get('/home', async ctx => {
ctx.redirect('http://baidu.com');
});
// 路由参数
router.get('/:id', async ctx => {
ctx.body = {
msg: 'index page',
params: ctx.params.id // 通过 ctx.params.id 获取到的是 1
};
});
router.get('/', async ctx => {
ctx.body = {
msg: 'index page',
query: ctx.query // ctx.query会获取url后⾯携带的参数对象
};
});
// allowedMethod
// 如果没有这⼀⾏, 当使⽤其他请求⽅式请求user时, 会提示404
// 如果有这⼀⾏, 当使⽤其他请求⽅式请求user时, 提示405 method not allowed
app.use(router.allowedMethod());

//2、koa-static
const KoaStatic = require('koa-static')
// 动态加载静态资源
app.use(KoaStatic(path.resolve(__dirname, '../dist/assets')))

//3、koa-mounut
const KoaMount = require('koa-mount')
// 添加前缀
app.use(KoaMount('/assets', KoaStatic(path.resolve(__dirname, '../dist/assets'))))

//4、koa-body
const bodyparser = require('koa-body');
// 在注册此中间件之后, 会在 ctx 上下⽂注⼊ ctx.request.body 属性 ⽤于获取客户端传递来的数据
app.use(bodyparser());
router.post('/', async ctx => {
ctx.body = {
code: 200,
//body 可以获取form表单数据, json数据
msg: ctx.request.body
}
})

//5、koa-parameter
const parameter = require('koa-parameter');
parameter(app);
router.post('/', async ctx => {
//接收⼀个对象
ctx.verifyParams({
// 校验的简写形式
username: 'string',
password: { type: 'string', required: true } // 或者也可以这样
})
ctx.body = { code: 1 }
})
````

## koa-compose

中间件合并处理

```javascript
compose(stack)({});
  • stack 是数组,收集了多个 async 函数,即返回值是 promise,内部有异步,总之就是收集了所有的异步操作;
  • compose 是核心代码,函数接收 stack 数组返回一个匿名函数(这里采用了闭包);
  • 核心代码就是执行这个匿名函数;

我们来看下 compose 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
function compose(middleware) {
if (!Array.isArray(middleware))
throw new TypeError("Middleware stack must be an array!");
for (const fn of middleware) {
if (typeof fn !== "function")
throw new TypeError("Middleware must be composed of functions!");
}

/**
* @param {Object} context
* @return {Promise}
* @api public
*/

// 核心匿名函数:接收上下文对象context和自定义next函数;
return function (context, next) {
// last called middleware #
let index = -1;
return dispatch(0); //调用第一个元素函数
function dispatch(i) {
// 防止连续执行两次next
if (i <= index)
return Promise.reject(new Error("next() called multiple times"));
index = i;
let fn = middleware[i];
if (i === middleware.length) fn = next; //此处i对应的fn是undefined,也支持自定义next
if (!fn) return Promise.resolve(); // 最后一个直接返回
try {
// 递归去执行dispatch,本质是获取下一个fn元素
return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
} catch (err) {
return Promise.reject(err);
}
}
};
}

Context(上下文)

Koa Context 将 node 的 request 和 response 对象封装到单个对象中,为编写 Web 应用程序和 API 提供了许多有用的方法。

1
2
3
4
5
app.use(async (ctx) => {
ctx; // 这是 Context
ctx.request; // 这是 koa Request
ctx.response; // 这是 koa Response
});

为方便起见许多上下文的访问器和方法直接委托给它们的 ctx.request 或 ctx.response ,不然的话它们是相同的。 例如 ctx.type 和 ctx.length 委托给 response 对象,ctx.path 和 ctx.method 委托给 request。

ctx.req

Node 的 request 对象.

ctx.res

Node 的 response 对象.
绕过 Koa 的 response 处理是 不被支持的. 应避免使用以下 node 属性:

  • res.statusCode
  • res.writeHead()
  • res.write()
  • res.end()

别名

Request 别名

以下访问器和 Request 别名等效:

  • ctx.header
  • ctx.headers
  • ctx.method
  • ctx.method=
  • ctx.url
  • ctx.url=
  • ctx.originalUrl
  • ctx.origin
  • ctx.href
  • ctx.path
  • ctx.path=
  • ctx.query
  • ctx.query=
  • ctx.querystring
  • ctx.querystring=
  • ctx.host
  • ctx.hostname
  • ctx.fresh
  • ctx.stale
  • ctx.socket
  • ctx.protocol
  • ctx.secure
  • ctx.ip
  • ctx.ips
  • ctx.subdomains
  • ctx.is()
  • ctx.accepts()
  • ctx.acceptsEncodings()
  • ctx.acceptsCharsets()
  • ctx.acceptsLanguages()
  • ctx.get()

Response 别名

以下访问器和 Response 别名等效:

  • ctx.body
  • ctx.body=
  • ctx.status
  • ctx.status=
  • ctx.message
  • ctx.message=
  • ctx.length=
  • ctx.length
  • ctx.type=
  • ctx.type
  • ctx.headerSent
  • ctx.redirect()
  • ctx.attachment()
  • ctx.set()
  • ctx.append()
  • ctx.remove()
  • ctx.lastModified=
  • ctx.etag=

介绍

Redis 是使用 c 语言开发的一个高性能键值数据库。
Redis 可以通过一些键值类型来存储数据。
键值类型:

  • String 字符类型
  • map 散列类型
  • list 列表类型
  • set 集合类型
  • sortedset 有序集合类型

Redis 使用起来就像是 JS 中的 Map 数据结构(但 key 只能是字符串,value 要符合支持的数据结构)。在业务中,我们可以用 Redis 来存储用户的鉴权 token,用户带着 token 访问时,可以快速从 redis 取出并对比。

适用场景

  • 缓存(数据查询、短连接、新闻内容、商品内容等等)。(最多使用)
  • 分布式集群架构中的 session 分离。
  • 聊天室的在线好友列表。
  • 任务队列。(秒杀、抢购、12306 等等)
  • 应用排行榜。
  • 网站访问统计。
  • 数据过期处理(可以精确到毫秒)

安装(macOS)

1
brew install redis

启动

1
2
3
4
5
6
7
8
9
10
// 前台启动redis服务器
redis-server
// server后台启动
redis-server --daemonize yes
// launch后台启动
brew services start redis
// 关闭后台启动
brew services stop redis
// 查看信息
brew services info redis

连接 Redis

启动 Redis 服务后,连接 redis 数据库:

1
2
3
4
// Redis命令行客户端
redis-cli
// 连接远程服务
redis-cli -h host -p port -a password

查看 Redis 状态

1
ps -ef | grep -i redis

停止 Redis

防止中止进程导致数据丢失,应该使用shutdown命令停止 Redis.

1
2
// 此方法关闭 redis-server启动的进程
redis-cli shutdown

另一种方法是杀进程,进程号通过查看 Redis 状态得知.

1
kill -9 进程号

通用命令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 返回所有KEY
KEYS *
# 获取key的类型
TYPE my*
# 查询某个key是否存在
EXISTS key [key...]
# 将key改名为newkey
RENAME key newkey
# 从当前数据库随机返回(非删除)一个key
RANDOMKEY
# 清空当前数据库内容
FLUSHDB
# 清空所有数据库内容
FLUSHALL
# 将当前数据库的key移动的指定db
MOVE key db

设置过期时间

1
2
3
4
5
6
7
8
# 为指定key设置过期时间,当key过期就删除
EXPIRE key seconds
# 以毫秒为单位
PEXPIRE key milliseconds
# 通过时间戳设置
EXPIREAT key timessamp
# 以毫秒为单位
PEXPIREAT key milliseconds-timessamp

获取过期时间

1
2
3
4
# 返回指定key的剩余时间(秒)
TTL key
# 毫秒单位
PTTL key

多数据库

默认分配 16 个数据库,数字 0-15.默认使用 0 数据库.数据库相对独立,可以配置参数databases修改个数.

切换数据库[SELECT]

1
SELECT 1

知识体系

多个数据库并不是完全独立.如果要不同应用存储建议开多个 Redis 实例.
image.png

数据结构

Redis 数据库支持五种数据类型。

  • 字符串(string)
  • 哈希(hash)
  • 列表(list)
  • 集合(set)
  • 有序集合(sorted set)
  • 位图 ( Bitmaps )
  • 基数统计 ( HyperLogLogs )

5 种基础数据类型

String 字符串

String 是一组字节。在 Redis 数据库中,字符串是二进制安全的。
这意味着它们具有已知长度,并且不受任何特殊终止字符的影响。
redis 的 string 可以包含任何数据。如数字,字符串,jpg 图片或者序列化的对象。
可以在一个字符串中存储最多 512 M 的内容。
使用 SET 命令在 name 键中存储字符串 redis.com.cn,然后使用 GET 命令查询 name。

1
2
3
4
SET name "redis.com.cn"
OK
GET name
"redis.com.cn"

命令使用

命令 简述 使用
GET 获取存储在给定键中的值 GET name
SET 设置存储在给定键中的值 SET name value
DEL 删除存储在给定键中的值 DEL name
INCR 将键存储的值加 1 INCR key
DECR 将键存储的值减 1 DECR key
INCRBY 将键存储的值加上整数 INCRBY key amount
DECRBY 将键存储的值减去整数 DECRBY key amount

命令执行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
127.0.0.1:6379> set hello world
OK
127.0.0.1:6379> get hello
"world"
127.0.0.1:6379> del hello
(integer) 1
127.0.0.1:6379> get hello
(nil)
127.0.0.1:6379> set counter 2
OK
127.0.0.1:6379> get counter
"2"
127.0.0.1:6379> incr counter
(integer) 3
127.0.0.1:6379> get counter
"3"
127.0.0.1:6379> incrby counter 100
(integer) 103
127.0.0.1:6379> get counter
"103"
127.0.0.1:6379> decr counter
(integer) 102
127.0.0.1:6379> get counter
"102"

实战场景

  • 缓存: 经典使用场景,把常用信息,字符串,图片或者视频等信息放到 redis 中,redis 作为缓存层,mysql 做持久化层,降低 mysql 的读写压力。
  • 计数器:redis 是单线程模型,一个命令执行完才会执行下一个,同时数据可以一步落地到其他的数据源。
  • session:常见方案 spring session + redis 实现 session 共享,

List 列表

Redis中的List其实就是链表(Redis用双端链表实现List)。
使用List结构,我们可以轻松地实现最新消息排队功能(比如新浪微博的TimeLine)。 List的另一个应用就是消息队列,可以利用List的 PUSH 操作,将任务存放在List中,然后工作线程再用 POP 操作将任务取出进行执行。

命令使用

命令 简述 使用
RPUSH 将给定值推入到列表右端 RPUSH key value
LPUSH 将给定值推入到列表左端 LPUSH key value
RPOP 从列表的右端弹出一个值,并返回被弹出的值 RPOP key
LPOP 从列表的左端弹出一个值,并返回被弹出的值 LPOP key
LRANGE 获取列表在给定范围上的所有值 LRANGE key 0 -1
LINDEX 通过索引获取列表中的元素。你也可以使用负数下标,以 -1 表示列表的最后一个元素, -2 表示列表的倒数第二个元素,以此类推。 LINDEX key index

使用列表的技巧

  • lpush+lpop=Stack(栈)
  • lpush+rpop=Queue(队列)
  • lpush+ltrim=Capped Collection(有限集合)
  • lpush+brpop=Message Queue(消息队列)

命令执行

1
2
3
4
5
6
7
8
9
10
11
12
13
127.0.0.1:6379> lpush mylist 1 2 ll ls mem
(integer) 5
127.0.0.1:6379> lrange mylist 0 -1
1) "mem"
2) "ls"
3) "ll"
4) "2"
5) "1"
127.0.0.1:6379> lindex mylist -1
"1"
127.0.0.1:6379> lindex mylist 10 # index不在 mylist 的区间范围内
(nil)

实战场景

  • 微博 TimeLine: 有人发布微博,用 lpush 加入时间轴,展示新的列表信息。
  • 消息队列

Set 集合

Redis 的 Set 是 String 类型的无序集合。集合成员是唯一的,这就意味着集合中不能出现重复的数据。
Redis 中集合是通过哈希表实现的,所以添加,删除,查找的复杂度都是 O(1)。
  • 图例

image.png

命令使用

命令 简述 使用
SADD 向集合添加一个或多个成员 SADD key value
SCARD 获取集合的成员数 SCARD key
SMEMBERS 返回集合中的所有成员 SMEMBERS key member
SISMEMBER 判断 member 元素是否是集合 key 的成员 SISMEMBER key member

其它一些集合操作,请参考这里https://www.runoob.com/redis/redis-sets.html

命令执行

1
2
3
4
5
6
7
8
9
127.0.0.1:6379> sadd myset hao hao1 xiaohao hao
(integer) 3
127.0.0.1:6379> smembers myset
1) "xiaohao"
2) "hao1"
3) "hao"
127.0.0.1:6379> sismember myset hao
(integer) 1

实战场景

  • 标签(tag),给用户添加标签,或者用户给消息添加标签,这样有同一标签或者类似标签的可以给推荐关注的事或者关注的人。
  • 点赞,或点踩,收藏等,可以放到 set 中实现

Hash 散列

Redis hash 是一个 string 类型的 field(字段) 和 value(值) 的映射表,hash 特别适合用于存储对象。
哈希是键值对的集合。在 Redis 中,哈希是字符串字段和字符串值之间的映射。因此,它们适合表示对象。
  • 图例

image.png

命令使用

命令 简述 使用
HSET 添加键值对 HSET hash-key sub-key1 value1
HGET 获取指定散列键的值 HGET hash-key key1
HGETALL 获取散列中包含的所有键值对 HGETALL hash-key
HDEL 如果给定键存在于散列中,那么就移除这个键 HDEL hash-key sub-key1

命令执行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
127.0.0.1:6379> hset user name1 hao
(integer) 1
127.0.0.1:6379> hset user email1 hao@163.com
(integer) 1
127.0.0.1:6379> hgetall user
1) "name1"
2) "hao"
3) "email1"
4) "hao@163.com"
127.0.0.1:6379> hget user user
(nil)
127.0.0.1:6379> hget user name1
"hao"
127.0.0.1:6379> hset user name2 xiaohao
(integer) 1
127.0.0.1:6379> hset user email2 xiaohao@163.com
(integer) 1
127.0.0.1:6379> hgetall user
1) "name1"
2) "hao"
3) "email1"
4) "hao@163.com"
5) "name2"
6) "xiaohao"
7) "email2"
8) "xiaohao@163.com"

实战场景

  • 缓存: 能直观,相比 string 更节省空间,的维护缓存信息,如用户信息,视频信息等。

Zset 有序集合

Redis 有序集合和集合一样也是 string 类型元素的集合,且不允许重复的成员。不同的是每个元素都会关联一个 double 类型的分数。redis 正是通过分数来为集合中的成员进行从小到大的排序。
有序集合的成员是唯一的, 但分数(score)却可以重复。有序集合是通过两种数据结构实现:
  1. 压缩列表(ziplist): ziplist 是为了提高存储效率而设计的一种特殊编码的双向链表。它可以存储字符串或者整数,存储整数时是采用整数的二进制而不是字符串形式存储。它能在 O(1)的时间复杂度下完成 list 两端的 push 和 pop 操作。但是因为每次操作都需要重新分配 ziplist 的内存,所以实际复杂度和 ziplist 的内存使用量相关
  2. 跳跃表(zSkiplist): 跳跃表的性能可以保证在查找,删除,添加等操作的时候在对数期望时间内完成,这个性能是可以和平衡树来相比较的,而且在实现方面比平衡树要优雅,这是采用跳跃表的主要原因。跳跃表的复杂度是 O(log(n))。
  • 图例

image.png

命令使用

命令 简述 使用
ZADD 将一个带有给定分值的成员添加到有序集合里面 ZADD zset-key 178 member1
ZRANGE 根据元素在有序集合中所处的位置,从有序集合中获取多个元素 ZRANGE zset-key 0-1 withccores
ZREM 如果给定元素成员存在于有序集合中,那么就移除这个元素 ZREM zset-key member1

更多命令请参考这里 https://www.runoob.com/redis/redis-sorted-sets.html

命令执行

1
2
3
4
5
6
7
127.0.0.1:6379> zadd myscoreset 100 hao 90 xiaohao
(integer) 2
127.0.0.1:6379> ZRANGE myscoreset 0 -1
1) "xiaohao"
2) "hao"
127.0.0.1:6379> ZSCORE myscoreset hao
"100"

实战场景

  • 排行榜:有序集合经典使用场景。例如小说视频等网站需要对用户上传的小说视频做排行榜,榜单可以按照用户关注数,更新时间,字数等打分,做排行。

3 种特殊类型

HyperLogLogs(基数统计)

Redis 2.8.9 版本更新了 Hyperloglog 数据结构!
  • 什么是基数?

举个例子,A = {1, 2, 3, 4, 5}, B = {3, 5, 6, 7, 9};那么基数(不重复的元素)= 1, 2, 4, 6, 7, 9; (允许容错,即可以接受一定误差)

  • HyperLogLogs 基数统计用来解决什么问题

这个结构可以非常省内存的去统计各种计数,比如注册 IP 数、每日访问 IP 数、页面实时 UV、在线用户数,共同好友数等。

  • 它的优势体现在哪

一个大型的网站,每天 IP 比如有 100 万,粗算一个 IP 消耗 15 字节,那么 100 万个 IP 就是 15M。而 HyperLogLog 在 Redis 中每个键占用的内容都是 12K,理论存储近似接近 2^64 个值,不管存储的内容是什么,它一个基于基数估算的算法,只能比较准确的估算出基数,可以使用少量固定的内存去存储并识别集合中的唯一元素。而且这个估算的基数并不一定准确,是一个带有 0.81% 标准错误的近似值(对于可以接受一定容错的业务场景,比如 IP 数统计,UV 等,是可以忽略不计的)。

  • 相关命令使用
1
2
3
4
5
6
7
8
9
10
11
12
13
127.0.0.1:6379> pfadd key1 a b c d e f g h i	# 创建第一组元素
(integer) 1
127.0.0.1:6379> pfcount key1 # 统计元素的基数数量
(integer) 9
127.0.0.1:6379> pfadd key2 c j k l m e g a # 创建第二组元素
(integer) 1
127.0.0.1:6379> pfcount key2
(integer) 8
127.0.0.1:6379> pfmerge key3 key1 key2 # 合并两组:key1 key2 -> key3 并集
OK
127.0.0.1:6379> pfcount key3
(integer) 13

Bitmap (位存储)

Bitmap 即位图数据结构,都是操作二进制位来进行记录,只有0 和 1 两个状态。
  • 用来解决什么问题

比如:统计用户信息,活跃,不活跃! 登录,未登录! 打卡,不打卡! 两个状态的,都可以使用 Bitmaps
如果存储一年的打卡状态需要多少内存呢? 365 天 = 365 bit 1 字节 = 8bit 46 个字节左右!

  • 相关命令使用

使用 bitmap 来记录 周一到周日的打卡! 周一:1 周二:0 周三:0 周四:1 ……

1
2
3
4
5
6
7
8
9
10
11
12
13
14
127.0.0.1:6379> setbit sign 0 1
(integer) 0
127.0.0.1:6379> setbit sign 1 1
(integer) 0
127.0.0.1:6379> setbit sign 2 0
(integer) 0
127.0.0.1:6379> setbit sign 3 1
(integer) 0
127.0.0.1:6379> setbit sign 4 0
(integer) 0
127.0.0.1:6379> setbit sign 5 0
(integer) 0
127.0.0.1:6379> setbit sign 6 1
(integer) 0

查看某一天是否有打卡!

1
2
3
4
127.0.0.1:6379> getbit sign 3
(integer) 1
127.0.0.1:6379> getbit sign 5
(integer) 0

统计操作,统计 打卡的天数!

1
2
127.0.0.1:6379> bitcount sign # 统计这周的打卡记录,就可以看到是否有全勤!
(integer) 3

geospatial (地理位置)

Redis 的 Geo 在 Redis 3.2 版本就推出了! 这个功能可以推算地理位置的信息: 两地之间的距离, 方圆几里的人

geoadd(添加地理位置)

规则
两级无法直接添加,我们一般会下载城市数据(这个网址可以查询 GEO: http://www.jsons.cn/lngcode)!

  • 有效的经度从-180 度到 180 度。
  • 有效的纬度从-85.05112878 度到 85.05112878 度。
1
2
3
# 当坐标位置超出上述指定范围时,该命令将会返回一个错误。
127.0.0.1:6379> geoadd china:city 39.90 116.40 beijin
(error) ERR invalid longitude,latitude pair 39.900000,116.400000

geopos(获取指定的成员的经度和纬度)

1
2
3
4
5
127.0.0.1:6379> geopos china:city taiyuan manjing
1) 1) "112.54999905824661255"
1) "37.86000073876942196"
2) 1) "118.75999957323074341"
1) "32.03999960287850968"

获得当前定位, 一定是一个坐标值!

geodist

如果不存在, 返回空

单位如下

  • m
  • km
  • mi 英里
  • ft 英尺
1
2
3
4
127.0.0.1:6379> geodist china:city taiyuan shenyang m
"1026439.1070"
127.0.0.1:6379> geodist china:city taiyuan shenyang km
"1026.4391"

georadius

附近的人 ==> 获得所有附近的人的地址, 定位, 通过半径来查询

获得指定数量的人

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
127.0.0.1:6379> georadius china:city 110 30 1000 km			以 100,30 这个坐标为中心, 寻找半径为1000km的城市
1) "xian"
2) "hangzhou"
3) "manjing"
4) "taiyuan"
127.0.0.1:6379> georadius china:city 110 30 500 km
1) "xian"
127.0.0.1:6379> georadius china:city 110 30 500 km withdist
1) 1) "xian"
2) "483.8340"
127.0.0.1:6379> georadius china:city 110 30 1000 km withcoord withdist count 2
1) 1) "xian"
2) "483.8340"
3) 1) "108.96000176668167114"
2) "34.25999964418929977"
2) 1) "manjing"
2) "864.9816"
3) 1) "118.75999957323074341"
2) "32.03999960287850968"

参数 key 经度 纬度 半径 单位 [显示结果的经度和纬度] [显示结果的距离] [显示的结果的数量]

georadiusbymember

显示与指定成员一定半径范围内的其他成员

1
2
3
4
5
6
7
8
9
10
11
12
13
127.0.0.1:6379> georadiusbymember china:city taiyuan 1000 km
1) "manjing"
2) "taiyuan"
3) "xian"
127.0.0.1:6379> georadiusbymember china:city taiyuan 1000 km withcoord withdist count 2
1) 1) "taiyuan"
2) "0.0000"
3) 1) "112.54999905824661255"
2) "37.86000073876942196"
2) 1) "xian"
2) "514.2264"
3) 1) "108.96000176668167114"
2) "34.25999964418929977"

参数与 georadius 一样

geohash(较少使用)

该命令返回 11 个字符的 hash 字符串

1
2
3
127.0.0.1:6379> geohash china:city taiyuan shenyang
1) "ww8p3hhqmp0"
2) "wxrvb9qyxk0"

将二维的经纬度转换为一维的字符串, 如果两个字符串越接近, 则距离越近

底层

geo 底层的实现原理实际上就是 Zset, 我们可以通过 Zset 命令来操作 geo

1
2
127.0.0.1:6379> type china:city
zset

查看全部元素 删除指定的元素

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
127.0.0.1:6379> zrange china:city 0 -1 withscores
1) "xian"
2) "4040115445396757"
3) "hangzhou"
4) "4054133997236782"
5) "manjing"
6) "4066006694128997"
7) "taiyuan"
8) "4068216047500484"
9) "shenyang"
1) "4072519231994779"
2) "shengzhen"
3) "4154606886655324"
127.0.0.1:6379> zrem china:city manjing
(integer) 1
127.0.0.1:6379> zrange china:city 0 -1
1) "xian"
2) "hangzhou"
3) "taiyuan"
4) "shenyang"
5) "shengzhen"

Stream 消息队列

为什么会设计 Stream

Redis5.0 中还增加了一个数据结构Stream,从字面上看是流类型,但其实从功能上看,应该是Redis对消息队列(MQ,Message Queue)的完善实现。
用过Redis做消息队列的都了解,基于Reids的消息队列实现有很多种,例如:
  • PUB/SUB,订阅/发布模式
    • 但是发布订阅模式是无法持久化的,如果出现网络断开、Redis 宕机等,消息就会被丢弃;
  • 基于List LPUSH+BRPOP 或者 基于 Sorted-Set的实现
    • 支持了持久化,但是不支持多播,分组消费等

为什么上面的结构无法满足广泛的 MQ 场景? 这里便引出一个核心的问题:如果我们期望设计一种数据结构来实现消息队列,最重要的就是要理解设计一个消息队列需要考虑什么?初步的我们很容易想到

  • 消息的生产
  • 消息的消费
    • 单播和多播(多对多)
    • 阻塞和非阻塞读取
  • 消息有序性
  • 消息的持久化

其它还要考虑啥嗯?借助美团技术团队的一篇文章,消息队列设计精要(opens new window) 中的图
image.png
我们不妨看看 Redis 考虑了哪些设计

  • 消息 ID 的序列化生成
  • 消息遍历
  • 消息的阻塞和非阻塞读取
  • 消息的分组消费
  • 未完成消息的处理
  • 消息队列监控
  • 这也是我们需要理解Stream的点,但是结合上面的图,我们也应该理解Redis Stream也是一种超轻量MQ并没有完全实现消息队列所有设计要点,这决定着它适用的场景。
    详细内容建议看这篇[Stream详细用法](https://pdai.tech/md/db/nosql-redis/db-redis-data-type-stream.html)

事务

Redis 事务的本质是一组命令的集合。事务支持一次执行多个命令,一个事务中所有命令都会被序列化。在事务执行过程,会按照顺序串行化执行队列中的命令,其他客户端提交的命令请求不会插入到事务执行命令序列中。

总结说:redis事务就是一次性、顺序性、排他性的执行一个队列中的一系列命令。

Redis 事务相关命令和使用

MULTI 、 EXEC 、 DISCARD 和 WATCH 是 Redis 事务相关的命令。
  • MULTI :开启事务,redis 会将后续的命令逐个放入队列中,然后使用 EXEC 命令来原子化执行这个命令系列。
  • EXEC:执行事务中的所有操作命令。
  • DISCARD:取消事务,放弃执行事务块中的所有命令。
  • WATCH:监视一个或多个 key,如果事务在执行前,这个 key(或多个 key)被其他命令修改,则事务被中断,不会执行事务中的任何命令。
  • UNWATCH:取消 WATCH 对所有 key 的监视。

标准的事务执行

给 k1、k2 分别赋值,在事务中修改 k1、k2,执行事务后,查看 k1、k2 值都被修改。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
127.0.0.1:6379> set k1 v1
OK
127.0.0.1:6379> set k2 v2
OK
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> set k1 11
QUEUED
127.0.0.1:6379> set k2 22
QUEUED
127.0.0.1:6379> EXEC
1) OK
2) OK
127.0.0.1:6379> get k1
"11"
127.0.0.1:6379> get k2
"22"
127.0.0.1:6379>

事务取消

1
2
3
4
5
6
7
8
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> set k1 33
QUEUED
127.0.0.1:6379> set k2 34
QUEUED
127.0.0.1:6379> DISCARD
OK

事务出现错误的处理

  • 语法错误(编译器错误)

在开启事务后,修改 k1 值为 11,k2 值为 22,但 k2 语法错误,最终导致事务提交失败,k1、k2 保留原值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
127.0.0.1:6379> set k1 v1
OK
127.0.0.1:6379> set k2 v2
OK
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> set k1 11
QUEUED
127.0.0.1:6379> sets k2 22
(error) ERR unknown command `sets`, with args beginning with: `k2`, `22`,
127.0.0.1:6379> exec
(error) EXECABORT Transaction discarded because of previous errors.
127.0.0.1:6379> get k1
"v1"
127.0.0.1:6379> get k2
"v2"
127.0.0.1:6379>
  • Redis 类型错误(运行时错误)

在开启事务后,修改 k1 值为 11,k2 值为 22,但将 k2 的类型作为 List,在运行时检测类型错误,最终导致事务提交失败,此时事务并没有回滚,而是跳过错误命令继续执行, 结果 k1 值改变、k2 保留原值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
127.0.0.1:6379> set k1 v1
OK
127.0.0.1:6379> set k1 v2
OK
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> set k1 11
QUEUED
127.0.0.1:6379> lpush k2 22
QUEUED
127.0.0.1:6379> EXEC
1) OK
2) (error) WRONGTYPE Operation against a key holding the wrong kind of value
127.0.0.1:6379> get k1
"11"
127.0.0.1:6379> get k2
"v2"
127.0.0.1:6379>

WATCH 命令

WATCH 命令可以为 Redis 事务提供 check-and-set (CAS)行为。

  • CAS? 乐观锁?Redis 官方的例子帮你理解

被 WATCH 的键会被监视,并会发觉这些键是否被改动过了。 如果有至少一个被监视的键在 EXEC 执行之前被修改了, 那么整个事务都会被取消, EXEC 返回 nil-reply 来表示事务已经失败。
举个例子, 假设我们需要原子性地为某个值进行增 1 操作(假设 INCR 不存在)。
首先我们可能会这样做:

1
2
3
val = GET mykey
val = val + 1
SET mykey $val

上面的这个实现在只有一个客户端的时候可以执行得很好。 但是, 当多个客户端同时对同一个键进行这样的操作时, 就会产生竞争条件。举个例子, 如果客户端 A 和 B 都读取了键原来的值, 比如 10 , 那么两个客户端都会将键的值设为 11 , 但正确的结果应该是 12 才对。
有了 WATCH ,我们就可以轻松地解决这类问题了:

1
2
3
4
5
6
WATCH mykey
val = GET mykey
val = val + 1
MULTI
SET mykey $val
EXEC

使用上面的代码, 如果在 WATCH 执行之后, EXEC 执行之前, 有其他客户端修改了 mykey 的值, 那么当前客户端的事务就会失败。 程序需要做的, 就是不断重试这个操作, 直到没有发生碰撞为止。
这种形式的锁被称作乐观锁, 它是一种非常强大的锁机制。 并且因为大多数情况下, 不同的客户端会访问不同的键, 碰撞的情况一般都很少, 所以通常并不需要进行重试。

  • watch 是如何监视实现的呢

Redis 使用 WATCH 命令来决定事务是继续执行还是回滚,那就需要在 MULTI 之前使用 WATCH 来监控某些键值对,然后使用 MULTI 命令来开启事务,执行对数据结构操作的各种命令,此时这些命令入队列。
当使用 EXEC 执行事务时,首先会比对 WATCH 所监控的键值对,如果没发生改变,它会执行事务队列中的命令,提交事务;如果发生变化,将不会执行事务中的任何命令,同时事务回滚。当然无论是否回滚,Redis 都会取消执行事务前的 WATCH 命令。

  • watch 命令实现监视

在事务开始前用 WATCH 监控 k1,之后修改 k1 为 11,说明事务开始前 k1 值被改变,MULTI 开始事务,修改 k1 值为 12,k2 为 22,执行 EXEC,发回 nil,说明事务回滚;查看下 k1、k2 的值都没有被事务中的命令所改变。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
127.0.0.1:6379> set k1 v1
OK
127.0.0.1:6379> set k2 v2
OK
127.0.0.1:6379> WATCH k1
OK
127.0.0.1:6379> set k1 11
OK
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> set k1 12
QUEUED
127.0.0.1:6379> set k2 22
QUEUED
127.0.0.1:6379> EXEC
(nil)
127.0.0.1:6379> get k1
"11"
127.0.0.1:6379> get k2
"v2"
127.0.0.1:6379>
  • unwatch 取消监视
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
127.0.0.1:6379> set k1 v1
OK
127.0.0.1:6379> set k2 v2
OK
127.0.0.1:6379> WATCH k1
OK
127.0.0.1:6379> set k1 11
OK
127.0.0.1:6379> UNWATCH
OK
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> set k1 12
QUEUED
127.0.0.1:6379> set k2 22
QUEUED
127.0.0.1:6379> exec
1) OK
2) OK
127.0.0.1:6379> get k1
"12"
127.0.0.1:6379> get k2
"22"
127.0.0.1:6379>

为什么 Redis 不支持回滚?

如果你有使用关系式数据库的经验, 那么 “Redis 在事务失败时不进行回滚,而是继续执行余下的命令”这种做法可能会让你觉得有点奇怪。
以下是这种做法的优点:

  • Redis 命令只会因为错误的语法而失败(并且这些问题不能在入队时发现),或是命令用在了错误类型的键上面:这也就是说,从实用性的角度来说,失败的命令是由编程错误造成的,而这些错误应该在开发的过程中被发现,而不应该出现在生产环境中。
  • 因为不需要对回滚进行支持,所以 Redis 的内部可以保持简单且快速。

有种观点认为 Redis 处理事务的做法会产生 bug , 然而需要注意的是, 在通常情况下, 回滚并不能解决编程错误带来的问题。 举个例子, 如果你本来想通过 INCR 命令将键的值加上 1 , 却不小心加上了 2 , 又或者对错误类型的键执行了 INCR , 回滚是没有办法处理这些情况的。

如何理解 Redis 与事务的 ACID?

一般来说,事务有四个性质称为ACID,分别是原子性,一致性,隔离性和持久性。

持久化

  • 为什么需要持久化

Redis 是个基于内存的数据库。那服务一旦宕机,内存中的数据将全部丢失。通常的解决方案是从后端数据库恢复这些数据,但后端数据库有性能瓶颈,如果是大数据量的恢复,1、会对数据库带来巨大的压力,2、数据库的性能不如 Redis。导致程序响应慢。所以对 Redis 来说,实现数据的持久化,避免从后端数据库中恢复数据,是至关重要的。

RDB 持久化

RDB 就是 Redis DataBase 的缩写,中文名为快照/内存快照,RDB 持久化是把当前进程数据生成快照保存到磁盘上的过程,由于是某一时刻的快照,那么快照中的值要早于或者等于内存中的值。

触发方式

触发 rdb 持久化的方式有 2 种,分别是手动触发自动触发

手动触发

手动触发分别对应 save 和 bgsave 命令

自动触发

在以下 4 种情况时会自动触发

  • redis.conf 中配置 save m n,即在 m 秒内有 n 次修改时,自动触发 bgsave 生成 rdb 文件;
  • 主从复制时,从节点要从主节点进行全量复制时也会触发 bgsave 操作,生成当时的快照发送到从节点;
  • 执行 debug reload 命令重新加载 redis 时也会触发 bgsave 操作;
  • 默认情况下执行 shutdown 命令时,如果没有开启 aof 持久化,那么也会触发 bgsave 操作;

redis.conf 中配置 RDB

快照周期:内存快照虽然可以通过技术人员手动执行 SAVE 或 BGSAVE 命令来进行,但生产环境下多数情况都会设置其周期性执行条件。

  • Redis 中默认的周期新设置
1
2
3
4
5
6
7
8
9
10
# 周期性执行条件的设置格式为
save <seconds> <changes>

# 默认的设置为:
save 900 1
save 300 10
save 60 10000

# 以下设置方式为关闭RDB快照功能
save ""

以上三项默认信息设置代表的意义是:

  • 如果 900 秒内有 1 条 Key 信息发生变化,则进行快照;
  • 如果 300 秒内有 10 条 Key 信息发生变化,则进行快照;
  • 如果 60 秒内有 10000 条 Key 信息发生变化,则进行快照。读者可以按照这个规则,根据自己的实际请求压力进行设置调整。
  • 其它相关配置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 文件名称
dbfilename dump.rdb

# 文件保存路径
dir /home/work/app/redis/data/

# 如果持久化出错,主进程是否停止写入
stop-writes-on-bgsave-error yes

# 是否压缩
rdbcompression yes

# 导入时是否检查
rdbchecksum yes

dbfilename: RDB 文件在磁盘上的名称。
dir: RDB 文件的存储路径。默认设置为“./”,也就是 Redis 服务的主目录。
stop-writes-on-bgsave-error:上文提到的在快照进行过程中,主进程照样可以接受客户端的任何写操作的特性,是指在快照操作正常的情况下。如果快照操作出现异常(例如操作系统用户权限不够、磁盘空间写满等等)时,Redis 就会禁止写操作。这个特性的主要目的是使运维人员在第一时间就发现 Redis 的运行错误,并进行解决。一些特定的场景下,您可能需要对这个特性进行配置,这时就可以调整这个参数项。该参数项默认情况下值为 yes,如果要关闭这个特性,指定即使出现快照错误 Redis 一样允许写操作,则可以将该值更改为 no。
rdbcompression:该属性将在字符串类型的数据被快照到磁盘文件时,启用 LZF 压缩算法。Redis 官方的建议是请保持该选项设置为 yes,因为“it’s almost always a win”。
rdbchecksum:从 RDB 快照功能的 version 5 版本开始,一个 64 位的 CRC 冗余校验编码会被放置在 RDB 文件的末尾,以便对整个 RDB 文件的完整性进行验证。这个功能大概会多损失 10%左右的性能,但获得了更高的数据可靠性。所以如果您的 Redis 服务需要追求极致的性能,就可以将这个选项设置为 no。

RDB 优缺点

  • 优点
    • RDB 文件是某个时间节点的快照,默认使用 LZF 算法进行压缩,压缩后的文件体积远远小于内存大小,适用于备份、全量复制等场景;
    • Redis 加载 RDB 文件恢复数据要远远快于 AOF 方式;
  • 缺点
    • RDB 方式实时性不够,无法做到秒级的持久化;
    • 每次调用 bgsave 都需要 fork 子进程,fork 子进程属于重量级操作,频繁执行成本较高;
    • RDB 文件是二进制的,没有可读性,AOF 文件在了解其结构的情况下可以手动修改或者补全;
    • 版本兼容 RDB 文件问题;

针对 RDB 不适合实时持久化的问题,Redis 提供了 AOF 持久化方式来解决

AOF 持久化

Redis 是“写后”日志,Redis 先执行命令,把数据写入内存,然后才记录日志。日志里记录的是 Redis 收到的每一条命令,这些命令是以文本形式保存。PS: 大多数的数据库采用的是写前日志(WAL),例如 MySQL,通过写前日志和两阶段提交,实现数据和逻辑的一致性。
而 AOF 日志采用写后日志,即先写内存,后写日志
为什么采用写后日志
Redis 要求高性能,采用写日志有两方面好处:

  • 避免额外的检查开销:Redis 在向 AOF 里面记录日志的时候,并不会先去对这些命令进行语法检查。所以,如果先记日志再执行命令的话,日志中就有可能记录了错误的命令,Redis 在使用日志恢复数据时,就可能会出错。
  • 不会阻塞当前的写操作

但这种方式存在潜在风险:

  • 如果命令执行完成,写日志之前宕机了,会丢失数据。
  • 主线程写磁盘压力大,导致写盘慢,阻塞后续操作。

如何实现 AOF

AOF 日志记录 Redis 的每个写命令,步骤分为:命令追加(append)、文件写入(write)和文件同步(sync)。

  • 命令追加 当 AOF 持久化功能打开了,服务器在执行完一个写命令之后,会以协议格式将被执行的写命令追加到服务器的 aof_buf 缓冲区。
  • 文件写入和同步 关于何时将 aof_buf 缓冲区的内容写入 AOF 文件中,Redis 提供了三种写回策略

Always,同步写回:每个写命令执行完,立马同步地将日志写回磁盘;
Everysec,每秒写回:每个写命令执行完,只是先把日志写到 AOF 文件的内存缓冲区,每隔一秒把缓冲区中的内容写入磁盘;
No,操作系统控制的写回:每个写命令执行完,只是先把日志写到 AOF 文件的内存缓冲区,由操作系统决定何时将缓冲区内容写回磁盘。

  • 三种写回策略的优缺点

上面的三种写回策略体现了一个重要原则:trade-off,取舍,指在性能和可靠性保证之间做取舍。

redis.conf 中配置 AOF

默认情况下,Redis 是没有开启 AOF 的,可以通过配置 redis.conf 文件来开启 AOF 持久化,关于 AOF 的配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# appendonly参数开启AOF持久化
appendonly no

# AOF持久化的文件名,默认是appendonly.aof
appendfilename "appendonly.aof"

# AOF文件的保存位置和RDB文件的位置相同,都是通过dir参数设置的
dir ./

# 同步策略
# appendfsync always
appendfsync everysec
# appendfsync no

# aof重写期间是否同步
no-appendfsync-on-rewrite no

# 重写触发配置
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb

# 加载aof出错如何处理
aof-load-truncated yes

# 文件重写策略
aof-rewrite-incremental-fsync yes

以下是 Redis 中关于 AOF 的主要配置信息:
appendonly:默认情况下 AOF 功能是关闭的,将该选项改为 yes 以便打开 Redis 的 AOF 功能。
appendfilename:这个参数项很好理解了,就是 AOF 文件的名字。
appendfsync:这个参数项是 AOF 功能最重要的设置项之一,主要用于设置“真正执行”操作命令向 AOF 文件中同步的策略。
什么叫“真正执行”呢?还记得 Linux 操作系统对磁盘设备的操作方式吗? 为了保证操作系统中 I/O 队列的操作效率,应用程序提交的 I/O 操作请求一般是被放置在 linux Page Cache 中的,然后再由 Linux 操作系统中的策略自行决定正在写到磁盘上的时机。而 Redis 中有一个 fsync()函数,可以将 Page Cache 中待写的数据真正写入到物理设备上,而缺点是频繁调用这个 fsync()函数干预操作系统的既定策略,可能导致 I/O 卡顿的现象频繁 。

与上节对应,appendfsync参数项可以设置三个值,分别是:always、everysec、no,默认的值为everysec。
**no-appendfsync-on-rewrite**:always和everysec的设置会使真正的I/O操作高频度的出现,甚至会出现长时间的卡顿情况,这个问题出现在操作系统层面上,所有靠工作在操作系统之上的Redis是没法解决的。为了尽量缓解这个情况,Redis提供了这个设置项,保证在完成fsync函数调用时,不会将这段时间内发生的命令操作放入操作系统的Page Cache(这段时间Redis还在接受客户端的各种写操作命令)。 **auto-aof-rewrite-percentage**:上文说到在生产环境下,技术人员不可能随时随地使用“BGREWRITEAOF”命令去重写AOF文件。所以更多时候我们需要依靠Redis中对AOF文件的自动重写策略。Redis中对触发自动重写AOF文件的操作提供了两个设置:auto-aof-rewrite-percentage表示如果当前AOF文件的大小超过了上次重写后AOF文件的百分之多少后,就再次开始重写AOF文件。例如该参数值的默认设置值为100,意思就是如果AOF文件的大小超过上次AOF文件重写后的1倍,就启动重写操作。 **auto-aof-rewrite-min-size**:参考auto-aof-rewrite-percentage选项的介绍,auto-aof-rewrite-min-size设置项表示启动AOF文件重写操作的AOF文件最小大小。如果AOF文件大小低于这个值,则不会触发重写操作。注意,auto-aof-rewrite-percentage和auto-aof-rewrite-min-size只是用来控制Redis中自动对AOF文件进行重写的情况,如果是技术人员手动调用“BGREWRITEAOF”命令,则不受这两个限制条件左右.

从持久化中恢复数据

其实想要从这些文件中恢复数据,只需要重新启动 Redis 即可.

加载步骤

  • redis 重启时判断是否开启 aof,如果开启了 aof,那么就优先加载 aof 文件;
  • 如果 aof 存在,那么就去加载 aof 文件,加载成功的话 redis 重启成功,如果 aof 文件加载失败,那么会打印日志表示启动失败,此时可以去修复 aof 文件后重新启动;
  • 若 aof 文件不存在,那么 redis 就会转而去加载 rdb 文件,如果 rdb 文件不存在,redis 直接启动成功;
  • 如果 rdb 文件存在就会去加载 rdb 文件恢复数据,如加载失败则打印日志提示启动失败,如加载成功,那么 redis 重启成功,且使用 rdb 文件恢复数据;

那么为什么会优先加载 AOF 呢?因为 AOF 保存的数据更完整,通过上面的分析我们知道 AOF 基本上最多损失 1s 的数据。

Node 中使用 Redis

推荐模块ioredis.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const ioredis = require("ioredis");

// 建立连接
const redis = new ioredis({
port: 6379,
host: "127.0.0.1",
});
// 操作Redis数据库
// redis.set('far', 'bar', (err, ret) => {
// if(err) {
// console.log('写入失败', err);
// }
// console.log('写入成功', ret);
// })

// 支持promise
redis
.get("far")
.then((ret) => {
console.log(ret);
})
.catch((err) => {
console.log("获取失败", err);
});

使用 pipeline 管道

存在多条命令时,使用pipeline可以提高效率.使用exec结束 pipeline 就会给数据库发送请求.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
async function main() {
try {
const pipeline = redis.pipeline();
for (let i = 0; i < 100; i++) {
pipeline.set(`${i}-foo`, i);
}
// 结束命令就执行
await pipeline.exec();
} catch (error) {
console.log(error);
}
}

// 甚至能够链式调用命令
redis
.pipeline()
.set("foo", "bar")
.del("cc")
.exec(function (err, results) {});

// `exec` 也是返回的promise
var promise = redis.pipeline().set("foo", "bar").get("foo").exec();
promise.then(function (result) {
// result === [[null, 'OK'], [null, 'bar']]
});

每个链式命令也可以有一个回调,当命令得到回复时将调用它

1
2
3
4
5
6
7
8
9
redis
.pipeline()
.set("foo", "bar")
.get("foo", function (err, result) {
// result === 'bar'
})
.exec(function (err, result) {
// result[1][1] === 'bar'
});

除了单独向管道队列添加命令外,还可以将一组命令和参数传递给构造函数:

1
2
3
4
5
6
7
8
redis
.pipeline([
["set", "foo", "bar"],
["get", "foo"],
])
.exec(function () {
/* ... */
});

查看管道中有多少命令

1
redis.pipeline().set("foo", "bar").get("foo").length; // 2

事务

调用multi时会自动创建pipeline.

1
2
3
4
5
6
7
8
async function main() {
try {
const ret = await redis.multi().set("Jack", 100).set("Tom", 200).exec();
console.log(ret);
} catch (error) {
console.log(error);
}
}

错误调试

showFriendlyErrorStack: 设置为 true 会在报错时提供错误信息.建议只在调试时开启.

NoSQL

不仅仅是 SQL,即非关系型数据库.

分类

  1. 键值数据库(Redis,Flare)

拥有极高的读写性能,用于处理大量数据的高访问负载,例如日志系统.

  1. 文档型数据库(mangoDB,CouchDB)

满足海量的访问和存储,同时对字段要求不严格,可以随意增删改,适用于网络应用.

  1. 列存储型数据库(Cassandra,Hbase)

查找快,扩展性强,适合用作分布式文件存储系统.(大数据)

  1. 图存储型数据库(InfoGrid,Heo4J)

利用图结构的相关算法来存储实体之间的关系信息,适用于构建社交网络和推荐系统的关系图谱.

选择

既然 NoSQL 数据库有这么多的优势,那它是否可以直接取代天系型数据库?
NoSQL 并不能完全取代关系型数据库,NoSQL 主要被用来处理大量且多元数据的存储及运算问题。在这样的特性差异下,我们该如何选择合适的数据库以解决数据存储与处理问题呢?这里提供以下几点作为判断依据。

  1. 数据模型的关联性要求

NoSQL 适合模型关联性比较低的应用。因此:
·如果需要多表关联,则更适合用 RDB
·如果对象实体关联少,则更适合用 NoSQL 数据库.
其中 MongoDB 可以支持复杂度相对高的数据结构,能够将相关联的数据以文档的方式嵌入,从而减少数据之间的关联操作

  1. 数据库的性能要求

如果数据量多切访问速度至关重要,那么使用 NoSQL 数据库可能是比较合适的。NoSQL 数据库能通过数据的分布存储大幅地提供存储性能。

  1. 数据的一致性要求

NoSQL 数据库有一个缺点:其在事务处理与一致性方面无法与 RDB 相提并论。
因此,NoSQL 数据库很难同时满足强一致性与高并发性。如果应用对性能有高要求,则 NoSQL 数据库只能做到数据最终一致。

  1. 数据的可用性要求

考虑到数据不可用可能会造成风险,NoSQL 数据库提供了强大的数据可用性(在一些需要快速反馈信息给使用者的应用中,响应延迟也算某种程度的高可用)。
一个项目并非只选择一种数据库,可以将其拆开设计,将需要 RDB 特性的放到 RDB 中管理,而其它数据放到 NoSQL 中管理。

安装 MongoDB

1
2
brew tap mongodb/brew
brew install mongodb-community@4.4

然后按照提示修改环境变量,使用source命令重启.

英特尔处理器 苹果 M1 处理器
配置文件 /usr/local/etc/mongod.conf /opt/homebrew/etc/mongod.conf
log directory(log 目录) /usr/local/var/log/mongodb /opt/homebrew/var/log/mongodb
data directory(data 目录) /usr/local/var/mongodb /opt/homebrew/var/mongodb

启动

1
2
3
4
5
brew services start mongodb-community@4.4
// 停止
brew services stop mongodb-community@4.4
// 查看后台服务列表
brew services list

mongo shell

mongoDB 退出的 shell 命令,进入方法,先启动 MongoDB 服务,再输入mongo.
shell 中支持基本的 js 语法.

端口号

默认链接 27017 端口,如果要修改

1
2
3
mongo --prot 27018
// 连接远程主机
mongo --host mongodb0.example.com:28015

常用操作

查看数据库: show dbs
当前数据库: db
切换/创建数据库: use <name>
查看集合: show collections
清空数据库: db dropDatabase()
创建集合: db.xxx.insert({})
查询集合: db.xxx.find()
删除集合: db.xxx.drop()

文档

MongoDB 将数据存储为 BSON 文档.

字段

文档对字段名称有以下限制:

  • 字段名称_id 保留用作主键;它的值在集合中必须是唯一的,不可变的,并且可以是数组以外的任何类型.
  • 字段名称不能包含空字符。

_id 字段

在 MongoDB 中,存储在集合中的每个文档都需要一个唯一的_id字段作为主键。如果插入的文档省略_id字段,则 MongoDB 驱动程序会自动为_id字段生成 Objectld。
id 字段具有以下行为和约束:

  • 默认情况下,MongoDB 在创建集合时会在_id字段上创建唯一索引。
  • _id字段始终是文档中的第一个字段
  • _id字段可以包含任何 BSON 数据类型的值,而不是数组。

创建文档

  • db.xxx.insertOne(): 插入一个,返回 id
  • db.xxx.insertMany(): 插入多个,返回 id
  • db.xxx.insert(): 插入任意个,返回是否成功

查询文档

  • db.xxx.find(query, projection)
    • query,可选,指定查询条件
    • projection,可选,使用投影操作符指定返回的键.
    • $or或查询,匹配一个或多个条件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 条件查询
db.xxx.find({
status: "A", //查询条件为A的所有
});
db.xxx.find(
{},
{
item: 1, //1 表示查询item,0表示不包括item
qty: 1,
}
);
// 或查询
db.xxx.find({
$or: [
(status: "A"),
{
qty: { $lt: 30 }, // qty小于30
},
],
});
  • db.xxx.findOne()

类型检查

BSON 类型为 Nul(类型编号 10):

1
db.xxx.find({ item: { $type: 10 } });

存在检查

查询匹配不包含 item 的文档:

1
db.xxx.find({ item: { $exists: false } });

更新文档

  • db.xxx.updateOne(<filter>, <update>, <options>)
  • db.xxx.updateMany(<filter>, <update>, <options>)
  • db.xxx.replaceOne(<filter>, <update>, <options>)

可以指定更新文档的条件或过滤器.

1
2
3
4
5
// 将age小于18 的文档中status更新为reject
db.xxx.updateMany({
{age: { $lt: 18 },
{$set: {stastus: 'reject'}}
})

替换文档

1
2
3
4
db.xxx.replaceOne(
{item: 'paper'},
{item: 'paper', instock: {qty: 60}
)

删除文档

  • db.xxx.deleteMany()
  • db.xxx.deleteOne(): 仅删除一个符合条件的文档

数据库的操作

  • 导出数据库 ./mongoexport –port [port] –db test –collection users –out export.json
  • 导入数据库 ./mongoimport -h 120.79.229.197:27-17 -d test -c scenics –file=./export.json

Node 中使用 MongoDB

1
2
//安装mongo,这是项目中使用的为node准备的工具
npm i mongo -D
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
const { MongoClient } = require("mongodb");

const client = new MongoClient("mongodb://127.0.0.1:27017"); // 连接本地mongo客户端

async function run() {
try {
// 开始连接
await client.connect();
// 操作数据库
const testDB = client.db("test");
// 通过数据库获取集合
const userCollection = testDB.collection("users");
// 增加
// const ret = await userCollection.insertOne({
// name: 'Jim',
// age: 12
// })
// console.log(ret);

// 查询参数
const ret = await userCollection.find();
console.log("ret: ", await ret.toArray()); // ret.toArray()返回promise所以await
} catch (error) {
console.log("连接失败", err);
} finally {
// 断开连接
await client.close();
}
}

run();

删除_id 的文档

1
2
3
4
5
6
const { ObjectId } = require("mongodb");
// 删除
const ret = await userCollection.deleteOne({
_id: ObjectId("636b5886e7de417db38a5610"),
});
console.log(ret);

网络通信方式

  • 交换机
  • 路由器

网络层次模型

OSI 七层模型

  • 应用层: 用户与网络的接口
  • 表示层: 数据加密,转换,压缩
  • 会话层: 控制网络连接建立与中止
  • 传输层: 控制数据传输可靠性
  • 网络层: 确定目标网络
  • 数据链路层: 确定目标主机
  • 物理层: 各种网络屋里设备

TCP 四层模型

数据封装与解封装

TCP 三次握手与四次挥手

TCP 协议

  • TCP 属于传输层协议
  • TCP 是面向连接的协议
  • TCP 用于处理实时通信

本质上请求连接或请求断开都是四次连接,但是请求断开中服务端请求不能合并成一条.

常见控制字段

  • SYN=1: 请求建立连接
  • FIN=1: 请求断开连接
  • ACK=1: 数据信息的确认

本来应该是两次一来一回,共四次,但是服务端这里可以将请求和确认合并发送,就是三次握手连接.
image.png
断开连接:
变成四次是因为确保服务端已经将数据全部传输完毕,所以不能进行合并.

创建 TCP 通信

通信过程

  • 创建服务端: 接收和诙谐客户端数据
  • 创建客户端: 发送接收服务端数据
  • 数据传输: 内置服务事件和方法读写数据

通信事件

  • listening 事件: 调用 server.listen 方法后触发
  • connection 事件: 新的连接建立时触发
  • close 事件: 当 server 关闭时触发
  • error 事件: 当错误出现时触发

通信事件&方法

  • data 事件: 当接收到数据时触发
  • write 方法: 在 socket 上发送数据,默认 UTF-8
  • end 操作: 当 socket 的一端发送 FIN 包时触发,结束可读端
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// server.js
const net = require("net");

// 创建服务端实例
const server = net.createServer();
const PORT = 1234;
const HOST = "localhost";

server.listen(PORT, HOST);

//调用listen后触发listening事件
server.on("listening", () => {
console.log(`服务端已开启在 ${HOST}: ${PORT}`);
});

// 接收消息 回写消息
server.on("connection", (socket) => {
socket.on("data", (chunk) => {
const msg = chunk.toString();
console.log(msg);

//回数据
socket.write(Buffer.from("您好" + msg));
});
});

server.on("close", () => {
console.log("服务端关闭");
});
server.on("error", (err) => {
if (err.code === "EADDRINUSE") {
console.log("地址占用");
} else {
console.log(err);
}
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// client.js
const net = require("net");

const client = net.createConnection({
port: 1234,
host: "127.0.0.1",
});

client.on("connect", () => {
client.write("edu");
});

client.on("data", (chunk) => {
console.log(chunk.toString());
});

client.on("close", () => {
console.log("客户端断开连接");
});
client.on("error", (err) => {
console.log(err);
});

TCP 数据粘包

发送端和接收端都是等待缓冲数据之后再消费.
出现问题的原因: 发送间隔太短导致数据堆积.

数据的封包和拆包

将消息头拆分为序列号和消息长度

数据传输过程

  • 进行数据编码,获取二进制数据包
  • 按规则拆解数据,获取指定长度数据

Buffer 数据读写

  • writeInt16BE: 将 value 从指定位置写入
  • readInt16BE: 从指定位置开始读取数据
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// 编码解码
class myTransform {
constructor() {
// 固定header总长度4字节
this.packageHeaderLen = 4;
// 当前包的编号
this.serialNum = 0;
this.serialLen = 2;
}

// 编码
encode(data, serialNum) {
//把传入数据变为二进制
const body = Buffer.from(data);
// 01 先按照指定长度申请内存空间作为header使用
const headerBuf = Buffer.alloc(this.packageHeaderLen);
// 02 写操作
headerBuf.writeInt16BE(serialNum || this.serialNum);
// 数据的长度作为消息体的总长度写入另一空间
headerBuf.writeInt16BE(body.length, this.serialLen); // 跳过前面占用的位置
// 设置编号
if (serialNum === undefined) {
this.serialNum++;
}
return Buffer.concat(headerBuf, body);
}

// 解码
decode(buffer) {
const headerBuf = buffer.slice(0, this.packageHeaderLen);
const bodyBuf = buffer.slice(this.packageHeaderLen);

return {
serialNum: headerBuf.readInt16BE(),
bodyLength: headerBuf.readInt16BE(this.serialLen),
body: bodyBuf.toString(),
};
}

// 获取数据包长度,用于判断是否还有数据
getPackageLen(buffer) {
if (buffer.length < this.packageHeaderLen) {
return 0;
} else {
return this.packageHeaderLen + buffer.readInt16BE(this.serialLen);
}
}
}

module.exports = myTransform;

数据粘包解决

利用上面的编解码工具类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// server.js
const myTransform = require("./myTransform.js");

let overageBuffer = null;
let ts = new myTransform();
// ...
// 接收消息 回写消息
server.on("connection", (socket) => {
socket.on("data", (chunk) => {
// 如果有未使用完的buffer
if (overageBuffer) {
//将数据拼接
chunk = Buffer.concat([overageBuffer, chunk]);
}

let packageLen = 0;
// 如果数据为0,则说明不足以读取,否则就是可读取
while ((packageLen = ts.getPackageLen(chunk))) {
// 把包的内容截取出来
const packageCon = chunk.slice(0, packageLen);
// 更新chunk,将已读取的截掉
chunk = chunk.slice(packageLen);

const ret = ts.decode(packageCon);
//回数据
socket.write(ret.body, ret.serialNum);
}
// 将剩余的chunk交给overageBuffer下一次处理
overageBuffer = chunk;
});
});

Http 协议

获取 http 请求信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const http = require("http");
const url = require("url");

const server = http.createServer((req, res) => {
const { pathname, query } = url.parse(req.url, true);
// 请求方式
console.log(req.method);
// http版本号
console.log(req.httpVersion);
// 请求头
console.log(req.headers);
// 请求体数据获取
let arr = [];
// req是个可读流
req.on("data", (data) => {
arr.push(data);
});

req.on("end", () => {
console.log(Buffer.concat(arr).toString());
});
});

server.listen(1234, () => {
console.log("server start ...");
});

设置 http 响应

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const http = require("http");
const url = require("url");

const server = http.createServer((req, res) => {
// res就是可写流
// res.write('ok')
// 使用end结束写入操作,或者直接在里面写入
// res.end()

// 设置响应头
res.statusCode = 200;
res.setHeader("Content-type", "text/html;charset=utf-8");
res.end("你好");
});

server.listen(1234, () => {
console.log("server start ...");
});

客户端代理

相当于服务端,客户端与不同端口域名协议的服务端通信有跨域,那就采用客户端代理,它与服务端就不存在跨域.

介绍

根据 Node.js 官网的定义:Node.js 是一个基于 Chrome V8 引擎的 JavaScript 运行环境。 Node.js 使用了一个事件驱动、非阻塞式 I/O 的模型,使其轻量又高效。

Node.js 是基于 Chrome V8 引擎构建的.由事件循环分发 I/O 任务,最终工作线程将任务丢进线程池中去执行,而事件循环只要等待执行结果就可以了.

异步 IO

重复调用 IO 操作,判断是否完成,称为轮询.包括read,select,poll,kqueue,event ports.
期望实现无须主动判断的非阻塞 I/O.

异步 IO 优点

  • 前端通过异步 IO 可以消除阻塞。
  • 请求耗时少,假如有两个请求 A 和 B,那么异步 IO 用时为:Max(A+B)。同步则为 A+B,请求越多差距越大。
  • IO 是昂贵的,分布式 IO 是更昂贵的。
  • Node.js 适用于 IO 密集型,而不适用于 CPU 密集型。
  • 并不是所有都用异步任务好,遵循一个公式: s= (Ws+Wp)/(Ws+Wp/p) Ws 表示同步任务,Wp 表示异步任务,p 表示处理器的数量。

异步 IO 实现

  • 应用程序先将 JS 代码经 V8 转换为机器码。
  • 通过 Node.js Bindings 层,向操作系统 Libuv 的事件队列中添加一个任务。
  • Libuv 将事件推送到线程池中执行。
  • 线程池执行完事件,返回数据给 Libuv。
  • Libuv 将返回结果通过 Node.js Bindings 返回给 V8。
  • V8 再将结果返回给应用程序。

libuv

多种异步 IO 实现的抽象封装层.
libuv 实现了 Node.js 中 Eventloop,主要分为以下几个阶段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
   ┌───────────────────────────┐
┌─>│ timers │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └─────────────┬─────────────┘ │ data, etc. │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │
└───────────────────────────┘

上图中每一个阶段都有一个先进先出的回调队列,只有当队列内的事件执行完成之后,才会进入下一个阶段。

  • timers:执行 setTimeoutsetInterval 中到期的 callback。
  • pending callbacks:上一轮循环中有少数的 I/O callback 会被延迟到这一轮的这一阶段执行。
    • 执行一些系统操作的回调,例如 tcp 连接发生错误。
  • idle, prepare:仅内部使用。
  • poll:最为重要的阶段,执行 I/O callback(node 异步 api 的回调,事件订阅回调等),在适当的条件下会阻塞在这个阶段。
    • 如果 poll 队列不为空,直接执行队列内的事件,直到队列清空。
    • 如果 poll 队列为空。
      • 如果有设置 setImmediate,则直接进入 check 阶段。
      • 如果没有设置 setImmediate,则会检查是否有 timers 事件到期。
        • 如果有 timers 事件到期,则执行 timers 阶段。
        • 如果没有 timers 事件到期,则会阻塞在当前阶段,等待事件加入。
  • check:执行 setImmediate 的 callback。
  • close callbacks:执行 close 事件的 callback,例如 socket.on("close",func)

除此之外,Node.js 提供了 process.nextTick(微任务,promise 也一样) 方法,在以上的任意阶段开始执行的时候都会触发。

  • Event Loop 是一个很重要的概念,指的是计算机系统的一种运行机制。
  • Libuv 在 Linux 下基于 Custom Threadpool 实现。
  • Libuv 在 Windows 下基于 IOCP 实现。

常见的异步 IO 使用方式

  • 使用 step,q,async 等异步控制库。
  • 使用 Promise 处理异步。
  • 使用 EventEmitter,实现“发布/订阅”模式处理异步。
  • Node.js 暂不支持协程,可使用 Generator 代替。
  • 终极解决方案:async、await。

事件驱动架构

commonjs 模块

用法

  • 引入模块:require(“./index.js”)
  • 导出模块:module.exports={…} 或者 exports.key={}
    • 注意:exports 为 module.exports 的引用,不可使用 exports 直接赋值,模块无法导出,eg:exports={}
  • 缓存:require 值会缓存,通过 require 引用文件时,会将文件执行一遍后,将结果通过浅克隆的方式,写入全局内存,后续 require 该路径,直接从内存获取,无需重新执行文件
  • 值拷贝:模块输出是值的拷贝,一但输出,模块内部变化后,无法影响之前的引用,而 ESModule 是引用拷贝。commonJS 运行时加载,ESModule 编译阶段引用
    • CommonJS 在引入时是加载整个模块,生成一个对象,然后再从这个生成的对象上读取方法和属性
    • ESModule 不是对象,而是通过 export 暴露出要输出的代码块,在 import 时使用静态命令的方法引用制定的输出代码块,并在 import 语句处执行这个要输出的代码,而不是直接加载整个模块

      面试题:为何直接使用 exports 导出 commonjs 模块,eg:exports = “hello”
      解析:commonjs 模块是通过 module.exports 导出模块,exports 为 module.exports 的引用,使用 exports 直接赋值导出模块,只会改变 exports 的引用不会导出 commonjs 模块。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// cjs正确导出用法
exports.key = "hello world";
module.exports = "hello world";
//错误导出
exports = "hello world"; //无法输出

//解析:
const obj = {
key: {},
};
obj.key = "hello world"; //可改变obj
const key = obj.key;
key.key1 = "hello world"; //可改变obj
key = "hello world"; //无法改变obj,改变了key的引用

module 属性

  • 任意 js 文件就是一个模块.可以直接使用 module 属性.
  • id: 返回模块标识符,一般是一个绝对路径
  • filename: 返回绝对路径
  • loaded: 返回布尔值,表示模块是否加载完成
  • parent: 返回对象存放调用当前模块的模块
  • children: 返回数组,存放当前模块调用的其他模块
  • exports;返回当前模块需要暴露的内容
  • paths: 返回数组,存放不同目录下的 node_modules 位置

原理实现

使用 fs、vm、path 内置模块,以及函数包裹形式实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
const vm = require("vm");
const path = require("path");
const fs = require("fs");

/**
* commonjs的require函数:引入module
* @param {string} filename module的名称
*/
function customRequire(filename) {
const pathToFile = path.resolve(__dirname, filename);
const content = fs.readFileSync(pathToFile, "utf-8");
//使用函数包裹模块,执行函数
//注入形参:require、module、exports、__dirname、__filename
const wrapper = [
"(function(require, module, exports, __dirname, __filename){",
"})",
];
const wrappedContent = wrapper[0] + content + wrapper[1];
const script = new vm.Script(wrappedContent, {
filename: "index.js",
});
const module = {
exports: {},
};
//转换为函数,类似eval,(funcion(require, module, exports){ xxx })
const result = script.runInThisContext();
//函数执行,引入模块,若内部有require继续递归
//exports为module.exports的引用
result(customRequire, module, module.exports);
return module.exports;
}

global.customRequire = customRequire;

源码分析

  • 源码路径:/lib/internal/modules/cjs/
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
//loader.js
//require函数定义
1Module.prototype.require:调用__load函数
2Module.__load:_cache处理,调用load函数
3Module.prototype.load函数:调用Module._extensions[extension](this, filename);
//不同的后缀通过定义不同的函数指定解析规则:以Module._extensions['.js']为例
4Module._extensions['.js'] = function(module, filename) {
//读取缓存或者通过readFileSync读取内容
if (cached?.source) {
content = cached.source;
cached.source = undefined;
} else {
content = fs.readFileSync(filename, 'utf8');
}
//...
//调用compile解析
module._compile(content, filename);
}
5Module.prototype._compile = function(content, filename){
//生成包裹函数:warpSafe获取函数字符串并使用vm执行生成执行函数
const compiledWrapper = wrapSafe(filename, content, this);
//执行函数
const exports = this.exports;
const thisValue = exports;
const module = this;
if (inspectorWrapper) {
result = inspectorWrapper(compiledWrapper, thisValue, exports,
require, module, filename, dirname);
} else {
//静态方法Reflect.apply(target, thisArgument, argumentsList)
//通过指定的参数列表发起对目标(target)函数的调用
result = ReflectApply(compiledWrapper, thisValue,
[exports, require, module, filename, dirname]);
}
return result;
}
6function wrapSafe(filename, content, cjsModuleInstance) {
/* 生成包裹函数字符:
let wrap = function(script) {
return Module.wrapper[0] + script + Module.wrapper[1];
};
const wrapper = [
'(function (exports, require, module, __filename, __dirname) { ',
'\n});',
];*/
const wrapper = Module.wrap(content);
//获取包裹函数
return vm.runInThisContext(wrapper, {
filename,
lineOffset: 0,
displayErrors: true,
importModuleDynamically: async (specifier) => {
const loader = asyncESM.ESMLoader;
return loader.import(specifier, normalizeReferrerURL(filename));
},
});
}
1
2
3
4
5
6
7
8
9
10
const Module = require("module");
//后缀解析扩展:.test后缀同.js后缀
Module._extensions[".test"] = Module._extensions[".js"];

//切面编程:解析js模块前做打印处理
const prevFunc = Module._extensions[".js"];
Module._extensions[".js"] = function (...args) {
console.log("load script");
prevFunc.apply(prevFunc, args);
};

事件循环

  • 同步代码
  • process.nextTick(优先级高于 promise)和 promise.then() ,之后进入事件循环
  • timers 阶段:这个阶段执行 timer(setTimeout、setInterval)的回调
  • I/O callbacks 阶段 :处理一些上一轮循环中的少数未执行的 I/O 回调
  • idle, prepare 阶段 :仅 node 内部使用
  • poll 阶段 :获取新的 I/O 事件, 适当的条件下 node 将阻塞在这里
  • check 阶段 :执行 setImmediate() 的回调
  • close callbacks 阶段:执行 socket 的 close 事件回调

执行流程

  • 执行同步代码
  • 查看是否有满足条件的微任务,执行
  • 执行 timer 中满足条件的宏任务
  • timer 中宏任务全部执行完毕切换队列
  • 切换前会清空微任务
  • 之后按照 timer => poll => check 检查是否有任务执行

和浏览器 eventloop 区别:

浏览器执行宏任务就会把本次循环生成的微任务清空,而 node 是在切换队列前清空,所以要将 timer 中宏任务全部执行完毕,再执行微任务.
习题解析:

  • new Promise(()=>{//同步执行}).then(()=>{//异步执行})
  • async function test(){console.log() //同步 -> await test(0) //同步 -> console.log()//异步}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
//习题1:
// 顺序不确定,只有两个语句,执行环境有差异
// 场景1: setTimeout 0 最少1ms,未推入timers队列中,执行结果为:setImmediate、setTimeout
// 场景2: setTimeout 0 已推入timers队列中,执行结果为:setTimeout、setImmediate
setTimeout(() => {
console.log("setTimeout");
}, 0);
setImmediate(() => {
console.log("setImmediate");
});

//习题2: 都在回调函数中,内容确定
//首轮事件循环setTimeout1的timers清空,执行至check阶段,先输出setImmediate
//第二轮事件循环setTimeout2
//最终输出:setTimeout1、setImmediate、setTimeout2
setTimeout(() => {
setTimeout(() => {
console.log("setTimeout2");
}, 0);
setImmediate(() => {
console.log("setImmediate");
});
console.log("setTimeout1");
}, 0);

//习题3: 混合题
setTimeout(() => {
console.log("timeout");
}, 0);
setImmediate(() => {
console.log("immediate");
setImmediate(() => {
console.log("immediate1");
});
new Promise((resolve) => {
console.log(77);
resolve();
}).then(() => {
console.log(88);
});
process.nextTick(function () {
console.log("nextTick1");
});
});
new Promise((resolve) => {
console.log(7);
resolve();
}).then(() => {
console.log(8);
});
process.nextTick(function () {
console.log("nextTick2");
});
console.log("start");

// 第一轮:7 start - timeout | immediate 77 | 8 | nextTick2
// 第二轮:7 start nextTick2 8 timeout immediate 77 - immediate1 | 88 | nextTick1
// 第三轮:7 start nextTick2 8 timeout immediate 77 nextTick1 88 immediate1

模块

全局对象

Node 中全局对象是global,如果直接在模块中获取 this 指向的是{}.
而模块最终会封装进自执行函数中,指向的是global.

  • 全局对象:global,下挂如下对象和函数,使用时无需模块引入
    • global
    • Buffer、process、console、queueMicrotask
    • setTimeout、clearTimeout、setInterval、setImmediate、clearInterval
  • 模块中使用注入变量:
    • __filename:当前文件名称带路径,eg:/Users/jian/workspace/cjs/index.js
    • __dirname:当前文件夹路径,eg:/Users/jian/workspace/cjs/
    • cjs:require, module, exports
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
//全局this指向global
//模块文件中this指向:module.exports的对象
/* 模块中才可以使用的变量:commonjs模块注入,非模块中使用报错
__dirname、__filename、exports、module、require */

//node命令行中
> console.log(__dirname)
// Uncaught ReferenceError: __dirname is not defined
> console.log(this)
/* <ref *1> Object [global] {
global: [Circular *1],
clearInterval: [Function: clearInterval],
clearTimeout: [Function: clearTimeout],
setInterval: [Function: setInterval],
setTimeout: [Function: setTimeout] {
[Symbol(nodejs.util.promisify.custom)]: [Getter]
},
queueMicrotask: [Function: queueMicrotask],
clearImmediate: [Function: clearImmediate],
setImmediate: [Function: setImmediate] {
[Symbol(nodejs.util.promisify.custom)]: [Getter]
}
}*/

//node模块中执行
console.log(this)
console.log(__filename)
console.log(__dirname)
/*
{}
/Users/jian/workspace/cjs/index.js
/Users/jian/workspace/cjs
*/

process

  • argv:返回一个数组,由命令行执行脚本时的各个参数组成。它的第一个成员总是 node,第二个成员是脚本文件名,其余成员是脚本文件的参数
  • env:返回一个对象,成员为当前 shell 的环境变量
  • stdin:标准输入流
  • stdout:标准输出流
  • cwd():返回当前进程的工作目录
1
2
3
4
console.log(process.argv);
//[ '/usr/local/bin/node', '/Users/jian/workspace/cjs/index.js' ]
console.log(process.cwd()); ////Users/jian/workspace/cjs
process.stdout.write("Hello World!"); //Hello World!

底层依赖模块

  • V8 引擎:主要是 JS 语法的解析,有了它才能识别 JS 语法
  • libuv:C 语⾔实现的⼀个⾼性能异步⾮阻塞 IO 库,⽤来实现 node.js 的事件循环
  • http-parser/llhttp:底层处理 http 请求,处理报⽂,解析请求包等内容
  • openssl:处理加密算法,各种框架运⽤⼴泛,底层依赖,无需 js 实现
  • zlib:处理压缩等内容

Eventmitter

  • 大多数时候我们不会直接使用 EventEmitter,而是在对象中继承它,包括 fs、net、 http 在内的,只要是支持事件响应的核心模块都是 EventEmitter 的子类。
  • EventEmitter 会按照监听器注册的顺序同步地调用所有监听器,所以必须确保事件的排序正确,且避免竞态条件。
  • 常见 api
    • addListener(event, listener)添加监听器
    • removeListener(event, listener)移除监听器
    • removeAllListeners([event])移除所有监听器
    • on(event, listener)注册监听器
    • off(eventName, listener)移除监听器
    • once(event, listener)为指定事件注册一个单次监听器
    • emit(event, [arg1], [arg2], […])按监听器的顺序执行执行每个监听器,如果事件有注册监听返回 true,否则返回 false
  • 错误处理:当 EventEmitter 实例出错时,应该触发 ‘error’ 事件。 这些在 Node 中被视为特殊情况。如果没有为 ‘error’ 事件注册监听器,则当 ‘error’ 事件触发时,会抛出错误、打印堆栈跟踪、并退出 Node.js 进程。作为最佳,应该始终为 ‘error’ 事件注册监听器
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const EventEmitter = require("events").EventEmitter;
var eventEmitter = new EventEmitter();
eventEmitter.on("event", function (a, b) {
console.log(a, b, this, this === eventEmitter);
// 打印: 普通函数,this指向eventEmitter实例
// a b MyEmitter {
// domain: null,
// _events: { event: [Function] },
// _eventsCount: 1,
// _maxListeners: undefined } true
});
eventEmitter.emit("event", "a", "b");

eventEmitter.on("event", (a, b) => {
//箭头函数,this指向module.exports
console.log(a, b, this, this === module.exports);
// 打印: a b {} true
});
eventEmitter.emit("event", "a", "b");

事件模型设计(使用:发布订阅模式)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
class EventEmitter {
constructor(maxListeners) {
this.events = {};
this.maxListeners = maxListners || Infinity;
}

on(event, listener) {
if (!this.events[event]) {
this.events[event] = [];
}
if (
this.maxListener != Infinity &&
this.events[event].length > this.maxListeners
) {
console.warn(`${event} has reached max listeners.`);
return this;
}
this.events[event].push(listener);
return this;
}

off(event, listener) {
if (!listener) {
this.events[event] = null; //无listener全部移除
} else {
this.events[event] = this.events[event].filter(
(item) => item !== listener
);
}
return this; //链式调用
}

once(event, listener) {
const func = (...args) => {
this.off(event, listener);
listener.apply(this, args);
};
this.on(event, func);
}

emit(event, ...args) {
const listeners = this.events[event];
if (!listeners) {
console.log("no listeners");
return this;
}
listeners.forEach((cb) => {
cb.apply(this, args);
});
return this;
}
}

常用模块

  • fs:⽂件系统,能够读取写⼊当前安装系统环境中硬盘的数据
  • path:路径系统,能够处理路径之间的问题
  • crypto:加密相关模块,能够以标准的加密⽅式对我们的内容进⾏加解密
  • dns:处理 dns 相关内容,例如我们可以设置 dns 服务器等等
  • http: 设置⼀个 http 服务器,发送 http 请求,监听响应等等
  • readline: 读取 stdin 的⼀⾏内容,可以读取、增加、删除我们命令⾏中的内容
  • os:操作系统层⾯的⼀些 api,例如告诉你当前系统类型及⼀些参数
  • vm: ⼀个专⻔处理沙箱的虚拟机模块,底层主要来调⽤ v8 相关 api 进⾏代码解析
  • promisify:可以把异步函数回调形式改为 promise 形式。

fs

  • 文件常识
    • 权限位 mode:文件所有者/文件所属组/其他用户的 rwx,eg:默认 0o666,对应十进制 438
    • 标志位 flag:文件的操作方式,r、w、s 同步、+增加相反操作、x 排他方式
    • 文件描述符 fd:识别和追踪文件
  • 完整性读写文件操作
    • fs.readFile(filename,[encoding],[callback(error,data)]
    • fs.writeFile(filename,data,[options],callback)
      • options:可选,为对象,{encoding, mode, flag}
      • 默认编码为 utf8,模式为 0666(可读可写可操作),flag 为 ‘w’
    • fs.unlink(filename, callback)
  • 指定位置读写文件操作(高级文件操作)
    • fs.open(path,flags,[mode],callback)
    • fs.read(fd, buffer, offset, length, position, callback);
    • fs.write(fd, buffer, offset, length, position, callback);
    • fs.close(fd,callback)
  1. fd:文件描述符,需要先使用 open 打开,使用 fs.open 打开成功后返回的文件描述符;
  2. buffer:一个 Buffer 对象,v8 引擎分配的一段内存,要将内容读取到的 Buffer;
  3. offset:整数,向 Buffer 缓存区写入的初始位置,以字节为单位;
  4. length:整数,读取文件的长度;
  5. position:整数,读取文件初始位置;文件大小以字节为单位
  6. callback:回调函数,有三个参数 err(错误),bytesRead(实际读取的字节数),buffer(被写入的缓存区对象),读取执行完成后执行。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
//nodejs中最好使用绝对路径
const pathToFile = path.resolve(__dirname, "./text");
//err first, 异步
fs.readFile(pathTofile, "utf-8", function (err, result) {
if (err) {
console.log("e", e);
return err;
}
console.log("result", result);
});
//同步文件
fs.readFileSync(pathToFile, "utf-8");

//耦合promise封装
function read(path) {
return new Promise(function (resolve, reject) {
fs.readFile(path, { flag: "r", encoding: "utf-8" }, function (err, data) {
if (err) {
// 失败执行的内容
reject(err);
} else {
// 成功执行的内容
resolve(data);
}
});
});
}

//通用promise封装,高版本已内置该库
function promisify(func) {
return function (...args) {
return new Promise((resolve, reject) => {
args.push(function (err, result) {
if (err) return reject(err);
return resolve(result);
});
return func.apply(func, args);
/* 等价于
fs.readFile(path, { flag: 'r', encoding: 'utf-8' }, function (err, data) {
if (err) {
// 失败执行的内容
reject(err)
} else {
// 成功执行的内容
resolve(data)
}
}) */
});
};
}
const creadFileAsync = promisify(fs.readFile);
creadFileAsync(pathToFile, "utf-8")
.then((content) => {
console.log(content);
})
.catch((e) => {
console.log("e", e);
});

大文件拷贝

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// copy 方法
function copy(src, dest, size = 16 * 1024, callback) {
// 打开源文件
fs.open(src, 'r', (err, readFd) => {
// 打开目标文件
fs.open(dest, 'w', (err, writeFd) => {
let buf = Buffer.alloc(size);
let readed = 0; // 下次读取文件的位置
let writed = 0; // 下次写入文件的位置

(function next() {
// 读取
fs.read(readFd, buf, 0, size, readed, (err, bytesRead) => {
readed += bytesRead;

// 如果都不到内容关闭文件
if (!bytesRead) fs.close(readFd, err => console.log('关闭源文件'));

// 写入
fs.write(writeFd, buf, 0, bytesRead, writed, (err, bytesWritten) => {
// 如果没有内容了同步缓存,并关闭文件后执行回调
if (!bytesWritten) {
fs.fsync(writeFd, err => {
fs.close(writeFd, err => return !err && callback());
});
}
writed += bytesWritten;

// 继续读取、写入
next();
});
});
})();
});
});
}

目录(文件夹)操作

  • fs.mkdir: 创建目录
  • fs.rmdir: 删除目录
  • fs.access: 判断文件或目录是否有操作权限
  • fs.stat: 获取目录及文件信息
  • fs.readdir: 读取目录中内容
  • fs.unlink: 删除指定文件

path

  • path.join([path1][, path2][, …]):连接路径,主要用途在于,会正确使用当前系统的路径分隔符
  • path.resolve([from …], to):用于返回绝对路径,类似于多次 cd 处理
  • path.dirname(p):返回文件夹
  • path.extname(p):返回后缀名
  • path.basename(p):返回路径中的最后一部分,也就是名称
  • path.parse(): 解析路径,返回数组
  • path.format(): 序列化路径,将上面的数组重组成路径.
  • path.normalize(): 规范化路径
  • path.isAbsolute(): 判断传入路径是否是绝对路径
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const path = require("path");

// 连接路径:/test/test1/2slashes/1slash
console.log('join path : ' + path.join('/test', 'test1', '2slashes/1slash', 'tab', '..'));

// 转换为绝对路径:/Users/jian/workspace/cjs/main.js
const mainPath = path.resolve('main.js')
console.log('resolve : ' + mainPath);
console.log(path.resolve()) // 什么都不传,返回绝对路径,不包含文件名.
console.log(path.resolve('a','b')) //返回绝对路径拼接/a/b
console.log(path.resolve('a',/b')) // 返回`/b`
// resolve接收两部分参数,如果to的部分有路径(包含/),就返回'/xxx',
// 如果不含路径,就将当前目录的绝对路径拼接上

// 路径中文件的后缀名:.js
console.log('ext name : ' + path.extname(mainPath));

// 路径最后名字:main.js
console.log('ext name : ' + path.basename(mainPath));

http

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
var http = require("http");
var fs = require("fs");
var url = require("url");

// 创建服务器
http
.createServer(function (request, response) {
// 解析请求,包括文件名
var pathname = url.parse(request.url).pathname;

// 输出请求的文件名
console.log("Request for " + pathname + " received.");

// 从文件系统中读取请求的文件内容
fs.readFile(pathname.substr(1), function (err, data) {
if (err) {
console.log(err);
// HTTP 状态码: 404 : NOT FOUND
// Content Type: text/html
response.writeHead(404, { "Content-Type": "text/html" });
} else {
// HTTP 状态码: 200 : OK
// Content Type: text/html
response.writeHead(200, { "Content-Type": "text/html" });

// 响应文件内容
response.write(data.toString());
}
// 发送响应数据
response.end();
});
})
.listen(8080);

// 控制台会输出以下信息
console.log("Server running at http://127.0.0.1:8080/");

Buffer

Buffer 用于读取或操作二进制数据流,做为 Node.js API 的一部分使用时无需 require,用于操作网络协议、数据库、图片和文件 I/O 等一些需要大量二进制数据的场景。Buffer 在创建时大小已经被确定且是无法调整的.
Buffer 是单独创建的,不占用 V8 的内存空间.

基本使用

Buffer 的创建
在此之前我们需要去简单的学习依一下 Buffer 的两个 API

  • Buffer.from(): 根据现有的数据结构去创建 buffer 数据
  • Buffer.alloc(): 指定 buffer 数据的长度来创建 buffer 数据
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 创建一个长度为 10、且用零填充的 Buffer。
const buf1 = Buffer.alloc(10); // <Buffer 00 00 00 00 00 00 00 00 00 00>

// 创建一个长度为 10、且用 0x1 填充的 Buffer。
const buf2 = Buffer.alloc(10, 1); // <Buffer 01 01 01 01 01 01 01 01 01 01>

// 创建一个长度为 10、且未初始化的 Buffer。
// 这个方法比调用 Buffer.alloc() 更快,
// 但返回的 Buffer 实例可能包含旧数据,
// 因此需要使用 fill() 或 write() 重写。
const buf3 = Buffer.allocUnsafe(10);

// 创建一个包含 [0x1, 0x2, 0x3] 的 Buffer。
const buf4 = Buffer.from([1, 2, 3]); // <Buffer 01 02 03>

// 创建一个包含 UTF-8 字节 [0x74, 0xc3, 0xa9, 0x73, 0x74] 的 Buffer。
const buf5 = Buffer.from("tést"); // <Buffer 74 c3 a9 73 74>

// 创建一个包含 Latin-1 字节 [0x74, 0xe9, 0x73, 0x74] 的 Buffer。
const buf6 = Buffer.from("tést", "latin1"); // <Buffer 74 e9 73 74>

Buffer 的读写
关于 Buffer 的数据读取在官网上有很多方法:如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
buf.readBigInt64BE([offset]);
buf.readBigInt64LE([offset]);
buf.readBigUInt64BE([offset]);
buf.readBigUInt64LE([offset]);
buf.readDoubleBE([offset]);
buf.readDoubleLE([offset]);
buf.readFloatBE([offset]);
buf.readFloatLE([offset]);
buf.readInt8([offset]);
buf.readInt16BE([offset]);
buf.readInt16LE([offset]);
buf.readInt32BE([offset]);
buf.readInt32LE([offset]);
buf.readIntBE(offset, byteLength);
buf.readIntLE(offset, byteLength);
buf.readUInt8([offset]);
buf.readUInt16BE([offset]);
buf.readUInt16LE([offset]);
buf.readUInt32BE([offset]);
buf.readUInt32LE([offset]);
buf.readUIntBE(offset, byteLength);
buf.readUIntLE(offset, byteLength);

这些方法名称有 BE 和 LE 的区别,这个大段和小段的意思,说白了就是方向的问题,而 Int8 是没有 BE 和 LE 的区别的,我们用代码来演示一下

1
2
3
4
5
6
7
8
buffer2.writeInt8(12, 1); // 在buffer2的第二位写入一个int8 的数字12
console.log(buffer2); // <Buffer 01 0c 03 04>

buffer2.writeInt16BE(512, 2); // 在buffer2的第三位开始写一个 int16的数字 512
console.log(buffer2); // <Buffer 01 0c 02 00>

buffer2.writeInt16LE(512, 2); // 在buffer2的第三位开始写一个 int16的数字 512
console.log(buffer2); // <Buffer 01 0c 00 02>

所以你看到了,因为 int8 类型的数字只占一位,所以不存在方向不方向的问题,而 int16 类型的数字要占两位,比如 512 用 16 进制表示就是 [02 00],所以使用 BE 表示正方向,而 LE 表示反方向。具体使用什么样的表示方法要和被人协商议定

实例属性

  • buf.length:返回内存中分配给 buf 的字节数。 不一定反映 buf 中可用数据的字节量。
  • buf.toString:根据 encoding 指定的字符编码将 buf 解码成字符串
  • buf.fill:用指定的 value 填充 buf。 如果没有指定 offset 与 end,则填充整个 buf
  • buf.equals:如果 buf 与参数具有完全相同的字节,则返回 true,否则返回 false
  • buf.indexOf:是否包含参数,包含则返回所在下标,不包含返回-1
  • buf.slice: 截取 buffer
  • buf.copy: 拷贝 buffer 中的数据

常用静态属性

Buffer.byteLength

这个表示整个 buffer 实际所占的字节数,因为不同的语言可能有不同的问题,比如英文字母在表达的时候一个字母就是一个字节,而中文用三个字节来表达汉字

1
2
3
4
5
console.log(Buffer.byteLength("test")); // 4
console.log(Buffer.byteLength("测试")); // 6

console.log(Buffer.from("test").length); // 4
console.log(Buffer.from("测试").length); // 6

Buffer.isBuffer

1
2
3
console.log(Buffer.isBuffer({})); // false
console.log(Buffer.isBuffer(Buffer.from("test"))); // true
console.log(Buffer.isBuffer(Buffer.from([1, 2, 3]))); // true

Buffer.concat

拼接 Buffer,注意的是里面不是传入多个 Buffer 对象,而是一个 Buffer 的数组

1
2
3
4
5
6
7
8
const buf1 = Buffer.from("This ");
const buf2 = Buffer.from("is ");
const buf3 = Buffer.from("buffer ");
const buf4 = Buffer.from("test ");
const buf5 = Buffer.from("demo ");

const bufConcat = Buffer.concat([buf1, buf2, buf3, buf4, buf5]);
console.log(bufConcat.toString()); // This is buffer test demo

自定义 Buffer: split 拆分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
ArrayBuffer.prototype.split = function (sep) {
let len = Buffer.from(sep).length;
let ret = [];
let start = 0;
let offset = 0;

while ((offset = this.indexOf(sep, start) !== -1)) {
ret.push(this.slice(start, offset));
start = offset + len;
}
ret.push(this.slice(start));
return ret;
};

let buf = "吃馒头, 吃面条, 吃所有";
let bufArr = buf.split("吃");

乱码问题

乱码的出现因为是汉字是三个字节的,现在 11 个字节不是三个倍数,没有办法正确按照每 3 个字节解析汉字,我们的解决方法是:使用内置的 string_decoder(字符串解码器)来解析 Buffer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const { StringDecoder } = require("string_decoder");
const decoder = new StringDecoder("utf8");

const buf1 = Buffer.from("我正在学习");
console.log(buf1); // <Buffer e6 88 91 e6 ad a3 e5 9c a8 e5 ad a6 e4 b9 a0>
for (let i = 0; i < buf1.length; i += 5) {
const buf2 = Buffer.allocUnsafe(5);
buf1.copy(buf2, 0, i);
console.log(decoder.write(buf2));
}
// <Buffer e6 88 91 e6 ad a3 e5 9c a8 e5 ad a6 e4 b9 a0>
//我
//正在
//学习

应用场景

I/O 操作

1
2
3
4
5
6
const fs = require("fs");

const inputStream = fs.createReadStream("input.txt"); // 创建可读流
const outputStream = fs.createWriteStream("output.txt"); // 创建可写流

inputStream.pipe(outputStream); // 管道读写

在 Stream 中我们是不需要手动去创建自己的缓冲区,在 Node.js 的流中将会自动创建。

加密解密

在一些加解密算法中会遇到使用 Buffer,例如 crypto.createCipheriv 的第二个参数 key 为 String 或 Buffer 类型,如果是 Buffer 类型,就用到了本篇我们讲解的内容,以下做了一个简单的加密示例,重点使用了 Buffer.alloc()初始化一个实例(这个上面有介绍),之后使用了 fill 方法做了填充,这里重点在看下这个方法的使用。 buf.fill(value[, offset[, end]][, encoding])

  • value: 第一个参数为要填充的内容
  • offset: 偏移量,填充的起始位置
  • end: 结束填充 buf 的偏移量
  • encoding: 编码集

扩展

Buffer 和 Cache 的区别

  • 缓冲(Buffer)是用于处理二进制流数据,将数据缓冲起来,它是临时性的,对于流式数据,会采用缓冲区将数据临时存储起来,等缓冲到一定的大小之后在存入硬盘中。视频播放器就是一个经典的例子,有时你会看到一个缓冲的图标,这意味着此时这一组缓冲区并未填满,当数据到达填满缓冲区并且被处理之后,此时缓冲图标消失,你可以看到一些图像数据。
  • 缓存(Cache)我们可以看作是一个中间层,它可以是永久性的将热点数据进行缓存,使得访问速度更快,例如我们通过 Memory、Redis 等将数据从硬盘或其它第三方接口中请求过来进行缓存,目的就是将数据存于内存的缓存区中,这样对同一个资源进行访问,速度会更快,也是性能优化一个重要的点。

Buffer 与 String 比较
在 HTTP 传输中传输的是二进制数据,那么如果使用 Buffer 和 String 情况如下:

  • 接口如果直接返回的字符串,这时候 HTTP 在传输之前会先将字符串转换为 Buffer 类型,以二进制数据传输,通过流(Stream)的方式一点点返回到客户端。
  • 但是直接返回 Buffer 类型,则少了每次的转换操作,对于性能也是有提升的。

所以在一些 Web 应用中,对于静态数据可以预先转为 Buffer 进行传输,可以有效减少 CPU 的重复使用(重复的字符串转 Buffer 操作)

stream

介绍

在 node 当中 stream 是一种处理流数据的抽象接口,stream 模块提供了一系列实现流的 API,在 node 当中提供了很多关于流的对象,比如 http 服务器的请求和 process.stdout 标准输出都是流的实例,流是可读的,可写的,同时也可以是可读写的,所有的流都是 EventEmitter 的实例。

之所以用 stream ,是因为一次性读取、操作大文件,内存和网络是“吃不消”的,因此要让数据流动起来,一点一点的进行操作,这其实也符合算法中一个很重要的思想 —— 分而治之

流转和应用

  • data 事件: 用来监听 stream 数据的流入
  • end 事件:用来监听 stream 数据输入的完成
  • pipe 方法: 用来做数据流转

① stream 从哪里来-soucre
stream 的常见来源方式有三种:

  • 从控制台输入
  • http 请求中的 request
  • 读取文件

这里先说一下从控制台输入这种方式,看一段 process.stdin 的代码:

1
2
3
4
process.stdin.on("data", function (chunk) {
console.log("stream by stdin", chunk); // stream by stdin <Buffer 6b 6f 61 6c 61 6b 6f 61 6c 61 0a>
console.log("stream by stdin", chunk.toString()); // stream by stdin koalakoala
});

然后从控制台输入任何内容都会被 data 事件监听到,process.stdin 就是一个 stream 对象,data 是 stream 对象用来监听数据传入的一个自定义函数, (说明: stream 对象可以监听”data”,”end”,”opne”,”close”,”error”等事件。node.js 中监听自定义事件使用.on 方法,例如 process.stdin.on(‘data’,…), req.on(‘data’,…),通过这种方式,能很直观的监听到 stream 数据的传入和结束)
② 连接水桶的管道-pipe
从水桶管道流转图中可以看到,在 source 和 dest 之间有一个连接的管道 pipe,它的基本语法是 source.pipe(dest) ,source 和 dest 就是通过 pipe 连接,让数据从 source 流向了 dest。
③ stream 到哪里去-dest
stream 的常见输出方式有三种:

  • 输出控制台
  • http 请求中的 response
  • 写入文件

我们上面借助控制台输入输出来讲解了 stream 的流转过程,可是在实际应用场景中,http 请求和文件操作当中使用 stream 的频率异常的频繁。原因是这样:
http 请求和文件操作都属于 IO ,即 stream 主要的应用场景就是处理 IO ,这就又回到了 stream 的本质 —— 由于一次性 IO 操作过大,硬件开销太多,影响软件运行效率,因此将 IO 分批分段操作,让数据一点一点的流动起来,直到操作完成 。

http 中的 steam

get 请求

1
2
3
4
5
6
7
8
9
10
11
12
const http = require("http");
const server = http.createServer(function (req, res) {
const method = req.method; // 获取请求方法
if (method === "GET") {
// get 请求
const fileName = path.resolve(__dirname, "data.txt");
let stream = fs.createReadStream(fileName);
stream.pipe(res); // 将 res 作为 stream 的 dest
}
// 其他method暂时忽略
});
server.listen(8000);

从上面例子可以看出,对 response 使用 stream 特性能提高性能。因此,在 nodejs 中如果要返回的数据是经过 IO 操作得来的,例如上面例子中读取文件内容,可以直接使用 stream.pipe(res) ,毕竟 response 也是一个 stream 对象,可以作为参数传入 pipe 当中,而不要再用 res.end(data)了。
这种应用的实例应该比较多,主要有两种场景:

  • 使用 node.js 作为服务代理,即客户端通过 node.js 服务作为跳板去请求其他服务,返回请求的内容
  • 使用 node.js 做静态文件服务器,直接返回静态文件

post 请求

web server 接收 http 请求肯定是通过 request,而 request 接收数据的本质其实就是 stream。所以 看似是 request 接收数据,但是在服务端的角度来说,request 就是产生数据的 source,那么 soucre 类型的 stream 对象都能监听 data,end 事件。分别触发数据接收和数据接收完成的通知,所以代码可以如下进行改造:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
var http = require("http");
var fs = require("fs");
var path = require("path");

var server = http.createServer(function (req, res) {
var method = req.method; // 获取请求方法
if (method === "POST") {
// 暂只关注 post 请求
req.on("data", function (chunk) {
// 接收到部分数据
console.log("chunk", chunk.toString().length);
});
req.on("end", function () {
// 接收数据完成
console.log("end");
res.end("OK");
});
}
// 其他请求方法暂不关心
});
server.listen(8000);

总结一下,request 和 response 一样,本身也是一个 stream 对象,可以用 stream 的特性,那肯定也能提高性能。两者的区别就在于,request 是 source 类型的、是 stream 的源头,而 response 是 dest 类型的、是 stream 的目的地。
这里再多举个例子,比如我们现在要将 node.js 接收到的 post 请求的数据写入文件,一般的小伙伴会这样写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
var http = require("http");
var fs = require("fs");
var path = require("path");

var server = http.createServer(function (req, res) {
var method = req.method; // 获取请求方法
if (method === "POST") {
// 暂只关注 post 请求
var dataStr = "";
req.on("data", function (chunk) {
// 接收到数据,先存储起来
var chunkStr = chunk.toString();
dataStr += chunkStr;
});
req.on("end", function () {
// 接收数据完成,将数据写入文件
var fileName = path.resolve(__dirname, "post.txt");
fs.writeFile(fileName, dataStr);

res.end("OK");
});
}
// 其他请求方法暂不关心
});
server.listen(8000);

这种写法也是对的,但是还没有真正理解 stream,当我们学习了文件操作中的 stream,你可能就 hi 写出更简单的代码,比如说下面这种:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var http = require("http");
var fs = require("fs");
var path = require("path");

var server = http.createServer(function (req, res) {
var method = req.method; // 获取请求方法
if (method === "POST") {
// 暂只关注 post 请求
var fileName = path.resolve(__dirname, "post.txt");
var writeStream = fs.createWriteStream(fileName);
req.pipe(writeStream);
req.on("end", function () {
// 接收数据完成
res.end("OK");
});
}
// 其他请求方法暂不关心
});
server.listen(8000);

和 get 请求使用 stream 的场景类似,post 请求使用 stream 的场景,主要是用于将接收的数据直接进行 IO 操作,例如:

  • 将接受的数据直接存储为文件
  • 将接受的数据直接 post 给其他的 web server

fs 中的 stream

用 stream 读写文件其实前面章节都有多处用到,这里在统一整理一下:

  • 可以使用 fs.createReadStream(fileName)来创建读取文件的 stream 对象
  • 可以使用 fs.createWriteStream(fileName)来创建写入文件的 stream 对象

读取文件的 stream 对象,对应的就是 source,即数据的来源。写入文件的 steram 对象对应的就是 dest ,即数据的目的地。下面我们分别用普通的读写和使用 stream 实现一个文件拷贝的功能,并通过监控工具 memeye 来监控 node.js 内存占用的情况

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
const fs = require("fs");
const path = require("path");
const memeye = require("memeye");
memeye();
function copy(num) {
const fileName1 = path.resolve(__dirname, "data.txt"); // 42kb左右
fs.readFile(fileName1, function (err, data) {
if (err) {
console.log("读取出错", err.message);
return;
}
var dataStr = data.toString();

var fileName2 = path.resolve(__dirname, "data-bak.txt");
fs.writeFile(fileName2, dataStr, function (err) {
if (err) {
console.log("写入出错", err.message);
return;
}
console.log(`拷贝第${num}次成功`);
});
});
}

setTimeout(() => {
for (let i = 0; i < 100; i++) {
copy(i);
}
}, 5000);

启动文件,在浏览器打开 localhost:23333/,可以看到 heapUsed 在 13.26M 左右,heapTotal 在 30M 左右,rss 在 40M 左右

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const fs = require("fs");
const path = require("path");
const memeye = require("memeye");
memeye();
function copy(num) {
var fileName1 = path.resolve(__dirname, "data.txt"); // 42KB左右
var fileName2 = path.resolve(__dirname, "data-bak.txt");
// 读取文件的stream对象
var readStream = fs.createReadStream(fileName1);
// 写入文件的stream对象
var writeStream = fs.createWriteStream(fileName2);
// 通过pipe实现数据的流转
readStream.pipe(writeStream);

// 监听数据完成的情况
readStream.on("end", function () {
console.log("拷贝完成");
});
}

setTimeout(() => {
for (let i = 0; i < 100; i++) {
copy(i);
}
}, 5000);

启动文件,在浏览器打开 localhost:23333/,可以看到 heapUsed 在 6.4M 左右,heapTotal 在 9.23M 左右,rss 在 30M 左右

总结:所有执行文件操作的场景,都应该尝试使用 stream ,例如文件的读写、拷贝、压缩、解压、格式转换等。除非是体积很小的文件,而且读写次数很少,性能上被忽略。如果是体积很大或者读写次数很多的情况下,建议使用 stream 来优化性能

逐行读取 readline

用 stream 操作文件,会来带很大的性能提升。但是原生的 stream 却对“行”无能为力,它只是把文件当做一个数据流、简单粗暴的流动。很多文件格式都是分行的,例如 csv 文件、日志文件,以及其他一些自定义的文件格式。

node.js 提供了非常简单的按行读取的 API —— readline,它本质上也是 stream,只不过是以“行”作为数据流动的单位。本节将结合一个分析日志文件的案例,讲解 readline 的使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const fs = require("fs");
const path = require("path");
const readline = require("readline");

var fileName = path.resolve(__dirname, "data.txt");
var readStream = fs.createReadStream(fileName);
// 创建readline对象
var readlineObj = readline.createInterface({
input: readStream,
});

readlineObj.on("line", (lineDate) => {
console.log(lineDate);
console.log("---- line ----");
});

readlineObj.on("close", () => {
console.log("end");
});

对 readline 最常见的使用应该是日志的逐行分析了,比如我们要从一个 10 万行的日志中去找一个 2018-10-23 14:00 这一分钟,对 user.html 的访问次数,我们就应该这样做

1
2
3
4
5
6
7
8
9
10
11
12
13
let num = 0
// 逐行读取日志内容,如果有包含`2018-10-23 14:00`和`user.html`的字符串就表示
// 在这一分钟有人访问了user.html,我们就记录一次
readlineObj.on('line'function(lineData){
if(lineData.indexOf('2018-10-23 14:00')>=0 && lineData.indexOf('user.html') >= 0){
num++
}
})

// 监听读取完成
readline.on('close',()=> {
console.log('num',num)
})

借用这个简单的例子来演示 readline 的使用以及使用场景,其实日常工作中情况会更加复杂,不过再复杂的场景选择 readline 做逐行分析一定是不会错的。方向选对了很重要,选择大于努力.

stream 的种类

readable stream

可读流是对提供数据的来源的一种抽象。
可读流的例子包括:客户端的 HTTP 响应、服务器的 HTTP 请求、fs 的读取流、zilb 流、crypto 流、TCP socket、子进程 stdout 与 stderr、process.stdin,因为这些可读流都实现了 stream.Readable 类定义的接口

  • readable 事件: 当流中存在可读取数据时触发
  • data 事件: 当流中数据块传给消费者时触发

writeable stream

可写流是对数据要被写入的目的地的一种抽象.
可写流内部可写入的数据只支持 Buffer 和字符串.
可写流的例子包括:客户端的 HTTP 响应、服务器的 HTTP 请求、fs 的读取流、zilb 流、crypto 流、TCP socket、子进程 stdout 与 stderr、process.stdin,因为这些可写流都实现了 stream.Writable 类定义的接口

  • pipe 事件: 可读流调用 pipe()方法时触发
  • unpipe 事件: 可读流调用 unpipe()方法时触发

duplex stream

1
2
3
4
5
var fs = require("fs");
var zlib = require("zlib");
var readStream = fs.createReadStream("./data.txt");
var writeStream = fs.createWriteStream("./data-bak.txt");
readStream.pipe(zlib.createGzip()).pipe(writeStream);

pipe 的严谨用法要遵循下面三个原则:

  • 调用 pipe 的对象必须是 readable stream 或者 duplex stream 这样的具有读取数据的功能的流对象
  • pipe 中的参数对象必须是 writeable stream 或者 duplex stream 这样的具有写入数据的功能的流对象
  • pipe 支持链式调用

transform stream

转换流(Transform)是一种 Duplex 流,但它的输出与输入是相关联的,而且一般只有触发了写入操作时才会进入_transform 方法中。 与 Duplex 流一样, Transform 流也同时实现了 Readable 和 Writable 接口。

写入流程

  1. 第一次调用 write 方法时是将数据直接写入到文件中
  2. 第二次开始 write 方法就是将数据写入至缓存中
  3. 生产速度 和消费速度是不一样的,一般情况下生产速度要比消费速度快很多
  4. 当 flag 为 false 之后并不意味着当前次的数据不能被写入了.但是我们应该告之数据的生产者,当前的消费速度已经跟不上生产速度了,所以这个时候,一般我们会将可读流的模块修改为暂停模式。
  5. 当数据生产者暂停之后,消费者会慢慢的消化它内部缓存中的数据,直到可以再次被执行写入操作
  6. 当缓冲区可以继续写入事件如何让生产者知道? 使用 drain 事件.

背压机制

因为流的读取操作中,读的速度大于写的速度,为了控制,防止内存溢出,GC 频繁调用.

stream 有什么弊端

  • rs.pipe(ws) 的方式来写文件并不是把 rs 的内容 append 到 ws 后面,而是直接用 rs 的内容覆盖 ws 原有的内容
  • 已结束/关闭的流不能重复使用,必须重新创建数据流
  • pipe 方法返回的是目标数据流,如 a.pipe(b) 返回的是 b,因此监听事件的时候请注意你监听的对象是否正确
  • 如果你要监听多个数据流,同时你又使用了 pipe 方法来串联数据流的话,你就要写成:
1
2
3
4
5
6
7
8
9
10
11
12
data
.on("end", function () {
console.log("data end");
})
.pipe(a)
.on("end", function () {
console.log("a end");
})
.pipe(b)
.on("end", function () {
console.log("b end");
});

promisify

1
2
3
4
5
import { readFile } from "fs";
import { promisify } from "uitl";

// 该方法可以将read变为返回promise形式的异步函数
const read = promisify(readFile);

链表

可理解为一种存储数据的结构.
链表是一组节点组成的集合,每个节点都使用一个对象的引用来指向它的后一个节点。指向另一节点的引用讲做链。

链表结构图

1
data1|next => data2|next => data3|next => Null

data 中保存着数据,next 保存着下一个链表的引用。上图中,我们说 data2 跟在 data1 后面,而不是说 data2 是链表中的第二个元素。上图,值得注意的是,我们将链表的尾元素指向了 null 节点,表示链接结束的位置。

有头节点的链表

由于链表的起始点的确定比较麻烦,因此很多链表的实现都会在链表的最前面添加一个特殊的节点,称为 头节点,表示链表的头部。进过改造,链表就成了如下的样子:

1
Header|next => data1|next => data2|next => data3|next => Null

插入节点

向链表中插入一个节点的效率很高,需要修改它前面的节点(前驱),使其指向新加入的节点,而将新节点指向原来前驱节点指向的节点即可。下面我将用图片演示如何在 data2 节点 后面插入 data4 节点。

1
2
3
4
Header|next => data1|next => data2|next => data3|next => Null
| ∧
∨ |
data4 | next

删除节点

同样,从链表中删除一个节点,也很简单。只需将待删节点的前驱节点指向待删节点的,同时将待删节点指向 null,那么节点就删除成功了。下面我们用图片演示如何从链表中删除 data4 节点

1
2
3
Header|next => data1|next => data2|next => data3|next  data4|next   Null
| ∧
∨ ----------------- |

单向链表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
class Node {
constructor(element, next) {
this.element = element
this.next = next
}
}
class LinkedList {
constructor(head, size) {
this.head = null
this.size = 0
}
// 获取节点
_getNode(index){
if(index<0 || index >= this.size) {
throw new Error('越界')
}
// 通过循环链表获取prevNode
let currentNode = this.head
for(let i=0; i < index; i++) {
currentNode = currentNode.next
}
return currentNode
}
// 增
add(index, element) {
if(argument.length ===1){
element = index
index = this.size
}
// 判断边界
if(index < 0 || index > this.size) {
throw new Error('越界
}

// 判断位置
if(index === 0) {
let head = this.head // 保存原有head的指向
this.head = new Node(element, head) // 现在的head指向newNode
}else {
// 如果不在第一个位置,获取上一个节点
let prevNode = this._getNode(index-1)
// 上一Node 的next指向新节点
prevNode.next = new Node(element, prevNode.next)
}
// add后注意size变化
this.size++
}
// 删
remove(index){
let rmNode = null
if(index === 0) {
//如果删的是第一个
rmNode = this.head
if(!rmNode) {
return undefined
}
this.head = head.next
} else {
let prevNode = _getNode(index -1)
rmNode = prevNode.next
prevNode.next = rmNode.next
}
this.size--
return rmNode
}
// 改
set(index, element) {
let node = _getNode(index)
node.element = element
}
// 查
get(index) {
return this._getNode(index)
}
// 清空
clear(){
this.head = null
this.size = 0
}
}
const l1 = new LinkedList()
li.add('node1')

单向链表队列

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Queue {
constructor() {
this.linkedList = new LinkedList();
}
// 进队列
enqueue(data) {
this.linkedList.add(data);
}
// 出队列
dequeue() {
return this.linkedList.remove(0);
}
}

const q = new Queue();

nvm

切换不同 node 版本.安装之前建议删除之前的 node 版本.

删除之前 node

打开 /usr/local/lib,删除 node 和 node_modules 相关的文件和文件夹
打开 /usr/local/include,删除 node 和 node_modules 相关的文件和文件夹
如果你是使用的 brew install node 安装的 NodeJS,那么你还需要在终端中执行 brew uninstall node 命令来卸载
检查你的个人主文件夹下面的所有的 local、lib 以及 include 文件夹,并且删除所有与 node 和 node_modules 相关的文件以及文件夹
打开 /usr/local/bin 并删除 node 可执行文件

1
2
3
4
5
6
7
8
sudo rm /usr/local/bin/npm
sudo rm /usr/local/share/man/man1/node.1
sudo rm /usr/local/lib/dtrace/node.d
sudo rm -rf ~/.npm
sudo rm -rf ~/.node-gyp
sudo rm /opt/local/bin/node
sudo rm /opt/local/include/node
sudo rm -rf /opt/local/lib/node_modules

安装 nvm

mac 安装: $ brew install nvm
命令安装:

1
2
3
$ curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash

$ wget -qO- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash

安装的时候可以需要配置解释器,按照当时反馈的描述写就行.

1
2
3
4
5
6

# 创建 zsh 配置文件,一遍安装了zsh就不用创建
$ touch ~/.zshrc

# 编辑配置文件
$ vim ~/.zshrc

比如:

1
2
3
4
5
6
7
8
# 1、这是本地不存在配置文件的时候提示需要添加的配置
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" # This loads nvm

# 2、这是本地存在配置文件的时候提示需要添加的配置(推荐)
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"  # This loads nvm
[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion"  # This loads nvm bash_completion

之后重新加载一遍

1
$ source ~/.zshrc

安装 node

列出所有远程版本$ nvm ls-remote
显示当前版本$ nvm current
列出所有已安装版本$ nvm ls
切换指定版本 node

1
2
3
4
5
// 临时版本 - 只在当前窗口生效指定版本
$ nvm use <version>

// 永久版本 - 所有窗口生效指定版本
$ nvm alias default <version>

注意:在任意一个命令行窗口进行切换之后,其他的窗口或其他命令行工具窗口 需要关掉工具,重启才能生效。(例如 VSCode 内或外部命令切换之后,需要重启 VSCode,才能正常生效,否则或处于 临时生效状态,也就是在 VSCode 中重新打开一个命令行查看版本还会是旧版本,所以必须要重启。)
这里的 重启 不是简单的关掉窗口重启,没有退出后台进程,而是完全退出杀死工具进程,重新启动。

本来用 nvm use <版本号> 切换到需要的版本号上,然后用 npm install -g taro 安装是没问题的,但切换了其它命令行后,发现 taro 提示 command not found 。后来细查之后才发现,nvm use <版本号> 只是在当前命令行环境下切换,并不是全局切换。如果想要全局切换,要用 nvm alias default <版本号>

pnpm

全局安装

1
2
3
npm install -g pnpm
// brew
brew install pnpm

注意

如果要全局安装其他,比如 typescript,还是用 npm,不然和上面 nvm 有冲突.

用法

命令 含义
pnpm add sax 保存到 dependencies 配置项下
pnpm add -D sax 保存到 devDependencies 配置项下
pnpm add -O sax 保存到 optionalDependencies 配置项下
pnpm add -g sax 安装软件包到全局环境中
pnpm add sax@next 安装标记为 next 的版本
pnpm add sax@3.0.0 安装指定版本 3.0.0