跳到内容

c

将数据存入 MongoDB

在我们开始主题——在数据库中持久化保存数据之前,我们先来看一下调试 Node 应用的几种不同方法。

调试 Node 程序

调试 Node 应用比调试浏览器中运行的 JavaScript 稍微困难一些。打印到控制台是一种经实践检验的方法,永远值得这么做。有些人认为应该使用更优雅的方法,但我不同意。即使是世界上的精英开源开发者也使用这种方法

Visual Studio Code

在某些情况下,Visual Studio Code 的调试器会很有用。你可以像这样以调试模式启动应用(在这张图片和接下来几张图片中,笔记有一个在当前版本的应用中已删除的 date 字段):

截图显示如何在 vscode 中启动调试器

注意,不应该在另一个控制台中运行应用,否则会占用端口。

新版的 Visual Studio Code 可能用的是运行而非调试。此外,你可能需要配置 launch.json 文件来开始调试。你可以通过在绿色播放按钮旁边,变量菜单上方的下拉菜单中选择添加配置…,然后选择在调试终端运行“npm start”来进行配置。更详细的设置说明参见 Visual Studio Code 的调试文档

下面的截图显示代码在保存新笔记的中途已暂停执行:

断点处执行的vscode屏幕截图

代码执行在第 69 行的断点处停止。在控制台中,你可以看到 note 变量的值。在左上角的窗口中,你可以看到应用状态的其他相关信息。

顶部的箭头可以用于控制调试器的流程。

出于某种原因,我并不经常使用 Visual Studio Code 的调试器。

Chrome dev tools

通过以下命令启动应用,也可以使用 Chrome 开发者控制台进行调试:

node --inspect index.js

你可以通过点击 Chrome 开发者控制台中的绿色图标——Node 的 logo——来访问调试器:

带有绿色node标志图标的开发者工具

调试界面的用法与调试 React 应用时的用法相同。可以在源代码选项卡中设置断点,代码将在断点处暂停执行。

开发者工具的 Sources 选项卡,包含断点和监视变量

应用所有的 console.log 消息都将出现在调试器的控制台选项卡中。你还可以检查变量的值并执行自己的 JavaScript 代码。

开发者工具的控制台选项卡显示输入的笔记对象

怀疑一切

调试全栈应用一开始可能看起来很棘手。我们的应用除了前端和后端之外,很快还将又有一个数据库,应用中将有许多可能出现问题的地方。

当应用“无法运行”时,我们首先必须找出问题实际发生在哪里。问题往往存在于你意想不到的地方,并且可能要几分钟、几小时甚至几天才能找到问题的根源。

关键是要有条不紊。由于问题可能存在于任何地方,你必须怀疑一切,逐个排除所有可能性。记录到控制台,借助 Postman、调试器和经验都会有所帮助。

当出现错误时,所有策略中最差的就是继续编写代码。这么做保证会让你的代码很快有更多的错误,并且更难以调试。丰田生产体系的自动化(停止和修复)原则在这种情况下也非常有效。

MongoDB

为了永久存储我们保存的笔记,我们需要一个数据库。赫尔辛基大学教授的大多数课程使用的都是关系数据库。在本课程的大部分章节中,我们将使用 MongoDB,这是一种文档数据库

使用 Mongo 作为数据库的原因是它相对于关系数据库来说更简单。本课程的第 13 章节展示了如何构建使用关系数据库的 Node.js 后端。

文档数据库与关系数据库在数据组织方式和支持的查询语言方面有所不同。文档数据库通常被归类为 NoSQL 的范畴。

你可以从 Introduction to Databases 课程第 7 周的教材中了解更多文档数据库和 NoSQL 的信息。不幸的是,该教材目前只有芬兰语。

现在阅读 MongoDB 手册中关于集合文档的章节,对文档数据库是如何存储数据的有一个基本概念。

当然,你可以在你自己的电脑上安装并运行 MongoDB。然而,互联网上也有许多可以利用的 Mongo 数据库服务。在本课程中,我们首选的 MongoDB 提供商是 MongoDB Atlas

创建你的帐户并登录后,让我们用首页上的按钮新建一个集群。在打开的页面中,选择免费计划,决定云服务提供商和数据中心,然后创建集群:

选择共享、AWS 和区域的 MongoDB

这里选择的云服务提供商是 AWS,地区是斯德哥尔摩(eu-north-1)。注意如果你选择了其他选项,你的数据库连接字符串会与本例中的略有不同。等待集群准备就绪。这可能需要几分钟时间。

在集群准备就绪之前,先不要继续阅读。

让我们使用 security 选项卡创建数据库的用户凭据。请注意,这些凭据不同于登录 MongoDB Atlas 的凭据。这些凭据是用来将你的应用连接到数据库的。

mongodb security quickstart

接下来,我们需要定义允许访问数据库的 IP 地址。为简单起见,我们将允许所有 IP 地址访问:

MongoDB 网络访问/添加 IP 访问列表

注:如果你的对话框菜单不同,根据 MongoDB 文档,将 0.0.0.0 添加为 IP 地址也会允许从任何地方访问。

终于,我们准备好连接到我们的数据库了。要连接到数据库,我们需要数据库连接字符串,在界面中选择 Connect,然后选择 Drivers,数据库连接字符串就在 Connect to your application 一节中:

MongoDB 数据库部署连接

界面显示了 MongoDB URI,这是我们要在应用中提供给 MongoDB 客户端库的数据库地址:

MongoDB 连接应用

地址类似这样:

mongodb+srv://fullstack:thepasswordishere@cluster0.a5qfl.mongodb.net/?retryWrites=true&w=majority&appName=Cluster0

我们现在已经准备好使用数据库了。

我们可以在我们的 JavaScript 代码中用 MongoDB 官方 Node.js 驱动来直接使用数据库,但是这个驱动用起来相当麻烦。因此我们将使用 Mongoose 库,它提供了一个更高级的 API。

Mongoose 可以当作一个对象文档映射器(ODM,Object Document Mapper),使用这个库后,将 JavaScript 对象保存为 Mongo 文档就简单了。

让我们在笔记项目的后端中安装 Mongoose:

npm install mongoose

暂时先不要在后端添加任何与 Mongo 相关的代码。让我们先在笔记后端应用的根目录下新建一个文件mongo.js来创建一个练习应用:

const mongoose = require('mongoose')

if (process.argv.length < 3) {
  console.log('give password as argument')
  process.exit(1)
}

const password = process.argv[2]

const url = `mongodb+srv://fullstack:${password}@cluster0.a5qfl.mongodb.net/?retryWrites=true&w=majority&appName=Cluster0`

mongoose.set('strictQuery',false)

mongoose.connect(url)

const noteSchema = new mongoose.Schema({
  content: String,
  important: Boolean,
})

const Note = mongoose.model('Note', noteSchema)

const note = new Note({
  content: 'HTML is easy',
  important: true,
})

note.save().then(result => {
  console.log('note saved!')
  mongoose.connection.close()
})

注:根据你在构建集群时选择的地区,MongoDB URI 可能会与上面提供的示例不同。你应该验证并使用从 MongoDB Atlas 生成的正确 URI。

代码还假定我们在 MongoDB Atlas 中创建的凭据的密码将通过命令行参数传入。我们可以像这样访问命令行参数:

const password = process.argv[2]

当使用命令 node mongo.js yourPassword 运行代码时,Mongo 将向数据库添加一个新文档。

注:请注意这里的密码是为数据库用户创建的密码,而不是 MongoDB Atlas 的密码。此外,如果你创建的密码带有特殊字符,那么你需要用 URL 编码该密码

我们可以从 MongoDB Atlas 的 Database 选项卡的 Browse Collections 中查看数据库的当前状态。

MongoDB 数据库浏览集合按钮

正如视图所示,与笔记匹配的文档已添加到 myFirstDatabase 数据库中的 notes 集合中。

MongoDB 集合选项卡 db myfirst app notes

让我们删除默认的数据库 test,并将连接字符串引用的数据库的名称更改为 noteApp,将 URI 改成:

const url = `mongodb+srv://fullstack:${password}@cluster0.a5qfl.mongodb.net/noteApp?retryWrites=true&w=majority&appName=Cluster0`

让我们再次运行我们的代码:

mongodb collections tab noteApp notes

数据现在存储在正确的数据库中。该界面还提供了 create database 功能,用于从网站新建数据库。没有必要这样新建数据库,因为当应用尝试连接到一个尚不存在的数据库时,MongoDB Atlas 会自动新建一个数据库。

模式

在建立数据库的连接后,我们定义了笔记的模式和对应的模型

const noteSchema = new mongoose.Schema({
  content: String,
  important: Boolean,
})

const Note = mongoose.model('Note', noteSchema)

首先,我们定义了笔记的模式并存储在 noteSchema 变量中。该模式告诉 Mongoose 笔记对象是怎么存储在数据库中的。

Note 模型定义中,第一个“Note”参数是模型单数形式的名称。集合的名称会是小写复数形式的 notes。这是因为 Mongoose 的习惯是当模式以单数形式(如 Note)引用集合时,会自动将集合命名为其复数形式(如 notes)。

像 Mongo 这样的文档数据库是无模式的,这意味着数据库本身并不关心数据库中存储的数据的结构。可以在同一集合中存储字段完全不同的文档。

Mongoose 的思想是,在应用层面给定数据库中存储的数据的模式,来定义存储在任何给定集合中的文档的形状。

创建和保存对象

接下来,应用借助 Note 模型创建一个新的笔记对象:

const note = new Note({
  content: 'HTML is Easy',
  important: false,
})

模型是根据提供的参数创建新的 JavaScript 对象的构造函数。由于对象是用模型的构造函数创建的,因此它们具有模型的所有属性,包括将对象保存到数据库的方法。

将对象保存到数据库使用顾名思义的 save 方法,可以通过 then 方法为 save 方法提供一个事件处理函数:

note.save().then(result => {
  console.log('note saved!')
  mongoose.connection.close()
})

当对象保存到数据库时,会调用提供给 then 的事件处理函数。事件处理函数使用命令 mongoose.connection.close() 关闭数据库连接。如果不关闭连接,那么在程序结束之前,连接将一直打开。

保存操作的结果在事件处理函数的 result 参数中。当我们在数据库中存储一个对象时,保存操作的结果没什么有趣的。如果你想在实现应用或在调试时仔细查看它,可以将对象打印到控制台。

让我们修改代码中的数据并再次执行程序来保存更多的笔记。

注:不幸的是,Mongoose 的文档并不非常一致,部分文档在示例中使用回调函数,其他部分使用其他风格,因此不建议直接从那里复制和粘贴代码。不建议在同一份代码中混合使用 Promise 和传统的回调。

从数据库中获取对象

让我们注释掉生成新笔记的代码,并用以下内容替换它:

Note.find({}).then(result => {
  result.forEach(note => {
    console.log(note)
  })
  mongoose.connection.close()
})

当代码执行时,程序会打印出数据库中存储的所有笔记:

node mongo.js outputs notes as JSON

对象是通过 Note 模型的 find 方法从数据库中获取的。find 方法的参数是一个表示搜索条件的对象。由于参数是一个空对象{},我们得到了 notes 集合中存储的所有笔记。

搜索条件遵循 Mongo 的搜索查询语法

我们可以这么限制我们的搜索,使其只包含重要的笔记:

Note.find({ important: true }).then(result => {
  // ...
})

将后端连接到数据库

现在我们已经有足够的知识来在我们笔记应用的后端开始使用 Mongo。

让我们通过将 Mongoose 的定义复制粘贴到 index.js 文件来快速开始:

const mongoose = require('mongoose')

// DO NOT SAVE YOUR PASSWORD TO GITHUB!!
const password = process.argv[2]
const url = `mongodb+srv://fullstack:${password}@cluster0.a5qfl.mongodb.net/noteApp?retryWrites=true&w=majority&appName=Cluster0`

mongoose.set('strictQuery',false)
mongoose.connect(url)

const noteSchema = new mongoose.Schema({
  content: String,
  important: Boolean,
})

const Note = mongoose.model('Note', noteSchema)

让我们将处理获取所有笔记的函数改成:

app.get('/api/notes', (request, response) => {
  Note.find({}).then(notes => {
    response.json(notes)
  })
})

让我们用命令 node --watch index.js yourpassword 启动后端,于是我们可以在浏览器中验证后端是否正确显示所有保存到数据库中的笔记:

api/notes in browser shows notes in JSON

应用几乎完美地运行。只是前端假定每个对象都有的一个唯一 id 是在 id 字段中。我们也不想将 mongo 的版本控制字段 __v 返回给前端。

更改 Mongoose 返回对象的格式的一种方法是修改模式的 toJSON 方法,该方法应用于该模式产生的所有模型的所有实例。可以这么修改:

noteSchema.set('toJSON', {
  transform: (document, returnedObject) => {
    returnedObject.id = returnedObject._id.toString()
    delete returnedObject._id
    delete returnedObject.__v
  }
})

尽管 Mongoose 对象的 _id 属性看起来像一个字符串,但它实际上是一个对象。我们定义的 toJSON 方法将其转换成字符串以确保安全。如果我们不这么改的话,一旦将来我们开始编写测试,对象形式的 _id 属性就会给我们带来更大的危害。

处理函数不需要更改:

app.get('/api/notes', (request, response) => {
  Note.find({}).then(notes => {
    response.json(notes)
  })
})

代码在将笔记格式化为响应的格式时会自动使用定义的 toJSON 方法。

将数据库配置移到自己的模块

在我们将后端的其余部分重构为使用数据库之前,让我们先将 Mongoose 特定的代码提取到它自己的模块中。

让我们为模块新建一个名为 models 的目录,并添加一个名为 note.js 的文件:

const mongoose = require('mongoose')

mongoose.set('strictQuery', false)

const url = process.env.MONGODB_URI
console.log('connecting to', url)
mongoose.connect(url)
  .then(result => {    console.log('connected to MongoDB')  })  .catch(error => {    console.log('error connecting to MongoDB:', error.message)  })
const noteSchema = new mongoose.Schema({
  content: String,
  important: Boolean,
})

noteSchema.set('toJSON', {
  transform: (document, returnedObject) => {
    returnedObject.id = returnedObject._id.toString()
    delete returnedObject._id
    delete returnedObject.__v
  }
})

module.exports = mongoose.model('Note', noteSchema)

代码与之前相比有一些变化。数据库连接 URL 现在通过 MONGODB_URI 环境变量传递给应用,因为将其硬编码到应用中并不明智:

const url = process.env.MONGODB_URI

有许多方法定义环境变量的值。例如,我们可以在启动应用时定义:

MONGODB_URI="your_connection_string_here" npm run dev

我们稍后会学习一种更优雅的定义环境变量的方法。

建立连接的方式略有变化:

mongoose.connect(url)
  .then(result => {
    console.log('connected to MongoDB')
  })
  .catch(error => {
    console.log('error connecting to MongoDB:', error.message)
  })

建立连接的方法现在有了处理连接成功和失败的函数。两个函数都只是将连接成功与否的消息记录到控制台:

node output when wrong username/password

定义 Node 模块的方式与在第 2 章节中定义 ES6 模块的方式略有不同。

模块的公共接口是通过设定 module.exports 变量的值来定义的。我们将值设置为 Note 模型。在模块内部定义的其他东西,如变量 mongooseurl,对模块的用户而言将不可访问,也不可见。

导入模块是通过在 index.js 中添加下面这一行来实现的:

const Note = require('./models/note')

这样,Note 变量将被赋值为模块定义的同一个对象。

使用 dotenv 库定义环境变量

一种更优雅地定义环境变量的方法是使用 dotenv 库。你可以用以下命令安装这个库:

npm install dotenv

要使用这个库,我们要在项目的根目录下创建一个 .env 文件。环境变量在这个文件内定义,可以类似这样:

MONGODB_URI=mongodb+srv://fullstack:thepasswordishere@cluster0.a5qfl.mongodb.net/noteApp?retryWrites=true&w=majority&appName=Cluster0
PORT=3001

我们也将硬编码的服务端端口添加到 PORT 环境变量中。

应当立即将 .env 文件添加到 .gitignore 中,不要把任何秘密信息发布到网上!

.gitignore in vscode with .env line added

.env 文件中定义的环境变量可以通过表达式 require('dotenv').config() 导入,然后你在代码中就可以像引用普通环境变量一样,用 process.env.MONGODB_URI 语法引用它们。

让我们在 index.js 文件的开头导入环境变量,这样就可以在整个应用中使用环境变量了。让我们将 index.js 文件更改为:

require('dotenv').config()const express = require('express')
const Note = require('./models/note')
const app = express()
// ..

const PORT = process.env.PORTapp.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`)
})

重要的是 dotenv 要在 note 模型之前导入。这能确保在导入其他模块的代码之前,.env 文件中的环境变量在全局范围内(译注:包括其他导入的模块内)都可用。

关于在 Fly.io 和 Render 中定义环境变量的重要注意事项

Fly.io 用户:因为 Fly.io 不与 GitHub 一起使用,所以当部署应用时,.env 文件也会传到 Fly.io 服务器上。因此,文件中定义的环境变量在 Fly.io 也可用。

然而,更好的选择是通过在项目根目录创建 .dockerignore 文件来防止 .env 被复制到 Fly.io,.dockerignore 的内容如下

.env

并在命令行用以下命令设置环境变量的值:

fly secrets set MONGODB_URI="mongodb+srv://fullstack:thepasswordishere@cluster0.a5qfl.mongodb.net/noteApp?retryWrites=true&w=majority&appName=Cluster0"

Render 用户:在使用Render时,数据库 URL 通过在仪表板中定义适当的环境变量提供:

browser showing render environment variables

只需将 value 字段设为以 mongodb+srv:// 开头的 URL。

在路由处理函数中使用数据库

接下来,让我们将后端的其余功能更改为使用数据库。

创建新的笔记可以这样完成:

app.post('/api/notes', (request, response) => {
  const body = request.body

  if (!body.content) {
    return response.status(400).json({ error: 'content missing' })
  }

  const note = new Note({
    content: body.content,
    important: body.important || false,
  })

  note.save().then(savedNote => {
    response.json(savedNote)
  })
})

笔记对象是用 Note 构造函数创建的。响应是在 save 操作的回调函数内发送的。这确保只有在操作成功时才发送响应。我们稍后会讨论如何处理错误。

回调函数中的 savedNote 参数是保存的新创建的笔记。响应中发送回来的数据是用 toJSON 方法自动创建的格式化版本:

response.json(savedNote)

通过使用 Mongoose 的 findById 方法,获取单个笔记的操作变为以下形式:

app.get('/api/notes/:id', (request, response) => {
  Note.findById(request.params.id).then(note => {
    response.json(note)
  })
})

验证前后端的整合

当后端扩展后,首先使用浏览器、Postman 或 VS Code REST Client 测试后端是明智的。接下来,让我们在启用数据库后尝试创建一个新的笔记:

VS code rest client doing a post

只有在后端的所有内容都经过验证并正确运行后,才是测试前端与后端是否协同工作的时候。仅通过前端进行测试效率极低。

逐个集成前后端的功能可能是个好主意。首先,我们可以实现从数据库获取所有笔记的功能,然后在浏览器中通过后端端点进行测试。然后,我们可以验证前端是否能与新的后端一起正确运行。一旦所有东西看起来都正确,我们就可以继续下一个功能。

一旦我们引入数据库,查看数据库中持久化的状态是非常有用的,比如通过 MongoDB Atlas 的控制面板查看。在开发过程中,小型 Node 辅助程序,比如我们之前写的 mongo.js,往往非常有帮助。

你可以在这个 GitHub 仓库part3-4 分支中找到我们当前应用的完整代码。

真正的全栈开发者的誓言

现在又是练习的时候了。我们的应用的复杂性现在又上升了一个阶段,因为除了前端和后端,我们还有一个数据库。

的确有很多可能的错误来源。

所以我们应该再次扩展我们的誓言:

全栈开发极其困难,因此我会尽一切可能使其变得更容易

  • 我会始终打开浏览器的开发者控制台

  • 我会使用浏览器开发者工具的网络标签页,确保前后端按预期通信

  • 我会持续留意服务端的状态,确保前端发送的数据按预期保存

  • 我会留意数据库:后端是否以正确的格式保存数据

  • 我小步前进

  • 我会写大量的 console.log 语句,以确保我理解代码的行为,并借此定位问题

  • 如果我的代码不能正确运行,我不会写更多的代码。相反,我会开始删除代码直到它能正确运行,或者直接回到一切都还正常的状态

  • 当我在课程的 Discord 频道或其他地方寻求帮助时,我会恰当地陈述我的问题,参见这里了解如何寻求帮助

错误处理

如果我们尝试访问一个不存在的笔记的 URL,例如 http://localhost:3001/api/notes/5c41c90e84d891c15dfa3431,其中 5c41c90e84d891c15dfa3431 不是存储在数据库中的 id,那么响应将为 null

让我们改变这种行为,如果给定 id 的笔记不存在,服务器将以 HTTP 状态码 404 not found 来响应请求。此外,让我们实现一个简单的 catch 块来处理 findById 方法返回的 Promise 被拒绝的情况:

app.get('/api/notes/:id', (request, response) => {
  Note.findById(request.params.id)
    .then(note => {
      if (note) {        response.json(note)      } else {        response.status(404).end()      }    })
    .catch(error => {      console.log(error)      response.status(500).end()    })})

如果数据库中没有找到匹配的对象,note 的值将为 null 并执行 else 块。这将导致响应状态码 404 not found。如果 findById 方法返回的 Promise 被拒绝,响应的状态码将是 500 internal server error。控制台会显示更详细的错误信息。

除了不存在的笔记,还有一个需要处理的错误情况。在这种情况下,我们试图获取一个错误类型的 id,也就是说,id 与 Mongo 的标识符 _id 的格式不匹配。

如果我们发出以下请求,我们将得到下面的错误消息:

Method: GET
Path:   /api/notes/someInvalidId
Body:   {}
---
{ CastError: Cast to ObjectId failed for value "someInvalidId" at path "_id"
    at CastError (/Users/mluukkai/opetus/_fullstack/osa3-muisiinpanot/node_modules/mongoose/lib/error/cast.js:27:11)
    at ObjectId.cast (/Users/mluukkai/opetus/_fullstack/osa3-muisiinpanot/node_modules/mongoose/lib/schema/objectid.js:158:13)
    ...

如果给出一个格式错误的 id 作为参数,findById 方法将抛出错误,导致返回的 Promise 被拒绝。这会调用 catch 块中定义的回调函数。

让我们对 catch 块中的响应做一些小调整:

app.get('/api/notes/:id', (request, response) => {
  Note.findById(request.params.id)
    .then(note => {
      if (note) {
        response.json(note)
      } else {
        response.status(404).end() 
      }
    })
    .catch(error => {
      console.log(error)
      response.status(400).send({ error: 'malformatted id' })    })
})

如果 id 的格式不正确,那么我们会以在 catch 块中定义的错误处理程序结束。适合这种情况的状态码是 400 Bad Request,因为这种情况完全符合其描述:

400(Bad Request)状态码表示服务器不能或不会处理请求,因为服务端认为某些东西是客户端错误(例如,请求语法格式错误、请求消息帧格式无效,或请求路由欺骗)。

我们还在响应中添加了一些数据,以便解释错误的原因。

在处理 Promise 时,添加错误和异常处理几乎总是明智的。否则,你会发现自己在处理奇怪的错误。

在错误处理程序中打印引发异常的对象永远不会错:

.catch(error => {
  console.log(error)  response.status(400).send({ error: 'malformatted id' })
})

导致错误处理程序被调用的原因可能完全不同于你所预期的。如果你将错误记录到控制台,你可能会从长时间令人沮丧的调试会话中解救出来。此外,大多数你部署应用的现代服务都支持某种形式的日志系统,你可以借此来检查这些日志。如前所述,Fly.io 就是其中之一。

每次你在处理一个有后端的项目时,关注后端的控制台输出至关重要。如果你的屏幕比较小,只需要在背景中看到一小部分输出就足够了。任何错误消息都会引起你的注意,即使控制台在很后面也如此:

sample screenshot showing tiny slice of output

将错误处理移至中间件

我们在其他代码中编写了错误处理函数的代码。有些情况下这可能是一个合理的解决方案,但有些情况下,最好在一个地方实现所有的错误处理。如果我们后面想向诸如 Sentry 这样的外部错误跟踪系统报告与错误相关的数据,这可能特别有用。

让我们更改 /api/notes/:id 路由的处理函数,使用 next 函数来传递错误。next 函数作为第三个参数传递给处理函数:

app.get('/api/notes/:id', (request, response, next) => {  Note.findById(request.params.id)
    .then(note => {
      if (note) {
        response.json(note)
      } else {
        response.status(404).end()
      }
    })
    .catch(error => next(error))})

传递的错误作为参数传给 next 函数。如果调用 next 时没有传递参数,那么就将简单继续执行下一个路由或中间件。如果调用 next 函数时有一个参数,那么将继续执行错误处理中间件

Express 的错误处理函数是一个定义为接受四个参数的函数的中间件。我们的错误处理函数类似这样:

const errorHandler = (error, request, response, next) => {
  console.error(error.message)

  if (error.name === 'CastError') {
    return response.status(400).send({ error: 'malformatted id' })
  } 

  next(error)
}

// this has to be the last loaded middleware, also all the routes should be registered before this!
app.use(errorHandler)

错误处理函数检查错误是否为 CastError 异常,我们知道这个错误是由 Mongo 的无效对象 id 引起的。在这种情况下,错误处理函数将使用作为参数传递的 response 对象向浏览器发送响应。对于其他所有错误情况,中间件将错误传递给默认的 Express 错误处理函数。

注意,错误处理中间件必须是最后加载的中间件,并且所有的路由都应该在错误处理函数之前注册!

加载中间件的顺序

中间件的执行顺序与它们通过 app.use 函数被加载到 Express 的顺序相同。因此,定义中间件时需要小心。

正确的顺序是:

app.use(express.static('dist'))
app.use(express.json())
app.use(requestLogger)

app.post('/api/notes', (request, response) => {
  const body = request.body
  // ...
})

const unknownEndpoint = (request, response) => {
  response.status(404).send({ error: 'unknown endpoint' })
}

// handler of requests with unknown endpoint
app.use(unknownEndpoint)

const errorHandler = (error, request, response, next) => {
  // ...
}

// handler of requests with result to errors
app.use(errorHandler)

json-parser 中间件应该是最先加载到 Express 中的中间件。如果顺序是这样的话:

app.use(requestLogger) // request.body is undefined!

app.post('/api/notes', (request, response) => {
  // request.body is undefined!
  const body = request.body
  // ...
})

app.use(express.json())

那么,HTTP 请求发送的 JSON 数据在 logger 中间件和 POST 路由处理函数中都将不可用,因为这时 request.body 还是 undefined

同样重要的是,处理不支持的路由的中间件应当在定义完所有端点之后才加载,只在错误处理函数之前。例如,以下加载顺序会导致问题:

const unknownEndpoint = (request, response) => {
  response.status(404).send({ error: 'unknown endpoint' })
}

// handler of requests with unknown endpoint
app.use(unknownEndpoint)

app.get('/api/notes', (request, response) => {
  // ...
})

现在,未知端点的处理是在 HTTP 请求处理函数之前进行的。由于未知端点处理函数对所有请求都以 404 unknown endpoint 响应,所以在未知端点中间件发送响应后,不会调用任何路由或中间件。唯一的例外是错误处理函数需要放在最后,在未知端点处理函数之后。

其他操作

让我们为我们的应用添加一些缺失的功能,包括删除和更新单个笔记。

从数据库删除笔记最简单的方法是使用 findByIdAndDelete 方法:

app.delete('/api/notes/:id', (request, response, next) => {
  Note.findByIdAndDelete(request.params.id)
    .then(result => {
      response.status(204).end()
    })
    .catch(error => next(error))
})

对于删除资源的两种“成功”情况,后端都以 204 no content 的状态码响应。这两种不同的情况是删除存在的笔记,和删除数据库中不存在的笔记。result 回调参数可以用于检查是否实际删除了资源,并且如果我们认为有必要的话,我们也可以根据 result 的信息为两种情况返回不同的状态码。任何发生的异常都会传递给错误处理函数。

让我们实现更新单条笔记的功能,允许更改笔记的重要性。更新笔记的功能实现如下:

app.put('/api/notes/:id', (request, response, next) => {
  const { content, important } = request.body

  Note.findById(request.params.id)
    .then(note => {
      if (!note) {
        return response.status(404).end()
      }

      note.content = content
      note.important = important

      return note.save().then((updatedNote) => {
        response.json(updatedNote)
      })
    })
    .catch(error => next(error))
})

要更新的笔记首先通过 findById 方法从数据库中获取。如果数据库中没有具有给定 id 的对象,变量 note 的值将为 null,会以状态码 404 Not Found 响应查询。

如果找到了具有给定 id 的对象,会用请求中提供的数据更新对象的 contentimportant 字段,然后用 save() 方法将修改后的笔记保存到数据库。最后发送更新后的笔记来响应 HTTP 请求。

值得注意的一点是,代码现在包含嵌套的 Promise,在外层的 .then 方法内部又定义了另一个 Promise 链

    .then(note => {
      if (!note) {
        return response.status(404).end()
      }

      note.content = content
      note.important = important

      return note.save().then((updatedNote) => {        response.json(updatedNote)      })

通常不推荐这样做,这会使代码难以阅读。然而这样做至少在本例中能正确运行,因为这样做能确保 save() 方法之后的 .then 块只有在数据库中找到了具有给定 id 的笔记并调用了 save() 方法时才会执行。在本课程的第四章节,我们将学习 async/await 语法,来更简单、更清晰地处理这类情况。

在直接使用 Postman 或 VS Code REST Client 测试后端后,我们可以验证它似乎能正确运行。前端也显示能与使用数据库的后端一起正确运行。

你可以在这个 GitHub 仓库part3-5 分支中找到我们当前应用的完整代码。