MENU

Express 实战(三):Express 基础

Express 框架的初衷是为了拓展 Node 内置模块的功能提高开发效率。当你深入研究后就会发现,Express 其实是在 Node 内置的 HTTP 模块上构建了一层抽象。理论上所有 Express 实现的功能,同样可以使用纯 Node 实现。

在本文中,我们将基于前面的 Node 内容去探究 Express 和 Node 之间的关系,其中包括:中间件和路由等概念。当然,这里只会进行一些综述具体的细节会在后面带来。

总的来说,Express 提供了 4 个主要特性:

  1. 与纯 Node 中使用一个函数处理所有请求的代码不同, Express 则使用“中间件栈”处理流。

  2. 路由与中间件类似,只有当你通过特定 HTTP 方法访问特定 URL 时才会触发处理函数的调用。

  3. 对 request 和 response 对象方法进行了拓展。

  4. 视图模块允许你动态渲染和改变 HTML 内容,并且使用其他语言编写 HTML 。

中间件

中间件是 Express 中最大的特性之一。中间件与原生的 Node 处理函数非常类似(接受一个请求并做出响应),但是与原生不同的是,中间件将处理过程进行划分,并且使用多个函数构成一个完整的处理流程。

我们将会看到中间件在代码中的各种应用。例如,首先使用一个中间件记录所有的请求,接着在其他的中间件中设置 HTTP 头部信息,然后继续处理流程。虽然在一个“大函数”中也可以完成请求处理,但是将任务进行拆分为多个功能明确独立的中间件明显更符合软件开发中的 SRP 规则。

中间件并不是 Express 特有,Python 的 Django 或者 PHP 的 Laravel 也有同样的概念存在。同样的 Ruby 的 Web 框架中也有被称为 Rack 中间件概念。

现在我们就用 Express 中间件来重新实现 Hello World 应用。你将会发现只需几行代码就能完成开发,在提高效率的同时还消除了一些隐藏 bug。

Express 版 Hello World

首先新建一个Express工程:新建一个文件夹并在其中新建 package.json 文件。回想一下 package.json 的工作原理,其中完整的列出了该工程的依赖、项目名称、作者等信息。我们新工程中的 package.json 大致如下:

{
  "name": "hello-world",
  "author": "Your Name Here!",
  "private": true,
  "dependencies": {}
}

接下来执行命令,安装最新的 Express 并且将其保存到 package.json 中:

npm install express -sava

命令执行完成后,Express 会自动安装到 node_modules 的文件下,并且会在 package.json 明确列出改依赖。此时 package.json 中的内容如下:

{
  "name": "hello-world",
  "author": "Your Name Here!",
  "private": true,
  "dependencies": {
        "express": "^5.0.0"
  }
}

接下来将下列代码复制到 app.js 中:

var express = require("express");  
var http = require("http");
var app = express();   
 
app.use(function(request, response) {  
    response.writeHead(200, { "Content-Type": "text/plain" });      
    response.end("Hello, World!");  
}); 
 
http.createServer(app).listen(3000);  

首先,我们依次引入了 Express 和 HTTP 模块。

然后,使用 express() 方法创建变量 app ,该方法会返回一个请求处理函数闭包。这一点非常重要,因为它意味着我可以像之前一样将其传递给 http.createServer 方法。

还记得前一章提到的原生 Node 请求处理吗?它大致如下:

var app = http.createServer(function(request, response) {
    response.writeHead(200, { "Content-Type": "text/plain" });
    response.end("Hello, world!");
});

两段代码非常相似,回调闭包都包含两个参数并且响应也一样。

最后,我们创建了一个服务并且启动了它。http.createServer 接受的参数是一个函数,所以合理猜测 app 也只是一个函数,只不过该函数表示的是 Express 中一个完整的中间件处理流程。

中间件如何在高层工作

在原生的 Node 代码中,所有的 HTTP 请求处理都在一个函数中:

function requestHandler(request, response) {
    console.log("In comes a request to: " + request.url);
    response.end("Hello, world!");
}

如果抽象成流程图的话,它看起来就像:

03_01

这并不是说在处理过程中不能调用其它函数,而是所有的请求响应都由该函数发送。

而中间件则使用一组中间件栈函数来处理这些请求,处理过程如下图:

03_02

那么,接下来我们就有必要了解 Express 使用一组中间件函数的缘由,以及这些函数作用。

现在我们回顾一下前面用户验证的例子:只有验证通过才会展示用户的私密信息,与此同时每次访问请求都要进行记录。

在这个应用中存在三个中间件函数:请求记录、用户验证、信息展示。中间件工作流为:先记录每个请求,然后进行用户验证,验证通过进行信息展示,最后对请求做出响应。所以,整个工作流有两种可能情形:

03_03

另外,这些中间件函数中部分函数需要对响应做出响应。如果没有做出任何响应的话,那么服务器会挂起请求而浏览器也会干等。

这样做的好处就是,我们可以将应用进行拆分。而拆分后的组件不仅利于后期维护,并且组件之间还可以进行不同组合。

不做任何修改的中间件

中间件函数可以对 request、response 进行修改,但它并不是必要操作。例如,前面的日志记录中间件代码:它只需要进行记录操作。而一个不做任何修改,纯功能性的中间函数代码大致如下:

function myFunMiddleware(request, response, next) {
  ...     
  nest(); 
}

因为中间件函数的执行是从上到下的。所以,加入纯功能性的请求记录中间件后,代码如下:

var express = require("express");
var http = require("http");
var app = express();
// 日志记录中间件
app.use(function(request, response, next) {
  console.log("In comes a " + request.method + " to " + request.url);
  next();
});

// 发送实际响应
app.use(function(request, response) {
  response.writeHead(200, { "Content-Type": "text/plain" });
  response.end("Hello, world!");
});
http.createServer(app).listen(3000);

修改 request、response 的中间件

并不是所有的中间件都和上面一样,在部分中间件函数需要对 request、response 进行处理,尤其是后者。

下面我们来实现前面提到的验证中间件函数。为了简单起见,这里只允许当前分钟数为偶数的情况通过验证。那么,该中间件函数代码大致如下:

app.use(function(request, response, next) {
  console.log("In comes a " + request.method + " to " + request.url);
  next();
});
app.use(function(request, response, next) {
  var minute = (new Date()).getMinutes();
  // 如果在这个小时的第一分钟访问,那么调用next()继续
  if ((minute % 2) === 0) {
    next();
  } else {
    // 如果没有通过验证,发送一个403的状态码并进行响应
    response.statusCode = 403;
    response.end("Not authorized.");
  }
});
app.use(function(request, response) {
  response.end('Secret info: the password is "swordfish"!'); // 发送密码信息
});

第三方中间件类库

在大多数情况下,你正在尝试的工作可能已经被人实现过了。也就是说,对于一些常用的功能社区中可能已经存在成熟的解决方案了。下面,我们就来介绍一些 Express 中常用的第三方模块。

MORGAN:日志记录中间件

Morgan 是一个功能非常强大的日志中间件。它能对用户的行为和请求时间进行记录。而这对于分析异常行为和可能的站点崩溃来说非常有用。大多数时候 Morgan 也是 Express 中日志中间件的首选。

使用命令 npm install morgan --save 安装该中间件,并修改 app.js 中的代码:

var express = require("express");
var logger = require("morgan");
var http = require("http");
var app = express();
app.use(logger("short")); 
app.use(function(request, response){
    response.writeHead(200, {"Content-Type": "text/plain"});
    response.end("Hello, world!");
});
http.createServer(app).listen(3000);

再次访问 http://localhost:3000 你就会看到 Morgan 记录的日志了。

Express 的静态文件中间件

通过网络发送静态文件对 Web 应用来说是一个常见的需求场景。这些资源通常包括图片资源、CSS 文件以及静态 HTML 文件。但是一个简单的文件发送行为其实代码量很大,因为需要检查大量的边界情况以及性能问题的考量。而 Express 内置的 express.static 模块能最大程度简化工作。

假设现在需要对 public 文件夹提供文件服务,只需通过静态文件中间件我们就能极大压缩代码量:

var express = require("express");
var path = require("path");
var http = require("http");
var app = express();
var publicPath = path.resolve(__dirname, "public"); 
app.use(express.static(publicPath)); 
app.use(function(request, response) {
    response.writeHead(200, { "Content-Type": "text/plain" });
    response.end("Looks like you didn't find a static file.");
});
http.createServer(app).listen(3000);

现在,任何在 public 目录下的静态文件都能直接请求了,所以你可以将所有需要的文件的放在该目录下。如果 public 文件夹中没有任何匹配的文件存在,它将继续执行下一个中间件并响应一段 没有匹配的文件信息。

为什么使用 path.resolve ?
之所以不直接使用 /public 是因为 Mac 和 Linux 中目录为 /public 而 Windows 使用万恶的反斜杠 public 。path.resolve 就是用来解决多平台目录路径问题。

更多中间件

除此上面介绍的 Morgan 中间件和 Express 静态中间之外,还有很多其他功能强大的中间件,例如:

  • connect-ratelimit:可以让你控制每小时的连接数。如果某人向服务发起大量请求,那么可以直接返回错误停止处理这些请求。

  • helmet:可以添加 HTTP 头部信息来应对一些网络攻击。具体内容会在后面关于安全的章节讲到。

  • cookie-parses:用于解析浏览器中的 cookie 信息。

  • response-time:通过发送 X-Response-Time 信息,让你能够更好的调试应用的性能。

路由

路由是一种将 URL 和 HTTP 方法映射到特定处理回调函数的技术。假设工程里有一个主页,一个关于页面以及一个 404 页面,接下来看看路由是如何进行映射的:


var express = require("express");
var path = require("path");
var http = require("http");
var app = express();

// 像之前一样设置静态文件中间件。
// 所有的请求通过这个中间件,如果没有文件被找到的话会继续前进
var publicPath = path.resolve(__dirname, "public");
app.use(express.static(publicPath));

// 当请求根目录的时候被调用
app.get("/", function(request, response) {
    response.end("Welcome to my homepage!");
});

// 当请求/about的时候被调用
app.get("/about", function(request, response) {
    response.end("Welcome to the about page!");
});

// 当请求/weather的时候被调用
app.get("/weather", function(request, response) {
    response.end("The current weather is NICE.");
});

// 前面都不匹配,则路由错误。返回 404 页面
app.use(function(request, response) {
    response.statusCode = 404;
    response.end("404");
});
http.createServer(app).listen(3000);

上面代码中除了添加前面提到的中间件之外,后面三个 app.get 函数就是 Express 中强大的路由系统了。它们使用 app.post 来响应一个 POST 或者 PUT 等所有网络请求。函数中第一个参数是一个路径,例如 /about 或者 /weather 或者简单的根目录 / ,第二个参数是一个请求处理函数。该处理函数与之前的中间件工作方式一样,唯一的区别就是调用时机。

除了固定路由形式外,它还可以匹配更复杂的路由(使用正则等方式):

// 指定“hello”为路由的固定部分
app.get("/hello/:who", function(request, response) {
    // :who 并不是固定住,它表示 URL 中传递过来的名字
    response.end("Hello, " + request.params.who + ".");
   
});

重启服务并访问 localhost:3000/hello/earth 等到的响应信息为:

Hello, earth

注意到如果你在 URL 后面插入多个 / 的话,例如:localhost:3000/hello/entire/earth 将会返回一个 404 错误。

你应该在日常生活中见过这种 URL 链接,特定的用户能够访问特定的 URL 。例如,有一个用户为 ExpressSuperHero ,那么他的个人信息页面 URL 可能是:

https://mywebsite.com/users/ExpressSuperHero

在 Express 中你可以通过这种通配方式简化路由定义,而不必将所有用户的特定路由都一一列举出来。

官方文档中还展示了一个使用正则表达式来进行复杂匹配的例子,并且你可以通过路由做更多其它的事情。不过这章中只需要知道路由概念就行了,更多的内容将会在第五章中深入讲解。

扩展 request 和 response

Express 在原来基础上对 request 和 response 对象进行了功能扩展。你可以在官方文档中找到所有细节内容,不过我们可以先来领略其中的一部分:

Express 提供的功能中 redirect 算一个非常棒的功能,使用方法如下:

response.redirect("/hello/world");
response.redirect("http://expressjs.com");

原生 Node 中并没有重定向 redirect 方法。虽然我们也能够使用原生代码实现重定向功能,但明显它的代码量会更多。

另外,在 Express 中文件发送也变的更加简单,只需一行代码就能实现:

response.sendFile("path/to/cool_song.mp3")

与之前一样,该功能的原生实现代码也比较复杂。

除了对响应对象 response 进行了拓展之外,Express 也对请求对象 request 进行了拓展。例如:你可以通过 request.ip 获取发送请求的机器 IP 地址或者通过 request.get 获取 HTTP 头部。

下面我们使用它实现 IP 黑名单功能,代码如下:

var express = require("express");
var app = express();

var EVIL_IP = "123.45.67.89";

app.use(function(request, response, next) {
    if (request.ip === EVIL_IP) {
        response.status(401).send("Not allowed!");
    } else {
        next();
    }
});

...

这里使用到了 req.ip 以及 res.status()res.send() ,而这些方法全都来自于 Express 的拓展。

理论上来说,我们只需要知道 Express 拓展了 request 和 response 并知道如何使用就行了,至于细节可以不去做了解。

上面的例子,只是 Express 所有拓展中的冰山一角,你可以在文档中看到更多的示例。

视图

几乎所有的网站内容都是基于 HTML 进行展示的,并且大多时候这些 HTML 内容都是动态生成的。你可能需要为当前登录用户提供特定欢迎页或者需要在页面中动态生成数据表。为了应对动态内容的渲染,社区中出现了大量的 Express 模版引擎,例如: EJS、Handlebars、Pug。

下面是 EJS 模版引擎使用示例:

var express = require("express");
var path = require("path");
var app = express();

// 告诉 Express 你的视图存在于一个名为 views 的文件夹中
app.set("views", path.resolve(__dirname, "views"));

// 告诉 Express 你将使用EJS模板引擎
app.set("view engine", "ejs");

在代码中,首先我们导入了必要的模块。然后设置了视图文件所在的路径。紧接着,我们将模版引擎设置为 EJS (文档)。当然在使用 EJS 执行,我们还需要通过 npm install ejs --save 命令进行安装。

安装并设置好 EJS 引擎之后,接下里就是如何使用的问题了。

首先,我们在 views 文件夹下面创建一个 index.ejs 文件,并拷贝下面的内容:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>Hello, world!</title>
</head>
<body>
    <%= message %>
</body>
</html>

EJS 实质上是 HTML 的一个超集,所有 HTML 的语法都可以直接使用并且完全兼容。但是 EJS 对语法进行了部分拓展。 例如,你可以通过 <%= message %> 语法将传递过来的参数 message 插入到标签中。

app.get("/", function(request, response) {
    response.render("index", {
        message: "Hey everyone! This is my webpage."
    });
});

Express 给 response 对象添加了一个名为 render 的方法。该方法在视图目录下查找第一个参数对应的模版视图文件并将第二个参数传递给该模版文件。

下面是经过引擎渲染动态生成后的 HTML 文件内容:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>Hello, world!</title>
</head>
<body>
    Hey everyone! This is my webpage. 
</body>
</html>

实例:一个留言板的实现

最后这部分,我们将会使用到前面的技术来构建一个完整的留言板 web 程序。通过这个示例来加深对上面内容的掌握,该应用主要包含两个页面:

  1. 一个主页:主要用于列出之前所有的留言

  2. 一个编辑页面:用于编辑新的留言

准备工作

首先,我们新建一个文件夹并新建项目,并复制下面内容到新建的 package.json 文件中:

{
    "name": "express-guestbook",
    "private": true,
    "scripts": {
        "start": "node app" 
    }
}

你可以在文件中添加其他字段信息(例如作者或者版本),但是在本例中这并不是必要信息。接下来,我们安装依赖文件,输入命令:

npm install express morgan body-parser ejs --save

因为需要实现留言新建动作,所以这里需要使用 body-parser 对 POST 请求进行解析。

核心代码

准备工作完成后,接下来就创建 app.js 文件并复制下面的代码:


var http = require("http");
var path = require("path");
var express = require("express");
var logger = require('morgan');
var bodyParser = require("body-parser");

var app = express();

// 设置引擎
app.set("views", path.resolve(__dirname, "views"));
app.set("view engine", "ejs");

// 设置留言的全局变量
var entries = [];
app.locals.entries = entries;

// 使用 Morgan 进行日志记录
app.use(logger("dev"));

// 设置用户表单提交动作信息的中间件,所有信息会保存在 req.body 里
app.use(bodyParser.urlencoded({ extended: false }));

// 当访问了网站根目录,就渲染主页(位于views/index.ejs)
app.get("/", function(request, response) {
    response.render("index");
});

// 渲染“新留言”页面(位于views/index.ejs)当get访问这个URL的时候
app.get("/new-entry", function(request, response) {
    response.render("new-entry");
});

// POST 动作进行留言新建的路由处理
app.post("/new-entry", function(request, response) {
    // 如果用户提交的表单没有标题或者内容,则返回一个 400 的错误
    if (!request.body.title || !request.body.body) {
        response.status(400).send("Entries must have a title and a body.");
        return;
    }
    
    // 添加新留言到 entries 中
    entries.push({
        title: request.body.title,
        content: request.body.body,
        published: new Date()
    });
    // 重定向到主页来查看你的新条目
    response.redirect("/");
});

// 渲染404页面,因为你请求了未知资源
app.use(function(request, response) {
    response.status(404).render("404");
});

// 在3000端口启动服务器
http.createServer(app).listen(3000, function() {
    console.log("Guestbook app started on port 3000.");
});

新建视图

最后我们需要将页面的视图文件补全,新建 views 文件夹,然后复制下面内容到新建 header.ejs 文件中:

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Express Guestbook</title>
<link rel="stylesheet" href="//maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css">
</head>
<body class="container">
    <h1>
        Express Guestbook
        <a href="/new-entry" class="btn btn-primary pull-right">
            Write in the guestbook
        </a>
    </h1>
    

这里使用了 Twitter 的 Bootstrap 框架,当然你也可以进行任意替换。最重要的一点是,该文件会做为所有页面的通用头部。

接下来,在相同目录下新建 footer.ejs 作为通用的 footer:

</body>
</html>

通用部分完成后,接下来就是 indexnew-entry404 页面文件了。复制下面代码到文件 views/index.ejs 中:

<% include header %>
<% if (entries.length) { %>
    <% entries.forEach(function(entry) { %>
        <div class="panel panel-default">
            <div class="panel-heading">
                <div class="text-muted pull-right">
                    <%= entry.published %>
                </div>
                <%= entry.title %>
             </div>
             <div class="panel-body">
                <%= entry.body %>
             </div>
         </div>
     <% }) %>
<% } else { %>
    No entries! <a href="/new-entry">Add one!</a>
<% } %>
<% include footer %>

同时将下面的代码复制到 views/new-entry.ejs

<% include header %>
<h2>Write a new entry</h2>
<form method="post" role="form">
    <div class="form-group">
        <label for="title">Title</label>
        <input type="text" class="form-control" id="title" name="title" placeholder="Entry title" required>
    </div>
    <div class="form-group">
        <label for="content">Entry text</label>
        <textarea class="form-control" id="body" name="body" placeholder="Love Express! It's a great tool for building websites." rows="3" required></textarea>
    </div>
    <div class="form-group">
        <input type="submit" value="Post entry" class="btn btn-primary">
    </div>
</form>
<% include footer %>

最后就是 views/404.ejs 文件了:

<% include header %>
<h2>404! Page not found.</h2>
<% include footer %>

所有的视图文件都创建完成了,接下来就是运行服务了。

运行服务

如果你现在就使用 npm start 拉起服务,然后访问对应的 URL ,你就能见到下图所示的场景了。

03_04

03_05

最后,我们回顾一下这个小项目的几个关键点:

  • 使用了一个中间件来记录所有的请求,并且对不匹配的 URL 链接进行了 404 页面响应。

  • 在新建留言后,我们将页面重定向到了主页。

  • 在该工程里使用了 EJS 作为 Express 的模版引擎。并使用它实现了 HTML 文件的动态渲染。

总结

  • Express 基于 Node 进行了工程拓展,使得开发过程更为流畅高效。

  • Express 主要有四个部分构成。

  • Express 的请求处理流程可以由多个中间件进行构建。

  • Express 中流行的模版引擎为 EJS ,它能实现对 HTML 的动态渲染并且语法也更为友好。

添加新评论

已有 1 条评论
  1. 写的好详细啊!学习到了!@(呵呵)