文章

Express 实战(五):路由

作为 Express 中的最大特点之一,路由让你可以将不同的请求映射到不同的中间件中。这一章我们将会深入学习这部分的内容,另外还包括如何在 Express 使用 HTTPS 以及部分 Express 4 中的新特性等等。当然,学习过程还是通过示例应用和代码的形式进行展现的。

什么是路由?

假设,现在你尝试通过 example.com/someone 访问某人的推特或者微博主页,你会发现该请求的 HTTP 内容大致如下:

GET /someone http/1.1

其中包含了 HTTP 请求使用的方法(GET),URI 信息(/someone) 以及 HTTP 协议版本 (1.1)。Express 中的路由就是负责将其中的 HTTP 方法和 URI 这对组合映射到对应的中间件。简单说就是, /about_me 的GET 请求会执行某个中间件而对于 /new_user 的 POST 请求则执行另一个中间件。

下面我们通过一个简单示例来看看到底路由时如何工作的。

路由的一个简单示例

下面我们就对 example.com/someone 请求进行一个简单的实现,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
var express = require("express");
var app = express();

app.get('/someone', function(request, response) {
    response.send(" Welcome to someone's homepage! ");
});

app.use(function(request, response) {
    response.status(404).send("Page not found!");
});

app.listen(3000);

上面代码中真正有价值的是第三行:当你通过 HTTP 的 GET 方法对 /someone 发起请求时,程序会执行该中间件中的代码,其他请求则会被忽略并跳转到下一个中间件。

路由的特性

从工作原理来说:路由就是通过对 HTTP 方法和的 URI 的组合进行映射来实现对不同请求的分别处理。当然,除了上面那种最简单的使用方式之外,Express 的路由还有更多实用的使用技巧和方法。

注意:在其它一些框架中(例如,Ruby on Rails )会有一个专门的文件进行路由管理,但是 Express 中并没有这样的规定,你可以将路由按模块分开管理。

含参的通配路由

在上面的使用方式中使用的是全等判断来进行路由匹配的。虽然对于 /someone 这类非常管用,但是对于形如 /users/1/users/2 这类 RESTful 路由就明显不那么友好了。因为如果将后者路由一一列出的话,不管是从工作量还是后期维护来说都是非常差开发体验。针对这种情况,我们可以使用 Express 中含参的通配路由来解决。

该方法的工作原理就是,在路由中使用参数进行通配表示。而该参数所表示的具体数值会在变量 params 中获取到,下面是简单的代码示例:

1
2
3
4
5
6
app.get("/users/:userid", function(req, res) {
    // 将userId转换为整型
    var userId = parseInt(req.params.userid, 10);
    // ...
});

这样 RESTful 风格的动态路由就完全可以通过这种含参的通配路由进行处理。那么无论是 /users/123 还是 /users/8 都会被映射到同一中间件。需要注意的是:虽然 /users/ 或者 /users/123/posts 不会被匹配,但是 /users/cake/users/horse_ebooks 确会被匹配到。所以,如果实现更精准的路由匹配的话就需要使用其他方式了。

使用正则表达式匹配路由

针对上面的问题,我们可以使用正则来对路由进行更精准的匹配。

注意:如果你对正则表达式部分的内容不熟悉的话,那么我建议你去查看该文档

假设现在我们只需要匹配 /users/123/users/456 这种通配参数为数字的动态路由的同时忽略其他路由格式,那么可以将代码改为:

1
2
3
4
app.get(/^\/users\/(\d+)$/, function(req, res) {
    var userId = parseInt(req.params[0], 10);
    // ...
});

通过正则表达式代码对通配参数作为了严格限定:该参数必须是数字类型。

正则表达式可能阅读起来并不是很友好,但是它却可以实现对复杂路由匹配规则的准确定义。例如,你想匹配路由 /users/100-500 这类表示某个用户范围的列表页面,那么该正则如下:

1
2
3
4
5
6
7
app.get(/^\/users\/(\d+)-(\d+)$/, function(req, res) {
    
    var startId = parseInt(req.params[0], 10);
    
    var endId = parseInt(req.params[1], 10);
    // …
});

甚至你还可以作出更复杂的正则匹配路由定义,例如:匹配某个包含特定 UUID 的路由。UUID 是一长串 16 进制的字符串,大致如下:

xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx

如果,其中的 x 表示任何 16 进制数字,而 y 只能是 8,9,A 或者 B 。那么该路由的正则匹配就是:

1
2
3
4
5
var horribleRegexp = /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/i;
app.get(horribleRegexp, function(req, res) {
    var uuid = req.params[0];
    // ...
});

还有更多的使用示例就不一一列举了。这里只需记住一点:正则表达式可以让你的路由匹配定义更上一层楼。

捕获查询参数

另一种常用的动态传入 URL 参数的方法就是通过查询字符串(query string)。例如,当你使用谷歌搜索 javascript-themed burrito 时,你可以会发现对应的 URL 可能是 https://www.google.com/search?q=javascript-themed%20burrito

如果 Google 是用 Express 进行实现的话(实际上不是),那么可以这样来获取用户传入的信息:

1
2
3
4
app.get("/search", function(req, res) {
    // req.query.q == "javasript-themed burrito"
    // ...
});

需要注意的是:查询参数中存在其实存在着类型安全问题。例如:如果你访问 ?arg=something 那么 req.query.arg 就是一个字符串类型,但是如果访问的是 ?arg=something&arg=somethingelse 的话 req.query.arg 就变为了一个数组类型。简单来说:不要轻易的断定查询参数的类型。

使用 Router 划分你的 app

伴随着应用的扩张,程序中产生的路由也会越来越多。而对这些庞大的路由进行管理并不是一件轻松的事,不过好在 Express 4 新增了 Router (可以理解为路由器)特性。Router 的官方描述是:

Router 是一个独立于中间件和路由的实例,你可以将 Router 看作是只能执行执行中间件和路由的小心应用。而 Express 程序本身就内置了一个 Router 实例。

Router 的行为与中间件类型,它可以通过 .use() 来调用其他的 Router 实例。

换句话就是,可以使用 Router 将应用划分为几个小的模块。虽然对于一些小型应用来说这样做可能是过度设计,但是一旦 app.js 中的路由扩张太快的话你就可以考虑使用 Router 进行模块拆分了。

注意:程序越大 Router 发挥的作用就越明显。虽然这里我不会编写一个大型应用程序,但是你可以在你的脑海中对下面的示例功能进行无限扩张。

1
2
3
4
5
6
7
8
9
10
11
12
var express = require("express");
var path = require("path");

// 引入 API  Router
var apiRouter = require("./routes/api_router");

var app = express();
var staticPath = path.resolve(__dirname, "static");
app.use(express.static(staticPath));
// API  Router 文件的调用
app.use("/api", apiRouter);
app.listen(3000);

如上所示,Router 的使用方式和之前的中间件非常类似。其实 Router 本质上就是中间件。在代码中我们将所有 /api 开头的 URL 全部转发到了 apiRouter 中了, 这意味着 /api/users/api/message 的处理都会在 apiRouter 中进行。

下面就是 api_router.js 文件的一个简单代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var express = require("express");
var ALLOWED_IPS = [
    "127.0.0.1",
    "123.456.7.89"
];
var api  = express.Router();
api.use(function(req, res, next) {
    var userIsAllowed = ALLOWED_IPS.indexOf(req.ip) !== -1;
    if(!userIsAllowed) {
        res.status(401).send("Not authorized!");
    } else {
        next();
    }
});
api.get("/users", function(req, res) { /* ... */ });
api.post("/users", function(req, res) { /* ... */ });
api.get("/messages", function(req, res) { /* ... */ });
api.post("/messages", function(req, res) { /* ... */ });
module.exports = api;

其实 Router 与 app.js 在功能上没有任何区别,都是处理中间件和路由。最大的不同在于:Router 只能已模块形式存在并不能独立运行。

参照示例,你可以在自己的应用中按模块划分出更多的 Router 。

静态文件

除非应用是纯 API 服务,否则总可能需要发送静态文件。这些文件可能是静态图片 CSS 样式文件或者是静态 HTML 文件。在前面文章的基础之上,这部分将介绍更深入的部分内容。

静态文件中间件

因为前面章节对静态文件中间件实现进行过详细介绍,所以这里直接查看代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
var express = require("express");
var path = require("path");
var http = require("http");
var app = express():
// 设置你的静态文件路径
var publicPath = pathresolve(dirname, "public");
// 从静态文件夹中发送静态文件
app.use(express.static(publicPath));
app.use(function(request, response) {
    response.writeHead(200, { "Content-Type": "text/plain"});
    reponse.end("Looks like you didn't find a static file.");
});
http.createServer(app).listen(3000);

修改静态文件的 URL

通常情况下,我们会把站点的静态文件 URL 路径直接挂在域名后面,例如:https://jokes.edu 站点中的 jokes.txt 文件 URL 样式应该是 https://jokes.edu/jokes.txt

当然,你可可以按照自己的习惯给这些静态文件提供 URL 。例如,将一些无序但有趣的图片存放在文件夹 offensive 中并将其中图片的 URL 设置为 https://jokes.edu/offensive/photo123.jpg 这种形式。那么该样式 URL 如何实现呢?

在 Express 中,我们可以使用指定前缀的中间件来对静态文件 URL 进行自定义。所以上面问题的代码实现如下:

1
2
3
4
// ... 
var photoPath = path.resolve(__dirname, "offensive-photos-folder");
app.use("/offensive", express.static(photoPath));
// ...

这样你所有静态文件的 URL 都可以实现自定义了,而不是粗暴的直接挂在域名后面了。其实除了静态中间件和前面 Router 外,其它中间件同样可以指定 URL 前缀。

多个静态文件夹的路由

实际上砸真实项目中可能户存在多个静态文件夹,例如:一个存放 CSS 等公用文件的 public 文件夹,一个存放用户上传文件的 user_uploads 文件夹。那么对于这种情况又该如何处理呢?

首先 epxress.static 本身作为中间件是可以在代码中多次调用的:

1
2
3
4
5
6
// ...
var publiscPath = path.resolve(__dirname, "public");
var userUploadPath = path.resove(__dirname, "user_uploads");
app.use(express.static(publicPath));
app.use(express.static(userUploadsPath));
// ...

接下来,我们通过四个模拟场景看看上面代码是如何工作的:

  1. 用户请求的资源两个文件夹里都没有则上面两个中间件都会被跳过执行。
  2. 用户请求的资源只在 public 里面则第一个中间件响应执行并返回。
  3. 用户请求的资源只在 user_uploads 里面则第一个中间件被跳过而第二个得道执行。
  4. 用户请求的资源在两个文件夹中都存在则第一个中间件响应执行并返回,第二个不会得到执行。

对于第四章情况,如果该资源是相同的还好说,但是一旦只是资源同名就存在明显错误了。为此,我们依旧可以使用 URL 前缀来应对:

1
2
3
4
// ...
app.use("/public", express.static(publicPath));
app.use("/uploads", express.static(userUploadsPath));
// ...

这样对于同名文件 image.jpg Express 会将其分别映射到 /public/image.jpg/uploads/image.jpg

路由到静态文件映射

在程序中有可能还存在对动态路由请求响应静态文件情形,例如,当用户访问 /users/123/profile_photo 路径时程序需要发送该用户的图片。静态中间件本身时无法处理该需求,不过好在 Express 可以使用与静态中间件类似的机制来处理这种情况。

假设当有人发起 /users/:userid/profile_photo 请求时,我们都需要响应对应 userid 用户的图片。另外,假设程序中存在一个名为 getProfilePhotoPath 的函数,该函数可以根据 userid 获取图片的存储路径。那么该功能的实现代码如下:

1
2
3
app.get("/users/:userid/profile_photo", function(req, res) {
    res.sendFile(getProfilePhotoPath(req.params.userid));
});

仅仅只需指定路由然后通过 sendFile 函数,我们就可以完成该路由对应文件的发送任务。

在 Express 使用 HTTPS

HTTPS 是在 HTTP 基础上添加了一个安全层,通常情况下该安全层被称为 TLS 或者 SSL 。虽然两个名字可以互换,但是 TSL 在技术上涵盖了 SSL。

这里并不会介绍 HTTPS 复杂的 RSA 加密数学原理(欧拉函数)。简单来说 HTTPS 的加密过程就是:所有的客户端都使用服务端公开的公钥加密请求信息,然后服务端使用私钥对加密后内容进行解密。这样就能在某种程度上防止信息被窃听。另外,加密的公钥也被称为证书。客户端在拿到公钥证书后会向 Google 这样的证书颁发机构进行验证。

注意:类似 Heroku 这样的虚拟主机商已经提供了 HTPPS 服务,所以这部分内容只在你需要自己实现 HTTPS 时才派得上用场。

首先,我们通过 OpenSSL 生成自签名的公钥和私钥。Windows 系统可以使用去官网获取 OpenSSL 安装文件,Linux 可以使用保管理器进行安装,而 macOS 系统已经预装过了。通过 openssl version 验证系统是否成功安装了 OpenSSL, 确保安装后输入下面两个命令:

openssl genrsa -out privatekey.pem 1024 openssl req -new -key privatekey.pem -out request.pem

第一个命令会生成名为 privatekey.pem 的私钥。第二个命令会让你输入一些信息,然后使用 privatekey.pem 生成签名的证书请求文件 request.pem 。然后你就可以去证书请求机构申请一个加密的公钥证书。虽然大部分证书都是收费的,但是你还是可以去 letsencrypt 申请免费版本证书。

一旦获取了 SSL 证书文件,你就可以使用 Node 内置的 HTTPS 模块了,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
var express = require("express");
var https = require("https");
var fs = require("fs");
var app = express();
// ... 定义你的app ...
// 定义一个对象来保存证书和私钥
var httpsOptions = {
    key: fs.fs.readFileSync("path/to/private/key.pem");
    cert: fs.fs.readFileSync("path/to/certificate.pem");
}

https.createServer(httpsOptions, app).listen(3000);

除了配置私钥和公钥证书参数之外,其他部分与之前 HTTP 模块的使用时一致的。当然,如果你想同时支持 HTTP 和 HTTPS 协议的话也是可以的:

1
2
3
4
5
6
7
8
9
10
11
12
var express = require("express");
var http = require("http");
var https = require("https");
var fs = require("fs");
var app = express();
// ... 定义你的app ...
var httpsOptions = {
    key: fs.readFileSync("path/to/private/key.pem"),
    cret: fs.readFileSync("path/to/certificate.pem")
};
http.createServer(app).listen(80);
https.createServer(httpsOptions, app).listen(443);

需要注意的是 HTTP 和 HTTPS 协议 同时开启时需要使用不同的端口号。

路由的应用示例

接下来,我们搭建一个简单的 web 程序巩固一下这章所学的路由内容。该应用的主要功能是通过美国的 ZIP 邮政编码返回该地区的温度。

示例使用的是美式邮政编码,所以该示例只能在作者所在的美国正常使用。当然,你完全可以使用 H5 的 Geolocation API 对其进行改造。

示例主要包含两个部分:

  1. 一个静态页,用于询问用户的 ZPI 编码。用户输入编码后会通过 AJAX 发送异步请求获取天气。
  2. 解析获得 JSON 格式数据,并将结果映射 ZIP 编码对应的动态路由上。

准备工作

在示例中需要使用的 Node 类库有:Express、ForecastIO (用于获取天气数据)、Zippity-do-dah ( 将ZIP编码转为纬度/经度 )、EJS 模版引擎。

新建应用文件夹,并复制下面内容到 package.json 文件中:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
    "name": "temperature-by-zip",
    "private": true,
    "scripts": {
        "start": "node app.js"
    },
    "dependencies": {
        "ejs": "^2.3.1",
        "express": "^5.0.0",
        "forecastio": "^0.2.0",
        "zippity-do-dah": "0.0.x"
    }
}

使用 npm install 命令完成依赖项的安装,并新建两个文件夹:public 和 views。另外,示例程序还会用到 jQuery 和名为 Pure 的 CSS 框架。最后,你需要去 Forecast.io 官网 注册开发账号获取 API 接口密钥。

主入口代码

准备工作完成后,接下来就是编写代码了。这里我们从程序的主入口开始编写 javascript 代码,新建 app.js 文件并拷贝代码:

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
var path = require("path");
var express = require("express");
var zipdb = require("zippity-do-dah");
var ForecastIo = require("forecastio");
var app = express();
var weather = new ForecastIo("你的FORECAST.IO的API密钥");

app.use(express.static(path.resolve(__dirname, "public")));
app.set("views", path.resolve(__dirname, "views"));
app.set("view engine", "ejs");

app.get("/", function(req, res) {
    res.render("index");
});

app.get(/^\/(\d{5})$/, function(req, res, next) {
    var zipcode = req.params[0];
    var location = zipdb.zipcode(zipcode);
    if (!location.zipcode) {
        next();
        return;
    }
    
    var latitude = location.latitude;
    var longitude = location.longitude;
    weather.forecast(latitude, longitude, function(err, data) {
        if (err) {
            next();
            return;
        }

        res.json({
            zipcode: zipcode,
            temperature: data.currently.temperature
        });
    });
});

app.use(function(req, res) {
    res.status(404).render("404");
});

app.listen(3000);

接下来就是使用 EJS 引擎编写视图文件了。

两个视图

示例应用中会有两个视图:404 页面和主页。为了尽可能保持页面风格的统一,这里将会使用到模版技术。首先动手实现通用的 headerfooter 模版。

其中 views/header.ejs 文件中的代码如下:

1
2
3
4
5
6
7
8
9
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>Temperature by ZIP code</title>
    <link rel="stylesheet" href="http://yui.yahooapis.com/pure/0.6.0/pure-min.css">
    <link rel="stylesheet" href="/main.css">
</head>
<body>

紧接着就是 views/footer.ejs

1
2
</body>
</html>

完成上面通用模版之后,下面就可以实现 404 页面 views/404.ejs 了:

1
2
3
<% include header %>
    <h1>404 error! File not found.</h1>
<% include footer %>

同样的,主页 views/index.ejs 代码如下:

1
2
3
4
5
6
7
8
9
10
11
<% include header %>
<h1>What's your ZIP code?</h1>
<form class="pure-form">
    <fieldset>
        <input type="number" name="zip" placeholder="12345" autofocus required>
        <input type="submit" class="pure-button pure-button-primary" value="Go">
    </fieldset>
</form>
<script src="//ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
<script src="/main.js"></script>
<% include footer %>

上面页面代码中使用了一些 Pure 框架里的样式来优化界面 UI 。

除此之外,我们还需要在 public/main.css 指定页面布局:

1
2
3
4
5
6
7
8
9
10
html {
    display: table;
    width: 100%;
    height: 100%;
}
body {
    display: table-cell;
    vertical-align: middle;
    text-align: center;
}

在该样式文件中,我们将页面内容同时设置为了水平和垂直居中。

最后拷贝下面的代码,把缺失的 public/main.js 补充完整。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
$(function() {
    var $h1 = $("h1");
    var $zip = $("input[name='zip']");
    $("form").on("submit", function(event) {
        // 禁止表单的默认提交
        event.preventDefault();
        var zipCode = $.trim($zip.val());
        $h1.text("Loading...");
        
        var request = $.ajax({
            url: "/" + zipCode,
            dataType: "json"
        });
        request.done(function(data) {
            var temperature = data.temperature;
            $h1.html("It is " + temperature + "° in " + zipCode + ".");
        });
        request.fail(function() {
            $h1.text("Error!");
        });
    });
});

运行示例程序

结束所有编码任务后,下面我们通过 npm start 运行示例程序。当你访问 http://localhost:3000 并输入 ZIP 编码后界面如下:

05_01

在这个简单的示例中,我们使用了 Express 中的路由特性,另外还使用了 EJS 模版引擎来编写视图文件。你可以在此基础上继续发挥想象力完善该示例。

总结

在本章中,我们学到了:

  • 从概念上知道了什么是路由:进行 URL 和代码的映射的工具。
  • 简单的路由以及常用映射处理。
  • 获取路由中的参数。
  • Express 4 路由模块的新特性。
  • 将路由应用到中间件处理。
  • 如何在 Express 中使用 HTTPS。
本文由作者按照 CC BY 4.0 进行授权