Create a RESTful API with Nodejs, Express & MongoDB

这篇博客主要是我学习 Nodejs 过程中记录的笔记整理而成,主要围绕基础概念及进阶应用进行阐述及举例说明。

This post will cover fundamentals, practical coding examples, and advanced techniques learned through building a Node.js backend.

Code Tree

源码在这里 Github: Natours-BackEnd,可以结合代码注释,搜索查看文章,结合 API 文档 使用 Postman 进行调试,会理解更加深刻

官方课程:Node.js, Express, MongoDB & More: The Complete Bootcamp

B 站链接:【Udemy 付费课程】Node.js, Express, MongoDB & More: The Complete Bootcamp 2022(中英文字幕)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
Natours
├── controllers # 处理业务逻辑的模块,每个文件负责对应功能(如旅游、用户、评论等)的请求响应
│ ├── tourController.js
│ ├── authController.js # 管理用户认证和授权
│ ├── errorController.js # 集中处理错误,开发环境生产环境报错配置
│ └── handlerFactory.js # 封装通用 CRUD 操作的逻辑,减少重复代码
│ └── ...
├── models # 生成 Mongoose 模型,定义数据库中文档的结构和数据交互逻辑
│ ├── tourModel.js
│ └── ...
├── routes # 定义 API 路由,将请求路径映射到对应的控制器函数
│ ├── tourRoute.js
│ └── ...
├── utils # 存放通用的辅助函数和中间件
│ ├── email.js # 实现邮件发送功能(如验证、通知等)
│ ├── appError.js # 自定义错误类,方便统一错误处理
│ ├── catchAsync.js # 封装异步函数错误捕捉,简化控制器中错误处理代码
│ └── apiFeatures.js # 提供 API 查询特性(如过滤、分页、排序等)的工具函数
├── .gitignore # 定义 Git 版本控制时忽略的文件和目录
├── config.env # 配置文件,存放环境变量(如数据库连接、端口号等),默认不上传 github
├── package.json # 管理项目依赖
├── package-lock.json
├── app.js # 初始化 Express、加载中间件和路由的入口文件
└── server.js # 程序总入口,启动服务器、监听请求
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
npm i express                           # Express 框架
npm i mongoose # ODM,可以直接使用Mongoose操作MongoDB
npm i dotenv nodemon --save-dev # dotenv管理环境变量,nodemon开发时自动重启服务
npm i morgan # 记录 HTTP 请求日志
npm i ndb # 调试 Node.js 程序
npm i crypt # 提供加密功能
npm i jsonwebtoken # 生成和验证 JSON Web Tokens
npm i bcryptjs # 密码加密与校验
npm i slugify # 生成 URL 友好的字符串(Slug)
npm i validator # 数据验证和清洗
npm i nodemailer # 发送邮件
npm i express-rate-limit # 限制请求频率(防止暴力破解等攻击)
npm i helmet # 设置 HTTP 头部提高应用安全性
npm i hpp # 防止 HTTP 参数污染攻击
npm i express-mongo-sanitize # 防止NOSQL注入
npm i xss-clean # 防止XSS攻击
npm i eslint prettier eslint-config-prettier eslint-plugin-prettier eslint-config-airbnb eslint-plugin-node eslint-plugin-import eslint-plugin-jsx-a11y eslint-plugin-react --save-dev # 安装 ESLint、Prettier 及其相关插件和配置,用于代码质量检查和格式化

基础概念 Fundamentals

Core Concepts

什么是 Synchronous/Asynchronous?

Reference Link: 常常讓人搞混的 Synchronous Programming 和 Asynchronous Programming

  1. sync 同步是按顺序执行,等待返回响应后才继续执行,也叫做blocking code,执行慢
  2. async 异步是请求后立刻返回响应,不等他返回结果,向下执行,也叫non-blocking code,推荐,执行快
  3. async 的理解有点像小学数学题:最短时间完成几件事,不用等着,可以利用这段时间去做其他事情
  4. 为什么要使用 async 而不是 sync:因为 node 是单线程,如果用 sync,一个堵塞会导致整个程序变慢,其他人需要等待,非常影响用户体验

什么是 callback?

Reference Link:什麼是 Callback 函式 一次搞懂同步與非同步的一切

  1. 一个函数成为另一个函数的参数,让函数可以控制参数函数的执行时机
  2. Callback function 的用途就在于可以让我们的程序在无论同步还是不同步执行的情况下都可以按顺序执行程序。
  3. Callback != Async

什么是 Promise 与 async/await?

Reference Link:详解 JavaScript Promise 和 Async/Await

什么是 middleware?

Reference Link: 何謂 Middleware?如何幫助我們建立 Express 的應用程式

Video Link: Middleware and the Request and Response cycle

说说我自己的理解:

  1. middleware 分为全局和局部,如果作用在 app.js,那么他就是全局的,如果作用在单独的 route 文件或者特定的 route,那么他就是局部的。全局 middleware 会影响所有请求,而局部 middleware 仅在特定 route 中生效。

  2. middleware 常用于使用 app.use() 来注册。也可以在定义 route 时,将 middleware 作为参数传入,如:

    1
    app.get('/path', middlewareFunction, (req, res) => { ... });
  3. middleware 有相关的库可以直接调用,也可以自定义,自定义 middleware 通常需要遵循 (req, res, next) 的函数签名。

  4. middleware 与书写顺序强相关,执行顺序是严格按照它们注册的顺序进行的

  5. Middleware 必须调用 next() 来传递控制权,否则请求会停在当前 middleware,最终导致请求超时或挂起。调用 next() 时也可以传递错误对象 (err),从而触发错误处理中间件 (Error Handler)。

  6. middleware 执行于 req 与 res 之间,在请求 (req) 到达路由处理器和响应 (res) 返回客户端之间发挥作用,负责诸如请求解析、身份验证、日志记录、安全控制等工作。

其他

  1. 静态文件通过 public 文件夹获取而不是 route,详见middleware板块

  2. 查看 nodejs 环境变量

    1
    console.log(process.env);

    命令行新增环境变量:在 terminal 中,执行 node server.js 前,加上想新增的环境变量,如下面的例子

    1
    NODE_ENV=development node server.js

    文件新增环境变量:.env,搭配 dotenv.config() 库一起使用。想要使用.env 文件写环境变量,需要安装 dotenv 库并在最开始引入,可以不用调用

    1
    dotenv = require("dotenv").config();

    如果不使用.env 而是使用 config.env 或者其他名称的 env 文件,配置时输入 path,这里的 path 相对于程序执行目录(也就是 server.js 所在的目录),而不是当前目录。

    1
    dotenv.config({ path: "./config.env" });
  3. Config.env 和.env 的区别是什么?什么情况下选择用哪一个?

    感觉都可以,就是配置 config() 时有点区别。.env 是广泛使用的标准文件名,dotenv 会默认加载根目录下的.env 文件,而 config.env 需要额外配置 path

  4. morgan 用于打印日志,设置为仅在开发环境下运行

    image-20250331202841254
    1
    2
    3
    if (process.env.NODE_ENV === "development") {
    app.use(morgan("dev"));
    }
  5. 使用 eslint、prettier 更好地检测错误,可以结合.eslintrc.json.prettierrc 文件使用

    Offical Link: https://prettier.io/docs/options

    1
    npm i eslint prettier eslint-config-prettier eslint-plugin-prettier eslint-config-airbnb eslint-plugin-node eslint-plugin-import eslint-plugin-jsx-a11y eslint-plugin-react --save-dev

fs 模块

Offical Link: https://nodejs.org/api/fs.html

fs 是文件库,可以读写文件

  • fs.readFileSync: (path, option),path 可以用相对路径,引号包裹;option 可以写 encoding,比如’utf-8’,否则会返回一个 buffer 而不是字符串

    image-20250318225005545

  • fs.writeFileSync: (path, data, option),path 相对路径,data 写入的内容,option 可以不写,默认 utf8,比如fs.writeFileSync('./outtext.txt', textOut)

  • fs.readfile: (path, option, callback),path 相对路径,option 写 utf-8,默认是 none, buffer,callback 回调函数

  • fs.writeFile: (path, data, option, callback),path 相对路径,data 需要写入的内容,option 默认是 utf-8,callback

JavaScript

  1. 字符串中写 js,用 ``包裹内容并加上${}写 js 变量

    1
    const textOut = `This is what we know about avocado: ${textIn}`;
  2. Object.assign:将两个 object 合并在一起

  3. JSON.parse:将 json 转换为 object

  4. JSON.stringify:将 object 转换为 json

  5. __dirname:获取当前路径

  6. Array.push():将当前数据加入到列表

  7. Array.find() : 返回第一个 callback 函数名中的数据

    1
    2
    3
    4
    5
    6
    7
    8
    const inventory = [
    { name: "apples", quantity: 2 },
    { name: "bananas", quantity: 0 },
    { name: "cherries", quantity: 5 },
    ];

    const result = inventory.find(({ name }) => name === "cherries");
    console.log(result); // { name: 'cherries', quantity: 5 }
  8. 字符串转数字:

    1
    id = req.params.id * 1;
  9. 浅拷贝,深拷贝

    以下四种方式的区别与使用,待补充…

    Object.assign

    JSON.parse(JSON.stringify())

    {…req.query}

    Object.getOwnPropertyNames - 最后用的是这种,其他三种都试过,都不能拷贝不可枚举的信息

  10. Class: JavaScript | ES6 中最容易誤會的語法糖 Class - 基本用法

中间件 Middleware

原理

Offical Link: Express Middleware

Middleware functions are functions that have access to the request object (req), the response object (res), and the next middleware function in the application’s request-response cycle. The next middleware function is commonly denoted by a variable named next.

常用中间件

  1. 获取当前时间,加入到请求体中

    1
    2
    3
    4
    app.use((req, res, next) => {
    req.requestTime = new Date().toISOString();
    next();
    });
  2. 打印日志

    image-20250331202841254
    1
    const morgan = require("morgan");
  3. 访问 public 文件夹下的静态文件,如 css、html、图片等

    前提:根目录下有一个public文件夹,文件夹中存放了 css、html、图片等文件,如 tour.html

    app.js中使用下面的中间件,程序运行后,打开浏览器 http://localhost:4001/tour.html 就能看到 html 页面了,链接不需要包含/public

    链接不包含/public的原因,当我们打开一个 url,在任何 route 都找不到的情况下,express 会去 public 文件夹下找,此时 public 文件夹被设为根目录,所以不用包含它

    1
    app.use(express.static(`${__dirname}/public`));
    image-20250322222556619
  4. 检测/:id 是否存在,getOne, updateOne, deleteOne 时使用

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    // val 通过路由参数传入的值,这里其实就是 req.params.id
    exports.checkID = (req, res, next, val) => {
    console.log(`check ID ${val}`);
    if (req.params.id * 1 > tours.length) {
    return res.status(404).json({
    status: "fail",
    message: "Invalid ID",
    });
    }
    next();
    };
    1
    2
    3
    const router = express.Router();
    // 使用 param 解析做中间件,对于路由中含有 :id 参数的所有请求,都先执行一次 checkID 中间件。
    router.param("id", tourController.checkID);
  5. 验证请求体 req.body 是否包含特有字段

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    exports.checkBody = (req, res, next) => {
    if (!req.body.name || !req.body.price) {
    return res.status(400).json({
    status: "fail",
    message: "need name or price",
    });
    }
    next();
    };
    // 下面中间件的使用说明先 check Body,无误后执行 create 操作
    router.route("/").post(tourController.checkBody, tourController.createTour);

其他中间件库

  1. 检查输入符不符合要求 validation,使用Joi

  2. JWT 验证 express-jwt

  3. 权限验证 express-jwt-authz

Mongoose 与 MongoDB 数 据建模

Official Link: Model SchemaTypes

Reference Link: 開始使用數據庫(Mongoose)

  1. Object Data Modeling (ODM),可以直接用 JavaScript 在 mongoose 上操作 mongoDB

  2. mongoose.schema:定义每个字段的数据类型,设定默认值、必填项、数据范围、正则表达式匹配等验证规则,定义 pre 和 post 钩子,用于在执行 save、remove 等操作前后执行自定义逻辑。

  3. mongoose.modelModel 是基于这个 Schema 创建的一个类,它既提供了操作数据的方法(比如查询、保存、更新等),也负责和数据库中的集合进行交互。通过 mongoose.model('ModelName', schema),我们将 Schema 编译成一个 Model。

  4. 下面的 Schema 仅作为示例,查看对应注释学习使用:

    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
    const hotelSchema = mongoose.Schema({
    name: {
    type: String, // 定义数据类型
    require: [true, "A tour must have a name"], // 指定该字段是必填字段,Array 可以在保证必填字段的同时显示错误信息
    unique: true // 指定字段唯一,不能有两个重名 name
    trim: true, // 用于删除字符串开头和结尾的多余空格
    maxlength: [40, 'A tour name must have less or equal than 40 characters'],
    minlength: [10, 'A tour name must have more or equal than 10 characters'],
    },
    email: {
    type: String,
    required: [true, 'Please tell us your email!'],
    unique: true,
    lowercase: true, // 自动转小写
    validate: [validator.isEmail, 'Please provide a valid email'], // validator 库,验证 email 格式
    },
    images: [String], // 代表 image 是 string,但是是一个数组合集
    createdAt: {
    type: Date,
    default: Date.now(), // 获取当前日期,毫秒级别,自动解析为可读形式
    },
    difficulty: {
    type: String,
    required: [true, 'A tour must have difficulty'],
    enum: { // 验证字符串与 values 一致,否则报错 message 内容
    values: ['easy', 'medium', 'difficult'],
    message: 'Difficulty is either: easy, medium and difficult',
    },
    },
    ratingAverage: {
    type: Number,
    default: 4.5, // 设置默认值
    max: [5, 'A rating must less than 5'],
    min: [1, 'A rating must more than 1'],
    set: (val) => Math.round(val * 10) / 10, // 设置数值四舍五入保留 1 位小数
    },
    passwordConfirm: {
    type: String,
    minlength: 8, // 最小长度为 8
    required: [true, 'Please confirm your password'],
    validate: { // 自定义 validator,这里是验证密码是否一致
    validator: function (el) {
    return el === this.password;
    },
    message: 'passwords are not the same',
    },
    select: false, // 表示查询时不返回该字段
    },
    })

    module.exports = mongoose.model('tour', tourSchema);

数据模型设计

Data Modeling 数据建模

Question

  1. 考虑不同类型数据之间的关系,如何识别
  2. Referencing/normalization vs embedding/denormalization 引用 vs 嵌入式之间的区别
  3. 什么情况下使用 embedding/referencing documents
  4. type of referencing 不同类型的引用

不同类型数据关系

1:1

1 个 field 对应 1 个 value,比如 movie: name

1:many(最重要)

1:few -> move: award 电影的奖项不会很多

1:many -> movie: reviews 电影的评论可能在成百上千

1:ton -> app: logs app 的日志可能上万

Many: many

Movies: actors 电影有不同的演员,演员也会演不同的电影

image-20250328183548174

Referencing/normalization vs embedding/denormalization

Referencing 通过主键与外键进行链接映射,一个 movie 有自己的 document,不同的 actors 也有各自的 documents,通过 ID 映射在一起

embedding 将 actors 嵌入到 movie 的 document 中,好处是减少对数据库的查询,提高性能,坏处是不能单独对 actor 进行查询

image-20250328184316934

什么情况下使用 embedding/referencing documents

数据集之间的类型关系 how two dataset are related to each other

数据访问模式 how often data is read and writen

如何从数据库中查询 How much data is related? how we want to query?

image-20250328185410654

不同类型的引用

Mongodb 中不应该允许 array 无限增长,每个 bson 有 16M 字节的限制

image-20250328190252998

总结

image-20250328190527944

Natours Data model

image-20250328191803456

高级特性

Modeling Tour guides

将 user documents 嵌入到 tour documents

embeding

通过 user.id,在生成 new tour 时,在 save 之前使用 query middleware 查询每一个 guide id 获取 user 信息,将查到的数据放在 tour.guides 下,就得到了嵌入 user 数据的 tour document

什么是 promise.all

1
2
3
4
5
6
7
8
9
10
11
// 先新增 schema
guides: Array;

// Embedding user document to tour document
tourSchema.pre("save", async function (next) {
// 因为 guides 是 array,所以遍历 guides,拿到每一个 id 并查找
const guidePromise = this.guides.map(async (id) => await User.findById(id));
// 将返回结果放在 tours.guides 中,这里的 promise.all 使用看 refer link
this.guides = await Promise.all(guidePromise);
next();
});

image-20250328231536548

Child Reference + Populate

步骤

  1. 创建一个对其他 Model 的引用
  2. populate 刚才指定的 field

tour 中仅引用 user 的 id,不嵌入,他们仍然是两个完全独立的数据库

Tours model 的 guides 字段设为 ObjectId数组。 ref 选项告诉 Mongoose 在填充的时候使用哪个 model,本例中为 Users model。所有储存在此的 _id 都必须是 Users model 中 document 的 _id

1
2
3
4
5
6
7
// 新增 guides 的 shcema,与 embedding 不同
guides: [
{
type: mongoose.Schema.ObjectId, // 表示 type 是 ObjectId
ref: "User", // 引用到 User, 这是建立不同 database 之间引用的方式,不需要 require 引用 User
},
];
image-20250328234752112

image-20250329001948061

Populating

populate 会在后台创建一个查询,可能会影响性能,小程序还好,如果很大的数据体量,可能影响会比较大

1
2
3
4
5
6
7
8
exports.getTour = catchAsync(async (req, res, next) => {
// 这里增加 populate 方法,填充 user 数据到 guides,此时引用 user 的 id 位置,会被填充为该 id 对应的 user 信息
const tour = await TourSchema.findById(req.params.id).populate({
path: 'guides', // 这里指代需要填充的位置
select: '-__v', // 这里使用符号-,表示不返回__v 的数据
});
...
});

image-20250329001542188

优化

使用 query middleware,让除了 getOneTour,getAllTour 也能 ref User 的数据,Tour 中所有需要查询的操作都会运行这个 middleware

1
2
3
4
5
6
7
tourSchema.pre(/^find/, function (next) {
this.populate({
path: "guides",
select: "-__v",
});
next();
});

image-20250329003653999

Parent Reference

和 child reference 的区别是引用元素 tour 和 user 只会有一个,所以不用[]

因为是父引用,孩子节点会指向父节点,但是父节点并不能获取孩子节点的数据

而 populate 时要填充两个字段,需要两次调用 populate

1
2
3
4
5
6
7
8
9
10
11
12
tour: {
// 因为这是一个 parent ref,所以不会有列表,只会有一个父元素,所以这里不用 []
type: mongoose.Schema.ObjectId,
ref: 'Tour',
required: [true, 'Review must belong to a tour'],
},
user: {
// 因为这是一个 parent ref,所以不会有列表,只会有一个父元素,所以这里不用 []
type: mongoose.Schema.ObjectId,
ref: 'User',
required: [true, 'Review must belong to a user'],
},
1
2
3
4
5
6
7
8
9
10
11
reviewSchema.pre(/^find/, function (next) {
this.populate({
path: "tour",
select: "name",
});
this.populate({
path: "user",
select: "name photo", // 只展示 name 和 photo
});
next();
});

创建 Review Model, Controller, Route 以及根目录下引用 route

顺序:Model -> Controller -> Route -> app.js

virtual populate

因为是父引用,孩子节点会指向父节点,但是父节点并不能获取孩子节点的数据,,现在我们想要从父节点 tour 获取所有子节点 review 的数据,用 review 填充 tour,所以我们这里引入了 virtual populate

我们可以获取某个 tour 中所有的 reviews,但是却不用将 review 的 id 存在 tour 中,也就是说 virtual populate 是一种:在 tour 中保留 review id 的 array,但实际上并没有将其持久化到数据库中,有点像虚拟字段,但是有填充

我们获取某个 tour 的全部评论,因此只将 getOneTour populate

1
2
3
4
5
6
7
// Virtual Populate
tourSchema.virtual("reviews", {
ref: "Review",
foreignField: "tour", // 这里的 tour 是 reviewModel.schema 中父引用 tour 字段
localField: "_id", // 这里的_id 是当前也就是 tourSchema 与 review 引用连接在一起的字段
// 他们俩连起来看就是在本地 tourSchema 中是_id,但在 reviewSchema 中是 tour,他们俩是等价的
});
1
2
3
4
exports.getTour = catchAsync(async (req, res, next) => {
const tour = await TourSchema.findById(req.params.id).populate('reviews');
...
});

image-20250329124204655

总结

embedding:一次旅行可能有多个导游,但不会超过 10 个,可以使用这个方式,这个方式会将 review 中导游的数据也存入数据库

child reference + populate:一次旅行可能有多个导游,但不会超过 10 个,这个方式只会存储导游的 id,查询时才会填充展示出来,不会存储多余数据

parent reference + virtual populate:一个酒店获取全部评论,评论数可能成千上万,因此只能使用这个方式

Nest Route

考虑一下,在 createReview 时,我们的 tour 和 user 中的 id 怎么放进去,测试时我们通过数据库拿到并复制到该字段,但是现实中,我们的 user id 来自于当前登录的 id,tour id 来自于当前的 tour

因此我们要设计一个这样的 route:tour/tourId/reviews 来调用 createReview

tourRoute.js 中增加

1
2
3
4
5
6
7
router
.route("/:tourId/reviews")
.post(
authController.protect,
authController.restrictTo("user"),
reviewController.createReview
);

修改 reviewController.js

1
2
3
4
5
6
7
8
9
10
11
12
13
exports.createReview = catchAsync(async (req, res, next) => {
console.log(req.param);
if (!req.body.tour) req.body.tour = req.params.tourId; // 从链接中获取 tour id
if (!req.body.user) req.body.user = req.user.id; // 从当前登录状态获取 user id
const newReview = await Review.create(req.body);

res.status(201).json({
status: "success",
data: {
newReview,
},
});
});

合并参数

场景:刚才我们创建了一个新 route:tours/tourId/reviews/createReview,而原来我们也有一个 route:reviews/createReview,这两个在功能上一集写法上有一定重叠

tours/tourId/reviews

1
2
3
4
5
6
7
router
.route("/:tourId/reviews")
.post(
authController.protect,
authController.restrictTo("user"),
reviewController.createReview
);

reviews/

1
2
3
4
5
6
7
router
.route("/")
.post(
authController.protect,
authController.restrictTo("user"),
reviewController.createReview
);

为了优化,我们使用合并参数来解决这个问题

tourRoute.js

1
2
3
4
5
6
7
8
9
10
11
const reviewRouter = require("../routes/reviewRoutes");

router.use("/:tourId/reviews", reviewRouter); // 表示当执行这个 route 时,我们会进到 reviewRoutes 中调用对应的 ('/') 方法,这和我们在 app.js 中使用 app.use 的方式一样

// router
// .route('/:tourId/reviews')
// .post(
// authController.protect,
// authController.restrictTo('user'),
// reviewController.createReview,
// );

reviewRoute.js

这里我们将 mergeParams 置为 true,可以让 reviewRoute 获取从 tourRoute 传过来的/:tourId,从而使用嵌套路由实现tours/tourId/reviews

Express merge params

1
const router = express.Router({ mergeParams: true });
1
2
3
4
5
6
7
8
9
10
// POST /tours/tourId/reviews
// POST /
// 因此下面这一个 route 可以实现上面两个 POST
router
.route("/")
.post(
authController.protect,
authController.restrictTo("user"),
reviewController.createReview
);

Aggregation Pipline

聚合 (Aggregation) 操作 (1) - Aggregation pipeline

Match and Group

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const stats = await TourSchema.aggregate([
{
$match: { ratingAverage: { $gte: 4.5 } }, // 匹配评分大于 4.5 的 document
},
{
$group: {
_id: "$difficulty", //根据不同字段聚合,比如这里是$difficulty,会返回 easy、medium、difficult 三个条件下的不同值
numTours: { $sum: 1 }, //符合条件的 document 求和
numRatings: { $sum: "$ratingQuality" },
avgRating: { $avg: "$ratingAverage" }, // 评分求平均值
avgPrice: { $avg: "$price" }, // 价格平均值
minPrice: { $min: "$price" }, // 价格最小值
maxPrice: { $max: "$price" }, // 价格最大值
},
},
{
$sort: { avgPrice: -1 }, //将上面返回的数据排序,需要用之前已经返回的字段,比如 avgPrice,1 代表升序,-1 代表降序
},
{
$match: { _id: { $ne: "easy" } }, //可以多次匹配,这里表示:匹配不包含_id 为 easy 的数据,仅返回 medium 和 difficult
},
]);
image-20250324140746675

Unwind and Project

用例:http://localhost:4001/api/v1/tours/monthly-plan/2021

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 year = req.params.year * 1;
const plan = await TourSchema.aggregate([
{
$unwind: "$startDates", // 解构 startDates,每一个日期都会有对应的 document
},
{
$match: {
startDates: {
// 日期大于本年 1.1,小于 12.31
$gte: new Date(`${year}-01-01`),
$lte: new Date(`${year}-12-31`),
},
},
},
{
$group: {
_id: { $month: "$startDates" }, // 显示月份
numTourStarts: { $sum: 1 }, // 显示当前月开始的 tour
tours: { $push: "$name" }, // 将对应的 tour name 合并展示到 Array 中
},
},
{
$project: {
_id: 0, // 不显示_id 字段
},
},
{
$sort: { numTourStarts: -1 }, // 按照 numTourStarts 降序排列
},
]);
image-20250324152249769

Virtual Properties

Mongoose Virtuals

  1. 怎么理解 Virtual Properties 含义?

    mongoose 特有的一种字段处理方式,生成的新字段可以返回给客户端,但不会存入 mongodb,适合处理 model 中的业务逻辑

  2. 用例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    const tourSchema = mongoose.Schema(
    {
    duration: {
    type: Number,
    required: [true, "A tour must have a duration"],
    },
    },
    {
    // mongoose.Schema 的第二个参数,可以在这里选择 virtual Properties 是否展示出来
    toJSON: { virtuals: true },
    toObject: { virtuals: true },
    }
    );

    //使用 function 而不是箭头函数是因为这里我们需要用到 this 关键字,箭头函数没有 this
    tourSchema.virtual("durationWeeks").get(function () {
    return this.duration / 7;
    });

    使用上述用例后,再次执行数据库 get 操作,会返回包含 durationWeeks 的数据

    image-20250324173905545

MongoDB

  1. Database -> Collection -> Documents

  2. 数据形式很像 json,是 bson 的格式

  3. 如果本地 terminal 运行,mongo 命令就可以进入到 mongo shell

    1
    2
    3
    4
    5
    6
    7
    use natours-test #新建/切换到数据库
    db.tours.insertOne({ name:"The forest hiker", price:297, rating:4.7 }) #新建 collection tours,并向其表中插入一条数据
    db.tours.insertMany([{}, {}, {}]) #插入多条
    db.tour.find() #全局查找
    show dbs #展示所有数据库
    show collections #展示所有表
    quit() #退出 mongo shell
    1
    2
    3
    4
    5
    6
    db.tours.find() #全局查找
    db.tours.find({name: "xxx"}) #指定某条数据内容查找
    db.tours.find({ price: {$lte:500} }) #查找价格小于 500 的数据,lte 代表 less than equal,这里也可以用 lt
    db.tours.find({ price: {$lte:500}, rating: {$gte:4.8} }) #与逻辑查找两个条件数据
    db.tours.find({ $or: [{price: {$lte:500}},{第二个查询条件} ]}) #或逻辑查询两个条件
    db.tours.find({ $or: [{price: {$lte:500}},{第二个查询条件} ]}, name: 1) #指定 name=1 会只返回 name 的数据
    1
    2
    3
    4
    db.tours.updateOne({name: xx}, {$set: {price: 597}}) #更新一条数据,第一个参数用于查询并获取指定数据,第二个参数用于修改指定内容,也可以用$set 新增字段,跟这个方式一样

    db.tours.deleteMany({rating: {$lt: 4.8}}) #删除评分小于 4.8 的所有数据
    db.tours.deleteMany({}) #删除所有数据!!!小心使用,提前备份数据
  4. mongodb 图形界面:compass,运行时需要保证 mongod 在 terminal 后台运行,这个有点像 beekeeper,但简单一些,连接时只需要保证 mongod 在运行,然后点击 connect 就行

  5. mongodb Atlas 可以连接 termianl 的 mongo shell,也可以连接 compass

    Mongodb Atlas -> project -> cluster -> collection

    配置 Atlas 的 Ip address 时,最好配置本地计算机的 ip 地址,0.0.0.0/0 是所有 ip 地址都可以访问,如果前期配置不好,连接不上,可以暂时使用这个 I have an issue connecting to my cluster with MongoDB VSCode on the M3 MacBook.

  6. vscode 配置连接 mongodb

    .env (要注意下面的链接要在 mongodb.net/后加上 database 名称,否则连不上)

    1
    2
    DATABASE=mongodb+srv://ellawu010:<PASSWORD>@cluster0.mgesg.mongodb.net/natours?retryWrites=true&w=majority&appName=Cluster0
    DATABASE_PASSWORD=YOUT_PASSWORD

    image-20250323101058531

Middleware

  1. 概念性:pre hooks, post hooks
  2. 四种 middleware: document, query, aggregate, model middleware

Document Middleware

准备:安装slugifynpm i slugify,schema 中加入slug: String

用途:在保存/生成数据之前进行一些操作,比如这里是保存 slug 到数据库

1
2
3
4
5
6
7
8
9
10
11
// DOCUMENT MIDDLEWARE: runs before .save() and .create()
tourSchema.pre("save", function (next) {
//这里的'save'是固定写法,不同的 middleware 对应不同的名称
this.slug = slugify(this.name, { lower: true }); // 将 name 转换为小写,这里的 this 是请求体
next(); //因为是 middleware,所以也需要有 next
});

tourSchema.post("save", function (doc) {
// post 方法不应该有 next
console.log(doc); //这里的 doc 是 pre 后生成的内容,包含 slug
});

Query Middleware

1
2
3
4
5
6
7
8
9
10
11
12
// QUERY MIDDLEWARE:
// runs before .find() .findOne() .findOneAndDelete() .findOneAndReplace() .findOneAndUpdate()
tourSchema.pre(/^find/, function (next) {
//正则,匹配 find 开头的函数
this.find({ secreTour: { $ne: true } }); // 匹配 secreTour 不为 true 的数据
this.start = Date.now();
next();
});

tourSchema.post(/^find/, function (doc) {
console.log(`find middleware took ${Date.now() - this.start} milliseconds`); // pre 到 post 用时
});

Aggregate Middleware

1
2
3
4
5
6
// AGGREGATE MIDDLEWARE: runs before .aggregate()
tourSchema.pre("aggregate", function (next) {
this.pipeline().unshift({ $match: { secreTour: { $ne: true } } }); // 匹配 secreTour 不为 true 的数据,unshift 代表在 array 首端加入
console.log(this); // 这里显示的是 aggregate 内容
next();
});

Data Validation

内置 validators

对于 create 操作,下面的验证会自动生效

对于 update 操作,需要配置 runValidators 为 true

1
2
3
4
const tour = await TourSchema.findByIdAndUpdate(req.params.id, req.body, {
new: true,
runValidators: true,
});
  1. 字符串长度验证 - max length minlength

    1
    2
    3
    4
    5
    6
    7
    name: {
    type: String,
    required: [true, 'A tour must have a name'],
    unique: true,
    maxlength: [40, 'A tour name must have less or equal than 40 characters'],
    minlength: [10, 'A tour name must have more or equal than 10 characters'],
    },
  2. number 范围 - max min

    1
    2
    3
    4
    5
    6
    ratingAverage: {
    type: Number,
    default: 4.5,
    max: [5, 'A rating must less than 5'],
    min: [1, 'A rating must more than 1'],
    },
  3. 字符串枚举验证 - enum

    1
    2
    3
    4
    5
    6
    7
    8
    difficulty: {
    type: String,
    required: [true, 'A tour must have difficulty'],
    enum: {
    values: ['easy', 'medium', 'difficult'],
    message: 'Difficulty is either: easy, medium and difficult',
    },
    },

自定义 validators

使用validate,this 指向当前的 document,({VALUE}) 是 mongoose 自带的方法,可以获取 val 的值

1
2
3
4
5
6
7
8
9
10
11
priceDiscount: {
type: Number,
validate: {
validator: function (val) {
// 'this' key word only work on current doc on New document creation.
// 因此'this'关键字只能用于 create,不能用于 update,这里的 this 指代当前的 schemam
return val < this.price;
},
message: 'Discount price ({VALUE}) should be below regular price',
},
},

validator (外置库)

安装:npm i validator

1
2
3
4
5
6
7
8
const validator = require('validator');
...
name: {
type: String,
...
// validator 相关的方法可以看 github,这里就是作为一个 callback function 在使用
validate: [validator.isAlpha, 'Tour name must only contain characters'],
},

Best Practice: 计算评分的平均数

create

create 新的 review 时,同步更新 review 总量和平均值,并同步到 Tour 表中

使用静态方法 reviewSchema.statics.calcAverageRatings

在 Model.js 中,this.constructor : this 是当前的 document,constructor 是创建了这个 document 的 Model,

步骤

  1. 使用静态方法 + aggregate 操作,匹配指定 tour 的 review 数据并计算数量及平均值
  2. 同步更新 current tour 中的评论数量、平均值字段
  3. 使用 post ‘save’方法保证在创建新评论之后进行调用它,使用 this.constructor 解决模型指向问题

代码实现

在 reviewModel.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
const Tour = require("./tourModel");

reviewSchema.statics.calcAverageRatings = async function (tourId) {
const stats = await this.aggregate([
{
$match: { tour: tourId }, // 先找到指定 tourId 对应的 review
},
{
$group: {
_id: "$tour", // 根据不同的 tour 分组
nRating: { $sum: 1 }, // 计算分组后 review 总量
avgRating: { $avg: "$rating" }, // 计算分组后 review 平均值
},
},
]);
await Tour.findByIdAndUpdate(tourId, {
// 同步更新 Tour Model 数据
ratingQuality: stats[0].nRating,
ratingAverage: stats[0].avgRating,
});
console.log(stats);
};

reviewSchema.post("save", function () {
this.constructor.calcAverageRatings(this.tour); // create review 后再调用这个方法
});

计算后,tour document 也会同步更新

image-20250330105537305

image-20250330105631516

Update, delete #168

update 或 delete review 时,同步更新 review 总量及平均值

这节难一点,因为之前使用的 save document middleware 在这里不适用,它不能用于除 save、create 之外的操作,我们只能用 query middleware,但是 query middlware 不能直接操作数据库

Query middleware 只有 query 的权限

如何绕过这个限制?

完整流程

  1. 用户执行 findOneAndUpdatefindOneAndDelete
  2. pre 钩子:
    • 通过 this.getQuery() 获取查询条件
    • findOne(this.getQuery()) 查询即将被修改/删除的 Review 文档
    • 存储该文档在 this.r 变量中
  3. MongoDB 执行 findOneAndUpdatefindOneAndDelete
  4. post 钩子:
    • 通过 this.r 访问 pre 钩子中存储的旧 Review 记录
    • 调用 calcAverageRatings(this.r.tour) 重新计算 Tour 评分

findByIdAndUpdate

findByIdAndDelete

他们俩执行后,会触发以findOneAnd开头的中间件,比如findByIdAndUpdate等价于findOneAndUpdate(),详见这里:findByIdAndUpdate

1
2
3
4
5
6
7
8
9
10
reviewSchema.pre(/^findOneAnd/, async function (next) {
this.r = await this.model.findOne(this.getQuery());
console.log(this.r); // 为什么要在这里获取 this.r 呢?先查询即将修改/删除的 Review 并存储它。如果该记录被删除了,post 文档是无法获取对应的 tourId,也就无法更新修改后的 review 总量和平均分
next();
});

// 为什么不能直接用 post?因为 post 执行说明 Mongoose 已经执行了查询,数据可能已经被删除或修改,无法再查找原始数据,也就无法获取修改的 tour
reviewSchema.post(/^findOneAnd/, async function (next) {
await this.r.constructor.calcAverageRatings(this.r.tour);
});

现在删除或者修改某个 tour 的 review,在那个 tour 中会有对应的修改

防止重复的 review

防止一个用户对同一个 tour 重复写 review

1
reviewSchema.index({ tour: 1, user: 1 }, { unique: true });

测试:

  1. 登录 user 账号,准备一个 review 为空的 tour

image-20250330130455618

  1. 为这个 tour 新增一个 review
image-20250330130641684
  1. 同一个账号对同一个 tour 再次新增 review(review 内容不重要,可以相同也可以不同),报错

    image-20250330130614366

如何修改 mongoose Schema 中的数值四舍五入保留一位小数

示例:修改 review 的平均值,四舍五入保留 1 位小数

使用setter

1
2
3
4
5
ratingAverage: {
type: Number,
...
set: (val) => Math.round(val * 10) / 10,
},

CRUD 操作与 API 设计

Mongoose CRUD

创建一个 factory function 统一管理 CRUD

Create, update, delete

我们目前有三个 model: tour, user, review,每个 model 都有各自的 delete 函数,但他们其实非常相似,所以这里我们使用一个 factory 函数来统一这些 CRUD 方法,通过传递不同的 model 使代码整洁高效复用

步骤:

  1. 创建一个 handlerFactory.js
  2. 重写 deleteOne 函数,(其他 create update 函数也是差不多的,注意权限的限制)
  3. 分别在 tour, user, review 中调用该函数
  4. 新增 router,同时增加 protect 和 restrict
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
// handlerFactory.js
const catchAsync = require("../utils/catchAsync");
const AppError = require("../utils/appError");

exports.deleteOne = (Model) =>
catchAsync(async (req, res, next) => {
const doc = await Model.findByIdAndDelete(req.params.id);
if (!doc) {
return next(new AppError("Not tour found with that ID", 404));
}
res.status(204).json({
status: "success",
data: null,
});
});

// tourController.js
const factory = require("./handlerFactory");
exports.deleteTour = factory.deleteOne(TourSchema);

// reviewController.js
const factory = require("./handlerFactory");
exports.deleteReview = factory.deleteOne(Review);

// userController.js
const factory = require("./handlerFactory");
exports.deleteUser = factory.deleteOne(User);
1
2
3
4
5
router.route("/:id").delete(
authController.protect, // 保证只有登录的用户才能进行该操作
authController.restrictTo("admin"), // 保证只有 admin 才能进行该操作
userController.deleteUser
);

Reading (getAll, getOne)

这个相比其他工厂函数会复杂一些,因为这里有一些 query middleware 以及 populate

getOne

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
exports.getOne = (Model, popuOptions) =>
catchAsync(async (req, res, next) => {
// 对 populate 做特殊处理
let query = Model.findById(req.params.id);
if (popuOptions) query = query.populate(popuOptions);
const doc = await query;

if (!doc) {
return next(new AppError("Not document found with that ID", 404));
}

res.status(200).json({
status: "success",
data: {
data: doc,
},
});
});

// tourController.js
exports.getTour = factory.getOne(TourSchema, { path: "reviews" });

getAll

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
exports.getAll = (Model) =>
catchAsync(async (req, res, next) => {
// 这里是为了只获取特定 tour 的全部 review
// To allow for nested GET reviews on tour
let filter = {};
if (req.params.tourId) filter = { tour: req.params.tourId };

const feature = new APIFeatures(Model.find(filter), req.query)
.filter()
.sort()
.limitFileds()
.paginate();

// EXECUTE QUERRY
const docs = await feature.query;

// SEND RESPONSE
res.status(200).json({
status: "success",
results: docs.length,
data: {
docs,
},
});
});

API 权限设计

Tour

getAllTour:获取全部的 tour,权限为:everyone

创建、删除、更新单个 tour,权限为:登录 + {admin, lead-guide}

getTour:获取单个 tour,权限为:everyone

User

Signup, login, forgotPassword, resetPassword:权限为:everyone

updatePassword, getMe, updateMe, deleteMe:权限为:登录

getAllUsers, createUser, getUser, updateUser, deleteUser:权限为:登录 + {admin}

这里权限的问题可以使用两个 router.use 解决,利用 middleware 依赖顺序执行的特性,使代码简洁

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 router = express.Router();

router.post("/signup", authController.signup);
router.post("/login", authController.login);
router.post("/forgotPassword", authController.forgotPassword);
router.patch("/resetPassword/:token", authController.resetPassword);

// 因为 middleware 与顺序关系很紧密,在这里使用这个 middleware 意味着这个 middleware 之后所有的 route 都会被保护,需要登录才能执行
// Protect all routes after this middleware
router.use(authController.protect);

router.patch("/updatePassword", authController.updatePassword);
router.get("/getMe", userController.getMe, userController.getUser);
router.patch("/updateMe", userController.updateMe);
router.delete("/deleteMe", userController.deleteMe);

// 给这个中间件之后的 route 都限制为权限只有 admin 才能执行
// restrict all routes To 'admin' after this middleware
router.use(authController.restrictTo("admin"));

router
.route("/")
.get(userController.getAllUsers)
.post(userController.createUser);
router
.route("/:id")
.get(userController.getUser)
.patch(userController.updateUser)
.delete(userController.deleteUser);

Review

只有登录后的 user 才能发布 review,只有 user 和 admin 才能更新、删除 review

getAllReviews, getReview:权限为:登录

createReview 权限为:登录 + {user}

updateReview, deleteReview:权限为:登录 + {user, admin}

Read Performance Optimization

使用 index,具体怎么选择需要看:具体字段查询的频率,维持 index 的成本,读写的模式

单索引

在 getAll 使用 explain() 分析,可以看到在 price>1000 的条件下查询,查询全部的数据返回 3 个数据,这里的效率很低,如何优化?

http://localhost:4001/api/v1/tours?price[lt]=1000

1
2
3
4
5
6
exports.getAll = (Model) =>
...
// EXECUTE QUERRY
const docs = await feature.query.explain();
...
});

image-20250329192543299

使用 index,在 tourModel 中对 price 进行排序,再查询

1
tourSchema.index({ price: 1 });

image-20250329192929149

复合索引

http://localhost:4001/api/v1/tours?price[lt]=1000&ratingAverage[gt]=4.5

image-20250329193414457

优化后

1
tourSchema.index({ price: 1, ratingAverage: -1 });

image-20250329193538285

可以看到,这是我们刚才生成的索引

image-20250329194335920

查询功能扩展(过滤、排序、字段选择和分页)

两种方式写 data.querry

硬编码 & req.query

1
2
3
4
5
6
const tour = await TourSchema.find({
duration: 5,
price: 1197,
});
//等同于
const tours = await TourSchema.find(req.query);

Mongoose.where()

1
2
3
4
5
const tour = await TourSchema.find()
.where("duration")
.equals(5)
.where("price")
.equals(1197);

筛选数据 req.querry

req.querry 代表 http://localhost:4001/api/v1/tours?difficulty=easy&sort=2&page=1&duration[gte]=5? 后的字段组成的 Array,在 terminal 中打印出来是这样:{ difficulty: ‘easy’, sort: ‘2’, page: ‘1’, duration: { gte: ‘5’ } }

  1. 仅筛选

    用例:http://localhost:4001/api/v1/tours?difficulty=easy , 解析出 difficulty 并用于筛选

    1
    2
    3
    4
    5
    //  EXECUTE QUERRY
    const tours = await TourSchema.find(req.query);

    console.log(req.query);
    // { difficulty: 'easy' }
  2. 过滤不做筛选的字段

    用例:http://localhost:4001/api/v1/tours?difficulty=easy&sort=2&page=1,sort、page 不参与筛选

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    // BUILD QUERRY
    const queryObj = { ...req.query }; //深拷贝 object
    const excludeFields = ["page", "limit", "sort", "fields"];
    excludeFields.forEach((el) => delete queryObj[el]); // 循环删除包含 excludeFields 字段的数据
    const query = TourSchema.find(queryObj);

    console.log(req.query, queryObj);
    // 输出:{ difficulty: 'easy', sort: '2', page: '1' } { difficulty: 'easy' },可以看出来 queryObj 中的某些数据被过滤掉了

    // EXECUTE QUERRY
    const tours = await query;
  3. 筛选 大于、小于数据

    用例:http://localhost:4001/api/v1/tours?difficulty=easy&sort=2&page=1&duration[gte]=5, 需要针对gte做操作,加上$符号。(为什么这里要加$符号?因为 mongodb 的命令行操作使用$gte 进行操作)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    // BUILD QUERRY
    // 1A) Filtering
    const queryObj = { ...req.query };
    const excludeFields = ["page", "limit", "sort", "fields"];
    excludeFields.forEach((el) => delete queryObj[el]);
    console.log(req.query, queryObj);
    // { difficulty: 'easy', sort: '2', page: '1', duration: { gte: '5' } } { difficulty: 'easy', duration: { gte: '5' } }

    // 1B) Advance filtering
    let queryStr = JSON.stringify(queryObj); //将 Object 转字符串

    // 正则匹配特定字段,g 代表多次匹配,callback 将每次匹配到的字段加上$符号,大于:gte, gt, 小于:lte, lt
    queryStr = queryStr.replace(/\b(gte|gt|lte|lt)\b/g, (match) => `$${match}`);

    console.log(JSON.parse(queryStr));
    // { difficulty: 'easy', duration: { '$gte': '5' } }

    const query = TourSchema.find(JSON.parse(queryStr));

    // EXECUTE QUERRY
    const tours = await query;

排序

  1. 单条件排序

    用例:http://localhost:4001/api/v1/tours?difficulty=easy&sort=price,针对 price 进行升序排序

    Q: 如何降序排序?

    A:price前加上-,即http://localhost:4001/api/v1/tours?difficulty=easy&sort=-price

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    // BUILD QUERRY
    // 1A) Filtering
    ...
    // 1B) Advance filtering
    ...
    // 2) Sorting
    if (req.query.sort) {
    query = query.sort(req.query.sort); // 根据 sort 对应的字段进行排序
    } else {
    query = query.sort('-createdAt'); // 默认时间升序
    }

    // EXECUTE QUERRY
    const tours = await query;
  2. 多个条件排序

    用例:http://localhost:4001/api/v1/tours?difficulty=easy&sort=-price,duration,先 price 降序,如果 price 相等,按 duration 升序,{ difficulty: 'easy', sort: '-price,duration' }

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    // BUILD QUERRY
    // 1A) Filtering
    ...
    // 1B) Advance filtering
    ...
    // 2) Sorting
    if (req.query.sort) {
    const sortBy = req.query.sort.split(',').join(' ') // 去掉逗号换为空格
    query = query.sort(sortBy); // 根据 sort 对应的字段进行排序
    } else {
    query = query.sort('-createdAt'); // 默认时间升序
    }

    // EXECUTE QUERRY
    const tours = await query;

仅返回特定字段 fields

  1. Fields Limiting

    用例:http://localhost:4001/api/v1/tours?difficulty=easy&fields=name,duration,先根据 difficulty 找出数据,然后返回这些数据中的 name 和 duration。

    Q: 如何返回不包含特定字段的数据?

    A: 同 sort,使用-,比如:http://localhost:4001/api/v1/tours?difficulty=easy&fields=-name,-price, 返回数据不包含 name 和 price

    Q: 如何让 schema 中某个字段默认不返回?

    A: 修改 model 中 Schema,该字段加上 select: false

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    // BUILD QUERRY
    // 1A) Filtering
    ...
    // 1B) Advance filtering
    ...
    // 2) Sorting
    ...
    // 3)Field limiting
    if (req.query.fields) {
    const fields = req.query.fields.split(',').join(' ');
    query = query.select(fields); // 根据 fields 对应字段返回数据
    } else {
    query = query.select('-__v'); // 不将 mongodb 默认生成的__v 字段返回,-代表不包括
    }

    // EXECUTE QUERRY
    const tours = await query;

分页 Page

  1. 用例 http://localhost:4001/api/v1/tours?page=4&limit=3

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    // BUILD QUERRY
    // 1A) Filtering
    ...
    // 1B) Advance filtering
    ...
    // 2) Sorting
    ...
    // 3)Field limiting
    ...
    // 4)Pagination
    const page = req.query.page * 1 || 1; // 默认值,* 1 是因为可以将字符串转换为数字
    const limit = req.query.limit * 1 || 100; // 默认值
    const skip = (page - 1) * limit;

    // page=3&limit=10, 1-10 page 1, 10-20 page 2, 20-30 page 3
    query = query.skip(skip).limit(limit);

    if (req.query.page) {
    const numTours = await TourSchema.countDocuments(); // 获取当前 collection 中有多少个 document
    if (skip >= numTours) throw new Error("This page doesn't exist"); //最外层有 try catch,可以接住 error
    }

    // EXECUTE QUERRY
    const tours = await query;

alias 自定义 api

  1. 用例:http://localhost:4001/api/v1/tours/top-5-cheap,可以用这种方式新增很多自定义 api,巧用中间件

    新增 route

    1
    2
    3
    router
    .route("/top-5-cheap")
    .get(tourController.aliasTopTours, tourController.getAllTours);

    新增中间件

    1
    2
    3
    4
    5
    6
    exports.aliasTopTours = (req, res, next) => {
    req.query.limit = "5";
    req.query.sort = "-ratingAverage,price";
    req.query.fields = "name,price,ratingAverage,summary,difficulty";
    next();
    };

代码示例

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 APIFeatures {
constructor(query, queryString) {
this.query = query;
this.queryString = queryString;
}

filter() {
// eslint-disable-next-line node/no-unsupported-features/es-syntax
const queryObj = { ...this.queryString }; // 深拷贝,生成新的 Object
const excludeFields = ["page", "limit", "sort", "fields"];
excludeFields.forEach((el) => delete queryObj[el]); //删除不需要匹配的字段

// Advance filtering: 匹配大于小于等范围
let queryStr = JSON.stringify(queryObj); //转字符串
queryStr = queryStr.replace(
/\b(gte|gt|lte|lt)\b/g,
(match) => `$${match}`
); // g 代表多次匹配,callback 将每次匹配到的字段加上$符号
this.query.find(JSON.parse(queryStr));
return this;
}

sort() {
if (this.queryString.sort) {
const sortBy = this.queryString.sort.split(",").join(" "); // 去掉逗号换为空格
this.query = this.query.sort(sortBy); // 根据 sort 对应的字段进行排序
} else {
this.query = this.query.sort("-createdAt");
}
return this;
}

limitFileds() {
if (this.queryString.fields) {
const fields = this.queryString.fields.split(",").join(" ");
this.query = this.query.select(fields);
} else {
this.query = this.query.select("-__v"); // 不将 mongodb 默认生成的__v 字段返回,-代表不包括
}
return this;
}

paginate() {
const page = this.queryString.page * 1 || 1; // 默认值,* 1 是因为可以将字符串转换为数字
const limit = this.queryString.limit * 1 || 100; // 默认值
const skip = (page - 1) * limit;
this.query = this.query.skip(skip).limit(limit);
return this;
}
}

module.exports = APIFeatures;

调用

1
2
3
4
5
6
7
8
const feature = new APIFeatures(TourSchema.find(), req.query)
.filter()
.sort()
.limitFileds()
.paginate();

// EXECUTE QUERRY
const tours = await feature.query;

常见报错码

HTTP 响应状态码

信息响应 (100199)

成功响应 (200299)

重定向消息 (300399)

客户端错误响应 (400499)

服务端错误响应 (500599)

常见状态码

200 OKGET, PUT 请求成功

201 CreatedPOST 请求成功,并因此创建了一个新的资源

204 No Content:请求成功,返回 null,一般用于DELETE

400 Bad Request:由于被认为是客户端错误(例如,错误的请求语法、无效的请求消息帧或欺骗性的请求路由),服务器无法或不会处理请求。

401 Unauthorized:这个响应意味着”unauthenticated”。也就是说,客户端必须对自身进行身份验证才能获得请求的响应。

403 Forbidden:客户端没有访问内容的权限;也就是说,它是未经授权的,因此服务器拒绝提供请求的资源

404 Not Found:服务器找不到请求的资源。

405 Method Not Allowed:服务器知道请求方法,但目标资源不支持该方法。例如,API 可能不允许调用DELETE来删除资源。

500 Internal Server Error:服务器遇到了不知道如何处理的情况。

安全与用户认证 Security and Authentication

Bcrypt 数据加密

Question:

  1. bcrypt vs bcryptjs
  2. bcrypt.hash 中的 number 代表什么?

安装npm i bcryptjs

使用 Document Middleware,在创建密码请求之后,写入数据库之前进行加密,只有当密码被修改时才调用

注意这里是一个异步函数,需要 await

删除 passwordConfirm 的原因:passwordConfirm 只是为了让用户 doublecheck 他的密码,我们加密后的密码不需要存两次到数据库

1
2
3
4
5
6
7
8
9
10
11
userSchema.pre("save", async function (next) {
// Only run this when password was actually modified
if (!this.isModified("password")) return next();

// Hash the pass word with cost of 12
this.password = await bcrypt.hash(this.password, 12);
// Delete the passwordConfirm field
this.passwordConfirm = undefined;

next();
});

JWT

Official Link:

https://jwt.io/

jsonwebtoken

Reference Link:

JSON Web Token 入门教程

JWT(JSON Web Token) — 原理介紹

Question:

  1. 为什么要保证 server 是 stateless?

安装npm i jsonwebtoken

流程

用户发起登录请求后,服务端使用 JWT 生成并返回 token,无需存储用户的登录状态;之后用户每次请求都必须携带该 token,才能从服务端获取正确的数据。

image-20250326105911829

原理

image-20250326111027701

JWT secret

  1. 建议写在 env 文件
  2. 因为使用 hsa 256 加密,secret 字符长度最短32

Auth Event

Sign up

代码示例

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 jwt = require("jsonwebtoken");

exports.signup = catchAsync(async (req, res, next) => {
// 这里不直接用 req.body 是为了防止用户自行在请求体里添加某些关键属性(如角色字段 role: "admin"),从而“伪造”出管理员账户或其他不希望用户直接设置的字段。
const newUser = await User.create({
name: req.body.name,
email: req.body.email,
password: req.body.password,
passwordConfirm: req.body.passwordConfirm,
});

// 使用 jwt.json 时,我们需要写入 payload 和 secret,header 会自动生成
const token = jwt.sign({ id: newUser._id }, process.env.JWT_SECRET, {
// 设置过期时间,超出过期时间,JWT 会过期,用户需要重新登录。设定类型:90d 8h 10m 5s
expiresIn: process.env.JWT_EXPIRES_IN,
});

res.status(201).json({
status: "success",
token,
data: {
user: newUser,
},
});
});

Login

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
// mongoose 的 methods 方法
userSchema.methods.correctPassword = async function (
candidatePassword,
userPassword
) {
return await bcrypt.compare(candidatePassword, userPassword);
};

exports.login = catchAsync(async (req, res, next) => {
const { email, password } = req.body;

// Check if email and password exist
if (!email || !password) {
return next(new AppError("Please provide email and password.", 400));
}

// Check if user exist && password is correct
// 这里 login 的验证,应该是先判定 email 是否存在于数据库,如果存在就找到对应的密码带回来和用户输入的密码进行比较,相同则说明验证通过。
const user = await User.findOne({ email }).select("+password");

// 这里 correctPassword 没有赋值给变量是因为他依赖 user,如果 user 不存在就没必要判定这里了
if (!user || !(await user.correctPassword(password, user.password))) {
return next(new AppError("Incorrect email or password", 401));
}

// If everything is ok, send token to client
const token = signToken(user._id);
res.status(200).json({
status: "success",
token,
});
});

针对这一句,因为 password 默认设置为不返回,这里需要获取 password 的值,使用+password的方式获取

1
const user = await User.findOne({ email }).select("+password");

未 ➕password

image-20250326123454646

➕password

image-20250326123638300

Project User access route

大多数教程只有 1、2,但是 3、4 的情况也需要关注

  1. Getting token and check of it’s there

    req.headers

  2. Verification token

    Promisify, Reference link

  3. Check if user be deleted but token still exist

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    // 找 token 中带过来的 id,看是否存在,如果不存在则报错
    const freshUser = await User.findById(decoded.id);
    if (!freshUser) {
    return next(
    new AppError(
    "The user belong to this token does no longer exist",
    401
    )
    );
    }
  4. Check if user changed password after the token was issued,这里需要关注 user 修改密码时间以及 token 的过期时间

如何获取 jwtTimestamp?

  1. 拿到一个标准的 jwt,比如:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjY3ZTRiZDg4NWUwMTRkNWUyZTFjN2FmZiIsImlhdCI6MTc0MzA0NDA1NywiZXhwIjoxNzQzMDQ1ODU3fQ.dOLsGB7wlzQKKGrfGJ2QZl-mjbf0stPuti04xrSepjI
  2. 打开 https://jwt.io/,输入 jwt,获得 payload
  3. 右侧 iat 是创建时间,exp 时是过期时间
  4. 将”2025-03-27T11:20:10.000Z”转换为”1743045803”这种格式,可以使用 getTime() 函数实现

image-20250327110750168

示例代码:

方法:changedPasswordAfter

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 判断用户是否修改了密码,比较用户修改密码时间和 token 生成时间,默认为 false 表示为未修改密码或者修改密码但在 token 生成之前
userSchema.methods.changedPasswordAfter = function (JWTTimestamp) {
// 方法中的 this 指向当前的 ducument,所以可以使用它访问 schema
// passwordChangedAt 这个字段在注册用户时不会写入数据库,只有在更新用户密码才会写入,因此如果不更新就不存在
if (this.passwordChangedAt) {
const changedTimestamp = parseInt(
// 转换为 10 进制整数
this.passwordChangedAt.getTime() / 1000, //passwordChangedAt是毫秒,需要转换为秒
10
);
return changedTimestamp > JWTTimestamp;
}
return false;
};

中间件:protect

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
exports.protect = catchAsync(async (req, res, next) => {
// 1) Getting token and check of it's there
let token;
if (
req.headers.authorization &&
req.headers.authorization.startsWith("Bearer")
) {
token = req.headers.authorization.split(" ")[1];
}

if (!token) {
return next(
new AppError("You are not login! Please login to get access.", 401)
);
}

// 2) Verification token
const decoded = await promisify(jwt.verify)(token, process.env.JWT_SECRET);
console.log(decoded);

// 3) Check if user has be deleted but token still exist
const freshUser = await User.findById(decoded.id);
if (!freshUser) {
return next(
new AppError(
"The user belong to this token does no longer exist",
401
)
);
}

// 4) Check if user changed password after the token was issued
if (freshUser.changedPasswordAfter(decoded.iat)) {
return next(
new AppError(
"User recently changed password! Please login again!",
401
)
);
}

// Grant access to protected route
req.user = freshUser; //暂存以后可能会用到
next();
});

标准的请求头 Header 包括:

key: Authorization

value: Bearer + ‘ ‘ + token

image-20250331225014245

授权特定 user 比如 admin 删除数据库的权利,其他人不能删除

使用中间件

protect 中间件:保护该 route,需要验证 token 成功才能访问

restrictTo 中间件:授予访问权限,只有 login 的人是 admin 以及 lead-guide 才能使用这个 route

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
router.delete(
authController.protect,
authController.restrictTo("admin", "lead-guide"),
tourController.deleteTour
);

exports.restrictTo = (...roles) => {
return (req, res, next) => {
if (!roles.includes(req.user.role)) {
//这里的 req.user.role 是从 protect 中间件传过来的,看源代码
return next(
new AppError(
"You do not have permission to perform this action",
403
)
);
}
next();
};
};

密码重置

  1. 点击忘记密码,携带 email 发送 post,会创建一个 reset token(随机 token)发送到 email 中

    forgotPassword: 收邮件

  2. 用户从电子邮件点击链接,发送携带 reset token 的请求,更新密码

    resetPassword: 接收 token 和新密码

步骤:

1
2
3
4
5
6
7
8
9
10
11
创建两个route api
forgotPassword:
1. 需要验证用户输入的邮箱是否存在
2. 生成一个随机的token,写入数据库
- 随机token使用内置函数crypto
- 这个token生成后要加密,防止他人攻击拿到这个token随意更改密码
- token要存入数据库,同时要给一个过期时间,写入数据库
3. 使用nodemailer发送邮件,使用mailtrap测试邮件
- 从使用mailtrap获取host, port, username, password
- 把token与链接结合,放在邮件内容中发出去
4. 如果发送邮件失败,则要将数据库中的token和过期时间清除

email.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
const nodemailer = require("nodemailer");

const sendEmail = async (options) => {
// 1) Create a transporter
const transporter = nodemailer.createTransport({
host: process.env.EMAIL_HOST,
port: process.env.EMAIL_PORT,
auth: {
user: process.env.EMAIL_USERNAME,
pass: process.env.EMAIL_PASSWORD,
},
});

// 2) Define the email options
const mailOptions = {
from: "ella <ellatest@gmail.com>",
to: options.email,
subject: options.subject,
text: options.message,
};

// 3) Actually send the email
await transporter.sendMail(mailOptions);
};

module.exports = sendEmail;
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
exports.forgotPassword = catchAsync(async (req, res, next) => {
// 1) Get user based on POSTed email
const user = await User.findOne({
email: req.body.email,
});
if (!user) {
return next(new AppError("There is no user with email address.", 404));
}

// 2) Generate the random reset token
const resetToken = user.createPasswordResetToken();
await user.save({
validateBeforeSave: false,
});

// 3) Send it to user's email
const resetURL = `${req.protocol}://${req.get(
"host"
)}/api/v1/users/resetPassword/${resetToken}`;
const message = `Please be aware, this link will expire in 10 min. This token is only available for a single use, if you click on the link a second time you will receive an error that the token is invalid. \n\nClick this link to reset your password ${resetURL}.\nIf you didn't forget your password, please ignore this email!`;

try {
await sendEmail({
email: user.email,
subject: "Your passwordr reset token (valid for 10 min",
message,
});

res.status(200).json({
status: "success",
message: "Token sent to email!",
});
} catch (err) {
user.passwordResetToken = undefined;
user.passwordResetExpires = undefined;
await user.save({ validateBeforeSave: false });

return next(
new AppError(
"There was an error sending the email. Try again later",
500
)
);
}
});

Question:

  1. crypto什么作用?

  2. 理解一下这两句

    1
    2
    const resetToken = crypto.randomBytes(32).toString("hex");
    crypto.createHash("sha256").update(resetToken).digest("hex");
  3. 什么是 mailtrap,怎么用?

    https://mailtrap.io/

    可以使用 test tool 发送邮件到虚拟邮箱进行测试验证,不使用真实邮箱一方面是真实邮箱不想收垃圾信息,一方面是真实邮箱有数量限制

遇到的 bug

  1. 现象:user.save()的报错没有 catch 到,terminal 能看到报错信息,程序直接崩溃,response 返回 200 请求成功字样

    原因:user.save()前缺少await关键字

    解释:在 Node.js(或任何支持 Promise 的环境)里,如果对一个返回 Promise 的异步函数没有使用 await(或 .then().catch()),那么它抛出的异常不会被常规的 try...catch 包裹到。结果就是 Promise 中产生的错误既没有被显式捕获,也没有被内部处理,就会变成 未处理的 Promise 拒绝(UnhandledPromiseRejection)。在较新版本的 Node.js 中,未处理的 Promise 拒绝会默认导致程序抛出警告或直接崩溃。

  2. 现象:数据库中存入的时间比北京时间早 8h

    原因:JavaScript 默认使用 Date.now() 存储毫秒级别的 UTC 时间,不是本地时区时间,在中国(UTC+8)查看时就会差 8 个小时。

    Reference Link:

    【踩坑】服务器和本地相差 8 小时

    为了统一各地服务器时间,我修改了 Date 函数

更新除密码外其他数据

步骤:

  1. 确认用户输入内容不包含 password 或 passwordConfirm
  2. 筛选用户输入内容,只更新固定字段,比如 name,email,过滤用户输入的其他字段
  3. 使用 findByIdAndUpdate 查找并更新数据库,使用 options.new 和 options.runValidators,保证返回修改后的数据以及保证数据使用 schema validator 验证
  4. 返回 client response

知识点:

Object.keys(obj).forEach

(obj, …allowedFields)

options.new 和 options.runValidators

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
const filterObj = (obj, ...allowedFields) => {
const newObj = {};
Object.keys(obj).forEach((el) => {
if (allowedFields.includes(el)) {
newObj[el] = obj[el];
}
});
return newObj;
};

exports.updateMe = catchAsync(async (req, res, next) => {
// 1) Create error if user POSTs password data
if (req.body.password || req.body.passwordConfirm) {
return next(
new AppError(
"This route is not for password updates. Please use /updatePassword."
)
);
}

// 2) Update user document
// 这里不使用 save() 而是 update 是因为:首先 save 不能用于 update,他们是互斥的;其次 save 需要验证 password 和 password Confirm,但这里不能输入密码
// 这里使用 filteredBody 是因为如果直接使用 req.body,用户可能直接在 body 中输入 role:admin 获取权限,这是很大的问题,需要避免,只能让用户修改固定内容
const filteredBody = filterObj(req.body, "name", "email");
const user = await User.findByIdAndUpdate(req.user.id, filteredBody, {
new: true, // 返回更新后的数据,而不是原来的
runValidators: true, // 保证 Update 也能使用 shcema 中的验证机制
});

// 3) SEND RESPONSE
res.status(200).json({
status: "success",
user,
});
});

删除账户

原理:并不删除,而是让用户的 active 关掉,这样以后用户重新激活它就可以直接使用

步骤:

  1. 配置 active 字段
  2. 新增 route:/deleteMe
  3. 查找并更新,将字段 active 更新为 false
  4. 返回给 client
  5. 配置 find 的 query middleware,查找时仅返回 active 为 true 的数据

配置 active 字段

1
2
3
4
5
active: {
type: Boolean,
default: true, // 默认值,自动生成
select: false, // 默认不返回给用户
},

更新 active 字段

1
2
3
4
5
6
7
8
exports.deleteMe = catchAsync(async (req, res, next) => {
await User.findByIdAndUpdate(req.user.id, { active: false });

res.status(204).json({
status: "success",
data: null,
});
});

配置 query middleware

1
2
3
4
5
// 功能:find 开头的函数,查找时仅返回 active 不为 false 的数据;false 代表该数据在用户角度已被删除
userSchema.pre(/^find/, async function (next) {
this.find({ active: { $ne: false } });
next();
});

防御机制 Best Practice

常见安全攻击方式

攻击者获得了数据库的访问权限 (Compromised Database)

解决:加密 password 和加密 rese tpassword token,防止攻击者窃取密码或者重置密码

暴力破解,攻击者试图猜测密码 (Brute Force Attacks )

解决:让登陆请求变得非常慢

1
2
3
- bcrypt 可以让登陆请求变慢
- 速率限制,express-rate-limit,限制来自一个ip的请求速率
- 用户最大登陆尝试次数限制,比如尝试10次登陆后,需要等待1小时才能再试

跨站脚本攻击 (Cross-site Scripting (XSS) Attacks)

问题:攻击者试图将脚本注入页面以运行他的恶意代码,如果 JWT 存储在本地,攻击者就可能获取

解决:

  • 仅将 JWT 存储在 HTTP 的 cookie 中,这样浏览器只能 receive 和 send,不能访问或修改 JWT
  • 清理用户输入数据
  • 配置一些特殊的 HTTP headers,让攻击难以发生

拒绝服务攻击 (Denial-of-Service (DOS) Attack)

问题:攻击者对服务器请求太多导致服务器崩溃

解决:

  • 速率限制,express-rate-limit

  • limit body payload (in body-parser),限制 POST 或 PATCH 时,req.body 的内容

  • 避免代码中出现 evil 的正则表达式

    NOSQL Query Injection

解决:

  • 使用 MongoDB 可以利用 SchemaTypes
  • 清理用户输入数据

其他建议

  1. 使用 HTTPS
  2. 生成随机的 password reset token 和过期时间
  3. 修改密码后,之前的 JWT 不再有效
  4. 不要把私密的配置文件上传到 git
  5. 不要发送 error 数据给用户
  6. 使用 scurf 防止 XSS 攻击
  7. 在用户进行支付操作时,再次让用户验证信息
  8. 创建一个不受信任的 jwt 列表,每次请求时验证一下
  9. 首次创建账户后确认 email
  10. 使用 refresh token 保证用户可以一直登陆,直到他们主动退出
  11. Two-factor authentication,比如登陆后需要验证手机短信

步骤

  1. 在 sendToken 函数中,将 cookie 写入
  2. 配置 cookie 的参数

知识点

cookie 配置:

过期时间:不需要单位,但需要转换为毫秒

secure 为 true:cookie 只能使用 HTTPS 发送,在生产环境下启用

httpOnly 为 true:浏览器不能以任何方式访问或修改 cookie

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 createSendToken = (user, statusCode, res) => {
const token = signToken(user._id);
// 配置 cookie 信息
const cookieOptions = {
expires: new Date(
Date.now() + process.env.JWT_COOKIE_EXPIRES_IN * 60 * 1000
),
httpOnly: true,
};
// 生产环境下启用 secure,因为启用后只能在 https 环境下收发 cookie,开发环境无法测试
if (process.env.NODE_ENV === "production") cookieOptions.secure = true;
// 配置 cookie
res.cookie("jwt", token, cookieOptions);
// 配置 password 不返回,无论是登录还是注册
user.password = undefined;
// 发送
res.status(statusCode).json({
status: "success",
token,
data: {
user,
},
});
};

开发环境下测试结果

image-20250328132004623

API 请求频率限制

使用一个全局的 middleware 来配置

安装:npm i express-rate-limit

1
2
3
4
5
6
7
8
// 一小时内允许来自同一个 IP 的 100 个请求
const limiter = rateLimit({
max: 100,
windowMs: 60 * 60 * 1000,
message: "Too many request from this IP, please try again in an hour",
});

app.use("/api", limiter);

验证:在 postman 中,任意发出一个请求,可以看到限制次数在减少,但如果此时 server 重启,次数会重置

image-20250328133730416

超过指定次数后,会报错

image-20250328133444096

配置 Security HTTP Headers

安装:npm i helmet

Offical Link: https://github.com/helmetjs/helmet

验证:查看 postman Headers 信息

数据清理 - 预防 NOSQL 和 XSS 攻击

用于抵御两种攻击

  1. NOSQL query injection

    npm i express-mongo-sanitize

  2. XSS

    npm i xss-clean

模拟 NOSQL query injection

现象:在知道密码但不知道用户邮箱的情况下登陆

步骤:点击 login,在 body 中输入"email": { "$gt": ""},点击 send,此时会登录成功,并且此时是以admin的身份登录的

原因:"email": { "$gt": ""}

image-20250328140026942

解决:安装 express-mongo-sanitize,并调用该中间件

1
2
3
4
5
const mongoSanitize = require("express-mongo-sanitize");

// Data sanitization against NOSQL query injection
// 查看 req.body, req.query, req.params,过滤所有的$和。符号
app.use(mongoSanitize());

修改后:

image-20250328142524437

模拟 XSS 攻击

现象:攻击者会将恶意 HTML 注入 server,可能会产生不好的影响

解决:使用 xss-clean middleware 可以将 HTML 符号转换

1
2
3
4
const xss = require("xss-clean");

// Data sanitization against XSS
app.use(xss());

修改前

image-20250328142906465

修改后

image-20250328143004494

防止参数污染 HPP (Prevent HTTP parameter pollution)

安装:npm i hpp

Reference Link: HTTP Parameter Pollution (HPP) 同名的 http 變數名稱

用例:http://localhost:4001/api/v1/tours?sort=duration&sort=price

问题点:我们的 feature.sort 函数中,对 sort 是用字符串 split(‘,’),正常使用 sort 应该这样使用?sort=duration,price,如果用用例的方式,会报错,并且会返回[ ‘duration’, ‘price’ ],不是字符串,因此无法解析;使用 hpp 后,会使用后一个也就是price进行排序,丢弃第一个。其他场景见 refer link。

另外,针对这种场景http://localhost:4001/api/v1/tours?difficulty=difficult&difficulty=medium,我们想要获取两种 difficulty 的数据,如果直接使用 hpp() 会过滤掉第一个,只留下第二个,因此针对这种情况设立白名单

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const hpp = require("hpp");

app.use(
hpp({
whitelist: [
"duration",
"maxGroupSize",
"ratingAverage",
"ratingQuality",
"difficulty",
"price",
],
})
);

项目管理 MVC

  1. 将 Application logic 写在 controller 中,更多技术逻辑

  2. 将 Business logic 写在 model 中,更多业务逻辑

    image-20250323114757587

  3. Create tour

    1
    2
    3
    4
    5
    6
    7
    8
    9
    // 两种方式 create documents
    // 第一种:Model.prototype.save(),其中 prototype 代表通过 model 生成的实例
    const newTour = new TourSchema({});
    newTour.save();

    // 第二种:Model.create()
    const newTour = new TourSchema.create({});

    // 建议使用第二种并配合 async 和 await 使用
  4. Catch err 的方式

    1
    2
    3
    4
    5
    6
    7
    8
    9
    // 目前我见过以下几种
    // 第一种,报错后程序不会崩溃,会返回 400 以及对应的 error json 信息
    try {
    } catch (err) {
    res.status(400).json({
    status: "fail",
    message: err,
    });
    }

    image-20250323121720707

    1
    2
    3
    4
    5
    //第二种
    if (!name || !email) {
    res.status(400);
    throw new Error("All fields are mandatory");
    }

    image-20250323121423921

    1
    2
    3
    4
    5
    6
    7
    //第三种 不太会用
    if (!id) {
    res.status(500).json("11");
    console.log("before return");
    return;
    console.log("after return");
    }

    第四种:写中间件 errorHandler.js – 最常用,具体看下一章节

调试、错误处理与性能优化

Debug 工具:NDB

Libiary: npm i ndb

npm run ndb server.js

可以用来调试代码,查看所有的变量

Node.js 你最熟悉的 Debug 方法

image-20250324214531817

Error Handler

Undefined route handler

在 app.js,所有 route 中间件之后定义

用途:对于不存在的 route 请求返回对应的 json 信息而不是 HTML 报错

1
2
3
4
5
6
7
// UNDEFINED ROUTE HANDLER
app.all("*", (req, res, next) => {
res.status(404).json({
status: "fail",
message: `Can't find ${req.originalUrl} on this server.`,
});
});

用例:http://localhost:4001/undefined/api

前:

image-20250324223020003

后:

image-20250324222943956

globalErrorHandler

  1. 在所有的路由和中间件之后,加入 errHandler 的中间件。只要参数内加入 err ,NodeJS 就会知道这是处理错误的中间件。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    // 中间件,最终返回 err 的地方,它需要获取 err 内容
    module.exports = (err, req, res, next) => {
    err.statusCode = err.statusCode || 500;
    err.status = err.status || "error";

    res.status(err.statusCode).json({
    status: err.status,
    message: err.message,
    });
    next();
    };
  2. 调用 errHandler 中间件

    在 next() 中传递 error object,这样它就不会运行其他中间层,而是直接传递到错误处理中间层。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    app.all("*", (req, res, next) => {
    /* 第一种方式
    res.status(404).json({
    status: 'fail',
    message: `Can't find ${req.originalUrl} on this server.`,
    });
    */
    /* 第二种方式 优化中
    const err = new Error(`Can't find ${req.originalUrl} on this server.);
    err.status = 'fail';
    err.statusCode = 404;
    next(err);
    */
    /* 第三种方式 优化后 推荐 */
    next(
    new AppError(`Can't find ${req.originalUrl} on this server.`, 404)
    );
    });
  3. 创建自定义的 Error Class 和重构

    关于 isOperational

    关于 Error.captureStackTrace

    Error.captureStackTrace(targetObject[, constructorOpt]) 在 targetObject 中添加一个.stack 属性。对该属性进行访问时,将以字符串的形式返回 Error.captureStackTrace() 语句被调用时的代码位置信息 (即:调用栈历史)。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    // 用于整合 error code 和 message,抽象出来,便于其他 api 方法调用
    class AppError extends Error {
    constructor(message, statusCode) {
    super(message);
    this.statusCode = statusCode;
    this.status = `${statusCode}`.startsWith("4") ? "fail" : "error";

    // 代表是否为可预期的错误,在后面的错误处理中可能会遇到一些非 operational 的错误,这些错误没有这个字段,便于管理
    this.isOperational = true;

    // 可以将 err.stack 通过这种方式返回给 errHandler
    Error.captureStackTrace(this, this.constructor);
    }
    }

    module.exports = AppError;
    1
    2
    3
    4
    5
    6
    7
    // 调用定义好的 class,写入 next()中,通过中间件返回错误信息
    app.all("*", (req, res, next) => {
    // 这里调用 class,并传递 err 到 errhandler 中间件,next(err) 会直接传到处理 err 的中间件
    next(
    new AppError(`Can't find ${req.originalUrl} on this server.`, 404)
    );
    });

Catch errors in Async function

现在想要重构 api 代码,之前写的都是 try catch,所有的 catch 内容都大同小异,现在需要一种间接的方式优化代码,并保证 catch 功能不变

原始代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
exports.getTour = async (req, res) => {
try {
const tour = await TourSchema.findById(req.params.id);
// equals to: TourSchema.findOne({ _id: req.params.id})
res.status(200).json({
status: "success",
data: {
tour,
},
});
} catch (err) {
res.status(400).json({
status: "fail",
message: err,
});
}
};

优化后代码

在 Express 里,如果想让错误自动传递给全局的错误处理中间件(即 app.use((err, req, res, next) => {}) 那种),需要在 getTour 外面包一层,手动捕获异常,然后调用 next(err),这里用了一个catchAsync 高阶函数实现

catchAsync是否能作用于 route 呢?可以,但是这个函数仅适用于异步 api,也就是说如果作用于 route,我需要去记忆哪些 api 是异步,哪些不是,所以不建议

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 catchAsync = (fn) => (req, res, next) => {
fn(req, res, next).catch(next); // catch(next) 等价于 catch(err => next(err))
};

/* 等价于
function catchAsync(fn) {
// 返回一个新的函数
return function (req, res, next) {
// 调用 fn,并捕获错误
fn(req, res, next).catch(next);
};
}
*/

exports.getTour = catchAsync(async (req, res) => {
const tour = await TourSchema.findById(req.params.id);
// equals to: TourSchema.findOne({ _id: req.params.id})
res.status(200).json({
status: "success",
data: {
tour,
},
});
});

返回 404error

上一个例子中,getTour 在使用有效的 mongodb id,但该 id 不存在于数据库时,会返回 200,null,针对这种情况,我们要修改为返回 404,not found

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
exports.getTour = catchAsync(async (req, res) => {
const tour = await TourSchema.findById(req.params.id);

// 增加这一句
if (!tour) {
return next(new AppError("Not tour found with that ID", 404));
}

res.status(200).json({
status: "success",
data: {
tour,
},
});
});

修改前

image-20250325122221594

修改后:

image-20250325122247722

开发/生产环境 Error 设定

修改 ErrorController.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
// 开发环境,尽可能返回多的信息,比如这里的 err 和 stack
const sendErrorDev = (err, res) => {
res.status(err.statusCode).json({
status: err.status,
error: err,
message: err.message,
stack: err.stack,
});
};

// 生产环境,返回必要信息,针对不同 error 种类返回不同信息
const sendErrorProd = (err, res) => {
// Operational, trusted error, send message to client
if (err.isOperational) {
res.status(err.statusCode).json({
status: err.status,
message: err.message,
});

// Programming or other unknown error, don't leak error details
} else {
// 1) print error
console.log("Erro 💥", err);

// 2) send generic message
res.status(500).json({
status: "error",
message: "Something went very wrong",
});
}
};

module.exports = (err, req, res, next) => {
err.statusCode = err.statusCode || 500;
err.status = err.status || "error";

if (process.env.NODE_ENV === "development") {
sendErrorDev(err, res);
} else if (process.env.NODE_ENV === "production") {
sendErrorProd(err, res);
}

next();
};

MongoDB 导致的不能 catch 的错误(生产环境)

CastError

示例:getTour, http://localhost:4001/api/v1/tours/6,6 不是一个合法的 mongodb id,所以这里会发生 CastError

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const handleCastErrorDB = (err) => {
const message = `Invalid ${err.path}: ${err.value}`;
return new AppError(message, 400);
};

module.exports = (err, req, res, next) => {
...
} else if (process.env.NODE_ENV === 'production') {
let error = JSON.parse(JSON.stringify(err)); // 深拷贝一个 obj
if (error.name === 'CastError') error = handleCastErrorDB(error); // 命中 castError
sendErrorProd(error, res); // 注意这里要用拷贝出来的 error
}
next();
}

unique shcema 重复报错

示例:createTour,http://localhost:4001/api/v1/tours,body 写重复的 name(因为 schema 中的 name 有 unique 属性)

1
2
3
4
5
6
7
8
9
10
11
12
const handleDuplicateFieldsDB = (err) => {
const key = Object.keys(err.keyValue); // 获取 key
const message = `Duplicate field value:{ ${key}: ${err.keyValue[key]} }. Please use another value`;
return new AppError(message, 400);
};

module.exports = (err, req, res, next) => {
...
if (error.code === 11000) error = handleDuplicateFieldsDB(error);
...
next();
}

ValidationError

示例:UpdateTour, http://localhost:4001/api/v1/tours/67e192164d84562515941d9b,一个合法的 id,输入错误的 body

1
2
3
4
5
{
"name": "shusjkiw", // name 太短,要求最低 10 个字节
"ratingAverage": 6, //rating 范围 1-5,这里大于 5
"difficulty": "dsjk" //difficulty 仅支持 easy, medium, difficulty 三种
}
1
2
3
4
5
6
7
8
9
10
11
12
// 上面的 body update 会返回三种报错信息,我们做的是将这三种报错信息整合在一起返回给用户
const handleValidationErrorDB = (err) => {
const message = `Invalid input data. ${err.message}`;
return new AppError(message, 400);
};

module.exports = (err, req, res, next) => {
...
if (error.name === 'ValidationError') error = handleValidationErrorDB(error);
...
next();
}

修改后

image-20250325161702402

Process Event 事件处理

Unhandled rejections & Uncaught exception

示例:比如 mongodb 数据库连接失败,密码修改或者 ip 变化,打印一个未定义的变量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const server = app.listen(PORT, () => {
console.log(`App runing on port ${PORT}`);
});

process.on("unhandledRejection", (err) => {
console.log(err.name, err.message);
console.log("UNHANDLED REJECTION 💥 Shutting down...");
server.close(() => {
process.exit(1);
});
});

process.on("uncaughtException", (err) => {
console.log(err.name, err.message);
console.log("UNCAUGHT REJECTION 💥 Shutting down...");
server.close(() => {
process.exit(1);
});
});

修改前:

image-20250325163040463

修改后:

image-20250325164410887

catchAsync 和 express-error-handler 区别?

p115 没看懂

关于代码错误该怎么 catch error?比如有一个 async 忘了写 await

常见报错及解决

  1. Cannot GET /

    因为看错了 api 路径,需要看 http://localhost:5001/api/contacts 而不是 http://localhost:5001/

  2. process.env.PORT is Undefined

    因为没有安装 dotenv 库

  3. Can not read properties from undefined, process.env.DATABASE

    问题:运行命令node ./dev-data/data/import-dev-data.js --delete一直报错,process.env 里没有我们配置的环境变量

    原因dotenv.config({ path: '../../config.env' });这句写错了,不应该是../../config.env,应该是./config.env,因为它的路径是相对于 process.cwd() 而不是 __dirname,也就是 config.env 的 path 是相对于程序执行目录(也就是 server.js 所在的目录),而不是当前目录。

    image-20250328195429336

  4. 导入 tours, user, reviews 数据到数据库是报错:

    Error: User validation failed: passwordConfirm: Please confirm your password

    因为我们会 create user,之前我们设置了 validator,所以这里我们用{ validateBeforeSave: false }将它手动关一下

    还需要将密码验证的 query middleware 先注释一下

    1
    await User.create(users, { validateBeforeSave: false });

    image-20250329173212735

    image-20250329173529095

MongoServerError: E11000 duplicate key error collection: natours.tours

​ 解决:先 delete 再 import

  1. 想要绕过 query middleware 不能操作 document 限制,使用如下代码,报错Error is !!!!!!! MongooseError: Query was already executed: Review.findOne({ _id: '5c8a3c7814eb5c17645c9138' })

    1
    2
    3
    4
    5
    6
    reviewSchema.pre(/^findOneAnd/, async function (next) {
    // 目标是获取当前 review document 的权限,但目前的 this 是当前的 query 权限
    const r = await this.findOne(); // 我们可以先执行一个 query 操作,这会返回给我们一个正在处理的 document
    console.log(r);
    // next();
    });

    image-20250330113104941

    解决:findOne(this.getQuery()) 是独立查询,不会影响 findOneAndUpdate 本身的执行流程

    1
    this.r = await this.model.findOne(this.getQuery());