MENU

Express 实战(八):利用 MongoDB 进行数据持久化

Cover

毫无疑问,几乎所有的应用都会涉及到数据存储。但是 Express 框架本身只能通过程序变量来保存数据,它并不提供数据持久化功能。而仅仅通过内存来保存数据是无法应对真实场景的。因为内存本身并不适用于大规模的数据储存而且服务停止后这些数据也会消失。虽然我们还可以通过文件的形式保存数据,但是文件中的数据对于查询操作明显不友好。所有,接下来我们将学习如何在 Express 中通过 MongoDB 数据库的形式来对数据进行持久化存储。

本文包含的主要内容有:

  • MongoDB 是如何工作的。

  • 如何使用 Mongoose 。

  • 如何安全的创建用户账户。

  • 如何使用用户密码进行授权操作。

为什么是 MongoDB ?

对于 Web 应用来说,通常数据库的选择可以划分为两大类:关系型和非关系型。其中前者优点类型于电子表格,它的数据是结构化并且伴随着严格规定。典型的关系型数据库包括:MySQL、 SQL Server 以及 PostgreSQL。而后者通常也被称为 NoSQL 数据库,它的结构相对更加灵活,而这一点与 JS 非常类似。

但是为什么 Node 开发者会特别中意 NoSQL 中的 Mongo 数据库,还形成了流行的 MEAN 技术栈呢?

第一个原因是:Mongo 是 NoSQL 类型数据里最流行的一个。这也让网上关于 Mogon 的资料非常丰富,所有你在实际使用过程中可能会遇到的坑大几率都能找到答案。而且作为一个成熟的项目,Mongo 也已经被大公司认可和应用。

另一个原因则是 Mongo 自身非常可靠、有特色。它使用高性能的 C++ 进行底层实现,也让它赢得了大量的用户信赖。

虽然 Mongo 不是用 JavaScript 实现的,但是原生的 shell 却使用的是 JavaScript 语言。这意味着可以使用 JavaScript 在控制台操作 Mongo 。另外,对于 Node 开发者来说它也减少了学习新语言的成本。

当然,Mongo 并不是所有 Express 应用的正确选择,关系数据库依然占据着非常重要的地位。顺便提一下,NoSQL 中的 CouchDB 功能也非常强大。

注意:虽然本文只会介绍 Mongo 以及 Mongoose 类库的使用。但是如果你和我一样对 SQL 非常熟悉并且希望在 Express 使用关系数据库的话,你可以去查看 Sequelize。它为很多关系型数据库提供了良好的支持。

Mongo 是如何工作的

在正式使用 Mongo 前,我们先来看看 Mongo 是如何工作的。

对于大多数应用来说都会在服务器中使用 Mongo 这样的数据库来进行持久化工作。虽然,你可以在一个应用中创建多个数据库,但是绝大多数都只会使用一个。

如果你想正常访问这些数据库的话,首先你需要运行一个 Mongo 服务。客户端通过给服务端发送指令来实现对数据库的各种操作。而连接客户端与服务端的程序通常都被称为数据库驱动。对于 Mongo 数据库来说它在 Node 环境下的数据库驱动程序是 Mongoose。

每个数据库都会有一个或多个类似于数组一样的数据集合。例如,一个简单的博客应用,可能就会有文章集合、用户集合。但是这些数据集合的功能远比数组来的强大。例如,你可以查询集合中 18 岁以上的用户。

而每一个集合里面存储了 JSON 形式的文档,虽然在技术上并没有采用 JSON。每一个文档都对应一条记录,而每一条记录都包含若干个字段属性。另外,同一集合里的文档记录并不一定拥有一样的字段属性。这也是 NoSQL 与 关系型数据库最大的区别之一。

实际上文档在技术上采用的是简称为 BSON 的 Binary JSON。在实际写代码过程中,我们并不会直接操作 BSON 。多数情况下会将其转化为 JavaScript 对象。另外,BSON 的编码和解码方式与 JSON 也有不同。BSON 支持的类型也更多,例如,它支持日期、时间戳。下图展示了应用中数据库使用结构:

08_01

最后还有一点非常重要:Mongo 会给每个文档记录添加一个 _id 属性,用于标示该记录的唯一性。如果两个同类型的文档记录的 id 属性一致的话,那么就可以推断它们是同一记录。

SQL 使用者需要注意的问题

如果你有关系型数据库的知识背景的话,其实你会发现 Mongo 很多概念是和 SQL 意义对应的。

首先, Mongo 中的文档概念其实就相当于 SQL 中的一行记录。在应用的用户系统中,每一个用户在 Mongo 中是一个文档而在 SQL 中则对应一条记录。但是与 SQL 不同的是,在数据库层 Mongo 并没有强制的 schema,所以一条没有用户名和邮件地址的用户记录在 Mongo 中是合法的。

其次,Mongo 中的集合对应 SQL 中的表,它们都是用来存储同一类型的记录。

同样,Mongo 中的数据库也和 SQL 数据库概念非常相似。通常一个应用只会有一个数据库,而数据库内部则可以包含多个集合或者数据表。

更多的术语对应表可以去查看官方的这篇文档

Mongo 环境搭建

在使用之前,首要的任务当然就是机器上安装 Mongo 数据库并拉起服务了。如果你的机器是 macOS 系统并且不喜欢命令行模式的话,你可以通过安装 Mongo.app 应用完成环境搭建。如果你熟悉命令行交互的话可以通过 Homebrew 命令 brew install mongodb 进行安装。

Ubuntu 系统可以参照文档,同时 Debian 则可以参照文档 进行 Mongo 安装。

另外,在本书中我们会假设你安装是使用的 Mongo 数据库的默认配置。也就是说你没有对 Mongo 的服务端口号进行修改而是使用了默认的 27017 。

使用 Mongoose 操作 Mongo 数据库

安装 Mongo 后接下来问题就是如何在 Node 环境中操作数据库。这里最佳的方式就是使用官方的 [Mongoose] [6]类库。其官方文档描述为:

Mongoose 提供了一个直观并基于 schema 的方案来应对程序的数据建模、类型转换、数据验证等常见数据库问题。

换句话说,除了充当 Node 和 Mongo 之间的桥梁之外,Mongoose 还提供了更多的功能。下面,我们通过构建一个带用户系统的简单网站来熟悉 Mongoose 的特性。

准备工作

为了更好的学习本文的内容,下面我们会开发一个简单的社交应用。该应用将会实现用户注册、个人信息编辑、他人信息的浏览等功能。这里我们将它称为 Learn About Me 或者简称为 LAM 。应用中主要包含以下页面:

  • 主页,用于列出所有的用户并且可以点击查看用户详情。

  • 个人信息页,用于展示用户姓名等信息。

  • 用户注册页。

  • 用户登录页。

和之前一样,首先我们需要新建工程目录并编辑 package.json 文件中的信息:

{
    "name": "learn-about-me",
    "private": true,
    "scripts": {
        "start": "node app"
    },
    "dependencies": {
        "bcrypt-nodejs": "0.0.3",
        "body-parser": "^1.6.5",
        "connect-flash": "^0.1.1",
        "cookie-parser": "^1.3.2",
        "ejs": "^1.0.0",
        "express": "^4.0.0",
        "express-session": "^1.7.6",
        "mongoose": "^3.8.15",
        "passport": "^0.2.0",
        "passport-local": "^1.0.0"
    }
}

接下来,运行 npm install 安装这些依赖项。在后面的内容中将会一一对这些依赖项的作用进行介绍。

需要注意的是,这里我们引入了一个纯 JS 实现的加密模块 bcrypt-nodejs 。其实 npm 中还有一个使用 C 语言实现的加密模块 bcrypt 。虽然 bcrypt 性能更好,但是因为需要编译 C 代码所有安装起来没 bcrypt-nodejs 简单。不过,这两个类库功能一致可以进行自由切换。

创建 user 模型

前面说过 Mongo 是以 BSON 形式进行数据存储的。例如,Hello World 的 BSON 表现形式为:

\x16\x00\x00\x00\x02hello\x00\x06\x00\x00\x00world\x00\x00

虽然计算机完全能够理解 BSON 格式,但是很明显 BSON 对人类来说并不是一种易于阅读的格式。因此,开发者发明了更易于理解的数据库模型概念。数据库模型以一种近似人类语言的方式对数据库对象做出了定义。一个模型代表了一个数据库记录,通常也代表了编程语言中的对象。例如,这里它就代表一个 JavaScript 对象。

除了表示数据库的一条记录之外,模型通常还伴随数据验证、数据拓展等方法。下面通过具体示例来见识下 Mongoose 中的这些特性。

在示例中,我们将创建一个用户模型,该模型带有以下属性:

  • 用户名,该属性无法缺省且要求唯一。

  • 密码,同样无法缺省。

  • 创建时间。

  • 用户昵称,用于信息展示且可选。

  • 个人简介,非必须属性。

在 Mongoose 中我们使用 schema 来定义用户模型。除了包含上面的属性之外,之后还会在其中添加一些类型方法。在项目的根目录创建 models 文件夹,然后在其中创建一个名为 user.js 的文件并复制下面代码:

var mongoose = require("mongoose");
var userSchema = mongoose.Schema({
    username: { type: String, require: true, unique: true },
    password: { type: String, require: true },
    createdAt: {type: Date, default: Date.now },
    displayName: String,
    bio: String
});

从上面的代码中,我们能看到属性字段的定义非常简单。同时我们还对字段的数据类型、唯一性、缺省、默认值作出了约定。

当模型定义好之后,接下来就是在模型中定义方法了。首先,我们添加一个返回用户名称的简单方法。如果用户定义了昵称则返回昵称否则直接返回用户名。代码如下:

...

userSchema.methods.name = function() {
    return this.displayName || this.username;
}

同样,为了确保数据库中用户信息安全,密码字段必须以密文形式存储。这样即使出现数据库泄露或者入侵行为也能载一定程度上确保用户信息的安全。这里我们将会使用对 Bcrypt 程序对用户密码进行单向哈希散列,然后在数据库中存储加密后的结果。

首先,我们需要在 user.js 文件头部引入 Bcrypt 类库。在使用过程中我们可以通过增加哈希次数来提高数据的安全性。当然,哈希操作是非常操作,所以我们应该选取一个相对适中的数值。例如,下面的代码中我们将哈希次数设定为了 10 。

var bcrypt = require("bcrypt-nodejs");
var SALT_FACTOR = 10;

当然,对密码的哈希操作应该在保存数据之前。所以这部分代码应该在数据保存之前的回调函数中完成,代码如下:

...

var noop = function() {};
// 保存操作之前的回调函数
userSchema.pre("save", function(done) {
    var user = this;
    if (!user.isModified("password")) {
        return done();
    }
   
    bcrypt.genSalt(SALT_FACTOR, function(err, salt) {
        if (err) { 
            return done(err); 
        }
        
        bcrypt.hash(user.password, salt, noop, 
            function(err, hashedPassword) {
                if (err) {
                    return done(err); 
                }
                user.password = hashedPassword;
                done();
            }
        );
    });
});

该回调函数会在每次进行数据库保存之前被调用,所以它能确保你的密码会以密文形式得到保存。

处理需要对密码进行加密处理之外,另一个常见需求就是用户授权验证了。例如,在用户登录操作时的密码验证操作。

...

userSchema.methods.checkPassword = function(guess, done) {
    bcrypt.compare(guess, this.password, function(err, isMatch) {
        done(err, isMatch);
    });
}

出于安全原因,这里我们使用的是 bcrypt.compare 函数而不是简单的相等判断 === 。

完成模型定义和通用方法实现后,接下来我们就需要将其暴露出来供其他代码使用了。不过暴露模型的操作非常简单只需两行代码:

...

var User = mongoose.model("User", userSchema);
module.exports = User;

models/user.js 文件中完整的代码如下:

// 代码清单 8.8 models/user.js编写完成之后
var bcrypt = require("bcrypt-nodejs");
var SALT_FACTOR = 10;
var mongoose = require("mongoose");
var userSchema = mongose.Schema({
    username: { type: String, require: true, unique: true },
    password: { type: String, require: true },
    createdAt: {type: Date, default: Date.now },
    displayName: String,
    bio: String
});
userSchema.methods.name = function() {
    return this.displayName || this.username;
}

var noop = function() {};

userSchema.pre("save", function(done) {
    var user = this;
    if (!user.isModified("password")) {
        return done();
    }

    bcrypt.genSalt(SALT_FACTOR, function(err, salt) {
        if (err) { return done(err); }
        bcrypt.hash(user.password, salt, noop, 
            function(err, hashedPassword) {
                if (err) { return done(err); }
                user.password = hashedPassword;
                done();
            }
        );
    });
});
userSchema.methods.checkPassword = function(guess, done) {
    bcrypt.compare(guess, this.password, function(err, isMatch) {
        done(err, isMatch);
    });
}
var User = mongoose.model("User", userSchema);
module.exports = User;

模型使用

模型定义好之后,接下来就是在主页、编辑页面、注册等页面进行使用了。相比于之前的模型定义,使用过程相对来说要更简单。

首先,在项目根目录创建主入口文件 app.js 并复制下面的代码:

var express = require("express");
var mongoose = require("mongoose");
var path = require("path");
var bodyParser = require("body-parser");
var cookieParser = require("cookie-parser");
var session = require("express-session");
var flash = require("connect-flash");

var routes = require("./routes");
var app = express();

// 连接到你MongoDB服务器的test数据库
mongoose.connect("mongodb://localhost:27017/test");
app.set("port", process.env.PORT || 3000);
app.set("views", path.join(__dirname, "views"));
app.set("view engine", "ejs");

app.use(bodyParser.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(session({
    secret: "TKRv0IJs=HYqrvagQ#&!F!%V]Ww/4KiVs$s,<<MX",
    resave: true,
    saveUninitialized: true
}));
app.use(flash());
app.use(routes);

app.listen(app.get("port"), function() {
    console.log("Server started on port " + app.get("port"));
});

接下来,我们需要实现上面使用到的路由中间件。在根目录新建 routes.js 并复制代码:

var express = require("express");
var User = require("./models/user");
var router = express.Router();
router.use(function(req, res, next) {
    res.locals.currentUser = req.user;
    res.locals.errors = req.flash("error");
    res.locals.infos = req.flash("info");
    next();
});
router.get("/", function(req, res, next) {
    User.find()
        .sort({ createdAt: "descending" })
        .exec(function(err, users) {
            if (err) { return next(err); }
            res.render("index", { users: users });
        });
});
module.exports = router;

这两段代码中,首先,我们使用 Mongoose 进行了数据库连接。然后,在路由中间件中通过 User.find 异步获取用户列表并将其传递给了主页视图模版。

接下来,我们就轮到主页视图的实现了。首先在根目录创建 views 文件夹,然后在文件夹中添加第一个模版文件 _header.ejs

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Learn About Me</title>
    <link rel="stylesheet" href="//maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css">
</head>
<body>
    <div class="navbar navbar-default navbar-static-top" role="navigation">
        <div class="container">
            <div class="navbar-header">
                <a class="navbar-brand" href="/">Learn About Me</a>
            </div>
            <!-- 
                如果用户已经登陆了则对导航条进行相应的改变。
                一开始你的代码中并不存在currentUser,所以总会显示一个状态
             -->
            <ul class="nav navbar-nav navbar-right">
                <% if (currentUser) { %>
                    <li>
                        <a href="/edit">
                            Hello, <%= currentUser.name() %>
                        </a>
                    </li>
                    <li><a href="/logout">Log out</a></li>
                 <% } else { %>
                    <li><a href="/login">Log in</a></li>
                    <li><a href="/signup">Sign up</a></li>  
                 <% } %>   
            </ul>
        </div>
    </div>
    <div class="container">
        <% errors.forEach(function(error) { %>
            <div class="alert alert-danger" role="alert">
                <%= error %>
            </div>
        <% }) %>
        <% infos.forEach(function(info) { %>
            <div class="alert alert-info" role="alert">
                <%= info %>
            </div>
        <% }) %>

你可能注意到了这些文件的名字是以下划线开始的。这是一个社区约定,所有组件模版都会以下划线进行区分。

接下来,添加第二个通用组件模版 _footer.js

</div>
</body>
</html>

最后,我们添加主页视图模版文件。该视图模版会接受中间件中传入的 users 变量并完成渲染:

<% include _header %>
<h1>Welcome to Learn About Me!</h1>
<% users.forEach(function(user) { %>
    <div class="panel panel-default">
        <div class="panel-heading">
            <a href="/users/<%= user.username %>">
                <%= user.name() %>
            </a>
        </div>
        <% if (user.bio) { %>
            <div class="panel-body"><%= user.bio %></div>
        <% } %>
    </div>
<% }) %>
<% include _footer %>

确保代码无误后,接下来启动 Mongo 数据库服务并使用 npm start 拉起工程。然后,通过浏览器访问 localhost:3000 就能类型下图的主页界面:

08_02

当然,因为此时数据库中并没有任何记录所有这里并没有出现任何用户信息。

接下来,我们就来实现用户用户注册和登录功能。不过在此之前,我们需要在 app.js 中引入 body-parser 模块并用于后面请求参数的解析。

var bodyParser = require("body-parser");
...

app.use(bodyParser.urlencoded({ extended: false }));
…

为了提高安全性,这里我们将 body-parser 模块的 extended 设置为 false 。接下来,我们在 routes.js 添加 sign-up 功能的中间件处理函数:


var passport = require("passport");
...
router.get("/signup", function(req, res) {
    res.render("signup");
});
router.post("/signup", function(req, res, next) {
    // 参数解析
    var username = req.body.username;
    var password = req.body.password;
    
    // 调用findOne只返回一个用户。你想在这匹配一个用户名
    User.findOne({ username: username }, function(err, user) {
        if (err) { return next(err); }
        // 判断用户是否存在
        if (user) {
            req.flash("error", "User already exists");
            return res.redirect("/signup");
        }
        // 新建用户
        var newUser = new User({
            username: username,
            password: password
        });
        // 插入记录
        newUser.save(next);
    });
    // 进行登录操作并实现重定向
}, passport.authenticate("login", {
    successRedirect: "/",
    failureRedirect: "/signup",
    failureFlash: true
}));

路由中间件定义完成后,下面我们就来实现视图模版 signup.ejs 文件。

// 拷贝代码到 views/signup.ejs
<% include _header %>
<h1>Sign up</h1>
<form action="/signup" method="post">
    <input name="username" type="text" class="form-control" placeholder="Username" required autofocus>
    <input name="password" type="password" class="form-control" placeholder="Password" required>
    <input type="submit" value="Sign up" class="btn btn-primary btn-block">
</form>
<% include _footer %>

如果你成功创建用户并再次访问主页的话,你就能看见一组用户列表:

08_03

而注册页的 UI 大致如下:

08_04

在实现登录功能之前,我们先把个人信息展示功能先补充完整。在 routes.js 添加如下中间件函数:

...
router.get("/users/:username", function(req, res, next) {
    User.findOne({ username: req.params.username }, function(err, user) {
        if (err) { return next(err); }
        if (!user) { return next(404); }
        res.render("profile", { user: user });
    });
});
...

接下来编写视图模版文件 profile.ejs

// 保存到 views 文件夹中
<% include _header %>
<!-- 
    参考变量currentUser来判断你的登陆状态。不过现在它总会是false状态
 -->
<% if ((currentUser) && (currentUser.id === user.id)) { %>
    <a href="/edit" class="pull-right">Edit your profile</a>
<% } %>
<h1><%= user.name() %></h1>
<h2>Joined on <%= user.createdAt %></h2>
<% if (user.bio) { %>
    <p><%= user.bio %></p>
<% } %>
<% include _footer %>

如果现在你通过首页进入用户详情页话,那么你就会出现类似下图的界面:

08_05

通过 Passport 来进行用户身份验证

除了上面这些基本功能之外,User 模型做重要的功能其实是登录以及权限认证。而这也是 User 模型与其他模型最大的区别。所以接下来的任务就是实现登录页并进行密码和权限认证。

为了减少很多不必要的工作量,这里我们会使用到第三方的 Passport 模块。该模版是特地为请求进行验证而设计处理的 Node 中间件。通过该中间件只需一小段代码就能实现复杂的身份认证操作。不过 Passport 并没有指定如何进行用户身份认证,它只是提供了一些模块化函数。

设置 Passport

Passport 的设置过程主要有三件事:

  • 设置 Passport 中间件。

  • 设置 Passport 对 User 模型的序列化和反序列化的操作。

  • 告诉 Passport 如何对 User 进行认证。

首先,在初始化 Passport 环境时,你需要在工程中引入一些其他中间件。它们分别为:

  1. body-parser

  2. cookie-parser

  3. express-session

  4. connect-flash

  5. passport.initialize

  6. passport.session

其中前面 4 个中间件已经引入过了。它们的作用分别为: body-parser 用于参数解析;cookie-parser 处理从浏览器中获取的cookies;express-session 用于处理用户 session;而 connect-flash 则用户展示错误信息。

最后,我们需要在 app.js 中引入 Passport 模块并在后面调用其中的两个中间件函数。

var bodyParser = require("body-parser");
var cookieParser = require("cookie-parser");
var flash = require("connect-flash");
var passport = require("passport");
var session = require("express-session");
...
app.use(bodyParser.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(session({
    // 需要一串随机字母序列,字符串不一定需要跟此处一样
    secret: "TKRv0IJs=HYqrvagQ#&!F!%V]Ww/4KiVs$s,<<MX",
    resave: true,
    saveUninitialized: true
}));
app.use(flash());
app.use(passport.initialize());
app.use(passport.session());
...

代码中,我们使用一串随机字符串来对客户端的 session 进行编码。这样就能在一定程度上增加 cookies 的安全性。而将 resave 设置为 true 则保证了即使 session 没有被修改也依然会被刷新。

接下来就是第二步操作:设置 Passport 对 User 模型的序列化和反序列化操作了。这样 Passport 就能实现 session 和 user 对象的互相转化了。Passport 文档对这一操作的描述为:

在标准的 web 应用中,只有当客户端发送了登录请求才会需要对用户进行身份认证。如果认证通过的话,两者之间就会新建一个 session 并将其保存到 cookie 中进行维护。任何后续操作都不会再进行认证操作,取而代之的是使用 cookie 中唯一指定的 session 。所以,Passport 需要通过序列化和反序列化实现 session 和 user 对象的互相转化。

为了后期代码维护方便,这里我们新建一个名为 setuppassport.js 的文件并将序列化和反序列化的代码放入其中。最后,我们将其引入到 app.js 中:

…
var setUpPassport = require("./setuppassport");
…
var app = express();
mongoose.connect("mongodb://localhost:27017/test");
setUpPassport();
…

下面就是 setuppassport.js 中的代码实现了。因为 User 对象都有一个 id 属性作为唯一标识符,所以我们就根据它来进行 User 对象的序列化和反序列化操作:

// setuppassport.js 文件中的代码
var passport = require("passport");
var User = require("./models/user");
module.exports = function() {
    passport.serializeUser(function(user, done) {
        done(null, user._id);
    });
    passport.deserializeUser(function(id, done) {
        User.findById(id, function(err, user) {
            done(err, user);
        });
    });
}

接下来就是最难的部分了,如何进行身份认证?

在开始进行认证前,还有一个小工作需要完成:设置认证策略。虽然 Passport 附带了 Facebook 、Google 的身份认证策略,但是这里我们需要的将其设置为 local strategy 。因为验证部分的规则和代码是由我们自己来实现的。

首先,我们在 setuppassport.js 中引入 LocalStrategy

...
var LocalStrategy = require("passport-local").Strategy;
…

接下来,按照下面的步骤使用 LocalStrategy 来进行具体的验证:

  1. 查询该用户。

  2. 用户不存在则提示无法通过验证。

  3. 用户存在则进行密码比较。如果匹配成功则返回当前用户否则提示“密码错误”。

下面就是将这些步骤转化为具体的代码:

// setuppassport.js 验证代码
...
passport.use("login", new LocalStrategy(function(username, password, done) {
    User.findOne({ username: username }, function(err, user) {
        if(err) { return done(err); }
        if (!user) {
            return done(null, false, { message: "No user has that username!" });
        }
        
        user.checkPassword(password, function(err, isMatch) {
            if (err) { return done(err); }
            if (isMatch) {
                return done(null, user);
            } else {
                return done(null, false, { message: "Invalid password." });
            }
        });
    });
}));
...

完成策略定义后,接下来就可以在项目的任何地方进行调用。

最后,我们还需要完成一些视图和功能:

  1. 登录

  2. 登出

  3. 登录完成后的个人信息编辑

首先,我们实现登录界面视图。在 routes.js 中添加登录路由中间件:

...
router.get("/login", function(req, res) {
    res.render("login");
});
...

在登录视图 login.ejs 中,我们会接收一个用户名和一个密码,然后发送登录的 POST 请求:

<% include _header %>
<h1>Log in</h1>
<form action="/login" method="post">
    <input name="username" type="text" class="form-control" placeholder="Username" required autofocus>
    <input name="password" type="password" class="form-control" placeholder="Password" required>
    <input type="submit" value="Log in" class="btn btn-primary btn-block">
</form>
<% include _footer %>

接下来,我们就需要处理该 POST 请求。其中就会使用到 Passport 的身份认证函数。

//  routes.js 中登陆功能代码
var passport = require("passport");
...

router.post("/login", passport.authenticate("login", {
    successRedirect: "/",
    failureRedirect: "/login",
    failureFlash: true 
}));
...

其中 passport.authenticate 函数会返回一个回调。该函数会根据我们的指定对不同的验证结果分别进行重定向。例如,登录成功会重定向到首页,而失败则会重定向到登录页。

登出操作相对来说要简单得多,代码如下

// routes.js 登出部分
...
router.get("/logout", function(req, res) {
    req.logout();
    res.redirect("/");
});
...

Passport 还附加了 req.user 和 connect-flash 信息。再回顾一下前面的这段代码,相信你能有更深的体会。

...
router.use(function(req, res, next) {
    // 为你的模板设置几个有用的变量
    res.locals.currentUser = req.user;
    res.locals.errors = req.flash("error");
    res.locals.infos = req.flash("info");
    next();
});
...

登录和登出玩抽,下面就该轮到个人信息编辑功能了。

首先,我们来实现一个通用的中间件工具函数 ensureAuthenticated 。该中间件函数会对当前用户的权限进行检查,如果检查不通过则会重定向到登录页。

// routes.js 中的 ensureAuthenticated 中间件
...
function ensureAuthenticated(req, res, next) {
    // 一个Passport提供的函数
    if (req.isAuthenticated()) {
        next();
    } else {
        req.flash("info", "You must be logged in to see this page.");
        res.redirect("/login");
    }
}
...

接下来,我们会在编辑中间件中调用该函数。因为我们需要确保在开始编辑之前,当前用户拥有编辑权限。

//  GET /edit(在router.js中)
...
// 确保用户被身份认证;如果它们没有被重定向的话则运行你的请求处理
router.get("/edit", ensureAuthenticated, function(req, res) {
    res.render("edit");
});
...

接下来我们需要实现 edit.ejs 视图模版文件。该视图模版的内容非常简单,只包含用户昵称和简介的修改。

//  views/edit.ejs
<% include _header %>
<h1>Edit your profile</h1>
<form action="/edit" method="post">
<input name="displayname" type="text" class="form-control" placeholder="Display name" value="<%= currentUser.displayName || "" %>">
<textarea name="bio" class="form-control" placeholder="Tell us about yourself!"> <%= currentUser.bio || "" %></textarea>
<input type="submit" value="Update" class="btn btn-primary btn-block">
</form>
<% include _footer %>

最后,我们需要对修改后提交的请求作出处理。在进行数据库更新之前,这里同样需要进行权限认证。

// POST /edit(在routes.js中)
...
// 通常,这会是一个PUT请求,不过HTML表单仅仅支持GET和POST
router.post("/edit", ensureAuthenticated, function(req, res, next) {
    req.user.displayName = req.body.displayname;
    req.user.bio = req.body.bio;
    req.user.save(function(err) {
        if (err) {
            next(err);
            return;
        }
        req.flash("info", "Profile updated!");
        res.redirect("/edit");
    });
});
...

该代码仅仅只是对数据库对应记录的字段进行了更新。最终渲染的编辑视图如下:

08_06

最后,你可以创建一些测试数据对示例应用的所有功能进行一遍验证。

08_07

总结

本文包含的内容有:

  • Mongo 的工作原理。

  • Mongoose 的使用。

  • 使用 bcrypt 对特定字段进行加密来提高数据安全性。

  • 使用 Passport 进行权限认证。

添加新评论

已有 7 条评论
  1. VagrantPi VagrantPi

    受教了! 感謝大大的系列翻譯教學

    1. @VagrantPi这是我对我最高的褒奖,我最大的希望就是在自己学习过程中也能帮助到他人。

  2. 非常感谢,终于能看到入门并且完整的 express 教程,受益很多

    1. @Redon其实还有一部分关于测试以及安全的内容。不过我觉得这些可以入门后自己去学习效果比看文章要好,尤其是安全部分。

  3. 壳壳 壳壳

    请问还会不会有后续内容呢?

    1. @壳壳应该不会。最后测试、部署、安全部分我觉得实践会来的收获更大。尤其是安全部分,是所有 Web 开发都需要考虑的问题。我建议你可以去看看原版的 《Express in Action》,书可以去 http://www.salttiger.com 上找找。

    2. 壳壳 壳壳

      @BigNerdCoding非常感谢大大@(笑眼)