a

Node.js 与 Express

在这一章中,我们的重点转向后端,也就是转向服务器端的功能实现。

我们将在NodeJS的基础上构建我们的后端,这是一个基于 Google 的 Chrome V8 引擎的 JavaScript 运行时环境。

本课程材料是使用 Node.js 的v10.18.0 版本编写的。 请确保您的 Node 版本不低于材料中使用的版本(您可以通过在命令行中运行 node -v 来检查版本)。

正如在 第1章中提到的,浏览器还不支持 JavaScript 的最新特性,这就是为什么在浏览器中运行的代码必须是babel转译过的。而在后端运行 JavaScript 的情况是不同的。 最新版本的 Node 支持大部分最新的 JavaScript 特性,因此我们可以使用最新的特性而不必转译我们的代码。

我们的目标是实现一个后端,它将与 第2章中的 notes 应用一起工作。 但还是让我们从实现经典的“ hello world”应用的基础开始。

注意:本章中的应用和练习并不都是 React 应用,我们不会使用create-react-app工具程序为此应用初始化项目。

在第2章节中,我们已经提到了npm ,这是一个用于管理 JavaScript 包的工具。 事实上,npm 来源于 Node 生态系统。

让我们进入到一个合适的目录,并使用npm init命令为应用创建一个新模板。 我们将回答该工具提出的问题,结果会在项目根目录下自动生成的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

start 这个npm 脚本之所以有效,是因为我们在 package.json 文件中定义了它:

{
  // ...
  "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

Simple web server

【简单的 web 服务器】

让我们把这个应用改成一个 web 服务器:

const http = require('http')

const app = http.createServer((req, res) => {
  res.writeHead(200, { 'Content-Type': 'text/plain' })
  res.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。 原因在于,早在 JavaScript 在语言规范中支持模块之前,Node 生态系统就有对模块需求。 在撰写本文的时候,Node 还不支持 ES6模块,但是支持 ES6 只是时间问题

Commonjs 模块的功能几乎完全类似于 ES6模块,至少就我们在本课程中的需求而言是这样。

我们代码中的下一块代码如下所示:

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

该代码使用了 http 模块的 createServer 方法来创建一个新的 web 服务器。 一个事件处理 被注册到服务器,每次 向服务器的地址http://localhost:3001 发出 HTTP 请求时,它就被调用。

响应请求的状态代码为200,Content-Type 头文件设置为 text/plain,将返回站点的内容设置为Hello World

最后一行将绑定的HTTP 服务器分配给 app 变量 ,并监听发送到端口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",    date: "2019-05-30T17:30:31.098Z",    important: true  },  {    id: 2,    content: "Browser can execute only Javascript",    date: "2019-05-30T18:39:34.091Z",    important: false  },  {    id: 3,    content: "GET and POST are the most important methods of HTTP protocol",    date: "2019-05-30T19:20:14.298Z",    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 格式。 使用 JSON.stringify(notes) 方法将 notes 数组转换为 JSON。

当我们打开浏览器的时候,显示的格式和第2章节 第2章 完全一样,在那里我们使用 json-server 来提供便笺列表:

fullstack content

Express

直接使用 Node 内置的http web 服务器实现我们的服务器代码是可行的。 但是,它很麻烦,特别是当应用规模“变大变长”时。

为了提供一个比内置的 http 模块更友好的界面,许多库已经开发出来,以简化使用 Node 作为服务器端开发。 到目前为止,最受欢迎的库是express

让我们通过下面的命令将它定义为一个项目依赖,来开始使用 express:

npm install express --save

该依赖项也被添加到了我们的package.json 文件中:

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

依赖的源代码安装在项目根目录中的 node_modules 目录中。 除了express,你还可以在目录中找到大量的其他依赖项:

fullstack content

这些实际上是express的依赖项,以及它所有依赖项的依赖项,等等。 这些被称为我们项目的 传递依赖transitive dependencies

我们的项目中安装了4.17.1版本的express。 在package.json 中,版本号前面的插入符号是什么意思?

"express": "^4.17.1"

npm 中使用的版本控制模型称为 语义版本semantic versioning.

^4.17.1 前面的插入符号表示,当项目的依赖项更新时,安装的 express 版本至少为 4.17.1。 但是,所安装的 express 版本也可以具有较大的patch 号(最后一个数字)或较大的minor 号(中间的数字)的版本。 第一个major 号表示库的主版本必须相同。

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

npm update

同样,如果我们在另一台计算机上开始工作,我们可以使用如下命令安装package.json 中定义的项目的所有最新依赖项:

npm install

如果依赖项的major值没有改变,那么新版本应该是向后兼容backwards compatible。 这意味着,如果我们的应用在将来碰巧使用了 express 的版本4.99.175,那么在这个部分中实现的所有代码仍然必须在不对代码进行更改的情况下正常工作。 相比之下,未来的5.0.0。 Express版本 可能包含may contain更改,将导致我们的应用不能正常工作。

Web and express

让我们回到我们的应用,并进行如下更改:

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

let notes = [
  ...
]

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

app.get('/api/notes', (req, res) => {
  res.json(notes)
})

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

为了使应用的新版本投入使用,我们必须重新启动应用。

这个应用没有太大的改变。 在代码的开头我们导入了 express,这次是一个function ,用于创建一个存储在 app 变量中的 express 应用:

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

接下来,我们定义了应用的两个路由。 第一个定义了一个事件处理,用于处理对应用的 / 根发出的 HTTP GET 请求:

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

事件处理接受两个参数。 第一个request 参数包含 HTTP 请求的所有信息,第二个 response 参数用于定义请求的响应方式。

在我们的代码中,请求是通过使用 response 对象的send 方法来应答的。 调用该方法,使服务器通过发送 <h1>Hello World!</h1>字符串,以response响应 HTTP 请求! 这些会被传递给 send 方法。 由于参数是一个字符串,所以 express 会自动将Content-Type 头的值设置为 text/html.。 响应的状态代码默认为200。

我们可以通过开发工具中的Network 选项卡来验证这一点:

fullstack content

第二个路由定义了一个事件处理,它处理对应用的notes 路径发出的 HTTP GET 请求:

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

请求用response对象的json方法进行响应。 调用该方法会将notes 数组作为 JSON 格式的字符串进行传递。 Express 自动设置Content-Type 头文件,其值为 application/json

fullstack content

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

在我们只使用 Node 的早期版本中,我们必须使用 JSON.stringify 方法将数据转换为 JSON 格式:

response.end(JSON.stringify(notes))

对于 express,不再需要这样做,因为这种转换是自动的。

值得注意的是,JSON是一个字符串,而不是像分配给 notes 的值那样的 JavaScript 对象。

下面的实验可以说明这一点:

fullstack content

上面的实验是在交互式的node-repl中完成的。 您可以通过在命令行中键入 node 来启动交互式 node-repl。 在编写应用代码时,对于测试命令的工作方式,repl 特别有用。 我强烈推荐!

nodemon

如果我们对应用的代码进行更改,我们必须重新启动应用以查看更改。 我们通过键入 ⌃+C 首先关闭应用,然后重新启动应用。 与 React 中方便的工作流程相比,Node就有点麻烦,在 React 中,浏览器会在进行更改后自动重新加载。

解决这个问题的方法是使用nodemon :

nodemon 将监视启动 nodemon 的目录中的文件,如果任何文件发生更改,nodemon 将自动重启节点应用。

让我们通过下面的命令将 nodemon 定义为开发依赖development dependency:

npm install --save-dev nodemon

package.json 的内容也发生了变化:

{
  //...
  "dependencies": {
    "express": "^4.17.1",
  },
  "devDependencies": {
    "nodemon": "^2.0.2"
  }
}

如果您不小心敲错了命令,并且 nodemon 依赖项被添加到“ dependencies”而不是“ devDependencies” ,那么手动更改package.json 的内容以匹配上面显示的内容也是可以的。

通过开发依赖,我们会指向仅在应用开发过程中需要的工具,例如用于测试或自动重启应用的工具,就像nodemon

当应用在生产服务器(例如 Heroku)的生产模式下运行时,并不需要这些开发依赖项。

我们可以用nodemon 这样来启动我们的应用:

node_modules/.bin/nodemon index.js

对应用代码的更改现在会导致服务器自动重新启动。 值得注意的是,即使后端服务器自动重启,浏览器仍然需要手动刷新。 这是因为不像在 React 中工作,我们甚至没有自动重新加载浏览器所需的热加载hot reload 方法。

这个命令很长,而且相当烦人,所以让我们在package.json 文件中为它定义一个专用的npm 脚本:

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

在脚本中,不需要指定node_modules/.bin/nodemon 到 nodemon ,因为 npm 自己知道从该目录搜索文件。

我们现在可以在开发模式下使用如下命令启动服务器:

npm run dev

starttest 脚本不同,我们还必须将run 添加到命令中。

REST

让我们扩展我们的应用,使它提供像json-server那样的 RESTful HTTP API 。

Representational State Transfer,又名REST, 是在2000年 Roy Fielding 的论文中引入的。 Rest 是一种架构风格,用于构建可伸缩的 web 应用。

我们不会深入探究 Fielding 对 REST 的定义,也不会花时间思考什么是 RESTful,什么不是 RESTful。 相反,我们只关注web应用对 RESTful API 的典型理解,从而采取了一种更为狭隘的视角 narrow view。 Rest 的最初定义实际上并不局限于 web 应用。

我们在 上一章节 中提到过,在我们的应用中,像便笺这样的单数实体,在 RESTful thinking 中称为resource。 每个resource都有一个相关联的 URL,这个 URL 是资源的唯一地址。

一个约定是结合resource 类型名称和resource的唯一标识符来创建resource唯一的地址。

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

如果我们将便笺的资源类型定义为note,那么标识为10的便笺资源的地址就是唯一的地址www.example.com/api/notes/10

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

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

URL verb functionality
notes/10 GET fetches a single resource
notes GET fetches all resources in the collection
notes POST creates a new resource based on the request data
notes/10 DELETE removes the identified resource
notes/10 PUT replaces the entire identified resource with the request data
notes/10 PATCH replaces a part of the identified resource with the request data

这就是我们如何粗略地定义 REST 所指的 统一接口 uniform interface ,这意味着一种一致的定义接口的方式,使系统能够进行合作。

这种解释 REST 的方式在 Richardson Maturity Model 属于RESTful 成熟度的第二个层次。 根据 Roy Fielding 提供的定义,我们实际上并没有定义一个REST API。 事实上,世界上大多数所谓的“REST” API都不符合 Fielding 在其论文中概述的原始标准。

在某些地方(例如Richardson,Ruby: RESTful Web Services) ,你会看到我们为一个简单的CRUD API 建立的模型,这被称为面向资源架构resource oriented architecture的例子,而不是 REST。 我们将避免陷入语义学的争论,而是回到应用的工作中。

Fetching a single resource

【获取一个单一资源】

让我们扩展我们的应用,以便它提供一个 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', ...)将处理所有的 HTTP GET 请求,这些请求的格式是/api/notes/SOMETHING,其中SOMETHING 是任意的字符串。

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

const id = request.params.id

现在使用熟悉的 find 方法查找与 id 参数匹配的的便笺。 然后,便笺被返回给request的发送者。

当我们通过在浏览器中键入 http://localhost:3001/api/notes/1 来测试我们的应用时,我们注意到它似乎不能正常工作,因为浏览器显示一个空白页面。 这对于我们软件开发人员来说并不奇怪,现在是调试的时候了。

在我们的代码中添加 console.log 命令是一个久经验证的技巧:

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

当我们在浏览器中再次访问 http://localhost:3001/api/notes/1 时,终端控制台将显示如下内容:

fullstack content

来自 route 的 id 参数被传递给我们的应用,但是 find 方法没有找到匹配的便笺。

为了进一步研究,我们还在传递给 find 方法的比较函数中添加了console log。 为了做到这一点,我们必须去掉紧凑箭头函数语法note => note.id === id,并使用显式的 return 语句这种语法:

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

当我们在浏览器中再次访问 URL 时,对比较函数的每次调用都会向控制台打印一些不同的内容。 控制台输出如下:


1 'number' '1' 'string' false
1‘ number’’1’‘ string’ false
2 'number' '1' 'string' false
2‘ number’’1’‘ string’ false
3 'number' '1' 'string' false
3‘ number’’1’‘ string’ false

这个错误的原因很清楚了。 id 变量包含一个字符串“1” ,而便笺的 id 是整数。 在 JavaScript 中,“三个等号 triple equals”比较默认认为不同类型的所有值都不相等,这意味着1不等于“1”。

让我们通过将 id 参数从一个字符串更改为一个number来解决这个问题:

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

现在获取单个资源可以正常工作了。

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 = Number(request.params.id)
  const note = notes.find(note => note.id === id)
  
  if (note) {    response.json(note)  } else {    response.status(404).end()  }})

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

If-condition 基于了这样一个事实,即所有的 JavaScript 对象都是truthy ,这意味着它们在比较操作中被当作 true。 然而,undefined 是 falsy,意思是它将评估为 false。

我们的应用正常工作,如果没有找到便笺,则发送错误状态代码。 然而,应用不会返回任何东西显示给用户,就像我们 在web 应用访问一个不存在的页面时所做的那样。 我们实际上不需要在浏览器中显示任何内容,因为 REST API 是用于编程使用的接口,只需要错误状态代码就行了。

Deleting resources

【删除资源】

接下来,让我们实现一个删除资源的路由。 通过向资源的 url 发出 HTTP DELETE 请求来删除:

app.delete('/api/notes/:id', (request, response) => {
  const id = Number(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在这种情况下是相当容易的。 定义 url 然后选择正确的请求类型就足够了。

后端服务器似乎响应正确。 通过向http://localhost:3001/api/notes 发出 HTTP GET 请求,我们可以看到 id 为2的便笺已经不在列表中,这表明删除是成功的。

因为应用中的便笺只保存到了内存中,所以当我们重新启动应用时,便笺列表将返回到原始状态。

The 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

Receiving data

【接受数据】

接下来,让我们使向服务器添加新便笺。 通过向地址 HTTP://localhost:3001/api/notes 发送一个 HTTP POST 请求,并以 JSON 格式在请求body中发送新便笺的所有信息,就可以添加一个便笺。

为了方便地访问数据,我们需要 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 对象,然后在调用路由处理程序之前将其附加到请求对象的 body 属性。

目前,除了将接收到的数据打印到控制台并在响应中将其发送回来之外,应用并不对其执行任何操作。

在实现应用逻辑的剩余部分之前,让我们先用 Postman 验证服务器实际接收到的数据。 除了在 Postman 中定义 URL 和请求类型外,我们还必须定义body 中发送的数据:

fullstack content

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

fullstack content

注意:当你在后端工作时,应该让运行应用的终端始终可见。 受益于 Nodemon,我们对代码所做的任何更改都将重新启动应用。 如果你注意控制台,你会立即发现应用中出现的错误:

fullstack content

类似地,检查控制台以确保后端在不同情况下的行为与我们期望的一样,比如在使用 HTTP POST 请求发送数据时。 当然,在开发应用时向代码中添加一些 console.log 命令是一个不错的主意。

导致问题的一个潜在原因是在请求中错误地设置了Content-Type 头。 如果body类型没有正确定义,这种情况可能发生在 Postman 身上:

fullstack content

Content-Type 的header设置为了 text/plain

fullstack content

服务器似乎只接收到一个空对象:

fullstack content

如果头部没有设置正确的值,服务器将无法正确解析数据。 它甚至不会去猜测数据的格式,因为有大量 massive amountContent-Types 可能性。

如果您正在使用 VS Code,那么您应该安装上一节中提到的 REST 客户端(如果您还没有安装的话)。 Post 请求可以像这样通过 REST 客户端发送:

fullstack content

我们为这个请求创建了一个新的create_note.rest文件,这个请求是根据文档中的说明格式化的。

Rest 客户端相对于 Postman 的一个好处是,请求可以在项目仓库的根部轻松获得,并且可以分发给开发团队中的每个人。 Postman也允许用户保存请求,但是当你在处理多个不相关的项目时,情况会变得非常混乱。

Important sidenote 重要旁注

有时在进行调试时,您可能希望了解 HTTP 请求中设置了哪些头。 实现这一点的一种方法是通过请求对象的get方法,该方法可用于获取单个头的值。 Request 对象还具有headers 属性,该属性包含特定请求的所有头信息。

如果您不小心在指定 HTTP 头的顶行和行之间添加了一个空行,那么 VS REST 客户端可能会出现问题。 在这种情况下,REST 客户端将其解释为所有头都是空的,这导致后端服务器不知道它接收的数据是 JSON 格式的。

如果您在代码中的某个位置使用 console.log(request.headers) 命令打印所有请求头,那么您将能够发现缺少了Content-Type 头。

让我们回到应用。 一旦我们知道应用正确地接收了数据,就是时候处理最终请求了:

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

  const note = request.body
  note.id = maxId + 1

  notes = notes.concat(note)

  response.json(note)
})

我们需要一个唯一的 id。 首先,找出当前列表中最大的 id 号,并将其赋值给 maxId 变量。 然后将新通知的 id 定义为 maxId + 1。 这种方法实际上是不被推荐的,但是我们暂时接受它,因为我们很快就会替换掉它。

当前版本仍然存在 HTTP POST 请求可添加任意属性的问题。 让我们通过定义content 属性不能为空来改进应用。importantdate 属性将被赋予默认值。 所有其他属性都被丢弃:

const generateId = () => {
  const maxId = notes.length > 0
    ? Math.max(...notes.map(n => n.id))
    : 0
  return 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,
    date: new Date(),
    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 属性具有值,则说明便笺内容将基于接收到的数据。 正如前面提到的,在服务器上生成时间戳比在浏览器上生成更好,因为我们不能确保运行浏览器的主机的时钟设置是正确的。 现在由服务器生成date 属性。

如果缺少important 属性,则将该值默认为false。 当前生成默认值的方式相当奇怪:

important: body.important || false,

如果保存在 body 变量中的数据具有important 属性,则表达式将计算它作为值。 如果该属性不存在,那么表达式将默认为 false,该表达式在双竖线的右侧定义。

确切地说,当important 属性为false 时,那么body.important || false 表达式实际上将从右侧返回false..。

您可以在this github repositorypart3-1 分支中找到我们当前应用的全部代码。

注意,仓库的主分支包含应用的后一个版本的代码。 应用当前状态的代码单独在 branch part3-1中。

fullstack content

如果您克隆了项目,在启动应用之前运行 npm install 命令,使用 npm start 或 npm run dev运行项目。

在我们开始练习之前还有一件事,生成 id 的函数现在是这样的:

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

函数体包含一行看起来很有趣的内容:

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

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

About HTTP request types

【关于 HTTP 请求类型】

HTTP 标准讨论了与请求类型相关的两个属性,安全幂等性

Http GET 请求应该是满足安全性的:

In particular, the convention has been established that the GET and HEAD methods SHOULD NOT have the significance of taking an action other than retrieval. These methods ought to be considered "safe". 特别是,已经建立了一个约定,即 GET 和 HEAD 方法除了检索之外不应该有其他行动的含义。 这些方法应该被认为是“安全的”。

安全性意味着执行请求不能在服务器中引起任何副作用。 副作用是指数据库的状态不能因请求而改变,响应只能返回服务器上已经存在的数据。

没有什么能够保证 GET 请求实际上是安全的,这实际上只是 HTTP 标准中定义的一个建议。 通过遵守我们的 API 中的 RESTful 原则,GET 请求实际上总是以一种安全safe 的方式使用。

Http 标准还定义了应该是安全的请求类型HEAD。 实际上,HEAD 应该像 GET 一样工作,但是它只返回状态码和响应头。 当您发出 HEAD 请求时,不会返回响应主体。

除了 POST 之外的所有 HTTP 请求都应该是幂等:

Methods can also have the property of "idempotence" in that (aside from error or expiration issues) the side-effects of N > 0 identical requests is the same as for a single request. The methods GET, HEAD, PUT and DELETE share this property 方法也可以具有“幂等”属性,即(除了错误或过期问题) N > 0 相同请求的副作用与单个请求相同。 方法 GET、 HEAD、 PUT 和 DELETE 都具有此属性

这意味着,如果一个请求有副作用,那么无论发送多少次请求,结果都应该是相同的。

如果我们对 /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个便笺将具有相同的内容。

Middleware

【中间件】

我们之前使用的 express json-parser是所谓的中间件

中间件是可用于处理请求和响应对象的函数。

我们前面使用的 json-parser 从请求对象中存储的请求中获取原始数据,将其解析为一个 JavaScript 对象,并将其作为一个新的属性、body 分配给请求对象。

在实践中,您可以同时使用多个中间件。 当你有多于一个的时候,将按照他们被使用的顺序,一个接一个地执行。

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

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

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

在函数体的末尾,调用作为参数传递的下一个函数。 函数将控制权交给下一个中间件。

中间件是这样使用的:

app.use(requestLogger)

中间件函数按照与express服务器对象的使用方法一起使用的顺序调用。 请注意,json-parser 是在 requestLogger 中间件之前使用的,否则在执行日志记录器时,不会初始化我们的 request.body

如果我们希望在调用路由事件处理程序之前执行路由,则必须在路由之前使用中间件函数。 还有一些情况,我们希望在路由之后定义中间件函数。 实际上,这意味着我们定义的中间件函数只有在没有路由处理 HTTP 请求的情况下才被调用。

让我们在路由之后添加如下中间件,它用于捕获对不存在的路由发出的请求。 对于这些请求,中间件将返回 JSON 格式的错误消息。

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

app.use(unknownEndpoint)

您可以在this github repository.的part3-2 分支中找到我们当前应用的全部代码。