Nodejs实战 —— 深入了解Connect 和 Express

读 《node.js实战2.0》,进行学习记录总结。

当当网购买链接

豆瓣网1.0链接

深入了解Connect 和 Express

本章内容

  • 了解 Connect 和 Express 是用来做什么的
  • 中间件的使用和创建
  • Express 程序的创建和配置
  • 用 Express 中的关键即使处理错误、渲染视图和表单
  • 用 Express 的 架构技术实现路由、REST API 和用户认证

Connect

创建 Connect 程序

Connect 以前是 Express 的基础,但实际上只用 Connect 也能做出完整的 Web 程序。安装:

1
npm i connect

最简单的 Connect 程序:

1
2
3
4
5
const app = require('connect')();
app.use((req,res,next) =>{
res.end('Hello, world!');
});
app.listen(3000);

运行:

1
node server.js

打开 http://localhost:3000 可以看到网页。

了解 Connect 中间件的工作机制

Connect 中间件是 JavaScript 函数,这个函数一般会有三个参数:请求对象、响应对象以及一个名为 next 的回调函数。一个中间件完成自己的工作,要执行后续的中间件,可以调用这个回调函数。

中间件运行之前,Connect 会用分派器接管请求对象,然后交给程序中的第一个中间件。下图是 典型的Connect 程序的示意图,由分派器和一组中间件组成,这些中间件包括日志记录、消息体解析器、静态文件服务器和定制中间件。

两个 HTTP 请求穿过 Connect 服务器的生命周期

由此可见,借助中间件 API ,可以把一些小的构件块组合在一起,实现复杂的处理逻辑。

组合中间件

Connect 中的 use 方法就是用来组合中间件的。我们来定义两个中间件函数,然后把它们都添加到程序中,其中一个是之前的那个例子里的 hello 函数,另外一个是 logger.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const connect = require('connect');
function logger(req,res,next){
console.log('%s %s',req.method,req.url);
next();
}
function hello(req,res){
res.setHeader('Content-Type','text/plain');
res.end('Hello world');
}

connect()
.use(logger)
.use(hello)
.listen(3000);

这两个中间件的名称签名不一样:一个有 next ,一个没有,因为后面这个中间件完成了 HTTP 响应,再也不需要把控制权交给分派器了。

use() 函数返回的是 Connect 程序的实例,支持方法链,不过并不一定要把 .use() 链起来,像下面这样也可以:

1
2
3
4
const app = connect();
app.use(logger);
app.use(hello);
app.listen(3000);

中间件的顺序

中间件的顺序会对程序的行为产生显著影响,漏掉 next() 能停止执行,也可以通过组合中间件实现用户认证之类的功能。

尝试调换顺序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const connect = require('connect');
function logger(req,res,next){
console.log('%s %s',req.method,req.url);
next();
}
function hello(req,res){
res.setHeader('Content-Type','text/plain');
res.end('Hello world');
}

connect()
.use(hello)
.use(logger)
.listen(3000);

上面例子先调用 hello,程序如期返回响应结果,但 logger 永远也不会执行,因为 hello 没有调用 next().所以控制权没有交回给分派器,它也不能调用下一个中间件。也就是说,如果某个中间件不调用 next(),那链在它后面的中间件就不会被调用了。

创建可配置的中间件

为了可配置,中间件一般会遵循一个简单的惯例:用一个函数返回另一个函数(闭包),这种可配置中间件的基本结构如下所示:

1
2
3
4
5
6
7
8
function setup(options){
// 设置逻辑
return function(req,res,next){
// 中间件逻辑
}
}
// 用法
app.use(setup({some:'options'}));

注意 app.use 中的 setup 函数,之前放的是对中间件函数的引用.

创建一个可以传参(字符串参数)自己配置的 logger 中间件:

1
2
3
4
5
6
7
8
9
10
11
12
13
// logger.js
function setup(format) {
const regexp = /:(\w+)/g;
return function createLogger(req, res, next) {
const str = format.replace(regexp, (match, property) => {
return req[property];
});
console.log(str);
next();
}
}

module.exports = setup;

现在这个 logger 成了可配置的中间件,所以,可以在同一程序中给.use()传入不同配置的 logger,并且为了保持一致性,所有 Connect 核心中间件都是可配置的。

调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const setup = require('./logger.js');
const connect = require('connect');
function logger(req, res, next) {
console.log('%s %s', req.method, req.url);
next();
}
function hello(req, res) {
res.setHeader('Content-Type', 'text/plain');
res.end('Hello world');
}


connect()
.use(setup(':method :url'))
.use(hello)
.listen(3000);

使用错误处理中间件

Connect 中间有一种用来处理错误的中间件变体,跟常规的中间件相比,除了请求、响应对象外,错误处理中间件的参数还多了一个错误对象。

Connect 刻意将处理做到很简单,让开发人员指明如何处理错误。比如,可以只让系统和程序级错误通过中间件,或者只让用户错误通过,或者让两者结合的组合通过。

介绍错误处理中间件的工作机制以及一些使用的模式:

用Connect 的默认错误处理器

因为函数 foo() 没有定义。所以下面的这个中间件会抛出错误 ReferenceError:

1
2
3
4
5
6
7
const connect = require('connect');
connect().
use((req,res)=>{
foo();
res.setHeader('Content-Type','text/plain');
res.end('hello world');
}).listen(3000);

Connect 默认的处理时返回状态码 500,响应主体的文本是 Internal Server Error 和错误的详细信息。但在真正的程序中,一般还会对这些错误做些特殊的处理,比如将它们发送给一个日志守护进程。

自行处理程序错误

Connect 也支持用错误处理中间件自行处理错误,比如说,为了在开发时看到简单快捷的错误报告,你可能想用 JSON 格式发送错误信息;而在生产环境中,为了不把敏感的内部信息暴露出来,可以发送一个简单的服务器错误响应。

错误处理中间件函数必须有四个参数:err,req,res和next。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// errors.js
const env = process.env.NODE_ENV || 'development';
function errorHandler(err, req, res, next) {
res.statusCode = 500;
switch (env) {
case 'development':
console.log('Error:');
console.log(err);
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify(err));
default:
res.end('Server Error');
}
}
module.exports = errorHandler;

用 NODE_ENV 设定程序的模式 Connect 一般会根据环境变量 NODE_ENV(process.env.NODE_ENV) 来切换不同服务器环境的行为。

当 Connect 遇到错误时,它会切换,只去调用错误处理中间件,如下图:

引发了错误的 HTTP 请求在 Connect 服务器中的生命周期

假设有一个允许用户登录到管理区域的博客程序。如果负责用户路由的中间件引发了一个错
误,则中间件 blog 和 admin 都会被跳过,因为它们不是错误处理中间件(只有三个参数)。然后 Connect 看到接受错误参数的 errorHandler,就会调用它。中间件看起来像下面这样:

1
2
3
4
5
connect() 
.use(router(require('./routes/user')))
.use(router(require('./routes/blog'))) // 跳过
.use(router(require('./routes/admin'))) // 跳过
.use(errorHandler);

基于中间件的执行顺序短路某些功能是组织 Express 程序的基本概念。

Express

生成程序的框架

Express 对程序结构不作要求,路由可以放在多个文件中,公共资源文件也可以放到任何目
录下。最简单的 Express 程序可能像下面这样,但它仍然是一个功能完备的 HTTP 服务器。

1
2
3
4
5
6
const express = require('express');
const app = express();
app.get('/',(req,res) =>{
res.end('Hello');
});
app.listen(3000);

安装 Express 的可执行程序

首先要用 npm 全局安装 express-generator:

1
npm install -g express-generator

装好之后,可以用 –help 选项看看可用的选项

生成程序

用-e(或–ejs)指定要使用的模板引擎是 EJS,执行 express -e shoutbox,一个功能完备的程序会出现在 shoutbox 目录中。其中会有描述项目和依赖项的 package.json文件、程序主文件、public 目录,以及一个放路由处理器的目录。

探索程序

在编辑器中打开 package.json 文件,看看程序的依赖项。安装依赖:

1
npm i

生成的 Express 程序框架:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
var createError = require('http-errors');
var express = require('express');
var path = require('path');
var cookieParser = require('cookie-parser');
var logger = require('morgan');

var indexRouter = require('./routes/index');
var usersRouter = require('./routes/users');

var app = express();

// view engine setup
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'ejs');

// 输出有颜色区分的日志,以便于开发调试
app.use(logger('dev'));
// 解析请求主体
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
// 提供./public 下的静态文件
app.use(express.static(path.join(__dirname, 'public')));
// 指定程序路由
app.use('/', indexRouter);
app.use('/users', usersRouter);

// catch 404 and forward to error handler
app.use(function(req, res, next) {
next(createError(404));
});

// error handler
app.use(function(err, req, res, next) {
// set locals, only providing error in development
res.locals.message = err.message;
res.locals.error = req.app.get('env') === 'development' ? err : {};

// render the error page
res.status(err.status || 500);
res.render('error');
});

module.exports = app;

在线留言板程序的规划

下面是这个在线留言板程序的需求。
(1) 用户应该可以注册、登录、退出。
(2) 用户应该可以发消息(条目)。
(3) 站点的访问者可以分页浏览条目。
(4) 应该有个支持认证的简单的 REST API。

针对这些需求,我们要存储数据和处理用户认证,还需要对用户的输入进行校验。必要的路
由应该有以下两种。

  • API 路由
    • GET /api/entries: 获取条目列表
    • GET /api/page: 获取单页条目
    • POST /api/entry:创建新的留言条目
  • Web UI 路由
    • GET /post:显示创建新条目的表单
    • POST /post:提交新条目
    • GET /register:显示注册表单
    • POST /register:创建新的用户账号
    • GET /login:显示登录表单
    • POST /login:登录
    • GET /logout:退出

Express 和程序的配置

Express 还支持自定义的配置项键/值对。

设置环境变量

在 Windows 中用这个:

1
2
set NODE_ENV=production 
node app

这些环境变量会出现在程序里的 process.env 对象中。

Express 有一个极简的环境驱动配置系统,这个系统由几个方法组成,全部由环境变量NODE_ENV 驱动:

  • app.set()
  • app.get()
  • app.enable()
  • app.disable()
  • app.enabled()
  • app.disabled()

基于环境的配置,环境变量 NODE_ENV 源于 Express,用它告知Node 程序在哪个环境中,其默认的是开发环境。

app.configure() 方法有一个可选的字符串参数,用来指定运行环境。还有一个参数是函数,如果有这个字符串,则在运行环境与字符串相同时才会调用那个函数。如果没有,则在所有环境中都会调用那个函数,这些环境的名称是随意的。为了实现可定制的行为,Express 在其内部使用了配置系统,我们也可以在自己的程序中使用这个系统。

Express 还为布尔类型的配置项提供了 app.set() 和 app.get() 的变体。比如说,app.enable (setting)等同于 app.set(setting, true),而 app.enabled(setting)可以用来检查该值是否被启用了。app.disable(setting)和 app.disabled(setting)是对它们的补充。

Express 为开发 API 提供了一个配置项,即 json spaces。如果把它加到 app.js 中,程序输
出 JSON 的格式会变得更易读:

1
app.set('json spaces',2);

渲染视图

不管是渲染整个 HTML 页面、一个 HTML 片段,还是一个 RSS 预订源,对几乎所有程序来
说,视图渲染都非常重要。其概念很简单:把数据传给视图,然后视图对数据进行转换,对 Web程序来说,通常是转换成 HTML。

Express 中有两种渲染视图的办法:程序层面用 app.render(),在请求或响应层面用res.render() , Express 内部用的是前一种。本章只用 res.render() 。如果你看一下./routes/index.js,会看到一个调用 res.render(‘index’)的函数,渲染是./views/index.ejs模板:

1
2
3
router.get('/', function(req, res, next) {
res.render('index', { title: 'Express' });
});

配置视图系统

查找目录:

1
app.set('views', path.join(__dirname, 'views'));

这个配置项指明了 Express 查找视图的目录,__dirname 是个全局变量,代表当前运行的文件所在的目录。在开发时,这个目录通常就是当前工作目录(CWD),但在生产环境中,这个文件可能运行在其他目录中。__dirname 有助于保持路径在各种环境中的一致性.

使用默认的模板引擎:

之前在命令行中用 -e 指定模板引擎 EJS ,所以 view engine 被设定为 ejs .Express 要靠扩展名确定哪个模板引擎渲染文件,有了这个配置项,我们可以用 index 指定要渲染的文件,而不需要 index.ejs.

使用带扩展名的模板文件,就可以在同一个 Express 程序中使用多个模板引擎。同时这样又能提供一个清晰的 API。

比如说,你发现用另一种模板引擎写 RSS 预订源更容易,或者正要换一个模板引擎用。你
可能将 Pug 作为默认引擎,用 EJS 渲染 /feed 路由的响应结果,就像下面的代码一样指明 .ejs 扩展名。

1
2
3
4
5
6
7
app.set('view engine','pug');
app.get('/',function(){
res.render('index');
});
app.get('/feed',function(){
res.render('rss.ejs');
});

视图缓存

在生产环境中,view cache 是默认开启的。以防止后续的 render() 从硬盘中读取模块文件。因为模块文件的内容会被放到内存中,所以性能会得到显著提升。但启用这个配置项后,只有重启服务器才能让模板文件的编辑生效,所以在开发时会禁用它。如果在分级(staging)环境中运行,很可能要启用和这个配置项。

view cache 被禁用时,每次请求都会从硬盘上读取模板。这样无须重启程序来让模板的修改生效。启用 view cache 后,每个模板只需要读取一次硬盘。

视图缓存设置

我们看看 Express如何定位视图来渲染它们。

视图查找

查找视图的过程跟 require() 查找的过程差不多,在程序中调用了 res.render() 或 app.render()后,Express 会先检查有没有这样的绝对路径,接着查找目录的相对路径。最后会尝试找目录中的 index 文件。

Express 视图查找过程

因为 ejs 被设为默认引擎,所以无须在 render 中指明模板文件的扩展名.ejs。

随着开发进展,程序中的视图会越来越多,并且有时一个资源会有几个视图。view lookup
可以帮我们组织这些视图,比如说把视图文件放在跟资源相连的子目录中。

用添加子目录的办法可以去掉模板文件名称中的冗余部分,比如 edit-entry.ejs和 show-entry.ejs。Express 会添加跟 view engine 匹配的扩展名,根据 res.render(‘entries/edit’)定位到 ./views/entries/edit.ejs。

Express会检查 views的子目录中是否有名为 index的文件。当文件的名称为复数时,比如 entries,通常表示这是一个资源列表。也就是说 res.render(‘entries’)一般会渲染文件views/entries/ index.ejs。

将数据传递给视图的办法

在 Express 中,要给被渲染的视图传递数据有几种办法,其中最常用的是将要传递的数据作为 res.render()的参数。此外,还可以在路由处理器之前的中间件中设定一些变量,比如用app.locals 传递程序层面的数据,用 res.locals 传递请求层面的数据。

将变量直接作为 res.render()的参数优先级最高,要高于在 res.locals 和 app.locals中设定的变量值:

给 render 函数的值优先级最高

默认情况下,Express 只会向视图中传递一个程序级变量——settings,这个对象中包含所
有用 app.set()设定的值。比如 app.set(‘title’, ‘My Application’)会把 settings. title 输出到模板中,请看下面的 EJS 代码片段:

1
2
3
4
5
6
7
8
<html> 
<head>
<title><%= settings.title %></title>
</head>
<body>
<h1><%= settings.title %></h1>
<p>Welcome to <%= settings.title %>.</p>
</body>

实际上,Express 是像下面这样输出这个对象的:

1
app.locals.settings = app.settings

Express 路由入门

Express 路由的主要任务是将特定模式上的 URL 匹配到响应逻辑上。但也可以将 URL 模式匹配到中间件上,以便于中间件实现某些路由上的可重用功能。

特定路由中间件的用法。

检验用户内容提交

给程序加上消息提交功能,实现这个功能需要下面几项工作:

  • 创建消息模型
  • 添加与模型消息相关的路由
  • 创建消息表单
  • 添加业务逻辑,用提交上来的表单数据创建消息
创建消息模型

创建模型之前,要先安装 Node redis 模块:

1
npm i -S redis

借助 Redis 和 ES6 可以轻松创建出来轻便的模型。

下面的这个ES6 类会把数据存在 Redis 列表中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// models/entry.js
const redis = require('redis');
// 创建 Redis 客户端实例
const db = redis.createClient();
class Entry {
constructor(obj) {
// 循环遍历传入对象的值
for (let key in obj) {
// 合并值
this[key] = obj[key];
}
}

save(cb) {
// 将保持的消息转换成 JSON 字符串
const entryJSON = JSON.stringify(this);
// 将 JSON 字符串保存到 Redis 列表中
db.lpush(
'entries',
entryJSON,
(err) => {
if (err) return cb(err);
cb();
}
);
}
}
module.exports = Entry;

基本模型有了,现在要添加获取消息用的 getRange 函数,代码如下所示,你可以用这个函数获取消息。

1
2
3
4
5
6
7
8
9
10
11
12
static getRange(from, to, cb) {
// 用来获取消息记录的 Redis lrange 函数
db.lrange('entries', from, to, (err, items) => {
if (err) return cb(err);
let entries = [];
// 解码之前保存为 JSON 的消息记录
items.forEach(item => {
entries.push(JSON.parse(item));
});
cb(null.entries);
});
}

创建好模型,现在就可以添加路由来创建消息和获取消息了。

创建消息表单
1
2
3
// app.js
app.get('/post', entries.form);
app.post('/post', entries.submit);

接着把下面的代码添加到 routes/entries.js 中。这个路由逻辑会渲染一个包含表单的模板:

1
2
3
exports.form = (req, res) => {
res.render('post', { title: 'Post' });
}

然后用下面的 EJS 代码创建表单模板 views/post.ejs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<!DOCTYPE html>
<html>

<head>
<title>
<%= title %>
</title>
<link rel="stylesheet" href="/stylesheet/style.css">
</head>

<body>
<%= include menu %>
<h1>
<%= title%>
</h1>
<p>Fill in the form below to add a new post</p>
<form action="/post" method="post">
<p>
<input type="text" name="entry[title]" placeholder="Title">
</p>
<p>
<textarea name="entry[body]" placeholder="Body" cols="30" rows="10"></textarea>
</p>
<p>
<input type="submit" value="Post">
</p>
</form>
</body>

</html>

这个表单用了形如 entry[title]之类的输入控件名称,需要用扩展的消息体解析器来解析。

1
app.use(express.urlencoded({ extended: true }));

显示表单的页面做好了,接下来我们要用表单提交上来的数据创建消息。

实现消息的创建

把下面的代码添加到文件 routes/entries.js 中,实现用表单提交上来的数据创建消息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const Entry = require('../models/entry');
exports.submit = (req, res, next) => {
const data = req.body.entry;
const user = res.locals.user;
const username = user ? user.name : null;
const entry = new Entry({
username: username,
title: data.title,
body: data.body
});
entry.save(err => {
if (err) return next(err);
res.redirect('/');
});
}
添加显示消息首页

先创建 routes/entries,然后把下面的代码放到里面,引入消息模型,输出渲染消息列表的函数。

1
2
3
4
5
6
7
8
9
10
exports.list = (req, res, next) => {
Entry.getRange(0, -1, (err, entries) => {
if (err) return next(err);
// 渲染 HTTP 响应
res.render('entries', {
title: 'Entries',
entries: entries
});
});
}

这个路由的业务逻辑定义好之后,还需要添加 EJS 模板来显示这些消息。在 views 目录下创
建 entries.ejs 文件,并加入下面的 EJS 代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<!DOCTYPE html>
<html>

<head>
<title>Page Title</title>
<link rel="stylesheet" type="text/css" media="screen" href="/stylesheets/style.css" />
</head>

<body>
<% include menu %>

<%entries.for((entry) =>{ %>
<div class="entry">
<h3><%= entry.title%></h3>
<p><%= entry.body></p>
<p>Post by <%= entry.username%></p>
</div>
<%})%>

</body>

</html>

在运行程序之前,先创建菜单模板文件menu.ejs ,后面再添加具体代码。视图和路由准备好后,需要告诉程序到哪里去找这些路由。

添加与消息相关的路由

在把与消息相关的路由添加到程序中之前,需要调整一下 app.js。先把下面这个 require
语句放在 app.js 文件的顶端:

1
app.get('/', entries.list);

现在运行这个程序,首页会显示消息列表。

首页

既然消息创建和显示列表都做好了,那么接下来该看看如何用特定路由中间件校验表单数据了.

使用特定路由中间件

假定你想将表单中的消息文本域设为必填项。能想到的第一种方式可能是像下面的代码那
样把它直接加在路由回调函数中。然而这种方式并不理想,因为校验逻辑是绑死在这个表单上的。而在大多数情况下,校验逻辑都能被提炼到可重用的组件中,让开发更容易、更快、更具声明性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
exports.submit = (req, res, next) => {
const data = req.body.entry;
// 校验
if (!data.title) {
res.error('Title is required.');
res.redirect('back');
return;
}
if (!data.title.length < 4) {
res.error('Title must be longer than 4 characters.');
res.redirect('back');
return;
}
const user = res.locals.user;
const username = user ? user.name : null;
const entry = new Entry({
username: username,
title: data.title,
body: data.body
});
entry.save((err) => {
if (err) return next(err);
res.redirect('/');
});
}

Express 路由可以有自己的中间件,其放在路由回调函数之前,只有跟这个路由匹配时才会调用。本章所用的路由回调并没有做特殊处理,这些中间件和其他中间件一样,甚至你即将创建的中间件也一样。

接下来我们要用特定路由中间件来做校验,先看一种虽然简单但不太灵活的实现方式。

用特定路由中间件实现表单校验

第一种方式是写几个简单但特定的中间件来执行校验,带有此类中间件的 POST/post 路由看起来像下面这样:

1
2
3
4
app.post('/post',
requireEntryTitle,
requireEntryTitleLengthAbove(4),
entries.submit);

一般的路由定义只有两个参数:路径和路由处理函数,而这个路由定义中又额外地增加了两个参数,这两个参数就是校验中间件。

下面的代码中,我们把原来的校验逻辑剥离出来做成了两个中间件,但它们的模块化程度还不高,只能用在输入域 entry[title]上。

更有潜力但是不完美的校验组件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function requireEntryTitle(req,res,next){
const title = req.body.entry.title;
if(title){
next();
}else{
res.error('Title is required');
res.redirect('back');
}
};
function requireEntryTitleLengthAbove(len){
return (req,res,next) =>{
const title = req.body.entry.title;
if(title.length > len){
next();
}else{
res.error(`Title must be longer than ${len}`);
res.redirect('back');
}
}
}

实际工作中更常用的方案是进一步抽象,剥离成更灵活的校验器,以目标输入域的名称为参
数进行校验。下面来看一下这种实现方式。

构建灵活的校验中间件

如果能重用校验逻辑,可以像下面这样传入输入域名称,那我们的工作量会进一步降低

1
2
3
4
app.post('/post',
validate.required('entry[title]'),
validate.lengthAbove('entry[title]',4),
entries.submit);

打开 app.js,把路由部分的 app.post(‘/post’, entries.submit);换成上面这段代码。这里有必要提一下,Express 社区已经创建了很多类似的公用库,但掌握校验中间件的工作机制以及如何编写中间件仍然很有必要.

创建 ./middleware/validate.js 文件。validate.js会输出 validate.required()和 validate.lengthAbove()两个中间件。这里的实现细节并不重要,关键是通过这个例子学习如何提炼出程序中的通用代码,用少量的工作成果发挥作用

为了让程序能访问到这个中间件,需要把下面这行代码放到 app.js 中:

1
const validate = require('./middleware/validate');

用户认证

本节会从头给程序创建一个认证系统,实现以下功能:

  • 存储和认证已注册用户
  • 注册功能
  • 登录功能
  • 加载用户信息的中间件

我们还是用 Redis 作为用户账号的存储,接下来创建 User 模型,看看如何让 Redis 用起来更加容易

1.保存和加载用户记录

实现用户加载、保存和认证,任务清单是:

  • 用 package.json 定义程序的依赖项
  • 创建用户模型
  • 用 Redis 加载和保存用户信息
  • 用 bcrypt 增强用户密码的安全性
  • 实现用户认证

Bcrypt 是一个加盐的哈希函数,可作为第三方模块专门对密码做哈希处理。Bcrypt 特别适合处理密码,因为计算机越来越快,而 bcrypt 能让破解变慢,从而有效对抗暴力攻击。

先安装:

1
npm i -S redis bcrypt
2.创建用户模型

在 models 文件夹下面创建 user.js

下面的代码引入了依赖项 redis 和 bcrypt,然后用 redis.createClient() 打开 Redis 连接。函数 User 可以合并传入的参数对象。比如说,new User({name:’Tobi’})会创建一个对象,并将对象的属性 name 设为 Tobi.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const redis = require('redis');
const bcrypt = require('bcrypt');
// 创建到 Redis 的长连接
const db = redis.createClient();

class User {
constructor(obj) {
// 循环遍历传入的对象
for (let key in obj) {
// 设定当前类的所有属性
this[key] = obj[key];
}
}
}

module.exports = User;

现在这个用户模型只是个架子,还需要创建和更新记录的方法。

3.把用户保存到 Redis 中

接下来要实现的功能是保存用户,把数据存到 Redis 中,save 方法会先检查用户是否有ID,如果没有就调用 update 方法,用名称索引用户 ID ,并把对象的属性组装出 Redis 哈希表中的记录,如果没有 ID ,则认为这是一个新用户,增加 user.ids 的值,给用户一个唯一的 ID ,然后对密码进行哈希处理,用之前提到的那个 update 方法将数据存到 Redis 中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
save(cb) {
if (this.id) {
// 如果设置了 ID,则用户已经存在了
this.update(cb);
} else {
// 创建唯一的 ID
db.incr('user:ids', (err, id) => {
if (err) return cb(err);
this.id = id;
// 哈希密码
this.hashPassword((err) => {
if (err) return cb(err);
// 保存用户属性
this.update(cb);
})
});
}
}
update(cb) {
const id = this.id;
// 用名称索引用户 id
db.set(`user:id:${this.name}`, id, (err) => {
if (err) return cb(err);
// 用 Redis 存储当前属性
db.hmset(`user:${id}`, this, (err) => {
cb(err);
});
});
}
4.增强用户密码的安全性

刚创建用户时,需要将 .pass 属性设为用户的密码。然后用户保存逻辑将 .pass 属性换做经过哈希处理的密码。

这个哈希会加盐,每个用户的加的盐不一样,加盐可以有效对抗彩虹表攻击。可以用bcrypt 的 genSalt()为哈希生成 12 个字符的盐

盐生成好之后,调用 bcrypt.hash()对.pass 属性和盐做哈希处理。在.update()把数据存到 Redis 之前,.pass 属性的值会换成最终的哈希值,保证不会保存密码的明文,只保存它的哈希结果。
下面代码中定义的函数会创建加盐的哈希,并把结果存到用户的属性.pass 中。把它加到models/user.js 中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
hashPassword(cb) {
// 生成有 12 个字符盐
bcrypt.genSalt(12, (err, salt) => {
if (err) return cb(err);
this.salt = salt;
// 生成哈希
bcrypt.hash(this.pass, salt, (err, hash) => {
if (err) return cb(err);
// 设定哈希以便于保存
this.pass = hash;
cb();
});
});
}
5.测试用户保存逻辑

接着运行 Redis-server 启动 Redis 服务器,新建一个 user-test.js,加入代码:

1
2
3
4
5
6
7
8
const User = require('./models/user');
const user = new User({ name: 'Example', pass: 'test' });

user.save((err) => {
// 保存用户
if (err) console.error(err);
console.log('user id %d', user.id);
});

可以看到类似下面的输出:

node user-test.js

接着使用 Redis 中的工具 redis-cli ,可以用 HGETALL 命令取出哈希表中的所有键和值,如下:

哈希表

6.获取用户数据

在 Web 程序中,用户登录通常是在表单中输入用户名和密码,然后将这些数据提交到后台进行认证 。在得到登录表单提交的数据后,需要一个通过用户名获取信息的方法。

下面的代码中的 User.getByName 就是这样的方法,这个函数先用 User.getId() 查找用户 ID,然后把 ID 传给 User.get(),由它负责取得 Redis 哈希表中的用户数据。把下面的方法加到 models/user.js 中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
static getByName(name, cb) {
// 根据名称查找用户 ID
User.getId(name, (err, id) => {
if (err) return cb(err);
// 用 ID 抓取用户
User.get(id, cb);
});
}
static getId(name, cb) {
// 取得由名称索引的 ID
db.get(`user:id:${name}`, cb);
}
static get(id, cb) {
// 获取普通对象哈希
db.hgetall(`user:${id}`, (err, user) => {
if (err) return cb(err);
// 将普通对象转换为新的 User 对象
cb(null, new User(user))
});
}
7.用户登录认证
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
static authenticate(name,pass,cb){
// 通过用户名查找用户
User.getByName(name,(err,user) =>{
if(err) return cb(err);
// 用户不存在
if(!user.id) return cb();
bcrypt.hash(pass,user.salt,(err,hash) =>{
if(err) return cb(err);
// 匹配发现向
if(hash == user.pass) return cb(null,user);
// 密码无效
cb();
});
});
}

认证功能一开始先用用户查找用户记录,如果没有找到,马上调用回调函数,反之把保存在用户对象中的盐和提交上来的密码做哈希处理,产生的结果应该跟 user.pass 哈希值相同。如果两个哈希值不匹配,说明用户输入的凭证是无效的。当查找不存在的键时,Redis 会返回一个空的哈希值,所以这里的检查方法是 !user.id 而不是 !user。

注册新用户

为了让用户创建新账号后登陆,需要提供注册和登录功能。

完成下面的任务实现注册:

  • 将注册和登录路由映射到URL路径上
  • 添加显示注册表单的注册路由处理器
  • 实现用户数据存储功能,存储从表单提交上来的用户数据
1.添加注册路由

要显示注册表单,首先要创建一个路由渲染这个表单,然后把它返回给用户的浏览器显示出来。

1
2
3
4
5
6
7
8
// app.js
const register = require('./routes/register.js');
app.get('/register',register.form);
app.post('/register',register.submit);
// routes/register.js
exports.form = (req, res) => {
res.render('register', { title: 'Register' });
}

app.js 新增的代码用 Node 模块系统从 routes 目录中引入定义注册路由行为的模块,并将 HTTP 方法及 URL 路径关联到路由函数上。由此构成了一个前端控制器。既有Get 路由们也有Post 路由。接着定义路由逻辑,输出渲染注册模板的路由。上面的路由用到了一个 EJS 模板,我们接下来创建用于定义注册表单的 HTML 模板。

2.创建注册表单

为了定义注册表单的 HTML ,需要在 views 目录下创建 register.ejs 文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<!DOCTYPE html>
<html lang="en">
<head>
<title><%= title %></title>
</head>
<body>
<% include menu %>s
<h1><%= title %></h1>
<p>Fill in the form below to sign up</p>
<% include messages %>
<form action='/register' method="post">
<p>
<input type="text" name='user[name]' placeholder="Username">
</p>
<p>
<input type="password" name="user[pass]" placeholder="password">
</p>
<p>
<input type="submit" value="Sign Up">
</p>
</form>
</body>
</html>

注意上面的 include messages,它嵌入了另一个模板 messages.ejs。我们接下来就定义这
个用来跟用户沟通的模板。

3.反馈消息传达给用户

在用户注册过程中,以及在大多数应用场景中,将反馈消息传达给用户都是必须要做的工作。

这个程序中的 messages.ejs 是用来显示错误的,它会嵌入到很多模板中。这段代码会检查是否有变量 locals.messages ,如果有,模板会遍历这个变量以显示消息对象。每个消息对象都有 type 属性(可以用消息做非错误通知) 和 string 属性(消息文本)。我们可以把要显示的错误添加到 res.locals.messages 数组中形成队列。消息显示后,调用 removeMessages 清空消息队列:

1
2
3
4
5
6
7
8
<% if(locals.messages) {%>
<% messages.forEach((message) =>){ %>
<p class='<%= message.type %>'>
<%= message.string %>
</p>
<% }) %>
<% removeMessages() %>
<% } %>

向 res.locals.messages 中添加消息是一种简单的用户沟通方式,但在重定向后 res.locals 会丢失,所以如果要跨越请求传递消息,那么需要到会话。

4.在会话中存储临时的消息

Post/Redirect/Get(PRG)是一种常用的 Web 程序设计模式,这种模式是指,用户请求表单,表单数据作为 HTTP Post 请求被提交,然后用户被重定向到另一个 Web 页面上。用户被重定向到哪里取决于表单数据是否有效。如果表单无效,程序会让用户回到表单页面,如果表单有效,程序会让用户到新的 Web 页面。PRG 模式主要是为了防止表单的重复提交。

在 Express 中,用户被重定向后,res.locals 中的内容会被重置,如果把发给用户的消息存在 res.locals 中,让它在一个会话变量中维护用户消息队列。

1
2
3
4
5
6
7
8
9
10
//./middleware/messages.js
const express = require('express');
function message(req) {
return (msg, type) => {
type = type || 'info';
let sess = req.session;
sess.messages = sess.messages || [];
sess.messages.push({ type: type, string: msg });
}
}

req.message 函数可以把消息添加到来自任何 Express 请求的会话变量中。express.response 对象是 Express 给响应对象用的原型。所有中间件和路由都能访问到添加到这个对象的属性。在前面的代码中, express.response 被赋值给了一个名为 res 的变量,这样添加属性更加容易。

这个功能需要会话支持,需要一个跟 Express 兼容的中间件模块:express-session.安装:

1
npm i -S express-session

添加到 app.js 中

1
2
3
4
5
6
const session = require('express-session');
app.use(session({
secret: 'secret',
resave: false,
saveUninitialized: true
}));

为了让添加消息更加容易,用 res.error 可以轻松地将类型为 error 的消息添加到消息队列中。它用到了前面定义的 res.message 函数:

1
res.error = meg => this.message(msg,'error');

最后一步是把这些消息输出到模板中显示。如果不做这一步,就只能把req.session.messages传给每个 res.render()调用,这很不明智。

为了解决这个问题,我们要创建一个中间件,在每个请求上用 res.session.messages 上
的内容组装出 res.locals.messages,这样可以更高效地把消息输出到所有要渲染的模板上。到目前为 止,./middleware/messages.js 只是扩展了响应的原型,还没输出任何东西。把下面的代码加到这个文件中,输出我们需要的中间件:

1
2
3
4
5
6
7
8
9
10
11
module.exports = (req, res, next) => {
res.message = message(req)
res.error = (msg) => {
return res.message(msg, 'error');
};
res.locals.messages = req.session.messages || [];
res.locals.removeMessages = () => {
req.session.messages = [];
}
next();
}

它首先定义了一个模板变量 messages 用来存放会话中的消息,在上一个请求中可能存在,也可能不存在。接下来,还需要一个把消息从会话中移除的方法,否则它们会因为没人清理而越积越多。

现在只要在 app.js 中 require() 这个文件就可以集成这个新功能了。这个中间件应该放在中间件 session下面,所以可以调用 app.use(messages),而不需调用 app.use(messages());为将来考虑,不管是否接受选项,第三方中间件最好用 app.use(messages()):

1
2
const messages = require('./middleware/messages');
app.use(messages);

这样任何视图中都可以访问到 messages 和 removeMessages()了,所以,不管出现在哪
个模板中,messages.ejs 应该都可以圆满完成任务。

5.实现用户注册

我们需要一个路由函数来处理提交到 /register 上的 HTTP POST 请求。可以将这个函数命名为 submit。

当表单数据提交上来时,中间件 bodyParser()会用这些数据组装 req.body。注册表单使用了
对象表示法 user[name],经过解析后会变成 req.body.user.name。同样,req.body.user.pass表示密码输入域。

注册一完成,就会把 user.id 赋值给会话变量,稍后还要通过检查它是否存在来判断用户是否通过了认证。如果校验失败,消息会作为 messages 变量通过 res.locals.messages 输出到模板中,并且用户会被重定向回注册表单

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
const User = require('../models/user');
exports.form = (req, res) => {
res.render('register', { title: 'Register' });
}
exports.submit = (req, res, next) => {
const data = req.body.user;
// 检查用户名是否唯一
User.getByName(data.name, (err, user) => {
if (err) return next(err);
// 用户名被占用
if (user.id) {
res.error('Username already taken!');
res.redirect('back');
} else {
// 用 POST 数据创建用户
user = new User({
name: data.name,
pass: data.pass
});
// 保存新用户
user.save(err=>{
// 为认证保存 uid
if(err) return next(err);
req.session.uid = user.id;
res.redirect('/');
});
}
});
}

访问 /register 注册一个用户。

register

已注册用户登录

实现登录功能比注册简单,因为之前定义的通用认证方法 User.authenticate() 里已经有登录所需的大部分代码。本节添加

  • 显示登录表单的路由逻辑
  • 认证从表单提交的用户数据的逻辑

修改app.js:

1
2
3
4
const login = require('./routes/login');
app.get('/login', login.form);
app.post('/login', login.submit);
app.get('/logout', login.logout);
1.显示登录表单

实现登录表单的第一步是为与登录和退出相关的路由创建一个文件: routers/login.js.显示登录表单的路由逻辑几乎跟之前那个显示注册表单的逻辑一模一样,唯一的区别是模板名称和页面标题不同:

1
2
3
exports.form = (req, res) => {
res.render('login', { title: 'Login' });
}

定义登录表单的 login.ejs 也和 注册的相似,只有说明文本和提交目标不同。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<!DOCTYPE html>
<html>
<head>
<title><%= title %></title>
<link rel='stylesheet' href='/stylesheets/style.css' />
</head>
<body>
<% include menu %>
<h1><%= title %></h1>
<p>Fill in the form below to sign in!</p>
<% include messages %>
<form action='/login' method='post'>
<p>
<input type='text' name='user[name]' placeholder='Username' />
</p>
<p>
<input type='password' name='user[pass]' placeholder='Password' />
</p>
<p>
<input type='submit' value='Login' />
</p>
</form>
</body>
</html>
2.登录认证

处理登录请求需要添加路由逻辑,对用户提交的用户名和密码进行检查,如果正确,将用户 ID 设为对话变量,并把用户重定向到首页上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//routers/login.js
const User = require('../modlels/user');
exports.submit = (req, res, next) => {
// 检查凭证
const data = req.body.user;
User.authenticate(data.name, data.pass, (err, user) => {
if (err) return next(err);
// 处理凭证有效的用户
if (user) {
// 为认证存储 uid
req.session.uid = user.id;
res.redirect('/');
} else {
res.error('Sorry! invalid credentials');
res.redirect('back');
}
});
}

如果用户时使用 User.authenticate() 认证,req.session.uid 就会想在 POST/register 路由中一样赋值:这个值会保存在会话中,可以用它获取 User 或其他用户相关的数据。如果找不到匹配的记录,会设定一个错误,并重新显示登录表单。

用户可能还希望有个退出功能,所以应该在程序中提供一个退出链接。

1
2
3
4
5
6
exports.logout = (req, res) => {
req.session.destroy(err => {
if (err) throw err;
res.redirect('/');
});
}

注册和登录页面都创建好了,接下来需要添加一个菜单,让用户可以进入这两个页面。

3.为已认证的和匿名的用户创建菜单

在所有程序页面的 EJS 模板中,标签 <body>之后都有这样的一段代码,<% include menu %>,这是要嵌入模板 ./views/menu.ejs,接下来马上创建

1
2
3
4
5
6
7
8
9
10
11
12
<% if(locals.user) { %>
<div id="menu">
<span class="name"><%= user.name %></span>
<a href="/post">post</a>
<a href="/logout">logout</a>
</div>
<% } else { %>
<div id="menu">
<a href="/post">post</a>
<a href="/logout">logout</a>
</div>
<% } %>

在这个程序中,你可以假定如果有 user 变量输出到了模板中,那么这个用户就已经通过认
证了,否则不会输出这个变量。也就是说当这个变量出现时,可以显示用户名、消息提交和退出链接。当访问者是匿名用户时,显示网站登录和注册链接。

我们要写一些代码为每个请求加载已登录用户的数据,并让模板可以得到这些数据.

用户加载中间件

在做 Web 程序时,一般都需要从数据库中加载用户消息,通常会表示为 JavaScript 对象,为了使其与用户交互简单,要保证这项数据的可持续访问。下面要用中间件为每个请求加载用户数据。

中间件脚本会放在 ./middleware/user.js 中,它会从上层目录models 中引入 User 模型。

显示输出中间件函数,然后检查会话查看用户ID 。当用户 ID 出现时,表明用户已经通过认证,所有从 Redis 中取出用户数据是安全的。

Node 是单线程的,没有线程本地存储。对于 HTTP 服务器而言,请求和响应变量是唯一的上下文。构建在 Node 之上的高层框架可能会提供额外的对象存放已认证用户之类的数据,但 Express 坚持使用 Node 原始对象。因此,上下文数据一般保存在请求对象上,下面的代码中,用户被存储 req.user,后续中间件和路由可以用这个属性访问它。

res.locals 是 Express 提供的请求层对象,可以将数据输出给模板。

1
2
3
4
5
6
7
8
9
10
11
12
13
const User = require('../models/user');
module.exports = (req,res,next) =>{
// 从会话中取出已登录用户的 id
const uid = req.session.uid;
if(!uid) return next();
// 从 Redis 中取出已登录的用户的数据
User.get(uid,(err,user)=>{
if(err) return next(err);
// 将用户数据输出到响应对象中
req.user = res.locals.user = user;
next();
});
}

改变app.js 代码,启用这个中间件。。在这个程序中,user 出现在路由器上面,所以只有路由和在 user 下面的中间件能访问 req.user。如果你正在使用加载数据的中间件,就像这个中间件一样, 可能要把 express.static 放到它上面。否则每次返回静态文件时,都会浪费时间到数据库中取用户数据。

1
2
const user = require('./middleware/user');
app.use(user);

增加菜单的样式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#menu { 
position: absolute;
top: 15px;
right: 20px;
font-size: 12px;
color: #888;
}
#menu .name:after {
content: ' -';
}
#menu a {
text-decoration: none;
margin-left: 5px;
color: black;
}

创建 REST API

本节创建一个 RESTful API,让第三方程序可以跟我们的在线留言板程序互动,进行公开数据的访问和添加。按照 REST 的思想,程序数据是可以用谓语和名词(即 HTTP 方法和 URL)访问和修改的。通过 REST 请求得到的数据一般是机器可读的格式,比如 JSON 和 XML

实现 API 需要完成下面的任务:

  • 设计一个让用户显示、列表、移除和提交消息的 API
  • 添加基本认证
  • 实现路由
  • 提供 JSON 和 XML 响应

能对 API 请求进行认证和签名的技术很有很多种,本章介绍以 basic-anth 包为例的集成认证功能。

1.设计 API

我们一般会在 REST ful API前加路径 /api 。

与其将回调函数放在 app.VERB() 里调用,不如把它做成单独的 Node 模块。保持路由列表的清爽简洁。

1
2
3
app.get('/api/user/:id',api.user);
app.get('/api/entries/:page?',api.entries);
app.post('/api/entry',api.add);
2.添加基本的认证

使用中间 api.auth 这一过程,会放在 routes/api.js 模块中,app.use() 方法可以接受路径参数,这在 Express 中被称为挂载点。不管是什么 HTTP 谓语,只要请求的路径以挂载点开头,就会触发这个中间件。

下面这段代码中的 app.use(‘/api’,api.auth); 应该放在加载数据的中间件前面,这样可以稍后再修改用户加载中间件,为已认证的 API 用户加载数据:

1
2
3
const api = require('./routes/api');
app.use('/api',api.auth);
app.use(user);

要执行基本认证,安装 basic-auth 模块:

1
npm i -S basic-auth

接着,创建 ./routes/api.js ,引入 Express 和用户模型,可以用 basic-auth 从请求中获取基本认证凭证,然后交给 User.authenticate 进行认证:

1
2
3
4
5
6
7
8
9
10
const auth = require('basic-auth');
const express = require('express');
const User = require('../models/user');
exports.auth = (req, res, next) => {
const { name, pass } = auth(req);
User.authenticate(name, pass, (err, user) => {
if (user) req.remoteUser = user;
next(err);
});
}

认证已经准备好了,接下来可以实现 API 的路由。

3.实现路由

第一个要实现的路由就是 GET /api/user/:id 。先根据 ID 取得用户数据,如果用户不存在,则返回 404 Not Found 的响应状态码,如果用户存在,则吧用户数据传给 res.json() 做串行化处理,并以 JSON 格式返回数据,将下面的代码加入 到 routes/api.js

1
2
3
4
5
6
7
exports.user = (req, res, next) => {
User.get(req.params.id, (err, user) => {
if (err) return next(err);
if (!user.id) return res.send(404);
res.json(user);
});
}

然后将这个路由,加到 app.js 中

1
app.get('/api/user:id',api.user);
4.测试用户数据获取

启动程序,然后用命令行工具 cURL 进行测试,URL中提供了凭证 tobi:ferret,cURL 用它生成了 Authorization 请求头域:

1
curl http://tobi:ferret@127.0.0.1:3000/api/user/4 -v

下面会测试结果:

测试用户数据获取

4 是ID ,可以用 redis-cli 的 GET user:ids 来获取 id 名。

5.去掉敏感的用户数据

上面的图片中,用户的密码和盐都在,可以在 User 中将它们去掉:

1
2
3
4
5
6
toJSON(){
return {
id: this.id,
name: this.name
}
}

如果有了 .toJSON ,JSON.stringify 就会用返回的 JSON 数据,现在再发送之前那个 cURL 请求,就只有 ID 和 name 属性了。

6.添加消息

因为通过 API 添加消息的实现和通过 HTML 表单添加的实现时几乎一模一样,所以可以重用之前 实现的 entries.submit() 路由逻辑.

不同的是 entries.submit()中,消息要有用户名和其他细节消息,所以需要修改用户加载中间件,用 basic-auth 中间件加载的用户数据封装在 res.locals.user 。之前在进行基本认证时,我们将用户数据设为了 请求对象的属性 req.remoteUser.现在只要在用户加载中间件中检查这个属性就可以了。按照下面修改 middleware/user.js 中的 module.exports 定义,用户加载中间件就能跟 API 进行协作了。

1
2
3
4
5
6
7
8
9
10
11
12
module.exports = (req, res, next) => {
if (req.remoteUser) {
res.locals.user = req.remoteUser;
}
const uid = req.session.uid;
if (!uid) return next();
User.get(uid, (err, user) => {
if (err) return next(err);
req.user = res.locals.user = user;
next();
})
}

存在一个问题,现在添加消息的响应还是重定向到首页,我们要针对 API 请求调整一下。

1
2
3
4
5
6
7
8
9
// routes/entries.js
entry.save((err) => {
if (err) return next(err);
if (req.remoteUser) {
res.json({ message: 'Entry added.' });
} else {
res.redirect('/');
}
});

启用消息添加 API

1
app.post('/api/entry',entries.submit);

用下面的 cURL 命令测试消息添加 API。它发送的标题和内容主体数据所用的名称跟 HTML
表单输入域的名称相同:

1
curl -X POST -d "entry[title]='Ho ho ho'&entry[body]='Santa loves you'" http://tobi:ferret@127.0.0.1:3000/api/entry
7.支持消息列表

接下来要实现的API 路由是 GET/api/entries/:page?。

1
2
3
4
5
6
7
8
// app.js
exports.entries = (req, res, next) => {
const page = req.page;
Entry.getRange(page.from, page.to, (err, entries) => {
if (err) return next(err);
res.json(entries);
});
};
8.实现分页中间件

在分页时,要查询字符串串?page=N 来确定当前页面。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// .middleware/page.js
module.exports = (cb, perpage) => {
// 每页记录条数的默认值为 10
perpage = perpage || 10;
// 返回中间件函数
return (req, res, next) => {
// 将参数 page 解析为十进制的整型值
let page = Math.max(parseInt(req.params.page || '1', 10), 1) - 1;
// 调用传入的函数
cb((err, total) => {
if (err) return next(err);
// 保存 page 来以后的引用
req.page = res.locals.page = {
number: page,
perpage: perpage,
from: page * perpage,
to: page * perpage + perpage - 1,
total: total,
count: Math.ceil(total / perpage)
}
// 将控制权交给中间件
next();
});
}
}

这个中间件抓取赋值给 ?page = N 的值,比如 ?page = 1,然后取得结果集的总数,并预先计算出一些值拼成 page 对象,把它输出到需要渲染的视图中。把这些值放在模板外计算可以减少模板中的逻辑,让模板更加简洁。

9.测试消息路由

下面的 cURL 命令从API获取消息:

1
curl http://tobi:ferret@127.0.0.1:3000/api/entries

输出的结果是一个 JSON 数组。

启用内容协商

内容协商让客户端可以指定它乐于接受并且喜欢的数据格式。本节介绍如何让API 提供JSON 和 XML 格式的数据,以便于 API 的使用可以决定他们要使用哪种数据格式。

HTTP 通过 accept 请求头域提供了内容协商机制,比如说,某个客户端更喜欢 HTML ,但是也可以接受普通的文本,则可以这样设定请求头:

1
Accept: text/plain; q = 0.5,text/html

q(qvalue)表明即便 text/html 放在第二位,它的优先级也比 text/plain 高 50%。Express 会解析这个消息并提供一个规范化的 req.accepted 数组:

1
2
3
4
5
[{
value:'text/html',quality:1
},{
value:'text/plain',quality:0.5
}]

Express 还提供了 res.format() 方法,它的参数是一个 MIME 类型的数组和一些回调函数。 Express 会决定客户端愿意接受什么格式的数据,以及你愿意提供什么格式的数据,然后调用相应的会调函数。

1.实现内容协商

在 router/api.js 中,支持内容协商的 GET/api/entries 路由如下面的例子。JSON 想之前那样被支持——用res.send()发送串行化为 JSON 的消息数据。XML 回调循环遍历消息,并将其写入 socket 中。注意,没必要显示设定 Content-type ,res.format() 会自动设定关联的类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
exports.entries = (req, res, next) => {
const page = req.page;
// 获取消息数据
Entry.getRange(page.from, page.to, (err, entries) => {
if (err) return next(err);
// 基于 Accept 头的值返回不同的响应
res.format({
'application/json': () => {
res.send(entries);
},
// XML 响应
'application/xml': () => {
res.write('<entries>\n');
entries.forEach(entry => {
res.write(`
<entry>
<title>${entry.title}</title>
<body>${entry.body}</body>
<username>${entry.username}</username>
</entry>
`);
});
res.end('</entries>');
}
});
res.json(entries);
});
};

如果设定了默认响应格式回调,当用户请求的格式不在你特意提供的格式中时,就会执行这个默认回调函数。

res.format() 方法还可以将扩展映射到相关联的 MIME 类型上面。比如 用 json 和 xml 代替 application/json 和 application/xml ,就想下面这样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
exports.entries = (req, res, next) => {
const page = req.page;
// 获取消息数据
Entry.getRange(page.from, page.to, (err, entries) => {
if (err) return next(err);
// 基于 Accept 头的值返回不同的响应
res.format({
json: () => {
res.send(entries);
},
// XML 响应
xml: () => {
res.write('<entries>\n');
entries.forEach(entry => {
res.write(`
<entry>
<title>${entry.title}</title>
<body>${entry.body}</body>
<username>${entry.username}</username>
</entry>
`);
});
res.end('</entries>');
}
});
res.json(entries);
});
};

2.XML 响应

在路由中写一大堆代码只是为了返回 XML 响应,不是最简洁的方法,我们可以用视图系统来实现这一功能。

用下面的 EJS 创建一个 xml 模板。会循环遍历消息生成的 `标签。

1
2
3
4
5
6
7
8
9
<entries>
<% entries.forEach(entry =>{ %>
<entry>
<title><%= entry.title %></title>
<body><%= entry.body %></body>
<username><%= entry.username %></username>
</entry>
<% }) %>
<entries>

然后,原来的 XML 回调以消息数组为参数的 res.render ()

1
2
3
xml: () => {
res.render('entries/xml', { entries: entries });
}

测试:

1
curl -i -H 'Accept: application/xml'  http://tobi:ferret@127.0.0.1:3000/api/entries

总结:

  • Connect 是一个 HTTP 框架,可以在处理请之前和之后堆叠中间件
  • Connect 中间件是一个函数,它的参数包括 Node 的请求和响应对象、一个调用下一个中间件的函数,以及一个可选的错误对象
  • Express Web 程序也是可用中间件搭建的。
  • 在用 Expresss 实现 REST ful API 时,可以用 HTTP 谓语定义路由
  • Express 路由的响应可以是 JSON / HTML 以及其他格式定义的数据
  • Express 有个简单的模板引擎 API ,支持很多引擎。
0%