Express

直接用Node.js内置的 http 模块去开发服务器有以下明显的弊端:

  • 需要写很多底层代码——例如手动指定 HTTP 状态码和头部字段,最终返回内容。如果我们需要开发更复杂的功能,涉及到多种状态码和头部信息(例如用户鉴权),这样的手动管理模式非常不方便
  • 没有专门的路由机制——路由是服务器最重要的功能之一,通过路由才能根据客户端的不同请求 URL 及 HTTP 方法来返回相应内容。但是上面这段代码只能在 http.createServer 的回调函数中通过判断请求 req 的内容才能实现路由功能,搭建大型应用时力不从心

由此就引出了 Express 对内置 http 的两大封装和改进:

  • 更强大的请求(Request)和响应(Response)对象,添加了很多实用方法
  • 灵活方便的路由的定义与解析,能够很方便地进行代码拆分

用 Express 搭建服务器

在第一步中,我们把服务器放在了一个JS文件中,也就是一个Node模块。从现在开始,我们将把这个项目变成一个npm项目。输入以下命令创建 npm 项目:

1
npm init

接着你可以一路回车下去(当然也可以仔细填),就会发现 package.json 文件已经创建好了。然后添加 Express 项目依赖:

1
npm install express

在开始用 Express 改写上面的服务器之前,我们先介绍一下上面提到的两大封装与改进

更强大的 Request 和 Response 对象

首先是 Request 请求对象,通常我们习惯用 req 变量来表示。下面列举一些 req 上比较重要的成员:

  • req.body:客户端请求体的数据,可能是表单或 JSON 数据
  • req.params:请求 URI 中的路径参数
  • req.query:请求 URI 中的查询参数
  • req.cookies:客户端的 cookies

然后是 Response 响应对象,通常用 res 变量来表示,可以执行一系列响应操作,例如:

1
2
3
4
5
6
7
8
// 发送一串 HTML 代码
res.send('HTML String');

// 发送一个文件
res.sendFile('file.zip');

// 渲染一个模板引擎并发送
res.render('index');

Response 对象上的操作非常丰富,并且还可以链式调用:

1
2
// 设置状态码为 404,并返回 Page Not Found 字符串
res.status(404).send('Page Not Found');

res.end() 和 res.send()

相同点:

  • 二者最终都是回归到 http.ServerResponse.Useresponse.end() 方法。
  • 二者都会结束当前响应流程。

不同点:

  • 前者只能发送 string 或者 Buffer 类型,后者可以发送任何类型数据。
  • 从语义来看,前者更适合没有任何响应数据的场景,而后者更适合于存在响应数据的场景。

Express 的 res.end() 和 res.send() 方法使用上,一般建议使用 res.send()方法即可,这样就不需要关心响应数据的格式,因为 Express 内部对数据进行了处理。

路由机制

客户端(包括 Web 前端、移动端等等)向服务器发起请求时包括两个元素:路径(URI)以及 HTTP 请求方法(包括 GET、POST 等等)。路径和请求方法合起来一般被称为 API 端点(Endpoint)。而服务器根据客户端访问的端点选择相应处理逻辑的机制就叫做路由。

在 Express 中,定义路由只需按下面这样的形式:

1
app.METHOD(PATH, HANDLER)

其中:

  • app 就是一个 express 服务器对象
  • METHOD 可以是任何小写的 HTTP 请求方法,包括 getpostputdelete 等等
  • PATH 是客户端访问的 URI,例如 //about
  • HANDLER 是路由被触发时的回调函数,在函数中可以执行相应的业务逻辑

nodemon 加速开发

Nodemon 是一款颇受欢迎的开发服务器,能够检测工作区代码的变化,并自动重启。通过以下命令安装 nodemon:

1
npm install nodemon --save-dev

正式实现

到了动手的时候了,我们用 Express 改写上面的服务器,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
const express = require('express');

const hostname = 'localhost';
const port = 3000;

const app = express();
app.get('/', (req, res) => {
res.send('Hello World');
});

app.listen(port, () => {
console.log(`Server running at http://${hostname}:${port}/`);
});

在上面的代码中,我们首先用 express() 函数创建一个 Express 服务器对象,然后用上面提到的路由定义方法 app.get 定义了主页 / 的路由,最后同样调用 listen 方法开启服务器。

从这一步开始,我们运行 npm start 命令即可开启服务器,并且同样可以看到 Hello World 的内容,但是代码却简单明了了不少。

提示

在运行 npm start 之后,可以让服务器一直打开着,编辑代码并保存后,Nodemon 就会自动重启服务器,运行最新的代码。如果不能自动重启,可以Ctrl+c关闭后手动重启。

中间件

接下来我们开始讲解 Express 第二个重要的概念:中间件(Middleware)。

理解中间件

中间件并不是 Express 独有的概念。相反,它是一种广为使用的软件工程概念(甚至已经延伸到了其他行业),是指将具体的业务逻辑和底层逻辑解耦的组件。换句话说,中间件就是能够适用多个应用场景、可复用性良好的代码。

Express 的简化版中间件流程如下图所示:

HLcYqK.png

首先客户端向服务器发起请求,然后服务器依次执行每个中间件,最后到达路由,选择相应的逻辑来执行。

有两点需要特别注意:

  • 中间件是按顺序执行的,因此在配置中间件时顺序非常重要,不能弄错
  • 中间件在执行内部逻辑的时候可以选择将请求传递给下一个中间件,也可以直接返回用户响应

Express 中间件的定义

在 Express 中,中间件就是一个函数:

1
2
3
4
function someMiddleware(req, res, next) {
// 自定义逻辑
next();
}

三个参数中,reqres 就是前面提到的 Request 请求对象和 Response 响应对象;而 next 函数则用来触发下一个中间件的执行。

注意

如果忘记在中间件中调用 next 函数,并且又不直接返回响应时,服务器会直接卡在这个中间件不会继续执行下去哦!

在 Express 使用中间件有两种方式:全局中间件路由中间件

全局中间件

通过 app.use 函数就可以注册中间件,并且此中间件会在用户发起任何请求都可能会执行,例如:

1
app.use(someMiddleware);

路由中间件

通过在路由定义时注册中间件,此中间件只会在用户访问该路由对应的 URI 时执行,例如:

1
2
3
app.get('/middleware', someMiddleware, (req, res) => {
res.send('Hello World');
});

那么用户只有在访问 /middleware 时,定义的 someMiddleware 中间件才会被触发,访问其他路径时不会触发。

编写中间件

接下来我们就开始实现第一个 Express 中间件。功能很简单,就是在终端打印客户端的访问时间、 HTTP 请求方法和 URI,名为 loggingMiddleware。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// ...

const app = express();

function loggingMiddleware(req, res, next) {
const time = new Date();
console.log(`[${time.toLocaleString()}] ${req.method} ${req.url}`);
next();
}

app.use(loggingMiddleware);

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

// ...

注意

在中间件中写 console.log 语句是比较糟糕的做法,因为 console.log(包括其他同步的代码)都会阻塞 Node.js 的异步事件循环,降低服务器的吞吐率。在实际生产中,推荐使用第三方优秀的日志中间件,例如 morganwinston 等等。

实际上,中间件不仅可以读取 req 对象上的各个属性,还可以添加新的属性或修改已有的属性(后面的中间件和路由函数都可以获取),能够很方便地实现一些复杂的业务逻辑(例如用户鉴权)。

添加静态文件服务

通常网站需要提供静态文件服务,例如图片、CSS 文件、JS 文件等等,而 Express 已经自带了静态文件服务中间件 express.static,使用起来非常方便。

例如,我们添加静态文件中间件如下,并指定静态资源根目录为 public

1
2
3
4
5
6
7
8
9
// ...

app.use(express.static('public'));

app.get('/', (req, res) => {
res.render('index');
});

// ...

app.use(express.static(__dirname)),假设我在server.js文件中写入这行代码,那么就是把server.js文件所在的目录设置为静态文件目录。该目录下的index.html会被默认打开。

处理 404 和服务器错误

HLcJr6.png

这张示意图和之前的图有两点重大区别:

  • 每个路由定义本质上是一个中间件(更准确地说是一个中间件容器,可包含多个中间件),当 URI 匹配成功时直接返回响应,匹配失败时继续执行下一个路由
  • 每个中间件(包括路由)不仅可以调用 next 函数向下传递、直接返回响应,还可以抛出异常

从这张图就可以很清晰地看出怎么实现 404 和服务器错误的处理了:

  • 对于 404,只需在所有路由之后再加一个中间件,用来接收所有路由均匹配失败的请求
  • 对于错误处理,前面所有中间件抛出异常时都会进入错误处理函数,可以使用 Express 自带的,也可以自定义。

处理 404

在 Express 中,可以通过中间件的方式处理访问不存在的路径:

1
2
3
app.use('*', (req, res) => {
// ...
});

* 表示匹配任何路径。将此中间件放在所有路由后面,即可捕获所有访问路径均匹配失败的请求。

处理内部错误

Express 已经自带了错误处理机制,我们先来体验一下。在 server.js 中添加下面这条”坏掉“的路由(模拟现实中出错的情形):

1
2
3
app.get('/broken', (req, res) => {
throw new Error('Broken!');
});

危险!

服务器直接返回了出错的调用栈!很明显,向用户返回这样的调用栈不仅体验糟糕,而且大大增加了被攻击的风险。

实际上,Express 的默认错误处理机制可以通过设置 NODE_ENV 来进行切换。我们将其设置为生产环境 production,再开启服务器。如果你在 Linux、macOS 或 Windows 下的 Git Bash 环境中,可以运行以下命令:

1
NODE_ENV=production node server.js

如果你在 Windows 下的命令行,运行以下命令:

1
2
set NODE_ENV=production
node server.js

这时候访问 localhost:3000/broken 就会直接返回 Internal Server Error(服务器内部错误),不会显示任何错误信息。

体验还是很不好,更理想的情况是能够返回一个友好的自定义页面。这可以通过 Express 的自定义错误处理函数来解决,错误处理函数的形式如下:

1
2
3
function (err, req, res, next) {
// 处理错误逻辑
}

和普通的中间件函数相比,多了第一个参数,也就是 err 异常对象。

实现自定义处理逻辑

通过上面的讲解,实现自定义的 404 和错误处理逻辑也就非常简单了。在 server.js 所有路由的后面添加如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 中间件和其他路由 ...

app.use('*', (req, res) => {
res.status(404).render('404', { url: req.originalUrl });
});

app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).render('500');
});

app.listen(port, () => {
console.log(`Server running at http://${hostname}:${port}/`);
});

提示

在编写处理 404 的逻辑时,我们用到了模板引擎中的变量插值功能。具体而言,在 res.render 方法中将需要传给模板的数据作为第二个参数(例如这里的 { url: req.originalUrl } 传入了用户访问的路径),在模板中就可以通过 {{ url }} 获取数据了。

JSON API

在之前提到的 Response 对象中,Express 为我们封装了一个 json 方法,直接就可以将一个 JavaScript 对象作为 JSON 数据返回,例如:

1
res.json({ name: '百万年薪', price: 996 });

会返回 JSON 数据 { "name": "百万年薪", "price": 996 },状态码默认为 200。我们还可以指定状态码,例如:

1
res.status(502).json({ error: '公司关门了' });

会返回 JSON 数据 { "error": "公司关门了"},状态码为 502。

使用子路由拆分逻辑

当我们的网站规模越来越大时,把所有代码都放在 server.js 中可不是一个好主意。“拆分逻辑”(或者说“模块化”)是最常见的做法,而在 Express 中,我们可以通过子路由 Router 来实现。

1
2
const express = require('express');
const router = express.Router();

express.Router 可以理解为一个迷你版的 app 对象,但是它功能完备,同样支持注册中间件和路由:

1
2
3
4
5
6
// 注册一个中间件
router.use(someMiddleware);

// 添加路由
router.get('/hello', helloHandler);
router.post('/world', worldHandler);

最后,由于 Express 中“万物皆中间件”的思想,一个 Router 也作为中间件加入到 app 中:

1
app.use('/say', router);

这样 router 下的全部路由都会加到 /say 之下,即相当于:

1
2
app.get('/say/hello', helloHandler);
app.post('/say/world', worldHandler);