跳到内容

a

Node.js 与 Express

在这一部分中,我们将重点转向后端,也就是在全栈中的服务端实现功能。

我们将在 NodeJS 的基础上建立后端,NodeJS 是一个基于谷歌的 Chrome V8 JavaScript 引擎的 JavaScript 运行时。

本教材是用 Node.js 的 v22.3.0 版本编写的。请确保你的 Node 版本至少和教材中使用的版本一样新(你可以通过在命令行中运行 node -v 来检查版本)。

正如第 1 章节中提到的,浏览器还不支持 JavaScript 的最新功能,因此浏览器中运行的代码必须用例如 babel 进行转译。运行在后端的 JavaScript 的情况则不同。最新版本的 Node 支持绝大部分 JavaScript 的最新特性,所以我们无需转译代码即可使用最新的特性。

我们的目标是实现一个能与第 2 章节中的笔记应用一起运行的后端。然而,让我们从最基础的实现一个经典的“hello world”应用开始。

注意本章节的应用和练习并不都是 React 应用,因此我们不会用 create vite@latest -- --template react 工具来初始化这些应用的项目。

我们在第 2 章节已经提到了 npm,它是一个用于管理 JavaScript 包的工具。事实上,npm 就是从 Node 生态发源的。

让我们进入一个合适的目录,用 npm init 命令为应用创建一个模板。回答完 npm 提出的问题后(译注:全部使用默认答案,也就是全部按回车),就会在项目的根目录自动生成一个包含项目信息的 package.json 文件。

{
  "name": "backend",
  "version": "0.0.1",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "Matti Luukkainen",
  "license": "MIT"
}

这个文件定义了,例如,应用的入口点是 index.js 文件。

让我们对 scripts 对象小小地改动一下,添加一条脚本命令。

{
  // ...
  "scripts": {
    "start": "node index.js",    "test": "echo \"Error: no test specified\" && exit 1"
  },
  // ...
}

接下来,让我们创建应用的第一个版本,在项目的根目录下添加一个 index.js 文件,代码如下:

console.log('hello world')

我们可以直接用 Node 从命令行中运行该程序:

node index.js

我们也可以用 npm 脚本来运行它:

npm start

因为我们在 package.json 文件中定义了 start npm 脚本,所以刚才可以运行:

{
  // ...
  "scripts": {
    "start": "node index.js",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  // ...
}

尽管可以通过从命令行调用 node index.js 来启动项目,但对于 npm 项目,更习惯用 npm 脚本来执行这些任务。

默认情况下,package.json 文件还定义了另一个常用的 npm 脚本,叫做 npm test。由于我们的项目还没有测试库,npm test 命令只是简单地执行以下命令:

echo "Error: no test specified" && exit 1

简单的 web 服务端

让我们将应用变成一个网络服务端,将 index.js 文件改成:

const http = require('http')

const app = http.createServer((request, response) => {
  response.writeHead(200, { 'Content-Type': 'text/plain' })
  response.end('Hello World')
})

const PORT = 3001
app.listen(PORT)
console.log(`Server running on port ${PORT}`)

一旦应用运行,控制台将打印下列信息:

Server running on port 3001

我们可以通过在浏览器中访问地址 http://localhost:3001 打开我们的简陋应用。

fullstack content

无论 URL 后面是什么,服务端的表现都是一样的。地址 http://localhost:3001/foo/bar 也将显示相同的内容。

如果 3001 端口已经被其他应用使用,那么启动服务端会显示以下错误信息:

➜  hello npm start

> hello@1.0.0 start /Users/mluukkai/opetus/_2019fullstack-code/part3/hello
> node index.js

Server running on port 3001
events.js:167
      throw er; // Unhandled 'error' event
      ^

Error: listen EADDRINUSE :::3001
    at Server.setupListenHandle [as _listen2] (net.js:1330:14)
    at listenInCluster (net.js:1378:12)

你有两个选择。要么关闭使用 3001 端口的应用(教材上一章节的 JSON Server 当时就运行在 3001 端口),要么让这个应用使用不同的端口。

让我们仔细看看这段代码的第一行:

const http = require('http')

在第一行中,应用导入了 Node 内置的 web server 模块。这实际上就是我们在浏览器端代码中已经在做的事情,只是语法略有不同:

import http from 'http'

现在,浏览器中运行的代码都使用 ES6 模块。模块用 export 来定义,用 import 来导入到当前文件。

Node.js 使用的是 CommonJS 模块。其原因是,Node 生态早在 JavaScript 在语言规范中支持模块之前就有对模块的需求了。现在,Node 也支持使用 ES6 模块,但支持还不是很完善,所以我们将继续使用 CommonJS 模块。

CommonJS 模块的功能与 ES6 模块几乎完全一样,至少在本课程我们需要用到的地方都一样。

代码的下一部分如下所示:

const app = http.createServer((request, response) => {
  response.writeHead(200, { 'Content-Type': 'text/plain' })
  response.end('Hello World')
})

代码使用 http 模块的 createServer 方法来创建一个网络服务端。服务端注册了一个事件处理函数每当有 HTTP 请求发送到服务端地址 http://localhost:3001 时,就会调用该函数。

请求会以状态码 200 响应,响应的 Content-Type 标头设为 text/plain,要返回的网站内容设为 Hello World

最后几行将 http 服务端赋值给 app 变量,再绑定到 3001 端口,以监听发送到 3001 端口的 HTTP 请求。

const PORT = 3001
app.listen(PORT)
console.log(`Server running on port ${PORT}`)

本课程中后端服务端的主要目的是向前端提供 JSON 格式的原始数据。因此,让我们立即改变服务端,让它以 JSON 格式返回一个硬编码的笔记列表。

const http = require('http')

let notes = [  {    id: "1",    content: "HTML is easy",    important: true  },  {    id: "2",    content: "Browser can execute only JavaScript",    important: false  },  {    id: "3",    content: "GET and POST are the most important methods of HTTP protocol",    important: true  }]const app = http.createServer((request, response) => {  response.writeHead(200, { 'Content-Type': 'application/json' })  response.end(JSON.stringify(notes))})
const PORT = 3001
app.listen(PORT)
console.log(`Server running on port ${PORT}`)

让我们重启服务端(你可以在控制台中按 Ctrl+C 来关闭服务端),然后刷新浏览器。

Content-Type 标头中的 application/json 值告诉接收者数据是 JSON 格式的。notes 数组通过 JSON.stringify(notes) 方法转换为 JSON 格式的字符串。这一步是必要的,因为 response.end() 方法只接受一个字符串或缓冲区来作为响应体。

当我们打开浏览器时,显示的格式与第 2 章节中我们使用 json-server 来提供的笔记列表完全一样:

fullstack content

Express

直接用 Node 内置的 http web server 实现服务端代码是可行的。然而,这很麻烦,特别是一旦应用的规模扩大的话,就更麻烦了。

已经有许多简化 Node 的服务端开发的库,这些库提供一个更好用的接口来与内置的 http 模块一起工作。这些库的目的是为我们建立后端服务端通常需要的通用情况提供更好的抽象概念。目前为止,用于这一目的的最流行的库是 Express

让我们用下列命令将 Express 定义为项目的依赖项,然后来使用它:

npm install express

这个依赖项也加进了 package.json 文件中:

{
  // ...
  "dependencies": {
    "express": "^5.1.0"
  }
}

依赖项的源码被安装到项目根目录下的 node_modules 目录中。除了 Express 之外,你还可以在该目录中找到大量其他的依赖项:

fullstack content

这些实际上是 Express 库的依赖项,以及它所有依赖项的依赖项,等等。这些统称为我们项目的传递性依赖项

项目中安装了 5.1.0 版本的 Express。在 package.json 中版本号前面的脱字号 ^ 是什么意思?

"express": "^5.1.0"

npm 中使用的版本模式称为语义化版本

^5.1.0 前面的脱字符 ^ 意味着将来如果项目的依赖项更新了,安装的 Express 版本最低是 5.1.0。然而,安装的 Express 版本也可以有更大的修订号(最后一个数字),或者更大的次版本号(中间的数字)。第一个主版本数字表示的库的主版本必须是相同的。

我们可以用下列命令更新项目的依赖项:

npm update

同样地,如果我们在另一台电脑上开始这个项目,我们可以在项目的根目录下用以下命令安装 package.json 中定义的项目所有最新的依赖项。

npm install

如果依赖项的主版本号没有改变,那么新版本应该是向后兼容的。这意味着,如果我们的应用将来使用了 5.99.175 版本的 Express,那么本章节中实现的所有代码仍然能正确运行,无需修改。相反,未来 6.0.0 版本的 Express 可能包含会导致我们的应用无法正确运行的更改。

Web 和 Express

让我们回到我们的应用,并做如下修改:

const express = require('express')
const app = express()

let notes = [
  ...
]

app.get('/', (request, response) => {
  response.send('<h1>Hello World!</h1>')
})

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

const PORT = 3001
app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`)
})

为了使用新版本的应用,首先必须重启应用。

应用并没有发生很大的变化。在代码的开头,我们导入了 express,这次 express 是一个函数,用来创建 Express 应用并将其存储在 app 变量中。

const express = require('express')
const app = express()

接下来,我们为应用定义了两个路由。第一个路由定义了处理向应用的 / 根发送的 HTTP GET 请求的事件处理函数。

app.get('/', (request, response) => {
  response.send('<h1>Hello World!</h1>')
})

该事件处理函数接受两个参数。第一个 request 参数包含 HTTP 请求的所有信息,第二个 response 参数用于定义如何对请求进行响应。

在我们的代码中,请求是通过使用 response 对象的 send 方法响应的。调用该方法会使服务端响应 HTTP 请求,发送一个响应,内容包含传递给 send 方法的 <h1>Hello World!</h1> 字符串。由于参数是一个字符串,Express 自动将 Content-Type 标头的值设为 text/html。响应的状态码默认为 200。

我们可以在开发者工具中的网络标签页中验证:

fullstack content

第二个路由定义了处理向应用的 notes 路径发送的 HTTP GET 请求的事件处理函数。

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

请求是用 response 对象的 json 方法响应的。调用该方法将以 JSON 格式的字符串发送传给它的 notes 数组。Express 自动将 Content-Type 标头设为合适值 application/json

fullstack content

接下来,让我们快速浏览一下以 JSON 格式发送的数据。

在先前只使用 Node 的版本中,我们必须用 JSON.stringify 方法将数据转换成 JSON 格式的字符串:

response.end(JSON.stringify(notes))

有了 Express,就不再需要这么做了,这一转换会自动进行。

值得注意的是,JSON 是一种数据格式。然而,它常以字符串形式表示,并且不等同于 JavaScript 对象,比如赋给 notes 的值。

下面的实验说明了这一点:

fullstack content

上面的实验是在交互式 node-repl 中完成的。你可以通过在命令行中输入 node 来启动交互式 node-repl。在你写应用代码的时候,这个交互窗口对测试命令是如何运行的特别有用。我强烈推荐这一工具!

自动跟踪更改

如果我们更改了应用的代码,我们首先需要在终端中停止应用(ctrl + c),然后再重启应用来使更改生效。相比 React 代码更改后,浏览器自动重新加载的丝滑的工作流,重启总感觉很麻烦。

你可以通过用 --watch 选项启动应用来让服务端跟踪更改:

node --watch index.js

现在,修改应用代码会自动重启服务端。注意即使服务端能自动重启,你仍然需要刷新浏览器。不同于 React,我们在这种情况(返回的是 JSON 数据)下没有,也无法有自动更新浏览器的热重载功能。

让我们在 package.json 文件中自定义一个 npm 脚本来启动开发服务端。

{
  // ..
  "scripts": {
    "start": "node index.js",
    "dev": "node --watch index.js",    "test": "echo \"Error: no test specified\" && exit 1"
  },
  // ..
}

我们现在可以用以下命令在开发模式下启动服务端

npm run dev

不同于运行 starttest 脚本,在运行 dev 脚本时,必须在命令中加入 run

REST

让我们扩展我们的应用,使其提供与 json-server 一样的 RESTful HTTP API。

表现层状态转换(Representational State Transfer),又称 REST,是 Roy Fielding 于 2000 年在论文中提出的。REST 是一种旨在建立可扩展的 web 应用的架构风格。

我们不会深入研究 Fielding 对 REST 的定义,也不会花时间琢磨什么是 RESTful,什么不是。我们会把眼光放得狭隘点,只关注 web 应用中通常是如何理解 RESTful API 的。REST 的原始定义甚至不限于网络应用。

我们在前一章节中提到,单个事物,比如我们应用中的笔记,在 RESTful 思想中称为资源。每个资源都有与之相关的 URL,即资源的唯一地址。

创建资源唯一地址的一个惯例是将资源类型的名称和资源的唯一标识符结合起来。

假设我们服务的根 URL 是 www.example.com/api

如果我们定义笔记的资源类型为 notes,那么标识符为 10 的笔记资源就有唯一地址 www.example.com/api/notes/10

所有笔记资源的整个集合的 URL 是 www.example.com/api/notes

我们可以对资源执行不同的操作。要执行的操作是通过 HTTP 动词定义的:

URL 动词 功能
notes/10 GET 获取单个资源
notes POST 根据请求数据新建一个资源
notes/10 DELETE 删除指定标识符的资源
notes/10 PUT 将指定标识符的资源整个替换为请求的数据
notes/10 PATCH 将指定标识符的资源部分替换为请求的数据

这就是我们设法粗略定义的 REST 所指的 统一接口,也就是一种一致的定义接口的方式,从而使不同系统之间可以合作。

这种解释 REST 的方式属于 Richardson 成熟度模型中 RESTful 成熟度的第二级。根据 Roy Fielding 提供的定义,我们定义的还不是 REST API。事实上,世界上绝大部分所谓的“REST” API 都不符合 Fielding 在其论文中列出的原始标准。

在某些地方(例如 Richardson 的 Ruby: RESTful Web Services),你会看到我们直接增删改查的 API 模型被称为是面向资源架构的示例,而非 REST 的。我们不要卡在咬文嚼字上,而是回到我们的应用。

获取单个资源

让我们扩展我们的应用,使其提供一个 REST 接口来操作单个笔记。首先,让我们创建一个用来获取单个资源的路由

我们将为单个笔记使用的唯一地址的形式是 notes/10,其中末尾的数字指笔记的唯一 id。

在 Express 中,可以用冒号语法来为路由定义参数

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

现在 app.get('/api/notes/:id', ...) 将处理所有形式为 /api/notes/SOMETHING 的 HTTP GET 请求,其中 SOMETHING 是一个任意的字符串。

请求的路由中的 id 参数可以通过 request 对象访问:

const id = request.params.id

我们用现在已经熟悉了的数组的 find 方法来寻找 id 与参数匹配的笔记。然后,该笔记被返回给请求的发送方。

现在我们可以在浏览器访问 http://localhost:3001/api/notes/1 来测试应用了:

fullstack content

然而,我们的应用还有一个问题。

如果我们搜索一个 id 不存在的笔记,服务端的响应是:

fullstack content

返回的 HTTP 状态码是 200,意味着响应成功。由于 content-length 标头的值为 0,所以该响应没有发回数据,这一点也可以从浏览器中验证。

出现这种行为的原因是,如果没有找到匹配的笔记,会将 note 变量设为 undefined。服务端需要以更好的方式来处理这种情况。如果没有找到笔记,服务端应该响应状态码 404 not found,而非 200。

让我们对代码做如下修改:

app.get('/api/notes/:id', (request, response) => {
  const id = request.params.id
  const note = notes.find(note => note.id === id)

  if (note) {    response.json(note)  } else {    response.status(404).end()  }})

由于响应中没有数据,我们使用 status 方法来设置状态,并使用 end 方法来响应请求,而不发送任何数据。

if 条件利用了这样一个事实——所有 JavaScript 对象都是真值,也就是在比较操作中会被计算为 true。然而,undefined假值,也就是会被计算为 false。

现在我们的应用能在没有找到笔记的情况下正确运行并发送错误状态码。然而,应用并没有返回任何显示给用户的内容,正如 web 应用在我们访问不存在的页面时通常做的那样。因为 REST API 是用于编程的接口,我们不需要在浏览器中显示任何内容,只需要错误状态码就够了。

不过,我们可以通过重载默认的 NOT FOUND 信息来提示关于发送 404 错误的原因。

删除资源

接下来,让我们实现一个用于删除资源的路由。删除是通过向资源的 URL 发送 HTTP DELETE 请求进行的:

app.delete('/api/notes/:id', (request, response) => {
  const id = request.params.id
  notes = notes.filter(note => note.id !== id)

  response.status(204).end()
})

如果删除资源成功,也就是笔记之前存在并且已删除,我们就以状态码 204 no content 响应请求,并且在响应中不返回数据。

对于资源不存在的情况下应该向 DELETE 请求返回什么状态码,目前还没有达成共识。仅有的两种选择是 204 和 404。为简单起见,我们的应用在这两种情况下都响应 204。

Postman

那么我们如何测试删除操作呢?HTTP GET 请求可以简单从浏览器发送。我们可以写一些 JavaScript 来测试删除操作,但写测试代码并不在每种情况下都是最佳解决方案。

有许多方便测试后端的工具。其中之一是命令行程序 curl。但我们不用 curl,而是来看一下用 Postman 来测试应用。

让我们从这里安装 Postman 桌面客户端并尝试一下:

fullstack content

注:Postman 在 VS Code 中也可用,通过左侧的“扩展”选项卡 -> 搜索 Postman -> 第一个结果(已验证的发布者) -> 下载

然后你就可以在活动栏的“扩展”选项卡下面看到添加了一个额外的图标。一旦登录,你就可以照着下面的步骤做了

在这种情况下使用 Postman 非常简单。只要定义网址,然后选择正确的请求类型(DELETE)就可以了。

后端服务端正确显示了响应。通过向 http://localhost:3001/api/notes 发送 HTTP GET 请求,我们看到 id 为 2 的笔记已经不在列表中了,也就表明删除成功了。

因为现在应用中的笔记是硬编码的,还没有存进数据库中,所以当重启应用时,笔记的列表将重置回原来的状态。

Visual Studio Code REST Client

如果你使用 Visual Studio Code,你可以使用 VS Code REST Client 插件来代替 Postman。

一旦插件安装好后,用起来就非常简单。在应用的根目录新建一个名为 requests 的目录。将所有 REST 客户端请求作为以 .rest 扩展名结尾的文件保存进该目录中。

让我们新建一个 get_all_notes.rest 文件并定义获取所有笔记的请求。

fullstack content

通过点击 Send Request 文本,REST 客户端将执行 HTTP 请求,然后在编辑器中打开服务端的响应。

fullstack content

WebStorm HTTP Client

如果你使用的是 IntelliJ WebStorm,你可以用其内置的 HTTP Client 进行类似的步骤。新建一个扩展名为 .rest 的文件,编辑器将显示创建和运行请求的选项。你可以按本指南了解更多信息。

获取数据

接下来,让我们实现向服务端添加新笔记的功能。添加笔记是通过向地址 http://localhost:3001/api/notes 发送 HTTP POST 请求,并在请求中以 JSON 格式发送新笔记的所有信息来完成的。

为了方便地访问数据,我们需要借助 Express json-parser,可以通过命令 app.use(express.json()) 来使用它。

让我们启用 json-parser 并实现处理 HTTP POST 请求的处理函数的初始版本。

const express = require('express')
const app = express()

app.use(express.json())
//...

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

事件处理函数可以从 request 对象的 body 属性中获取数据。

如果没有启用 json-parser,body 属性将是 undefined。json-parser 会获取请求的 JSON 数据,将其转化为 JavaScript 对象,然后在调用路由处理程序之前将该对象附加到 request 对象的 body 属性上。

目前而言,应用只是将收到的数据打印到控制台并在响应中发回,并没有对数据做任何处理。

在我们实现应用的其余逻辑之前,让我们用 Postman 验证服务端是否确实收到了数据。在 Postman 中,除了定义 URL 和请求类型外,我们还必须定义 body 中发送的数据:

fullstack content

应用将我们在请求中发送的数据打印到控制台:

fullstack content

注:在后端编程时,始终保持应用运行的终端可见。任何对代码的更改都会重启开发服务端,所以通过关注控制台,你可以立即注意到应用的代码是否出现了错误。

fullstack content

类似的,检查控制台也很有用,可以确保后端在不同的情况下,比如当我们用 HTTP POST 请求发送数据时,都像我们期望的那样运行。当然,在开发应用时,在代码中添加大量的 console.log 命令也是可取的。

可能造成问题的原因有请求中 Content-Type 标头的值不正确。如果在 Postman 上没有正确定义请求体的类型,这种情况就会发生:

fullstack content

Content-Type 标头的值会设为 text/plain

fullstack content

服务端显示只收到一个空对象:

fullstack content

如果标头中没有正确的值,服务端就不能正确解析数据。服务端甚至不会尝试去猜数据的格式,因为 Content-Types 可能的值太多了。

如果你用的是 VS Code,但是还没有安装上一章的 REST Client 的话,那么你应该现在安装。可以这么使用 REST Client 发送 POST 请求:

fullstack content

我们为请求新建了一个 create_note.rest 文件。请求的格式按文档中的说明

REST Client 相较于 Postman 的一个优点是,可以轻松在项目库的根目录获得请求,而且请求可以分发给开发团队的每个人。你还可以在一个文件中用 ### 分隔符添加多个请求:

GET http://localhost:3001/api/notes/

###
POST http://localhost:3001/api/notes/ HTTP/1.1
content-type: application/json

{
    "name": "sample",
    "time": "Wed, 21 Oct 2015 18:27:50 GMT"
}

Postman 也允许用户保存请求,但情况可能变得相当混乱,特别是当你在同时处理多个不相关的项目时。

重要的附注

有时当你在调试时,你可能想找出 HTTP 请求中设了哪些标头。一种方法是通过 request 对象的 get 方法获取单个标头的值。request 对象还有 headers 属性,包含某个请求的所有标头。

在 VS REST Client 中,如果你不小心在第一行和指定 HTTP 标头的行之间添加了一个空行,就会出现问题。在这种情况下,REST Client 会将其解释为所有的标头都是空的,这会导致后端服务端不知道收到的数据是 JSON 格式的。

如果你在代码中的某处用 console.log(request.headers) 命令打印请求的所有标头,你就能发现 Content-Type 标头丢失。

让我们回到应用。一旦我们知道应用正确接收了数据,就可以完成处理请求的代码了:

app.post('/api/notes', (request, response) => {
  const maxId = notes.length > 0
    ? Math.max(...notes.map(n => Number(n.id))) 
    : 0

  const note = request.body
  note.id = String(maxId + 1)

  notes = notes.concat(note)

  response.json(note)
})

我们需要给这个笔记一个唯一的 id。首先,我们找出当前列表中最大的 id 号赋值给 maxId 变量。然后将新笔记的 id 定义为 maxId + 1 的字符串形式。这种方法并不推荐,但我们现在先这么用着,我们很快就会换另一种方法。

目前的版本仍有问题,HTTP POST 请求可以用来添加具有任意属性的对象。让我们改进应用,定义 content 属性不得为空。important 属性默认赋 false 值。其他所有属性都丢弃:

const generateId = () => {
  const maxId = notes.length > 0
    ? Math.max(...notes.map(n => Number(n.id)))
    : 0
  return String(maxId + 1)
}

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

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

  const note = {
    content: body.content,
    important: body.important || false,
    id: generateId(),
  }

  notes = notes.concat(note)

  response.json(note)
})

为笔记生成新 id 号的逻辑已经提取到专门的 generateId 函数中。

如果收到的数据缺失 content 属性的值,服务端将以状态码 400 bad request 响应请求:

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

注意,调用 return 至关重要,否则代码会执行到最后,错误格式的笔记会被保存到应用中。

如果 content 属性有值,将基于收到的数据新建笔记。

如果 important 属性丢失,我们将默认其值为 false。默认值目前是以一种看起来相当奇怪的方式生成的:

important: body.important || false,

如果 body 变量保存的数据有 important 属性,表达式将计算其值并转换为布尔值。如果 important 属性不存在,那么表达式将计算为竖线||右侧定义的 false。

准确地说,当 important 属性为 false 时,body.important || false 表达式实际上返回的是右边的 false……

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

fullstack content

如果你克隆了这个项目,在用 npm startnpm run dev 启动应用之前,运行 npm install 命令。

在我们开始练习之前还有一件事。生成 id 的函数目前看起来是这样的:

const generateId = () => {
  const maxId = notes.length > 0
    ? Math.max(...notes.map(n => Number(n.id)))
    : 0
  return String(maxId + 1)
}

函数体包含了一行看起来有点耐人寻味的内容:

Math.max(...notes.map(n => Number(n.id)))

这行代码中到底发生了什么?notes.map(n => Number(n.id)) 新建了一个包含所有笔记 id 的数字形式的数组。Math.max 返回传递给它所有数字的最大值。然而,notes.map(n => Number(n.id)) 是一个数组,所以它不能直接作为一个参数给 Math.max。数组可以通过使用“三点”展开语法 ... 转换为各个数字。

关于 HTTP 请求类型

HTTP 标准提到了关于请求类型的两个属性:安全性幂等性

HTTP GET 请求应该是安全的

特别是已经约定俗成的,GET 和 HEAD 方法不应具有除获取数据以外的任何意义。GET 和 HEAD 方法应被认为是“安全的”。

安全性意味着执行这类请求不能在服务端引起任何副作用。这里的副作用是指数据库的状态不能因请求而改变,而且响应必须只返回服务端上已有的数据。

还没有办法能保证 GET 请求是安全的,这只是 HTTP 标准定义的一个建议。通过在 API 中遵守 RESTful 原则,就总能以安全的方式使用 GET 请求。

HTTP 标准定义的请求类型中,应该是安全的还有 HEAD。实际上,HEAD 的效果应该与 GET 完全一样,但不返回除状态码和响应标头外的任何信息。当你发送 HEAD 请求时,不会有响应体返回。

除了 POST,所有 HTTP 请求都应该是幂等的

方法也可以具有“幂等性”,即(除了错误或过期的问题) N > 0 个相同的请求的副作用与单个请求的副作用相同。GET、HEAD、PUT 和 DELETE 方法都有这一属性

这意味着如果一个请求确实会产生副作用,那么无论发送多少次该请求,结果都应该是一样的。

如果我们向 URL /api/notes/10 发送 HTTP PUT 请求,并随请求发送数据 { content:"no side effects!", important: true },那么无论发送多少次请求,结果都应该是一样的。

和 GET 请求的安全性一样,幂等性也只是 HTTP 标准中的一个建议,并不是简单根据请求类型就能保证的。然而,当我们的 API 遵守 RESTful 原则时,就能以幂等的方式使用 GET、HEAD、PUT 和 DELETE 请求。

POST 是唯一的既不安全也不幂等的 HTTP 请求类型。如果我们向 /api/notes 发送 5 个 HTTP POST 请求,每个请求的请求体都是 {content:"many same", important: true},那么服务端会产生 5 个相同内容的笔记。

中间件

之前使用的 Express json-parser 是一个中间件

中间件是可以用来处理 requestresponse 对象的函数。

我们之前使用的 json-parser 从请求中获取存储在 request 对象中的原始数据,将其解析为一个 JavaScript 对象,并将其赋值给 request 对象作为其新属性 body

实际上,你可以同时使用多个中间件。当你有多个中间件时,它们会按照应用代码中的顺序一个一个地执行。

让我们来实现我们自己的中间件,来打印出发送到服务端的每个请求的信息。

中间件是一个接收三个参数的函数:

const requestLogger = (request, response, next) => {
  console.log('Method:', request.method)
  console.log('Path:  ', request.path)
  console.log('Body:  ', request.body)
  console.log('---')
  next()
}

函数体在最后调用了作为参数传递的 next 函数。next 函数将控制权交给下一个中间件。

中间件是这样用的:

app.use(requestLogger)

记住,中间件函数是按照 JavaScript 引擎遇到它们的顺序调用的。注意 json-parser 要放在 requestLogger 之前,否则在执行 requestLogger 的时候,request.body 还没有初始化!

如果我们想让路由事件处理函数执行中间件函数,那么就必须在路由之前使用这些中间件函数。有时,我们想在路由之后才使用中间件函数。我们只在没有路由处理函数处理 HTTP 请求时才会调用路由之后的中间件函数。

让我们在路由之后添加以下中间件,用于捕捉向不存在的路由发出的请求。对于这些请求,中间件将返回一个 JSON 格式的错误信息。

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

app.use(unknownEndpoint)

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