跳到内容

c

将数据存入MongoDB

在我们进入关于在数据库中持久化数据的主题之前,我们先来看一下调试 Node 应用程序的几种不同方法。

Debugging Node applications

调试 Node 应用程序比调试在浏览器中运行的 JavaScript 稍微困难一些。打印到控制台是一种经过验证的方法,值得一试。有些人认为应该使用更复杂的方法,但我不同意。即使是世界上顶级的开源开发人员也会使用这种方法。

Visual Studio Code

在某些情况下,Visual Studio Code 的调试器可能很有用。您可以像这样以调试模式启动应用程序(在这个和接下来的几个图像中,注释中有一个名为“日期”的字段,在当前版本的应用程序中已被删除):

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

请注意,应用程序不应该在另一个控制台中运行,否则端口将已经被占用。

注意:Visual Studio Code 的较新版本可能会将“Debug”更改为“Run”。此外,您可能需要配置您的 launch.json 文件来开始调试。您可以通过选择下拉菜单上方的绿色播放按钮旁边的 Add Configuration...,然后选择 Run "npm start" in a debug terminal 来进行配置。有关更详细的设置说明,请访问 Visual Studio Code 的调试文档

下面是一张截图,显示代码执行在保存新笔记的过程中被暂停:

断点处执行的vscode屏幕截图

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

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

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

Chrome dev tools

您也可以通过在命令中启动应用程序来使用 Chrome 开发者控制台进行调试:

node --inspect index.js

您还可以将 --inspect 标志传递给 nodemon

nodemon --inspect index.js

您可以通过点击 Chrome 开发者控制台中出现的绿色图标(node logo)来访问调试器:

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

调试器的界面与在 React 应用程序中的使用方式相同。可以使用Sources选项卡设置断点,代码执行将在断点处暂停。

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

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

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

Question everything

调试全栈应用程序可能一开始看起来很棘手。很快,我们的应用程序除了前端和后端之外还将有一个数据库,而应用程序中可能存在许多潜在的错误。

当应用程序"无法工作"时,我们首先必须找出问题实际发生在哪里。问题往往存在于您意想不到的地方,可能需要几分钟、几小时甚至几天才能找到问题的根源。

关键是要有系统性。由于问题可能存在于任何地方,您必须对所有事物提出质疑,逐个排除所有可能性。记录到控制台、使用 Postman、调试器和经验都会有所帮助。

当出现错误时,最糟糕的策略就是继续编写代码。这将确保您的代码很快会有更多的错误,并且调试它们将变得更加困难。丰田生产系统的 Jidoka(停止和修复)原则 在这种情况下也非常有效。

MongoDB

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

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

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

您可以从 数据库导论课程part7 材料中了解有关文档数据库和 NoSQL 的更多信息。不幸的是,该材料目前仅提供芬兰语版本。

现在,请阅读 MongoDB 手册中关于 集合(collections)文档(documents) 的章节,以了解文档数据库如何存储数据的基本概念。

当然,您可以在计算机上安装和运行 MongoDB。然而,互联网上也有许多可用的 Mongo 数据库服务。在本课程中,我们首选的 MongoDB 提供商将是 MongoDB Atlas

创建并登录到您的帐户后,让我们首先选择免费选项:

mongodb部署云数据库免费共享

选择云提供商和位置,并创建集群:

选择共享、AWS 和区域的 MongoDB

让我们等待集群准备就绪。这可能需要几分钟时间。

注意:在集群准备就绪之前,请不要继续进行。

让我们使用security(安全)选项卡为数据库创建用户凭据。请注意,这些凭据与您用于登录 MongoDB Atlas 的凭据不同。这些凭据将用于您的应用程序连接到数据库。

mongodb security quickstart

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

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

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

最后,我们准备好连接到我们的数据库了。首先点击connect

MongoDB 数据库部署连接

然后选择:Connect to your application

MongoDB 连接应用程序

视图显示了MongoDB URI,这是我们将提供给我们的应用程序的 MongoDB 客户端库的数据库地址。

地址看起来是这样子的:

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

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

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

Mongoose可以被描述为一个对象文档映射器(ODM),使用这个库将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.o1opl.mongodb.net/?retryWrites=true&w=majority`

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的浏览集合选项卡中查看数据库的当前状态。

MongoDB 数据库浏览集合按钮

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

MongoDB 集合选项卡 db myfirst app notes

让我们销毁默认数据库test,并通过修改URI中引用的数据库名称将其更改为noteApp

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

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

mongodb collections tab noteApp notes

数据现在存储在正确的数据库中。该视图还提供了create database(创建数据库)功能,可以用于从网站创建新数据库。这样创建数据库是不必要的,因为当应用程序尝试连接到尚不存在的数据库时,MongoDB Atlas会自动创建一个新数据库。

Schema

在与数据库建立连接后,我们为笔记定义了schema,并创建了相应的model

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

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

首先,我们定义了存储在 noteSchema 变量中的笔记的schema。该schema告诉 Mongoose 如何将笔记对象存储在数据库中。

Note 模型定义中,第一个"Note"参数是模型的单数名称。集合的名称将是小写复数形式的notes,因为Mongoose的惯例是自动将集合命名为复数形式(例如notes),当schema以单数形式(例如Note)引用它们时。

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

Mongoose的思想是,存储在数据库中的数据在应用程序级别被赋予一个schema,该schema定义了存储在任何给定集合中的文档的形状。

Creating and saving objects

接下来,应用程序使用Notemodel创建一个新的笔记对象:

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

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

将对象保存到数据库使用的是适当命名的 save 方法,可以通过 then 方法提供一个事件处理程序:

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

当对象保存到数据库时,提供给 then 的事件处理程序会被调用。事件处理程序使用命令 mongoose.connection.close() 关闭数据库连接。如果不关闭连接,程序将永远不会结束执行。

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

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

注意:不幸的是,Mongoose的文档并不非常一致,部分文档在其示例中使用回调,其他部分使用其他样式,因此不建议直接从那里复制和粘贴代码。不建议在同一代码中混合使用promise和旧式的回调。

Fetching objects from the database

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

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

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

node mongo.js outputs notes as JSON

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

搜索条件遵循Mongo搜索查询syntax

我们可以限制我们的搜索只包括重要的笔记,像这样:

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

Connecting the backend to a database

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

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

const mongoose = require('mongoose')

const password = process.argv[2]

// DO NOT SAVE YOUR PASSWORD TO GITHUB!!
const url =
  `mongodb+srv://fullstack:${password}@cluster0.o1opl.mongodb.net/?retryWrites=true&w=majority`

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)
  })
})

我们可以在浏览器中验证后端是否可以显示所有的文档:

api/notes in browser shows notes in JSON

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

格式化Mongoose返回的对象的一种方法是modify(修改)模式的 toJSON 方法,该方法在用该模式产生的模型的所有实例上使用。

要modify(修改)该方法,我们需要更改模式的可配置选项,可以使用模式的set方法更改选项,更多关于此方法的信息请参见:https://mongoosejs.com/docs/guide.html#options 。有关 toJSON 选项的更多信息,请参阅 https://mongoosejs.com/docs/guide.html#toJSONhttps://mongoosejs.com/docs/api.html#document_Document-toObject

有关 transform 函数的更多信息,请参阅https://mongoosejs.com/docs/api/document.html#transform

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

尽管Mongoose对象的_id属性看起来像一个字符串,但实际上它是一个对象。我们定义的 toJSON 方法将其转换为字符串以确保安全。如果我们不做这个改变,一旦我们开始编写测试,它将在未来对我们造成更大的麻烦。

在处理器中不需要做任何改变:

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

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

Database configuration into its own module

在我们将后端的其余部分重构为使用数据库之前,让我们将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)

定义Node modules(模块)的方式与在第2部分中定义ES6 modules的方式略有不同。

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

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

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

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

建立连接的方式有所改变:

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)
  })

将数据库的地址硬编码到代码中并不是一个好主意,所以我们通过MONGODB_URI环境变量将数据库的地址传递给应用程序。

建立连接的方法现在被赋予了处理成功和失败的连接尝试的函数。两个函数只是将成功状态的消息记录到控制台:

node output when wrong username/password

有许多方法可以定义环境变量的值。一种方法是在启动应用程序时定义它:

MONGODB_URI=address_here npm run dev

更聪明的方法是使用dotenv库。你可以用以下命令安装这个库:

npm install dotenv

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

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

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

我们应该立即将.env文件添加到gitignore中,因为我们不希望公开发布任何机密信息!

.gitignore in vscode with .env line added

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

让我们以以下方式更改index.js文件:

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

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

在导入note模型之前导入dotenv非常重要。这确保了在导入其他模块的代码之前,.env文件中的环境变量在全局范围内可用。

Important note to Fly.io users

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

然而,更好的选择是通过在项目根目录创建 .dockerignore 文件,内容如下

.env

并使用以下命令从命令行设置环境值:

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

由于PORT也在我们的.env中定义,所以实际上在Fly.io中忽略该文件是至关重要的,否则应用程序将在错误的端口启动。

在使用Render时,通过在仪表板中定义适当的环境变量给出数据库url:

browser showing render environment variables

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

Using database in route handlers

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

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

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

  if (body.content === undefined) {
    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)
  })
})

Verifying frontend and backend integration

当后端的功能被扩展时,首先使用浏览器、Postman或VS Code REST客户端测试后端是个好主意。接下来,让我们在启用数据库后尝试创建一个新的笔记:

VS code rest client doing a post

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

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

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

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

Error handling

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

让我们改变这种行为,如果给定id的笔记不存在,服务器将以HTTP状态码404未找到来响应请求。此外,让我们实现一个简单的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内部服务器错误。控制台会显示关于错误的更详细的信息。

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

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


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) 状态码表示服务器不能或不会处理请求,因为有些东西被认为是客户端错误(例如,请求语法格式错误,请求消息帧格式无效,或请求路由欺骗)。

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

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

在错误处理程序中打印引发异常的对象永远不是个坏主意:

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

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

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

sample screenshot showing tiny slice of output

Moving error handling into middleware

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

让我们更改/api/notes/:id路由的处理程序,使其使用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的(error handlers)错误处理器是定义了一个接受四个参数的函数的中间件。我们的错误处理器看起来像这样:

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引起的。在这种情况下,错误处理器将使用作为参数传递的响应对象向浏览器发送响应。在所有其他错误情况下,中间件将错误传递给默认的Express错误处理器。

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

The order of middleware loading

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

正确的顺序是:

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

同样重要的是,处理不支持的路由的中间件是加载到Express中的最后一个中间件,就在错误处理器之前。

例如,以下加载顺序会导致问题:

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响应,所以在未知端点中间件发送响应后,不会调用任何路由或中间件。唯一的例外是错误处理器,它需要在未知端点处理器之后,放在最后。

Other operations

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

从数据库删除笔记的最简单方法是使用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 回调参数可以用于检查是否实际删除了资源,如果我们认为有必要,我们可以使用这个信息为这两种情况返回不同的状态码。任何发生的异常都会传递给错误处理器。

使用findByIdAndUpdate方法可以轻松地切换笔记的重要性。

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

  const note = {
    content: body.content,
    important: body.important,
  }

  Note.findByIdAndUpdate(request.params.id, note, { new: true })
    .then(updatedNote => {
      response.json(updatedNote)
    })
    .catch(error => next(error))
})

在上面的代码中,我们还允许编辑笔记的内容。

注意,findByIdAndUpdate方法接收的是一个常规的JavaScript对象作为参数,而不是一个用Note构造函数创建的新笔记对象。

关于使用findByIdAndUpdate方法有一个重要的细节。默认情况下,事件处理器的updatedNote参数接收的是没有修改的原始文档。我们添加了可选的{ new: true }参数,这将导致我们的事件处理器被调用时,使用新的修改过的文档而不是原始文档。

在直接使用Postman或VS Code REST客户端测试后端后,我们可以验证它似乎是工作的。前端也似乎能够使用数据库与后端一起工作。

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

A true full stack developer's oath

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

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

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

全栈开发是极其困难的,这就是为什么我会使用所有可能的手段来使它变得更容易

  • 我会一直打开浏览器开发者控制台
  • 我会使用浏览器开发工具的网络标签,确保前端和后端的通信符合我的预期
  • 我会不断关注服务器的状态,确保前端发送到那里的数据按我预期的方式保存
  • 我会关注数据库:后端是否以正确的格式保存数据
  • 我会以小步骤前进
  • 我会写很多的console.log语句,以确保我理解代码的行为,并帮助定位问题
  • 如果我的代码不能工作,我不会写更多的代码。相反,我开始删除代码,直到它工作,或者只是返回到一切都还在工作的状态
  • 当我在课程的Discord频道或其他地方寻求帮助时,我会合适地提出我的问题,看这里了解如何寻求帮助。