MEAN-Web-开发第二版-全-

MEAN Web 开发第二版(全)

原文:zh.annas-archive.org/md5/F817AFC272941F1219C1F4494127A431

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

回到 1995 年春天,网络浏览器与现在的浏览器有很大不同。距离 WorldWideWeb(由 Tim Berners-Lee 编写的第一个互联网浏览器,后来更名为 Nexus)发布已经有 4 年了,距离 Mosaic 的初始发布已经有 2 年了,而 Internet Explorer 1.0 距离发布还有几个月的时间。万维网开始显示出受欢迎的迹象,尽管一些大公司对这个领域表现出了兴趣,但当时的主要颠覆者是一家名为 Netscape 的小公司。

Netscape 已经很受欢迎的浏览器 Netscape Navigator 正在制作其第二个版本,当时客户端工程团队和联合创始人 Marc Anderseen 决定 Navigator 2.0 应该嵌入一种编程语言。这项任务被分配给一位名叫 Branden Eich 的软件工程师,他在 1995 年 5 月 6 日至 5 月 15 日之间完成了这项任务,将这种语言命名为 Mocha,然后是 LiveScript,最终是 JavaScript。

Netscape Navigator 2.0 于 1995 年 9 月发布,改变了我们对网络浏览器的看法。到 1996 年 8 月,Internet Explorer 3.0 推出了自己的 JavaScript 实现,同年 11 月,Netscape 宣布他们已经向 ECMA 提交了 JavaScript 的标准化。1997 年 6 月,ECMA-262 规范发布,使 JavaScript 成为了 Web 的事实标准编程语言。

多年来,JavaScript 被许多人贬低为业余程序员的编程语言。JavaScript 的架构、分散的实现和原始的“业余”受众使专业程序员对其不屑一顾。但随后引入了 AJAX,当谷歌在 2000 年代中期发布了他们的 Gmail 和 Google Maps 应用程序时,突然间清楚地看到 AJAX 技术可以将网站转变为 Web 应用程序。这激发了新一代的 Web 开发人员将 JavaScript 开发推向新的高度。

从最初的实用库(如 jQuery 和 Prototype)开始,很快就得到了谷歌的下一个重大贡献,即 Chrome 浏览器及其于 2008 年底发布的 V8 JavaScript 引擎的推动。V8 引擎以其 JIT 编译能力大大提高了 JavaScript 的性能。这导致了 JavaScript 开发的新时代。2009 年是 JavaScript 的奇迹之年;突然间,诸如 Node.js 之类的平台使开发人员能够在服务器上运行 JavaScript,诸如 MongoDB 之类的数据库推广和简化了 JSON 存储的使用,诸如 Angular 和 React 之类的框架简化了复杂前端应用程序的创建。在其原始发布 20 多年后,JavaScript 现在无处不在。曾经是一种能够执行小脚本的“业余”编程语言,现在是世界上最流行的编程语言之一。开源协作工具的兴起,以及才华横溢的工程师的投入,创造了世界上最丰富的社区之一,许多贡献者播下的种子现在正在以纯粹的创造力迸发。

这个实际意义是巨大的。曾经是一个分散的开发团队,每个人都是自己领域的专家,现在可以成为一个能够使用单一语言跨所有层开发更精简、更敏捷软件的同质团队。

有许多全栈 JavaScript 框架,一些是由优秀团队构建的,一些解决了重要问题,但没有一个像 MEAN 堆栈那样开放和模块化。这个想法很简单,我们将 MongoDB 作为数据库,Express 作为 Web 框架,Angular 作为前端框架,Node.js 作为平台,以模块化的方式组合它们,以确保现代软件开发所需的灵活性。MEAN 的方法依赖于每个开源模块周围的社区保持其更新和稳定,确保如果其中一个模块变得无用,我们可以无缝地用更合适的模块替换它。

我想欢迎您加入 JavaScript 革命,并向您保证我会尽力帮助您成为全栈 JavaScript 开发人员。

在本书中,我们将帮助您设置您的环境,并解释如何使用最佳模块将不同的 MEAN 组件连接在一起。您将了解保持代码清晰简单的最佳实践,并学会如何避免常见陷阱。我们将逐步构建您的身份验证层并添加您的第一个实体。您将学会如何利用 JavaScript 非阻塞架构来构建服务器和客户端应用程序之间的实时通信。最后,我们将向您展示如何使用适当的测试覆盖您的代码,并向您展示自动化开发流程中使用的工具。

本书涵盖内容

第一章,“MEAN 简介”,向您介绍了 MEAN 堆栈,并向您展示了如何在每个操作系统上安装不同的先决条件。

第二章,“Node.js 入门”,解释了 Node.js 的基础知识以及它在 Web 应用程序开发中的使用。

第三章,“构建 Express Web 应用程序”,解释了如何通过实现 MVC 模式来创建和构建 Express 应用程序。

第四章,“MongoDB 简介”,解释了 MongoDB 的基础知识以及如何用它来存储应用程序的数据。

第五章,“Mongoose 简介”,展示了如何使用 Mongoose 将 Express 应用程序与 MongoDB 数据库连接起来。

第六章,“使用 Passport 管理用户身份验证”,解释了如何管理用户的身份验证并为他们提供不同的登录选项。

第七章,“Angular 简介”,解释了如何在 Express 应用程序中实现 Angular 应用程序。

第八章,“创建 MEAN CRUD 模块”,解释了如何编写和使用 MEAN 应用程序的实体。

第九章,“使用 Socket.io 添加实时功能”,向您展示了如何在客户端和服务器之间创建和使用实时通信。

第十章,“测试 MEAN 应用”,解释了如何自动测试 MEAN 应用的不同部分。

第十一章,“MEAN 应用的自动化和调试”,解释了如何更高效地开发 MEAN 应用程序。

您需要为本书做好准备

本书适合具有 HTML、CSS 和现代 JavaScript 开发基础知识的初学者和中级 Web 开发人员。

本书的受众

本书面向有兴趣学习如何使用 MongoDB、Express、Angular 和 Node.js 构建现代 Web 应用程序的 Web 开发人员。

约定

在本书中,您将找到一些文本样式,用于区分不同类型的信息。以下是这些样式的一些示例及其含义的解释。

文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄显示如下:“要测试您的静态中间件,请将名为logo.png的图像添加到public/img文件夹中。”

代码块设置如下:

const message = 'Hello World';

exports.sayHello = function() {
  console.log(message);
}

当我们希望引起您对代码块的特定部分的注意时,相关的行或项目将以粗体显示:

const express = require('express');
const app = express();

app.listen(3000);

console.log('Server running at http://localhost:3000/');

任何命令行输入或输出都以以下方式编写:

$ npm start

新术语重要单词以粗体显示。屏幕上显示的单词,例如菜单或对话框中的单词,会在文本中以这种方式出现:“一旦您点击下一步按钮,安装应该开始。”

注意

警告或重要提示会以这种方式出现在框中。

提示

提示和技巧会以这种方式出现。

第一章:MEAN 简介

MEAN 堆栈是一个强大的全栈 JavaScript 解决方案,由四个主要构建模块组成:MongoDB 作为数据库,Express 作为 Web 服务器框架,Angular 作为 Web 客户端框架,Node.js 作为服务器平台。这些构建模块由不同的团队开发,并涉及一个庞大的开发人员和倡导者社区,推动每个组件的开发和文档化。该堆栈的主要优势在于将 JavaScript 作为主要编程语言。然而,连接这些工具的问题可能为扩展和架构问题奠定基础,这可能会严重影响您的开发过程。

在本书中,我将尝试介绍构建 MEAN 应用程序的最佳实践和已知问题,但在您开始实际的 MEAN 开发之前,您首先需要设置您的环境。本章将涵盖一些编程概述,但主要介绍安装 MEAN 应用程序的基本先决条件的正确方法。通过本章的学习,您将了解如何在所有常见操作系统上安装和配置 MongoDB 和 Node.js 以及如何使用 NPM。在本章中,我们将涵盖以下主题:

  • MEAN 堆栈架构简介

  • 在 Windows、Linux 和 Mac OS X 上安装和运行 MongoDB

  • 在 Windows、Linux 和 Mac OS X 上安装和运行 Node.js

  • npm 简介及如何使用它安装 Node 模块

三层 Web 应用程序开发

大多数 Web 应用程序都是建立在三层架构上的,包括三个重要的层:数据、逻辑和呈现。在 Web 应用程序中,应用程序结构通常分解为数据库、服务器和客户端,而在现代 Web 开发中,它也可以分解为数据库、服务器逻辑、客户端逻辑和客户端 UI。

实现这种模型的一种流行范式是模型-视图-控制器MVC)架构模式。在 MVC 范式中,逻辑、数据和可视化被分为三种类型的对象,每个对象处理自己的任务。视图处理视觉部分,负责用户交互。控制器响应系统和用户事件,命令模型和视图适当地进行更改。模型处理数据操作,响应对信息的请求或根据控制器的指示改变其状态。MVC 架构的简单可视化表示如下图所示:

三层 Web 应用程序开发

常见的 MVC 架构通信

在 Web 开发的 25 年中,许多技术堆栈变得流行,用于构建三层 Web 应用程序。在那些现在无处不在的堆栈中,你可以找到 LAMP 堆栈、.NET 堆栈和丰富多样的其他框架和工具。这些堆栈的主要问题是,每个层都需要一个知识库,通常超出了单个开发人员的能力范围,使团队比他们应该的更大,生产力更低,面临意外风险。

JavaScript 的演变

JavaScript 是一种为 Web 开发而构建的解释性计算机编程语言。最初由 Netscape Navigator 网络浏览器实现,它成为 Web 浏览器用于执行客户端逻辑的编程语言。在 2000 年代中期,从网站向 Web 应用程序的转变,以及更快的浏览器的发布,逐渐形成了一个编写更复杂应用程序的 JavaScript 开发人员社区。这些开发人员开始创建缩短开发周期的库和工具,催生了一代更先进的 Web 应用程序。他们反过来创造了对更好浏览器的持续需求。这个循环持续了几年,供应商不断改进他们的浏览器,JavaScript 开发人员不断推动边界。

真正的革命始于 2008 年,当谷歌发布了其 Chrome 浏览器,以及其快速的 JIT 编译 V8 JavaScript 引擎。谷歌的 V8 引擎使 JavaScript 运行速度大大加快,完全改变了 Web 应用程序开发。更重要的是,引擎源代码的发布使开发人员开始重新构想浏览器之外的 JavaScript。这场革命的第一个产物之一就是 Node.js。

在研究了一段时间其他选项之后,程序员 Ryan Dahl 发现 V8 引擎非常适合他的非阻塞 I/O 实验,称为 Node.js。这个想法很简单:帮助开发人员构建非阻塞的代码单元,以更好地利用系统资源并创建更具响应性的应用程序。结果是一个简洁而强大的平台,利用了 JavaScript 在浏览器之外的非阻塞特性。Node 的优雅模块系统使开发人员可以自由地使用第三方模块来扩展平台,实现几乎任何功能。在线社区的反应是创建了各种工具,从现代 Web 框架到机器人服务器平台。然而,服务器端 JavaScript 只是一个开始。

当 Dwight Merriman 和 Eliot Horowitz 在 2007 年开始构建可扩展的托管解决方案时,他们已经在构建 Web 应用程序方面有了很多经验。然而,他们构建的平台并没有按计划成功,因此在 2009 年,他们决定拆开它,并开源其组件,包括一个名为 MongoDB 的基于 V8 的数据库。MongoDB 源自“巨大”的单词,是一个可扩展的 NoSQL 数据库,使用动态模式的类 JSON 数据模型。MongoDB 立即获得了很多关注,因为它为开发人员提供了处理复杂数据时所需的灵活性,同时提供了高级查询和易于扩展的 RDBMS 功能,这些功能最终使 MongoDB 成为领先的 NoSQL 解决方案之一。JavaScript 打破了另一个界限。然而,JavaScript 革命者并没有忘记一切的起源。事实上,现代浏览器的普及创造了 JavaScript 前端框架的新浪潮。

回到 2009 年,当 Miško Hevery 和 Adam Abrons 在构建他们的 JSON 作为平台服务时,他们注意到常见的 JavaScript 库并不够用。他们丰富的 Web 应用程序的性质引发了对更有结构的框架的需求,这将减少繁重的工作并保持有组织的代码库。他们放弃了最初的想法,决定专注于开发他们的前端框架,并开源了该项目,命名为 AngularJS。这个想法是为了弥合 JavaScript 和 HTML 之间的差距,并帮助推广单页应用程序的开发。

结果是一个丰富的 Web 框架,为前端 Web 开发人员提供了诸如双向数据绑定、跨组件依赖注入和基于 MVC 的组件等概念。Angular,以及其他现代框架,通过将曾经难以维护的前端代码库转变为可以支持更高级开发范式的结构化代码库,彻底改变了 Web 开发。

开源协作工具的兴起,以及这些才华横溢的工程师的投入,创造了世界上最丰富的社区之一。更重要的是,这些重大进步使得三层 Web 应用程序的开发能够在 JavaScript 的统一编程语言下进行——这个想法通常被称为全栈 JavaScript。MEAN 堆栈就是这个想法的一个例子。

ECMAScript 2015 介绍

经过多年的工作,ES6 规范于 2015 年 6 月发布。它提出了自 ES5 以来 JavaScript 最大的进步,并在语言中引入了几个功能,将彻底改变我们 JavaScript 开发人员编写代码的方式。描述 ES2015 所做的所有改进是雄心勃勃的。相反,让我们试着通过我们将在下一章中使用的基本功能来工作。

模块

模块现在是一种受支持的语言级特性。它允许开发人员将其组件包装在模块模式中,并在其代码中导出和导入模块。实现与前几章描述的 CommonJS 模块实现非常相似,尽管 ES2015 模块还支持异步加载。处理 ES2015 模块的基本关键字是exportimport。让我们看一个简单的例子。假设您有一个名为lib.js的文件,其中包含以下代码:

export function halfOf(x) {
    return x / 2;
}

因此,在您的main.js文件中,您可以使用以下代码:

import halfOf from 'lib';
console.log(halfOf(84));

然而,模块可能更有趣。例如,假设我们的lib.js文件看起来像这样:

export function halfOf(x) {
    return x / 2;
}
export function multiply(x, y) {
    return x * y;
}

在您的主文件中,使用以下代码:

import {halfOf, multiply} from 'lib';
console.log(halfOf(84));
console.log(multiply(21, 2));

ES2015 模块还支持默认的export值。因此,例如,假设您有一个名为doSomething.js的文件,其中包含以下代码:

export default function () { 
    console.log('I did something')
};

您可以在main.js文件中如下使用它:

import doSomething from 'doSomething';
doSomething();

重要的是要记住,默认导入应该使用模块名称标识其实体。

另一件重要的事情要记住的是,模块导出绑定而不是值。因此,例如,假设您有一个名为validator.js的文件,看起来像这样:

export let flag = false;
export function touch() {
    flag = true;
}

您还有一个名为main.js的文件,看起来像这样:

import { flag, touch } from 'validator';
console.log(flag); 
touch();
console.log(flag); 

第一个输出将是false,第二个将是true。现在我们对模块有了基本的了解,让我们转到类。

关于类与原型的长期辩论得出结论,即 ES2015 中的类基本上只是基于原型的继承的一种语法糖。类是易于使用的模式,支持实例和静态成员、构造函数和 super 调用。这里有一个例子:

class Vehicle {
    constructor(wheels) {
        this.wheels = wheels;
    }
    toString() {
        return '(' + this.wheels + ')';
    }
}

class Car extends Vehicle {
    constructor(color) {
        super(4);
        this.color = color;
    }
    toString() {
        return super.toString() + ' colored:  ' + this.color;
    }
}

let car = new Car('blue');
car.toString(); 

console.log(car instanceof Car); 
console.log(car instanceof Vehicle); 

在这个例子中,Car类扩展了Vehicle类。因此,输出如下:

 (4) in blue
true
true

箭头函数

箭头函数是=>语法的函数简写。对于熟悉其他语言如 C#和 Java 8 的人来说,它们可能看起来很熟悉。然而,箭头函数也非常有帮助,因为它们与其作用域共享相同的词法this。它们主要以两种形式使用。一种是使用表达式体:

const squares = numbers.map(n => n * n); 

另一种形式是使用语句体:

numbers.forEach(n => {
  if (n % 2 === 0) evens.push(n);
});

使用共享词法的一个例子是:

const author = {
  fullName: "Bob Alice",
  books: [],
  printBooks() {
     this.books.forEach(book => console.log(book + ' by ' + this.fullName));
  }
};

如果作为常规函数使用,this将是book对象,而不是author

Let 和 Const

LetConst是用于符号声明的新关键字。Let几乎与var关键字相同,因此它的行为与全局和函数变量相同。但是,在块内部,let的行为不同。例如,看下面的代码:

function iterateVar() {
  for(var i = 0; i < 10; i++) {
    console.log(i);
  }

  console.log(i)
}

function iterateLet() {
  for(let i = 0; i < 10; i++) {
    console.log(i);
  }

  console.log(i)
}

第一个函数将在循环后打印i,但第二个函数将抛出错误,因为i是由let定义的。

const关键字强制单一赋值。因此,这段代码也会抛出错误:

const me = 1
me = 2

默认、Rest 和 Spread

默认、Rest 和 Spread 是与函数参数相关的三个新功能。默认功能允许您为函数参数设置默认值:

function add(x, y = 0) {
    return x + y;
}
add(1) 
add(1,2)

在这个例子中,如果没有传递值或设置为undefinedy的值将设置为0

Rest 功能允许您将数组作为尾随参数传递,如下所示:

function userFriends(user, ...friends) {
  console.log(user + ' has ' + friends.length + ' friends');
}
userFriends('User', 'Bob', 'Alice');

Spread 功能将数组转换为调用参数:

function userTopFriends(firstFriend, secondFriend, thirdFriends) {
  console.log(firstFriend);
  console.log(secondFriend);
  console.log(thirdFriends);
}

userTopFriends(...['Alice', 'Bob', 'Michelle']);

总结

进入现代 Web 开发,ES2015 将成为您日常编程会话的一个可行部分。这里显示的只是冰山一角,强烈建议您继续深入研究。但是,对于本书的目的,这就足够了。

介绍 MEAN

MEAN 是 MongoDB、Express、Angular 和 Node.js 的缩写。其背后的概念是只使用 JavaScript 驱动的解决方案来覆盖应用程序的不同部分。其优势很大,如下所示:

  • 整个应用程序只使用一种语言

  • 应用程序的所有部分都可以支持并经常强制使用 MVC 架构

  • 不再需要数据结构的序列化和反序列化,因为数据编组是使用 JSON 对象完成的

然而,仍有一些重要的问题尚未解答:

  • 如何将所有组件连接在一起?

  • Node.js 有一个庞大的模块生态系统,那么你应该使用哪些模块?

  • JavaScript 是范式不可知的,那么你如何维护 MVC 应用程序结构?

  • JSON 是一种无模式的数据结构,那么你应该如何以及何时对你的数据进行建模?

  • 如何处理用户认证?

  • 如何使用 Node.js 的非阻塞架构来支持实时交互?

  • 如何测试你的 MEAN 应用程序代码库?

  • 考虑到 DevOps 和 CI 的兴起,你可以使用哪些 JavaScript 开发工具来加快 MEAN 应用程序的开发过程?

在本书中,我将尝试回答这些问题和更多。但是,在我们继续之前,你首先需要安装基本的先决条件。

安装 MongoDB

对于 MongoDB 的稳定版本,官方 MongoDB 网站提供了链接的二进制文件,为 Linux、Mac OS X 和 Windows 提供了安装 MongoDB 的最简单方式。请注意,你需要根据你的操作系统下载正确的架构版本。如果你使用 Windows 或 Linux,请确保根据你的系统架构下载 32 位或 64 位版本。Mac 用户可以安全地下载 64 位版本。

注意

MongoDB 的版本方案是这样工作的,只有偶数版本号标记稳定版本。因此,版本 3.0.x 和 3.2x 是稳定的,而 2.9.x 和 3.1.x 是不稳定的版本,不应该在生产中使用。MongoDB 的最新稳定版本是 3.2.x。

当你访问mongodb.org/downloads下载页面时,你将得到一个包含安装 MongoDB 所需二进制文件的存档文件的下载。下载并提取存档文件后,你需要找到mongod二进制文件,通常位于bin文件夹中。mongod进程运行主 MongoDB 服务器进程,可以用作独立服务器或 MongoDB 副本集的单个节点。在我们的情况下,我们将使用 MongoDB 作为独立服务器。mongod进程需要一个文件夹来存储数据库文件(默认文件夹是/data/db)和一个要监听的端口(默认端口是27017)。在接下来的小节中,我们将介绍每个操作系统的设置步骤。我们将从常见的 Windows 安装过程开始。

注意

建议你通过访问官方文档mongodb.org来更多了解 MongoDB。

在 Windows 上安装 MongoDB

下载正确的版本后,运行.msi文件。MongoDB 应该安装在C:\Program Files\MongoDB\文件夹中。在运行时,MongoDB 使用默认文件夹来存储其数据文件。在 Windows 上,默认文件夹位置是C:\data\db。因此,在命令提示符中,转到C:\并输入以下命令:

> md c:\data\db

提示

你可以告诉 mongod 服务使用--dbpath命令行标志来使用替代路径的数据文件。

创建完数据文件夹后,在运行主 MongoDB 服务时会得到两个选项。

手动运行 MongoDB

要手动运行 MongoDB,你需要运行mongod二进制文件。因此,打开命令提示符并导航到C:\Program Files\MongoDB\Server\3.2\bin文件夹。然后,输入以下命令:

C:\Program Files\MongoDB\Server\3.2\bin> mongod

上述命令将运行主 MongoDB 服务,该服务将开始监听默认的27017端口。如果一切顺利,您应该看到类似以下截图的控制台输出:

手动运行 MongoDB

在 Windows 上运行 MongoDB 服务器

根据 Windows 安全级别,可能会发出安全警报对话框,通知您有关某些服务功能的阻止。如果发生这种情况,请选择私人网络,然后单击允许访问

注意

您应该知道,MongoDB 服务是自包含的,因此您也可以选择从任何文件夹运行它。

将 MongoDB 作为 Windows 服务运行

更流行的方法是在每次重启后自动运行 MongoDB。在将 MongoDB 设置为 Windows 服务之前,最好指定 MongoDB 日志和配置文件的路径。首先在命令提示符中运行以下命令创建这些文件的文件夹:

> md C:\data\log

然后,您需要在C:\Program Files\MongoDB\Server\3.2\mongod.cfg创建一个包含以下内容的配置文件:

systemLog:
    destination: file
    path: c:\data\log\mongod.log
storage:
    dbPath: c:\data\db

当您的配置文件就位时,请通过右键单击命令提示符图标并单击以管理员身份运行来打开具有管理员权限的新命令提示符窗口。请注意,如果已经运行较旧版本的 MongoDB 服务,您首先需要使用以下命令将其删除:

> sc stop MongoDB
> sc delete MongoDB

然后,通过运行以下命令安装 MongoDB 服务:

> "C:\Program Files\MongoDB\Server\3.2\bin\mongod.exe" --config "C:\Program Files\MongoDB\Server\3.2\mongod.cfg" --install

请注意,只有在正确设置配置文件时,安装过程才会成功。安装 MongoDB 服务后,您可以通过在管理命令提示符窗口中执行以下命令来运行它:

> net start MongoDB

请注意,MongoDB 配置文件可以修改以适应您的需求。您可以通过访问docs.mongodb.org/manual/reference/configuration-options/了解更多信息。

在 Mac OS X 和 Linux 上安装 MongoDB

在本节中,您将学习在基于 Unix 的操作系统上安装 MongoDB 的不同方法。让我们从最简单的安装 MongoDB 的方式开始,这涉及下载 MongoDB 的预编译二进制文件。

从二进制文件安装 MongoDB

您可以通过访问www.mongodb.org/downloads的下载页面下载正确版本的 MongoDB。或者,您可以通过执行以下命令使用 CURL 来执行此操作:

$ curl -O http://downloads.mongodb.org/osx/mongodb-osx-x86_64-3.2.10.tgz

请注意,我们已经下载了 Mac OS X 64 位版本,因此请确保修改命令以适合您的机器版本。下载过程结束后,请通过在命令行工具中发出以下命令解压文件:

$ tar -zxvf mongodb-osx-x86_64-3.2.10.tgz

现在,通过运行以下命令将提取的文件夹更改为更简单的文件夹名称:

$ mv mongodb-osx-x86_64-3.2.10 mongodb

MongoDB 使用默认文件夹来存储其文件。在 Linux 和 Mac OS X 上,默认位置是/data/db,所以在命令行工具中运行以下命令:

$ mkdir -p /data/db

提示

您可能会在创建此文件夹时遇到一些问题。这通常是权限问题,因此在运行上述命令时,请使用sudo或超级用户。

上述命令将创建datadb文件夹,因为-p标志也会创建父文件夹。请注意,默认文件夹位于您的主文件夹外部,因此请确保通过运行以下命令设置文件夹权限:

$ chown -R $USER /data/db

现在您已经准备好了,使用命令行工具并转到bin文件夹以运行mongod服务,如下所示:

$ cd mongodb/bin
$ mongod

这将运行主 MongoDB 服务,它将开始监听默认的27017端口。如果一切顺利,您应该看到类似以下截图的控制台输出:

从二进制文件安装 MongoDB

在 Mac OS X 上运行 MongoDB 服务器

使用软件包管理器安装 MongoDB

有时,安装 MongoDB 的最简单方法是使用软件包管理器。缺点是一些软件包管理器在支持最新版本方面落后。幸运的是,MongoDB 团队还维护了 RedHat、Debian 和 Ubuntu 的官方软件包,以及 Mac OS X 的 Homebrew 软件包。请注意,您需要配置软件包管理器存储库以包括 MongoDB 服务器以下载官方软件包。

要在 Red Hat Enterprise、CentOS 或 Fedora 上使用 Yum 安装 MongoDB,请按照docs.mongodb.org/manual/tutorial/install-mongodb-on-red-hat-centos-or-fedora-linux/上的说明进行操作。

要在 Ubuntu 上使用 APT 安装 MongoDB,请按照docs.mongodb.org/manual/tutorial/install-mongodb-on-ubuntu/上的说明进行操作。

要在 Debian 上使用 APT 安装 MongoDB,请按照docs.mongodb.org/manual/tutorial/install-mongodb-on-debian/上的说明进行操作。

在 Mac OS X 上使用 Homebrew 安装 MongoDB,请按照docs.mongodb.org/manual/tutorial/install-mongodb-on-os-x/上的说明进行操作。

使用 MongoDB shell

MongoDB 存档文件包括 MongoDB shell,它允许您使用命令行与服务器实例进行交互。要启动 shell,请转到 MongoDB bin文件夹,并运行以下mongo服务:

$ cd mongodb/bin
$ mongo

如果成功安装了 MongoDB,shell 将自动连接到您的本地实例,使用测试数据库。您应该看到类似以下屏幕截图的控制台输出:

使用 MongoDB shell

在 Mac OS X 上运行 MongoDB shell

要测试您的数据库,请运行以下命令:

> db.articles.insert({title: "Hello World"})

上述命令将创建一个新的文章集合,并插入一个包含title属性的 JSON 对象。要检索文章对象,请执行以下命令:

> db.articles.find()

控制台将输出类似以下消息的文本:

{ _id: ObjectId("52d02240e4b01d67d71ad577"), title: "Hello World" }

恭喜!这意味着您的 MongoDB 实例正常工作,并且您已成功使用 MongoDB shell 与其进行交互。在接下来的章节中,您将了解更多关于 MongoDB 以及如何使用 MongoDB shell 的知识。

安装 Node.js

对于稳定版本,官方 Node.js 网站提供了链接的二进制文件,为 Linux、Mac OS X 和 Windows 提供了安装 Node.js 的最简单方法。请注意,您需要为您的操作系统下载正确的架构版本。如果您使用 Windows 或 Linux,请确保根据您的系统架构下载 32 位或 64 位版本。Mac 用户可以安全地下载 64 位版本。

注意

在 Node.js 和 io.js 项目合并后,版本方案直接从 0.12.x 继续到 4.x。团队现在使用长期支持LTS)政策。您可以在en.wikipedia.org/wiki/Long-term_support上了解更多信息。Node.js 的最新稳定版本是 6.x。

在 Windows 上安装 Node.js

在 Windows 机器上安装 Node.js 是一项简单的任务,可以使用独立安装程序轻松完成。首先,转到nodejs.org/en/download/并下载正确的.msi文件。请注意有 32 位和 64 位版本,因此请确保为您的系统下载正确的版本。

下载安装程序后,运行它。如果出现任何安全对话框,只需单击运行按钮,安装向导应该会启动。您将看到类似以下屏幕截图的安装屏幕:

在 Windows 上安装 Node.js

Node.js Windows 安装向导

一旦点击下一步按钮,安装将开始。几分钟后,您将看到一个类似以下截图的确认屏幕,告诉您 Node.js 已成功安装:

在 Windows 上安装 Node.js

Node.js 在 Windows 上的安装确认

在 Mac OS X 上安装 Node.js

在 Mac OS X 上安装 Node.js 是一个简单的任务,可以使用独立安装程序轻松完成。首先转到nodejs.org/en/download/页面并下载.pkg文件。下载安装程序后,运行它,您将看到一个类似以下截图的安装屏幕:

在 Mac OS X 上安装 Node.js

Node.js 在 Mac OS X 上的安装向导

点击继续,安装过程应该开始。安装程序将要求您确认许可协议,然后要求您选择文件夹目标。在再次点击继续按钮之前,选择最适合您的选项。然后安装程序将要求您确认安装信息,并要求您输入用户密码。几分钟后,您将看到一个类似于以下截图的确认屏幕,告诉您 Node.js 已成功安装:

在 Mac OS X 上安装 Node.js

Node.js 在 Mac OS X 上的安装确认

在 Linux 上安装 Node.js

要在 Linux 机器上安装 Node.js,您需要使用官方网站上的 tarball 文件。最好的方法是下载最新版本,然后使用make命令构建和安装源代码。首先转到nodejs.org/en/download/页面,下载适合的.tar.gz文件。然后,通过以下命令扩展文件并安装 Node.js:

$ tar -zxf node-v6.9.1.tar.gz
$ cd node-v6.9.1
$ ./configure && make && sudo make install

如果一切顺利,这些命令将在您的机器上安装 Node.js。请注意,这些命令适用于 Node.js 6.9.1 版本,所以请记得用您下载的版本替换版本号。

注意

建议您通过访问官方文档nodejs.org来了解更多关于 Node.js 的信息。

运行 Node.js

安装成功后,您将能够使用提供的命令行界面(CLI)开始尝试使用 Node.js。转到命令行工具并执行以下命令:

$ node

这将启动 Node.js CLI,它将等待 JavaScript 输入。要测试安装,请运行以下命令:

> console.log('Node is up and running!');

输出应该类似于以下内容:

Node is up and running!
undefined

这很好,但您还应该尝试执行一个 JavaScript 文件。首先创建一个名为application.js的文件,其中包含以下代码:

console.log('Node is up and running!');

要运行它,您需要通过以下命令将文件名作为第一个参数传递给 Node CLI:

$ node application.js
Node is up and running!

恭喜!您刚刚创建了您的第一个 Node.js 应用程序。要停止 CLI,请按CTRL + DCTRL + C

介绍 npm

Node.js 是一个平台,这意味着它的功能和 API 被保持在最低限度。为了实现更复杂的功能,它使用了一个模块系统,允许您扩展平台。安装、更新和删除 Node.js 模块的最佳方式是使用 npm。npm 主要用途包括:

  • 用于浏览、下载和安装第三方模块的包注册表

  • 用于管理本地和全局包的 CLI 工具

方便的是,npm 是在 Node.js 安装过程中安装的,所以让我们快速开始学习如何使用它。

使用 npm

为了了解 npm 的工作原理,我们将安装 Express web 框架模块,这将在接下来的章节中使用。npm 是一个强大的包管理器,它为公共模块保持了一个集中的注册表。要浏览可用的公共包,请访问官方网站www.npmjs.com/

注册表中的大多数包都是开源的,由 Node.js 社区开发者贡献。在开发开源模块时,包的作者可以决定将其发布到中央注册表,允许其他开发者下载并在他们的项目中使用它。在包配置文件中,作者将选择一个名称,以后将用作下载该包的唯一标识符。

注意

建议你通过访问官方文档docs.npmjs.com来学习更多关于 Node.js 的知识。

npm 的安装过程

重要的是要记住,npm 有两种安装模式:本地和全局。默认的本地模式经常被使用,并且会将第三方包安装在本地的node_modules文件夹中,放在应用程序文件夹内。它不会对系统产生影响,并且用于安装应用程序需要的包,而不会用不必要的全局文件污染系统。

全局模式用于安装你想要 Node.js 全局使用的包。通常,这些是 CLI 工具,比如 Grunt,在接下来的章节中你会学到。大多数情况下,包的作者会明确指示你全局安装包。因此,当有疑问时,请使用本地模式。全局模式通常会将包安装在 Unix 系统的/usr/local/lib/node_modules文件夹中,以及 Windows 系统的C:\Users\%USERNAME%\AppData\Roaming\npm\node_modules文件夹中,使其对系统上运行的任何 Node.js 应用程序可用。

使用 npm 安装一个包

一旦找到合适的包,你就可以使用npm install命令进行安装,如下所示:

$ npm install <Package Unique Name>

全局安装模块与本地安装模块类似,但你需要添加-g标志,如下所示:

$ npm install –g <Package Unique Name>

注意

你可能会发现你的用户没有权限全局安装包,所以你需要使用 root 用户或使用 sudo 进行安装。

例如,要在本地安装 Express,你需要导航到你的应用程序文件夹,并发出以下命令:

$ npm install express

上述命令将在本地的node_modules文件夹中安装 Express 包的最新稳定版本。此外,npm 支持广泛的语义版本。因此,要安装一个特定版本的包,你可以使用npm install命令,如下所示:

$ npm install <Package Unique Name>@<Package Version>

例如,要安装 Express 包的第二个主要版本,你需要发出以下命令:

$ npm install express@2.x 

这将安装 Express 2 的最新稳定版本。请注意,这种语法使 npm 能够下载并安装 Express 2 的任何次要版本。要了解更多关于支持的语义版本语法,请访问github.com/npm/node-semver

当一个包有依赖关系时,npm 会自动解析这些依赖关系,在package文件夹内的node_modules文件夹中安装所需的包。在前面的例子中,Express 的依赖关系将安装在node_modules/express/node_modules下。

使用 npm 移除一个包

要移除一个已安装的包,你需要导航到你的应用程序文件夹,并运行以下命令:

$ npm uninstall < Package Unique Name>

npm 然后会寻找这个包,并尝试从本地的node_modules文件夹中移除它。要移除一个全局包,你需要使用-g标志,如下所示:

$ npm uninstall –g < Package Unique Name>

使用 npm 更新一个包

要将一个包更新到最新版本,发出以下命令:

$ npm update < Package Unique Name>

npm 会下载并安装这个包的最新版本,即使它还不存在。要更新一个全局包,使用以下命令:

$ npm update –g < Package Unique Name>

使用 package.json 文件管理依赖关系

安装单个包很好,但很快,您的应用程序将需要使用多个包。因此,您需要一种更好的方法来管理这些依赖关系。为此,npm 允许您在应用程序的根文件夹中使用名为package.json的配置文件。在package.json文件中,您将能够定义应用程序的各种元数据属性,包括应用程序的名称、版本和作者等属性。这也是您定义应用程序依赖关系的地方。

package.json文件基本上是一个 JSON 文件,其中包含了描述应用程序属性所需的不同属性。使用最新的 Express 和 Grunt 包的应用程序将具有以下package.json文件:

{
  "name" : "MEAN",
  "version" : "0.0.1",
  "dependencies" : {
    "express" : "latest",
    "grunt" : "latest"
  }
}

注意

您的应用程序名称和版本属性是必需的,因此删除这些属性将阻止 npm 正常工作。

创建 package.json 文件

虽然您可以手动创建package.json文件,但更简单的方法是使用npm init命令。要这样做,使用命令行工具并发出以下命令:

$ npm init

npm 会询问您关于您的应用程序的一些问题,并将自动为您创建一个新的package.json文件。示例过程应该类似于以下截图:

创建 package.json 文件

在 Mac OS X 上使用npm init

创建package.json文件后,您需要修改它并添加一个dependencies属性。您的最终package.json文件应该如下代码片段所示:

{
  "name": "mean",
  "version": "0.0.1",
  "description": "My First MEAN Application",
  "main": "server.js",
  "directories": {
    "test": "test"
  },
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [
    "MongoDB",
    "Express",
    "Angular",
    "Node.js"
  ],
  "author": "Amos Haviv",
  "license": "MIT",
  "dependencies": {
    "express": "latest",
    "grunt": "latest"
  }
}

注意

在上述代码示例中,我们使用了latest关键字告诉 npm 安装这些包的最新版本。然而,强烈建议您使用特定的版本号或范围,以防止您的应用程序依赖关系在开发周期中发生变化。这是因为新的包版本可能与旧版本不兼容,这将导致应用程序出现重大问题。

安装 package.json 的依赖项

创建package.json文件后,您可以通过转到应用程序的根文件夹并使用npm install命令来安装应用程序的依赖项,如下所示:

$ npm install

npm 将自动检测您的package.json文件并安装所有应用程序的依赖项,将它们放在本地的node_modules文件夹下。安装依赖项的另一种方法,有时更好的方法是使用以下npm update命令:

$ npm update

这将安装任何缺少的包,并将更新所有现有依赖项到它们指定的版本。

更新 package.json 文件

npm install命令的另一个强大功能是能够安装新包并将包信息保存为package.json文件中的依赖项。在安装特定包时,可以使用--save可选标志来实现这一点。例如,要安装最新版本的 Express 并将其保存为依赖项,只需使用以下命令:

$ npm install express --save

npm 将安装 Express 的最新版本,并将 Express 包添加为package.json文件的依赖项。为了清晰起见,在接下来的章节中,我们更喜欢手动编辑package.json文件。然而,这个有用的功能在您的日常开发周期中可能非常方便。

注意

建议您通过访问官方文档docs.npmjs.com/files/package.json了解更多关于 npm 庞大的配置选项。

摘要

在本章中,您学习了如何安装 MongoDB 以及如何使用 MongoDB shell 连接到本地数据库实例。您还学习了如何安装 Node.js 并使用 Node.js CLI。您了解了 npm 并发现了如何使用它来下载和安装 Node.js 包。您还学习了如何使用package.json文件轻松管理应用程序的依赖关系。

在下一章中,我们将讨论一些 Node.js 基础知识,您将构建您的第一个 Node.js Web 应用程序。

第二章:开始使用 Node.js

在上一章中,您设置了您的环境并发现了 Node.js 的基本开发原则。本章将介绍构建您的第一个 Node.js Web 应用程序的正确方法。您将学习 JavaScript 事件驱动的基础知识以及如何利用它来构建 Node.js 应用程序。您还将了解 Node.js 模块系统以及如何构建您的第一个 Node.js Web 应用程序。然后,您将继续学习 Connect 模块,并了解其强大的中间件方法。在本章结束时,您将知道如何使用 Connect 和 Node.js 构建简单而强大的 Web 应用程序。在本章中,我们将涵盖以下主题:

  • Node.js 介绍

  • JavaScript 闭包和事件驱动编程

  • Node.js 事件驱动的 Web 开发

  • CommonJS 模块和 Node.js 模块系统

  • Connect Web 框架介绍

  • Connect 的中间件模式

Node.js 介绍

在 2009 年的 JSConf EU 上,一位名叫 Ryan Dahl 的开发人员上台介绍了他的项目 Node.js。从 2008 年开始,Dahl 研究了当前的 Web 趋势,并发现了 Web 应用程序工作方式的一些奇怪之处。几年前引入的异步 JavaScript 和 XMLAJAX)技术将静态网站转变为动态 Web 应用程序,但 Web 开发的基本构建块并没有遵循这一趋势。

问题在于 Web 技术不支持浏览器和服务器之间的双向通信。他使用的测试案例是 Flickr 上传文件功能,浏览器无法知道何时更新进度条,因为服务器无法告知它已上传文件的多少。

Dahl 的想法是构建一个 Web 平台,能够从服务器优雅地支持向浏览器推送数据,但这并不简单。当扩展到常见的 Web 使用时,该平台必须支持服务器和浏览器之间数百(有时甚至数千)个正在进行的连接。大多数 Web 平台使用昂贵的线程来处理请求,这意味着要保持相当数量的空闲线程以保持连接活动。因此,Dahl 采用了不同的方法。他意识到使用非阻塞套接字可以在系统资源方面节省很多,并且证明了这可以通过 C 来实现。鉴于这种技术可以在任何编程语言中实现,以及 Dahl 认为使用非阻塞 C 代码是一项繁琐的任务,他决定寻找一种更好的编程语言。

当谷歌在 2008 年底宣布推出 Chrome 及其新的 V8 JavaScript 引擎时,很明显 JavaScript 可以比以前运行得更快 - 快得多。 V8 引擎相对于其他 JavaScript 引擎的最大优势是在执行之前将 JavaScript 代码编译为本机机器代码。这和其他优化使 JavaScript 成为一种能够执行复杂任务的可行编程语言。 Dahl 注意到了这一点,并决定尝试一个新的想法:在 JavaScript 中使用非阻塞套接字。他拿了 V8 引擎,用已经稳固的 C 代码包装起来,创建了 Node.js 的第一个版本。

在社区的热烈反响之后,他继续扩展了 Node 核心。 V8 引擎并不是为了在服务器环境中运行而构建的,因此 Node.js 必须以一种在服务器上更有意义的方式来扩展它。例如,浏览器通常不需要访问文件系统,但在运行服务器代码时,这变得至关重要。结果是 Node.js 不仅仅是一个 JavaScript 执行引擎,而是一个能够运行简单编码、高效且易于扩展的复杂 JavaScript 应用程序的平台。

io.js 和 Node.js 基金会

到 2014 年底,Joyent 公司,拥有 Node.js 资产的公司,与项目的一些核心贡献者之间产生了冲突。这些开发人员认为项目的治理不足,因此他们要求 Joyent 创建一个非营利基金会来管理该项目。2015 年 1 月,该团队决定分叉 Node.js 项目,并将其称为 io.js。新项目旨在实现更快和更可预测的发布周期,并开始获得一些关注。

几个月后,io.js 团队得到公司和社区开发者的支持,受邀到 Joyent 的办公室讨论项目的未来。他们一起决定创建一个由技术指导委员会领导的 Node 基金会,将项目合并为 Node.js 品牌,并基于 io.js 存储库。这导致了 Node 发布周期的大幅升级和项目治理的更加透明。

Node.js ES6 支持

尽管 Node.js 在旧版本中已经实现了部分 ES6 支持,但最新版本在实现 ES6 功能方面取得了更好的进展。出于稳定性原因,Node V8 引擎将 ES6 功能分为三个分类:

  • Shipping:所有被认为是稳定的功能并且默认开启。这意味着它们需要任何运行时标志来激活。

  • Staged:几乎稳定但不建议在生产中使用的所有功能。这些功能可以使用--es_staging运行时标志或其更为常见的同义词--harmony标志来激活。

  • In progress:所有仍在进行中且不稳定的功能。这些功能可以使用它们各自的--harmony标志来激活。

尽管这超出了本书的范围,但建议您访问官方文档nodejs.org/en/docs/es6/,了解更多关于 Node.js 中 ES6 实现的信息。

Node.js LTS 支持

随着 Node.js 社区的不断壮大,越来越多的公司和大型组织加入进来,导致对稳定性和可预测版本发布的需求不断增加。为了满足这些新需求,Node.js 基金会决定了一个新的发布周期。基本上,团队每年 10 月发布一个新的稳定版本。这个版本总是有一个偶数版本号,比如 v4 或 v6。这些稳定版本受 LTS 计划支持。它包括安全和稳定更新,并且一旦它们在 10 月进入 LTS 计划,就可以在生产中使用。每年 4 月,一个稳定版本从 LTS 计划中发布。这意味着总是有两个重叠的稳定版本,最长为 6 个月,每个稳定版本都有 18 个月的支持。奇数版本被认为不稳定,主要用于向社区展示路线图的实现。这些版本在 10 月份被切割,以便及时合并到新的稳定版本中。

以下是未来几年发布周期的简单路线图:

Node.js LTS support

JavaScript 事件驱动编程

Node.js 利用 JavaScript 的事件驱动特性来支持平台中的非阻塞操作,这一特性使其具有出色的效率。JavaScript 是一种事件驱动的语言,这意味着您可以将代码注册到特定的事件上,一旦事件被触发,这些代码就会被执行。这个概念允许您无缝地执行异步代码,而不会阻止程序的其余部分运行。

为了更好地理解这一点,看一下以下的 Java 代码示例:

System.out.print("What is your name?"); 
String name = System.console().readLine();
System.out.print("Your name is: " + name); 

在这个例子中,程序执行第一行和第二行,但在第二行之后的任何代码都不会被执行,直到用户输入他们的名字。这是同步编程,其中 I/O 操作阻止程序的其余部分运行。然而,这不是 JavaScript 的工作方式。

最初设计用于支持浏览器操作,JavaScript 围绕浏览器事件进行了设计。尽管它自早期以来已经大大发展,但其设计理念是允许浏览器接收 HTML 用户事件并将其委托给 JavaScript 代码。让我们看下面的 HTML 示例:

<span>What is your name?</span>
<input type="text" id="nameInput">
<input type="button" id="showNameButton" value="Show Name">
<script type="text/javascript">
const showNameButton = document.getElementById('showNameButton');

showNameButton.addEventListener('click', (event) => {
    alert(document.getElementById('nameInput').value);
});

// Rest of your code...
</script>

在上面的例子中,我们有一个文本框和一个按钮。当按下按钮时,它将警报文本框内的值。这里要关注的主要函数是addEventListener()方法。如您所见,它接受两个参数:事件的名称和一个匿名函数,该函数在事件发生时运行一次。我们通常将后一种参数称为回调函数。请注意,addEventListener()方法之后的任何代码都将相应地执行,而不管我们在回调函数中写了什么。

尽管这个例子很简单,但很好地说明了 JavaScript 如何使用事件来执行一组命令。由于浏览器是单线程的,在这个例子中使用同步编程会冻结页面上的所有其他内容,这将使每个网页都变得极其不响应,并且会影响整体的网页体验。幸运的是,事实并非如此。浏览器使用内部循环(通常称为事件循环)来管理单个线程来运行整个 JavaScript 代码。事件循环是浏览器无限运行的单线程循环。每次发出事件时,浏览器都会将其添加到事件队列中。然后循环将从队列中获取下一个事件,以执行注册到该事件的事件处理程序。

所有事件处理程序执行完毕后,循环会获取下一个事件,执行其处理程序,再获取另一个事件,依此类推。事件循环周期如下图所示:

JavaScript 事件驱动编程

事件循环周期

虽然浏览器通常处理用户生成的事件(例如按钮点击),但 Node.js 必须处理从不同来源生成的各种类型的事件。

Node.js 事件驱动编程

在开发 Web 服务器逻辑时,您可能会注意到大量系统资源被阻塞代码浪费。例如,让我们观察以下 PHP 数据库交互:

$output = mysql_query('SELECT * FROM Users');
echo($output);

我们的服务器将尝试查询数据库。数据库将执行SELECT语句,并将结果返回给 PHP 代码,最终将数据输出为响应。上述代码会阻塞其他操作,直到从数据库获取结果。这意味着该进程,或更常见的是线程,将保持空闲状态,消耗系统资源,同时等待其他进程。

为了解决这个问题,许多 Web 平台已经实现了一个线程池系统,通常为每个连接发出一个单个线程。这种多线程可能一开始看起来很直观,但有一些显著的缺点。它们如下:

  • 管理线程变得复杂

  • 系统资源被空闲线程浪费

  • 这些应用程序的扩展性不容易实现

这在开发单向 Web 应用程序时是可以容忍的,其中浏览器发出快速请求,以服务器响应结束。但是,当您想要构建保持浏览器和服务器之间长期连接的实时应用程序时会发生什么?要了解这些设计选择的现实后果,请看以下图表。它们展示了 Apache(一个阻塞式 Web 服务器)和使用非阻塞事件循环的 NGINX 之间的著名性能比较。以下截图显示了 Apache 与 NGINX 中的并发请求处理(blog.webfaction.com/2008/12/a-little-holiday-present-10000-reqssec-with-nginx-2/):

Node.js 事件驱动编程

Apache 与 NGINX 中并发连接对请求处理的影响。

在上图中,您可以看到 Apache 的请求处理能力下降得比 NGINX 快得多。在下图中可以更清楚地看到 NGINX 的事件循环架构如何影响内存消耗:

Node.js 事件驱动编程

Apache 与 NGINX 中并发连接对内存分配的影响。

从结果中可以看出,使用事件驱动架构将帮助您大大减少服务器的负载,同时利用 JavaScript 的异步行为来构建您的 Web 应用程序。这种方法更容易实现,这要归功于一个称为闭包的简单设计模式。

JavaScript 闭包

闭包是指从其父环境引用变量的函数。为了更好地理解它们,让我们看一个例子:

function parent() {
    const message = 'Hello World';

    function child() { 
        alert (message);
    }

    child(); 
}

parent();

在上面的例子中,您可以看到child()函数可以访问在parent()函数中定义的常量。然而,这只是一个简单的例子,让我们看一个更有趣的例子:

function parent() {
   const message = 'Hello World'; 

    function child() { 
    alert (message); 
  }

   return child;
}

const childFN = parent();
childFN();

这一次,parent()函数返回了child()函数,并且child()函数是在parent()函数已经执行之后被调用的。这对一些开发人员来说是违反直觉的,因为通常parent()函数的局部成员应该只在函数执行时存在。这就是闭包的全部内容!闭包不仅仅是函数,还包括函数创建时存在的环境。在这种情况下,childFN()是一个闭包对象,包括child()函数和在创建闭包时存在的环境成员,包括message常量。

闭包在异步编程中非常重要,因为 JavaScript 函数是一级对象,可以作为参数传递给其他函数。这意味着您可以创建一个回调函数,并将其作为参数传递给事件处理程序。当事件被触发时,函数将被调用,并且它将能够操作在创建回调函数时存在的任何成员,即使其父函数已经执行。这意味着使用闭包模式将帮助您利用事件驱动编程,而无需将作用域状态传递给事件处理程序。

Node 模块

JavaScript 已经成为一种功能强大的语言,具有一些独特的特性,可以实现高效而可维护的编程。它的闭包模式和事件驱动行为在现实场景中被证明非常有帮助,但像所有编程语言一样,它并不完美。其主要设计缺陷之一是共享单个全局命名空间。

要理解这个问题,我们需要回到 JavaScript 的浏览器起源。在浏览器中,当您将脚本加载到网页中时,引擎将其代码注入到所有其他脚本共享的地址空间中。这意味着当您在一个脚本中分配一个变量时,您可能会意外地覆盖先前脚本中已定义的另一个变量。虽然这可能适用于小型代码库,但在更大的应用程序中很容易引起冲突,因为错误将很难追踪。这可能是 Node.js 作为一个平台的主要威胁,但幸运的是,在 CommonJS 模块标准中找到了一个解决方案。

CommonJS 模块

CommonJS 是一个于 2009 年开始的项目,旨在规范浏览器外部的 JavaScript 工作方式。从那时起,该项目已经发展,以支持各种 JavaScript 问题,包括全局命名空间问题,通过简单的规范来编写和包含隔离的 JavaScript 模块来解决。

CommonJS 标准在处理模块时指定了以下关键组件:

  • require(): 用于将模块加载到您的代码中的方法。

  • exports: 每个模块中包含的对象,允许在加载模块时公开代码片段。

  • module:最初用于提供有关模块的元数据信息的对象。它还包含exports对象的指针作为属性。然而,将exports对象作为独立对象的流行实现实际上改变了module对象的用例。

在 Node 的 CommonJS 模块实现中,每个模块都是在单个 JavaScript 文件中编写的,并具有一个持有自己成员的隔离作用域。模块的作者可以通过exports对象公开任何功能。为了更好地理解这一点,假设我们创建了一个名为hello.js的模块文件,其中包含以下代码段:

const message = 'Hello';

exports.sayHello = function(){
  console.log(message);
}

我们还创建了一个名为server.js的应用程序文件,其中包含以下代码:

const hello = require('./hello');
hello.sayHello();

在前面的例子中,你有一个名为hello的模块,其中包含一个名为message的常量。消息常量是在hello模块内部自包含的,它只通过将其定义为exports对象的属性来公开sayHello()方法。然后,应用程序文件使用require()方法加载hello模块,这允许它调用hello模块的sayHello()方法。

创建模块的另一种方法是使用module.exports指针公开单个函数。为了更好地理解这一点,让我们修改前面的例子。修改后的hello.js文件应该如下所示:

module.exports = function() {
  const message = 'Hello';

  console.log(message);
}

然后,模块在server.js文件中加载如下:

const hello = require('./hello');
hello();

在前面的例子中,应用程序文件直接将hello模块作为函数使用,而不是将sayHello()方法作为hello模块的属性使用。

CommonJS 模块标准允许对 Node.js 平台进行无限扩展,同时防止污染 Node 的核心。没有它,Node.js 平台将变成一团混乱。然而,并非所有模块都是相同的,在开发 Node 应用程序时,你将遇到多种类型的模块。

注意

当你需要模块时,可以省略.js扩展名。Node 会自动查找同名的文件夹,如果找不到,它会查找一个适用的.js文件。

Node.js 核心模块

核心模块是编译到 Node 二进制文件中的模块。它们与 Node 一起预先捆绑,并在其文档中有详细解释。核心模块提供了 Node 的大部分基本功能,包括文件系统访问、HTTP 和 HTTPS 接口等。要加载核心模块,你只需要在你的 JavaScript 文件中使用require方法。

使用fs核心模块读取环境主机文件内容的示例代码如下所示:

const fs = require('fs');

fs.readFile('/etc/hosts', 'utf8', (err, data) => { 
  if (err) { 
   return console.log(err); 
  } 

  console.log(data); 
});

当你需要fs模块时,Node 会在core modules文件夹中找到它。然后你就可以使用fs.readFile()方法来读取文件内容并将其打印在命令行输出中。

注意

要了解更多关于 Node 的核心模块的信息,建议你访问官方文档nodejs.org/api/

Node.js 第三方模块

在上一章中,你学会了如何使用 npm 安装第三方模块。你可能还记得,npm 会将这些模块安装在应用程序根文件夹下名为node_modules的文件夹中。要使用第三方模块,你可以像通常加载核心模块一样加载它们。Node 首先会在core modules文件夹中查找模块,然后尝试从node_modules文件夹中的module文件夹加载模块。例如,要使用express模块,你的代码应该如下所示:

const express = require('express');
const app = express();

然后 Node 会在node_modules文件夹中查找express模块,并将其加载到你的应用程序文件中,你将能够将其用作生成express应用程序对象的方法。

Node.js 文件模块

在前面的例子中,您看到了 Node 如何直接从文件加载模块。这些例子描述了文件位于同一文件夹中的情况。但是,您也可以将模块放在文件夹中,并通过提供文件夹路径来加载它们。假设您将 hello 模块移动到一个名为 modules 的文件夹中。应用程序文件将不得不更改,因此 Node 将在新的相对路径中寻找模块:

const hello = require('./modules/hello');

请注意,路径也可以是绝对路径,如下所示:

const hello = require('/home/projects/first-example/modules/hello');

然后 Node 将在该路径中查找 hello 模块。

Node.js 文件夹模块

尽管这对于不编写第三方 Node 模块的开发人员来说并不常见,但 Node 也支持加载文件夹模块。加载文件夹模块的方式与加载文件模块相同,如下所示:

const hello = require('./modules/hello');

现在,如果存在一个名为 hello 的文件夹,Node 将浏览该文件夹,寻找一个 package.json 文件。如果 Node 找到了 package.json 文件,它将尝试解析它,寻找 main 属性,一个看起来像以下代码片段的 package.json 文件:

{
  "name": "hello",
  "version": "1.0.0",
  "main": "./hello-module.js"
}

Node 将尝试加载 ./hello/hello-module.js 文件。如果 package.json 文件不存在或 main 属性未定义,Node 将自动尝试加载 ./hello/index.js 文件。

Node.js 模块被发现是编写复杂 JavaScript 应用程序的一个很好的解决方案。它们帮助开发人员更好地组织他们的代码,而 npm 及其第三方模块注册表帮助他们找到并安装了社区创建的众多第三方模块之一。Ryan Dahl 建立更好的 Web 框架的梦想最终成为了一个支持各种解决方案的平台。然而,这个梦想并没有被放弃;它只是作为一个名为 express 的第三方模块实现了。

开发 Node.js Web 应用程序

Node.js 是一个支持各种类型应用程序的平台,但最流行的是 Web 应用程序的开发。Node 的编码风格取决于社区通过第三方模块扩展平台。然后,这些模块被用来创建新模块,以此类推。全球的公司和单个开发人员都参与到这个过程中,通过创建包装基本 Node API 的模块,为应用程序开发提供更好的起点。

有许多模块支持 Web 应用程序开发,但没有一个像 Connect 模块那样受欢迎。Connect 模块提供了一组包装器,围绕 Node.js 低级 API,以实现丰富的 Web 应用程序框架的开发。要了解 Connect 的全部内容,让我们从一个基本的 Node Web 服务器的基本示例开始。在您的工作文件夹中,创建一个名为 server.js 的文件,其中包含以下代码片段:

const http = require('http');

http.createServer(function(req, res) => {
  res.writeHead(200, {
    'Content-Type': 'text/plain'
  });
  res.end('Hello World');
}).listen(3000);

console.log('Server running at http://localhost:3000/');

启动您的 Web 服务器,使用命令行工具并导航到您的工作文件夹。然后,运行 Node.js CLI 工具,并运行 server.js 文件如下:

$ node server

现在,在浏览器中打开 http://localhost:3000,您将看到 Hello World 的响应。

那么,这是如何工作的呢?在这个例子中,http 模块用于创建一个监听 3000 端口的小型 Web 服务器。您首先需要引入 http 模块,然后使用 createServer() 方法返回一个新的服务器对象。然后使用 listen() 方法来监听 3000 端口。请注意,回调函数作为参数传递给 createServer() 方法。

每当 Web 服务器收到 HTTP 请求时,回调函数都会被调用。然后服务器对象将传递 reqres 参数,其中包含发送 HTTP 响应所需的信息和功能。然后回调函数将遵循以下两个步骤:

  1. 首先,它将调用 res 对象的 writeHead() 方法。此方法用于设置响应的 HTTP 标头。在这个例子中,它将把 content-type 标头值设置为 text/plain。例如,当响应 HTML 时,只需用 html/plain 替换 text/plain

  2. 然后,它将调用res对象的end()方法。这个方法用于完成响应。end()方法接受一个单字符串参数,它将作为 HTTP 响应主体使用。另一种常见的写法是在end()方法之前添加一个write()方法,然后调用end()方法,如下所示:

res.write('Hello World');
res.end();

这个简单的应用程序展示了 Node 的编码风格,其中使用低级 API 来简单实现某些功能。虽然这是一个很好的例子,但是使用低级 API 运行完整的 web 应用程序将需要您编写大量的辅助代码来支持常见的需求。幸运的是,一个名为 Sencha 的公司已经为您创建了这个脚手架代码,以 Node.js 模块的形式称为 Connect。

了解 Connect 模块

Connect 是一个模块,旨在以更模块化的方式支持请求的拦截。在第一个 web 服务器示例中,您学习了如何使用http模块构建一个简单的 web 服务器。如果您希望扩展此示例,您将需要编写代码来管理发送到服务器的不同 HTTP 请求,正确处理它们,并为每个请求提供正确的响应。

Connect 创建了一个专门用于此目的的 API。它使用了一个名为middleware的模块化组件,允许您简单地将应用逻辑注册到预定义的 HTTP 请求场景中。Connect 中间件基本上是回调函数,当发生 HTTP 请求时会被执行。然后中间件可以执行一些逻辑,返回一个响应,或者调用下一个注册的中间件。

虽然您大多数情况下会编写自定义中间件来支持应用程序的需求,但 Connect 还包括一些常见的中间件,以支持日志记录、静态文件服务等。

Connect 应用程序的工作方式是使用一个名为dispatcher的对象。调度程序对象处理服务器接收到的每个 HTTP 请求,然后以级联形式决定中间件执行的顺序。要更好地理解 Connect,请查看以下图示:

了解 Connect 模块

使用中间件执行请求

上述图示了对 Connect 应用程序的两个调用:第一个由自定义中间件处理,第二个由静态文件中间件处理。Connect 的调度程序启动了这个过程,使用next()方法继续到下一个处理程序,直到它到达一个使用res.end()方法响应的中间件,这将结束请求处理。

在下一章中,您将创建您的第一个 Express 应用程序,但 Express 是基于 Connect 的方法。因此,为了理解 Express 的工作原理,我们将从创建一个 Connect 应用程序开始。

在您的工作文件夹中,创建一个名为server.js的文件,其中包含以下代码片段:

const connect = require('connect');
const app = connect();
app.listen(3000); 

console.log('Server running at http://localhost:3000/');

如您所见,您的应用程序文件正在使用connect模块创建一个新的 web 服务器。但是,Connect 不是一个核心模块,因此您需要使用 npm 安装它。正如您已经知道的,有几种安装第三方模块的方法。最简单的方法是直接使用npm install命令进行安装。要这样做,使用命令行工具,导航到您的工作文件夹。然后,执行以下命令:

$ npm install connect

npm 将在node_modules文件夹中安装connect模块,这将使您能够在应用程序文件中引用它。要运行 Connect web 服务器,只需使用 Node 的 CLI 并执行以下命令:

$ node server

Node 将运行您的应用程序,并使用console.log()方法报告服务器状态。您可以尝试在浏览器中访问http://localhost:3000来访问您的应用程序。但是,您应该会得到类似以下截图所示的响应:

了解 Connect 模块

这个响应的意思是没有任何中间件注册来处理 GET HTTP 请求。这意味着首先,您成功安装并使用了 Connect 模块,其次,现在是时候编写您的第一个 Connect 中间件了。

Connect 中间件

Connect 中间件基本上是一个具有独特签名的 JavaScript 函数。每个中间件函数都使用以下三个参数定义:

  • req:这是一个保存 HTTP 请求信息的对象

  • res:这是一个保存 HTTP 响应信息并允许您设置响应属性的对象

  • next:这是在有序的 Connect 中间件集合中定义的下一个中间件函数

当您定义了一个中间件时,您只需使用app.use()方法将其注册到 Connect 应用程序中。让我们修改前面的例子,包括您的第一个中间件。将您的server.js文件更改为以下代码片段:

const connect = require('connect');
const app = connect();

function helloWorld(req, res, next) {
 res.setHeader('Content-Type', 'text/plain');
 res.end('Hello World');
};
app.use(helloWorld);

app.listen(3000); 
console.log('Server running at http://localhost:3000/');

然后,通过在命令行工具中发出以下命令,再次启动您的 Connect 服务器:

$ node server

再次访问http://localhost:3000。您现在将会得到与以下截图中类似的响应:

Connect middleware

如果您看到 Connect 应用程序的响应与之前的截图相同,那么恭喜您!您刚刚创建了您的第一个 Connect 中间件!

让我们回顾一下。首先,您添加了一个名为helloWorld()的中间件函数,它有三个参数:reqresnext。在您的中间件函数内部,您使用了res.setHeader()方法来设置响应的Content-Type头部和res.end()方法来设置响应文本。最后,您使用了app.use()方法来将您的中间件注册到 Connect 应用程序中。

理解 Connect 中间件的顺序

Connect 最大的特点之一是能够注册尽可能多的中间件函数。使用app.use()方法,您可以设置一系列中间件函数,这些函数将按顺序执行,以实现编写应用程序时的最大灵活性。Connect 将使用next参数将下一个中间件函数传递给当前执行的中间件函数。在每个中间件函数中,您可以决定是调用下一个中间件函数还是停在当前中间件函数。请注意,每个中间件函数将使用下一个参数按照先进先出FIFO)的顺序执行,直到没有更多的中间件函数要执行或者没有调用下一个中间件函数。

为了更好地理解这一点,我们将回到之前的例子,并添加一个记录器函数,它将在命令行中记录发送到服务器的所有请求。为此,返回到server.js文件,并更新如下:

const connect = require('connect');
const app = connect();

function logger(req, res, next) {
 console.log(req.method, req.url);
 next();
};

function helloWorld(req, res, next) {
  res.setHeader('Content-Type', 'text/plain');
  res.end('Hello World');
};

app.use(logger);
app.use(helloWorld);
app.listen(3000);

console.log('Server running at http://localhost:3000/');

在前面的例子中,您添加了另一个名为logger()的中间件。logger()中间件使用console.log()方法简单地将请求信息记录到控制台。请注意,logger()中间件在helloWorld()中间件之前注册。这很重要,因为它决定了每个中间件执行的顺序。还要注意的一点是logger()中间件中的next()调用,它负责调用helloWorld()中间件。如果删除next()调用,将会停止在logger()中间件处执行中间件函数,这意味着请求将永远挂起,因为没有调用res.end()方法来结束响应。

要测试您的更改,请通过在命令行工具中发出以下命令,再次启动您的 Connect 服务器:

$ node server

然后,在浏览器中访问http://localhost:3000,注意命令行工具中的控制台输出。

挂载 Connect 中间件

正如你可能已经注意到的,你注册的中间件会响应任何请求,而不管请求路径如何。这不符合现代 Web 应用程序开发的要求,因为响应不同路径是所有 Web 应用程序的一个重要部分。幸运的是,Connect 中间件支持一种称为挂载的功能,它使你能够确定中间件函数需要执行的请求路径。挂载是通过向app.use()方法添加路径参数来完成的。为了更好地理解这一点,让我们重新访问我们之前的例子。修改你的server.js文件,使其看起来像以下代码片段:

const connect = require('connect');
const app = connect();

function logger(req, res, next) {
  console.log(req.method, req.url);

  next();
};

function helloWorld(req, res, next) {
  res.setHeader('Content-Type', 'text/plain');
  res.end('Hello World');
};

function goodbyeWorld(req, res, next) {
 res.setHeader('Content-Type', 'text/plain');
 res.end('Goodbye World');
};

app.use(logger);
app.use('/hello', helloWorld);
app.use('/goodbye', goodbyeWorld);
app.listen(3000);

console.log('Server running at http://localhost:3000/');

在之前的例子中有一些变化。首先,你将helloWorld()中间件挂载到仅响应对/hello路径发出的请求。然后,你添加了另一个(有点令人沮丧)中间件,名为goodbyeWorld(),它将响应对/goodbye路径发出的请求。请注意,正如logger应该做的那样,我们让logger()中间件响应服务器上的所有请求。另一件你应该注意的事情是,任何发往基本路径的请求都不会被任何中间件响应,因为我们将helloWorld()中间件挂载到了特定路径。

Connect 是一个很棒的模块,支持常见 Web 应用程序的各种功能。Connect 中间件非常简单,因为它是以 JavaScript 风格构建的。它允许无限扩展应用逻辑,而不会破坏 Node 平台的灵活哲学。虽然 Connect 在编写 Web 应用程序基础设施方面有很大改进,但它故意缺少一些你在其他 Web 框架中习惯拥有的基本功能。原因在于 Node 社区的一个基本原则:创建精简的模块,让其他开发人员在你创建的模块基础上构建自己的模块。社区应该用自己的模块扩展 Connect,并创建自己的 Web 基础设施。事实上,一个名叫 TJ Holowaychuk 的非常有活力的开发人员做得比大多数人都好,他发布了一个基于 Connect 的 Web 框架,名为 Express。

总结

在本章中,你学会了 Node.js 如何利用 JavaScript 的事件驱动行为来获益。你还了解了 Node.js 如何使用 CommonJS 模块系统来扩展其核心功能。此外,你还了解了 Node.js Web 应用程序的基本原则,并发现了 Connect Web 模块。最后,你创建了你的第一个 Connect 应用程序,并学会了如何使用中间件函数。

在下一章中,当我们讨论基于 Connect 的 Web 框架 Express 时,我们将解决 MEAN 拼图的第一部分。

第三章:构建一个 Express Web 应用程序

本章将介绍构建你的第一个 Express 应用程序的正确方法。你将首先安装和配置 Express 模块,然后学习 Express 的主要 API。我们将讨论 Express 请求、响应和应用程序对象,并学习如何使用它们。然后我们将介绍 Express 路由机制,并学习如何正确使用它。我们还将讨论应用程序文件夹的结构以及如何利用不同的结构来处理不同的项目类型。在本章结束时,你将学会如何构建一个完整的 Express 应用程序。在本章中,我们将涵盖以下主题:

  • 安装 Express 并创建一个新的 Express 应用程序

  • 组织你的项目结构

  • 配置你的 Express 应用程序

  • 使用 Express 路由机制

  • 渲染 EJS 视图

  • 提供静态文件

  • 配置 Express 会话

介绍 Express

说 TJ Holowaychuk 是一个富有成效的开发者几乎是一个巨大的低估。TJ 在 Node.js 社区的参与几乎是任何其他开发者无法比拟的,他负责一些 JavaScript 生态系统中最受欢迎的框架,拥有 500 多个开源项目。

他最伟大的项目之一是 Express web 框架。Express 框架是一组常见的 Web 应用程序功能的最小集合,以保持 Node.js 风格。它建立在 Connect 之上,并利用其中间件架构。其功能扩展 Connect,允许各种常见的 Web 应用程序用例,例如包含模块化 HTML 模板引擎,扩展响应对象以支持各种数据格式输出,路由系统等等。

到目前为止,我们已经使用了一个server.js文件来创建我们的应用程序。然而,使用 Express 时,你将学习更多关于更好的项目结构,正确配置你的应用程序,并将应用程序逻辑分解为不同的模块。你还将学习如何使用 EJS 模板引擎,管理会话,并添加路由方案。在本节结束时,你将拥有一个可用的应用程序框架,你将在本书的其余部分中使用它。让我们开始创建你的第一个 Express 应用程序的旅程。

安装 Express

到目前为止,我们使用 npm 直接为我们的 Node 应用程序安装外部模块。当然,你可以使用这种方法,并通过输入以下命令来安装 Express:

$ npm install express

然而,直接安装模块并不是真正可扩展的。想一想:你将在应用程序中使用许多 Node 模块,在工作环境之间传输它,并且可能与其他开发人员共享它。因此,以这种方式安装项目模块很快就会变成一项可怕的任务。相反,你应该开始使用package.json文件,它可以组织项目元数据并帮助你管理应用程序的依赖关系。首先,创建一个新的工作文件夹,并在其中创建一个新的package.json文件,其中包含以下代码片段:

{
  "name" : "MEAN",
  "version" : "0.0.3",
  "dependencies" : {
    "express" : "4.14.0"
  }
}

package.json文件中,注意到你包含了三个属性:应用程序的名称和版本,以及依赖属性,它定义了在应用程序运行之前应安装哪些模块。要安装应用程序的依赖项,请使用命令行工具并导航到应用程序文件夹,然后发出以下命令:

$ npm install

npm 然后会安装 Express 模块,因为目前它是在你的package.json文件中定义的唯一依赖项。

创建你的第一个 Express 应用程序

创建你的第一个 Express 应用程序

const express = require('express');
const app = express();

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

app.listen(3000);
console.log('Server running at http://localhost:3000/');

module.exports = app;

你应该已经认识到大部分代码了。前两行需要 Express 模块并创建一个新的 Express 应用程序对象。然后,我们使用app.use()方法来挂载一个具有特定路径的中间件函数,以及app.listen()方法来告诉 Express 应用程序监听端口3000。注意module.exports对象是如何用于返回app对象的。这将帮助你加载和测试你的 Express 应用程序。

这段新代码对你来说也应该很熟悉,因为它类似于你在之前的 Connect 示例中使用的代码。这是因为 Express 以多种方式包装了 Connect 模块。app.use()方法用于挂载一个中间件函数,该函数将响应任何发送到根路径的 HTTP 请求。在中间件函数内部,res.status()方法用于设置 HTTP 响应代码,res.send()方法用于发送响应。res.send()方法基本上是一个 Express 包装器,根据响应对象类型设置 Content-Type 标头,然后使用 Connect 的res.end()方法发送响应。

注意

当将缓冲区传递给res.send()方法时,Content-Type 标头将设置为application/octet-stream;当传递字符串时,它将设置为text/html;当传递对象或数组时,它将设置为application/json

要运行你的应用程序,只需在命令行工具中执行以下命令:

$ node server

恭喜!你刚刚创建了你的第一个 Express 应用程序。你可以通过访问http://localhost:3000在浏览器中测试它。

应用程序、请求和响应对象

Express 提供了三个主要对象,你会经常使用它们。应用对象是你在第一个例子中创建的 Express 应用程序的实例,通常用于配置你的应用程序。请求对象是 Node 的 HTTP 请求对象的包装器,用于提取关于当前处理的 HTTP 请求的信息。响应对象是 Node 的 HTTP 响应对象的包装器,用于设置响应数据和标头。

应用对象

应用对象包含以下方法,帮助你配置你的应用程序:

  • app.set(name, value): 这是一个用于设置 Express 将在其配置中使用的环境变量的方法。

  • app.get(name): 这是一个用于获取 Express 在其配置中使用的环境变量的方法。

  • app.engine(ext, callback): 这是一个用于定义给定模板引擎以渲染特定文件类型的方法;例如,你可以告诉 EJS 模板引擎使用 HTML 文件作为模板,就像这样:app.engine('html', require('ejs').renderFile)

  • app.locals: 这是一个用于向所有渲染的模板发送应用级变量的属性。

  • app.use([path], callback): 这是一个用于创建 Express 中间件来处理发送到服务器的 HTTP 请求的方法。可选地,你可以挂载中间件来响应特定路径。

  • app.VERB(path, [callback...], callback): 这用于定义一个或多个中间件函数来响应与声明的 HTTP 动词一起使用的特定路径的 HTTP 请求。例如,当你想要响应使用 GET 动词的请求时,你可以使用app.get()方法来分配中间件。对于 POST 请求,你将使用app.post(),依此类推。

  • app.route(path).VERB([callback...], callback): 这是一个用于定义一个或多个中间件函数来响应与多个 HTTP 动词一起使用的特定统一路径的 HTTP 请求的方法。例如,当你想要响应使用 GET 和 POST 动词的请求时,你可以使用app.route(path).get(callback).post(callback)来分配适当的中间件函数。

  • app.param([name], callback): 这是一种方法,用于将特定功能附加到包含特定路由参数的路径上发出的任何请求。例如,您可以使用app.param('userId', callback)将逻辑映射到包含userId参数的任何请求。

您可以使用许多其他应用程序方法和属性,但使用这些常见的基本方法使开发人员能够以他们认为合理的方式扩展 Express。

请求对象

请求对象还提供了一些有助于包含有关当前 HTTP 请求的信息的方法。请求对象的关键属性和方法如下:

  • req.query: 这是一个包含解析后的查询字符串参数的属性。

  • req.params: 这是一个包含解析后的路由参数的属性。

  • req.body: 这是用于检索解析后的请求体的属性。它包含在bodyParser()中间件中。

  • req.path / req.hostname / req.ip: 这些用于检索当前请求的路径、主机名和远程 IP。

  • req.cookies: 这是与cookieParser()中间件一起使用的属性,用于检索用户代理发送的 cookie。

请求对象包含许多我们将在本书后面讨论的方法和属性,但这些方法通常是您在常见的 Web 应用程序中使用的。

响应对象

响应对象在开发 Express 应用程序时经常使用,因为发送到服务器的任何请求都将使用响应对象方法进行处理和响应。它有几个关键方法,如下所示:

  • res.status(code): 这是用于设置响应 HTTP 状态代码的方法。

  • res.set(field, [value]): 这是用于设置响应 HTTP 标头的方法。

  • res.cookie(name, value, [options]): 这是用于设置响应 cookie 的方法。选项参数用于传递定义常见 cookie 配置的对象,例如maxAge属性。

  • res.redirect([status], url): 这是用于将请求重定向到给定 URL 的方法。请注意,您可以向响应添加 HTTP 状态代码。当不传递状态代码时,它将默认为302 Found

  • res.status([status]).send( [body]): 这是用于非流式响应的方法。它会做很多后台工作,例如设置 Content-Type 和 Content-Length 标头,并使用适当的缓存标头进行响应。

  • res.status([status]).json( [body]): 当发送对象或数组时,这与res.send()方法相同。大多数情况下,它被用作语法糖,但有时您可能需要使用它来强制将 JSON 响应发送到非对象,例如nullundefined

  • res.render(view, [locals], callback): 这是用于呈现视图并发送 HTML 响应的方法。

响应对象还包含许多其他方法和属性,用于处理不同的响应场景,您将在本书后面学习到。

外部中间件

Express 核心是最小的,但是背后的团队提供了各种预定义的中间件来处理常见的 Web 开发功能。这些类型的中间件在大小和功能上都有所不同,并扩展了 Express 以提供更好的框架支持。流行的 Express 中间件如下:

  • morgan: 这是一个 HTTP 请求记录器中间件。

  • body-parser: 这是一个用于解析请求体的中间件,它支持各种请求类型。

  • method-override: 这是一个提供 HTTP 动词支持的中间件,例如在客户端不支持的地方使用 PUT 或 DELETE。

  • compression: 这是一个压缩中间件,用于使用 GZIP/deflate 压缩响应数据。

  • express.static: 这是用于提供静态文件的中间件。

  • cookie-parser: 这是一个用于解析 cookie 的中间件,它填充了req.cookies对象。

  • Session: 这是用于支持持久会话的会话中间件。

有许多种类型的 Express 中间件,可以帮助您缩短开发时间,同时还有更多的第三方中间件。

注意

要了解更多关于 Connect 和 Express 中间件的信息,请访问 Connect 模块的官方存储库页面github.com/senchalabs/connect#middleware。如果您想浏览第三方中间件集合,请访问 Connect 的 wiki 页面github.com/senchalabs/connect/wiki

实现 MVC 模式

Express 框架是模式不可知的,这意味着它不支持任何预定义的语法或结构,就像其他一些 Web 框架所做的那样。将 MVC 模式应用于您的 Express 应用程序意味着您可以创建特定的文件夹,将您的 JavaScript 文件按照一定的逻辑顺序放置在其中。所有这些文件基本上都是作为逻辑单元的 CommonJS 模块。例如,模型将是包含在models文件夹中的 Mongoose 模型定义的 CommonJS 模块,视图将是放置在views文件夹中的 HTML 或其他模板文件,控制器将是放置在controllers文件夹中的具有功能方法的 CommonJS 模块。为了更好地说明这一点,现在是讨论不同类型的应用程序结构的时候了。

应用程序文件夹结构

我们之前讨论了在开发真实应用时的最佳实践,我们推荐使用package.json文件而不是直接安装模块。然而,这只是一个开始;一旦您继续开发应用程序,您很快会想知道如何安排项目文件并将它们分解为逻辑代码单元。总的来说,JavaScript 和因此 Express 框架对于应用程序的结构是不可知的,因为你可以很容易地将整个应用程序放在一个 JavaScript 文件中。这是因为没有人预期 JavaScript 会成为一个全栈编程语言,但这并不意味着你不应该特别注意组织你的项目。由于 MEAN 堆栈可以用于构建各种大小和复杂度的应用程序,因此也可以以各种方式处理项目结构。决定往往直接与您的应用程序的预估复杂性有关。例如,简单的项目可能需要更简洁的文件夹结构,这样有利于更清晰和更容易管理,而复杂的项目通常需要更复杂的结构和更好的逻辑分解,因为它将包括许多功能和更大的团队在项目上工作。为了简化这个讨论,将其合理地分为两种主要方法:较小项目的水平结构和功能丰富应用程序的垂直结构。让我们从一个简单的水平结构开始。

水平文件夹结构

水平项目结构是基于按功能角色划分文件夹和文件,而不是按照它们实现的功能来划分,这意味着所有应用程序文件都放在一个主应用程序文件夹中,其中包含一个 MVC 文件夹结构。这也意味着有一个单独的controllers文件夹,其中包含所有应用程序控制器,一个单独的models文件夹,其中包含所有应用程序模型,依此类推。水平应用程序结构的一个示例如下:

水平文件夹结构

让我们来回顾一下文件夹结构:

  • app文件夹是您保存 Express 应用程序逻辑的地方,它分为以下文件夹,代表了功能的分离,以符合 MVC 模式:

  • controllers文件夹是您保存 Express 应用程序控制器的地方

  • models文件夹是您保存 Express 应用程序模型的地方

  • routes文件夹是您保存 Express 应用程序路由中间件的地方

  • views文件夹是您保存 Express 应用程序视图的地方

  • config文件夹是您保存 Express 应用程序配置文件的地方。随着时间的推移,您将向应用程序添加更多模块,每个模块将在专用的 JavaScript 文件中进行配置,该文件放在此文件夹中。目前,它包含几个文件和文件夹,如下所示:

  • env文件夹是您保存 Express 应用程序环境配置文件的地方

  • config.js文件是您配置 Express 应用程序的地方

  • express.js文件是您初始化 Express 应用程序的地方

  • public文件夹是您保存静态客户端文件的地方,它分为以下文件夹,代表了功能的分离,以符合 MVC 模式:

  • config文件夹是您保存 Angular 应用程序配置文件的地方

  • components文件夹是您保存 Angular 应用程序组件的地方

  • css文件夹是您保存 CSS 文件的地方

  • directives文件夹是您保存 Angular 应用程序指令的地方

  • pipes文件夹是您保存 Angular 应用程序管道的地方

  • img文件夹是您保存图像文件的地方

  • templates文件夹是您保存 Angular 应用程序模板的地方

  • bootstrap.ts文件是您初始化 Angular 应用程序的地方

  • package.json文件是帮助您组织应用程序依赖关系的元数据文件。

  • server.js文件是您的 Node.js 应用程序的主文件,它将加载express.js文件作为模块,以启动您的 Express 应用程序。

如您所见,水平文件夹结构对于功能有限的小型项目非常有用,因此文件可以方便地放在代表其一般角色的文件夹中。然而,为了处理大型项目,在那里您将有许多处理特定功能的文件,这可能太简单了。在这种情况下,每个文件夹可能会被过多的文件所超载,您可能会在混乱中迷失。更好的方法是使用垂直文件夹结构。

垂直文件夹结构

垂直项目结构基于按功能实现的文件夹和文件的划分,这意味着每个功能都有自己独立的文件夹,其中包含一个 MVC 文件夹结构。垂直应用程序结构的示例如下:

垂直文件夹结构

如您所见,每个功能都有自己类似应用程序的文件夹结构。在这个例子中,我们有包含主应用程序文件的core feature文件夹和包含功能文件的feature文件夹。一个示例功能将是包含身份验证和授权逻辑的用户管理功能。为了更好地理解这一点,让我们来看一个单个功能的文件夹结构:

  • server文件夹是您保存功能的服务器逻辑的地方,它分为以下文件夹,代表了功能的分离,以符合 MVC 模式:

  • controllers文件夹是您保存功能的 Express 控制器的地方

  • models文件夹是您保存功能的 Express 模型的地方

  • routes文件夹是您保存功能的 Express 路由中间件的地方

  • views文件夹是您保存功能的 Express 视图的地方

  • config文件夹是您保存功能服务器配置文件的地方

  • env文件夹是您保存功能环境服务器配置文件的地方

  • feature.server.config.js文件是您配置功能的地方

  • client文件夹是您保存功能的客户端文件的地方,它分为以下文件夹,代表了功能的分离,以符合 MVC 模式:

  • config文件夹是您保存特性的 Angular 配置文件的地方

  • components文件夹是您保存特性的 Angular components的地方

  • css文件夹是您保存特性的 CSS 文件的地方

  • directives文件夹是您保存特性的 Angular 指令的地方

  • pipes文件夹是您保存特性的 Angular 管道的地方

  • img文件夹是您保存特性的图像文件的地方

  • templates文件夹是您保存特性的 Angular 模板的地方

  • feature.module.ts文件是您初始化特性的 Angular 模块的地方

正如您所看到的,垂直文件夹结构对于特性数量无限且每个特性包含大量文件的大型项目非常有用。它将允许大型团队共同工作并分别维护每个特性,并且在不同应用程序之间共享特性时也很有用。

虽然这两种类型的应用程序结构是不同的,但事实上 MEAN 堆栈可以以许多不同的方式组装。甚至一个团队可能会以结合这两种方法的方式来构建他们的项目;因此,基本上由项目负责人决定使用哪种结构。在本书中,出于简单起见,我们将使用水平方法,但我们将以垂直方式整合我们应用程序的 Angular 部分,以展示 MEAN 堆栈结构的灵活性。请记住,本书中提出的所有内容都可以轻松重构以适应您项目的规格。

文件命名约定

在开发应用程序时,您很快会注意到您最终会得到许多具有相同名称的文件。原因是 MEAN 应用程序通常对 Express 和 Angular 组件都有并行的 MVC 结构。要理解这个问题,看一下常见的垂直特性文件夹结构:

文件命名约定

正如您所看到的,强制文件夹结构有助于理解每个文件的功能,但也会导致多个文件具有相同的名称。这是因为一个应用程序的特性通常是使用多个 JavaScript 文件来实现的,每个文件都有不同的角色。这个问题可能会给开发团队带来一些困惑,因此为了解决这个问题,您需要使用某种命名约定。

最简单的解决方案是将每个文件的功能角色添加到文件名中。因此,特性控制器文件将被命名为feature.controller.js,特性模型文件将被命名为feature.model.js,依此类推。然而,当考虑到 MEAN 应用程序同时使用 JavaScript MVC 文件来处理 Express 和 Angular 应用程序时,情况变得更加复杂。这意味着您经常会有两个具有相同名称的文件。为了解决这个问题,还建议您扩展文件名以包含它们的执行目的地。这一开始可能看起来有些多余,但您很快会发现,快速识别应用程序文件的角色和执行目的地是非常有帮助的。

注意

重要的是要记住这是一种最佳实践约定。您可以轻松地用自己的关键字替换controllermodelclientserver

实施水平文件夹结构

要开始构建您的第一个 MEAN 项目的结构,请在其中创建一个新的项目文件夹,并在其中创建以下文件夹:

实施水平文件夹结构

创建了所有前述文件夹后,返回到应用程序的根文件夹并创建一个包含以下代码片段的package.json文件:

{
  "name" : "MEAN",
  "version" : "0.0.3",
  "dependencies" : {
    "express" : "4.14.0"
  }
}

现在,在app/controllers文件夹中,创建一个名为index.server.controller.js的文件,其中包含以下代码:

exports.render = function(req, res) {
  res.status(200).send('Hello World');
};

恭喜!你刚刚创建了你的第一个 Express 控制器。这段代码可能看起来很熟悉;那是因为它是你在之前示例中创建的中间件的副本。你在这里所做的是使用 CommonJS 模块模式来定义一个名为render()的函数。稍后,你将能够获取这个模块并使用这个函数。一旦你创建了一个控制器,你就需要使用 Express 路由功能来利用这个控制器。

处理请求路由

Express 支持使用app.route(path).VERB(callback)方法或app.VERB(path, callback)方法来路由请求,其中VERB应该替换为小写的 HTTP 动词。看一下以下例子:

app.get('/', (req, res) => {
  res.status(200).send('This is a GET request');
});

这告诉 Express 执行中间件函数来处理任何使用GET动词并指向根路径的 HTTP 请求。如果你想处理POST请求,你的代码应该如下所示:

app.post('/', (req, res) => {
  res.status(200).send('This is a POST request');
});

然而,Express 还允许你定义单个路由,然后链接多个中间件来处理不同的 HTTP 请求。这意味着前面的代码示例也可以写成如下形式:

app.route('/').get((req, res) => {
  res.status(200).send('This is a GET request');
}).post((req, res) => {
  res.status(200).send('This is a POST request');
});

Express 的另一个很酷的功能是能够在单个路由定义中链接多个中间件。这意味着中间件函数将按顺序调用,将它们传递给下一个中间件,以便你可以确定如何继续执行中间件。这通常用于在执行响应逻辑之前验证请求。要更好地理解这一点,看一下以下代码:

const express = require('express');

function hasName(req, res, next) {
 if (req.param('name')) {
 next();
 } else {
 res.status(200).send('What is your name?');
 }
};

function sayHello(req, res, next) {
 res.status(200).send('Hello ' + req.param('name'));
}

const app = express();
app.get('/', hasName, sayHello);

app.listen(3000);
console.log('Server running at http://localhost:3000/');

在上面的代码中,有两个名为hasName()sayHello()的中间件函数。hasName()中间件正在寻找name参数;如果找到了定义的name参数,它将使用 next 参数调用下一个中间件函数。否则,hasName()中间件将自己处理响应。在这种情况下,下一个中间件函数将是sayHello()中间件函数。这是可能的,因为我们使用app.get()方法将中间件函数按顺序添加。还值得注意的是中间件函数的顺序,因为它决定了哪个中间件函数首先执行。

这个例子很好地演示了路由中间件如何在确定响应时执行不同的验证。当然,你可以利用这个功能来执行其他任务,比如验证用户身份验证和资源授权。不过,现在让我们继续我们的例子。

添加路由文件

你接下来要创建的文件是你的第一个路由文件。在app/routes文件夹中,创建一个名为index.server.routes.js的文件,其中包含以下代码片段:

module.exports = function(app) {
    const index = require('../controllers/index.server.controller');
 app.get('/', index.render);
};

在这里,你做了一些事情。首先,你再次使用了 CommonJS 模块模式。你可能还记得,CommonJS 模块模式支持导出多个函数,比如你在控制器中所做的,以及使用单个模块函数,就像你在这里所做的那样。接下来,你需要引入你的index控制器,并将其render()方法用作中间件来处理根路径的 GET 请求。

注意

路由模块函数接受一个名为app的参数,所以当你调用这个函数时,你需要传递 Express 应用程序的实例。

你所剩下的就是创建 Express 应用程序对象,并使用你刚刚创建的控制器和路由模块进行引导。为此,转到config文件夹,并创建一个名为express.js的文件,其中包含以下代码片段:

const express = require('express');

module.exports = function() {
  const app = express();
 require('../app/routes/index.server.routes.js')(app);
  return app;
};

在上述代码片段中,您需要引入 Express 模块,然后使用 CommonJS 模块模式来定义一个module函数,该函数初始化 Express 应用程序。首先,它创建一个新的 Express 应用程序实例,然后需要您的路由文件并将其作为函数调用,将应用程序实例作为参数传递给它。路由文件将使用应用程序实例来创建新的路由配置,然后调用控制器的render()方法。module函数通过返回应用程序实例来结束。

注意

express.js文件是我们配置 Express 应用程序的地方。这是我们添加与 Express 配置相关的所有内容的地方。

要完成您的应用程序,您需要在根文件夹中创建一个名为server.js的文件,并复制以下代码:

const configureExpress = require('./config/express');

const app = configureExpress();
app.listen(3000);
module.exports = app;

console.log('Server running at http://localhost:3000/');

就是这样!在主应用程序文件中,通过需要 Express 配置模块并使用它来检索您的应用程序对象实例,并侦听端口3000,您连接了所有松散的端点。

要启动您的应用程序,请使用npm在命令行工具中导航到您的应用程序的根文件夹,并安装您的应用程序依赖项,如下所示:

$ npm install

安装过程结束后,您只需使用 Node 的命令行工具启动应用程序:

$ node server 

您的 Express 应用程序现在应该可以运行了!要测试它,请导航到http://localhost:3000

在这个例子中,您学会了如何正确构建您的 Express 应用程序。重要的是,您注意到了使用 CommonJS 模块模式创建文件并在整个应用程序中引用它们的不同方式。这种模式在本书中经常重复出现。

配置 Express 应用程序

Express 具有一个非常简单的配置系统,可以让您为 Express 应用程序添加某些功能。虽然有预定义的配置选项可以更改以操纵其工作方式,但您也可以为任何其他用途添加自己的键/值配置选项。Express 的另一个强大功能是根据其运行的环境配置应用程序。例如,您可能希望在开发环境中使用 Express 记录器,而在生产环境中不使用,同时在生产环境中压缩响应主体可能看起来是一个不错的主意。

为了实现这一点,您需要使用process.env属性。process.env是一个全局变量,允许您访问预定义的环境变量,最常见的是NODE_ENV环境变量。NODE_ENV环境变量通常用于特定环境的配置。为了更好地理解这一点,让我们回到之前的例子并添加一些外部中间件。要使用这些中间件,您首先需要将它们下载并安装为项目的依赖项。

要做到这一点,请编辑您的package.json文件,使其看起来像以下代码片段:

{
  "name": "MEAN",
  "version": "0.0.3",
  "dependencies": {
 "body-parser": "1.15.2",
 "compression": "1.6.0",
    "express": "4.14.0",
 "method-override": "2.3.6",
 "morgan": "1.7.0"
  }
}

正如我们之前所述,morgan模块提供了一个简单的日志记录中间件,compression模块提供了响应压缩,body-parser模块提供了几个中间件来处理请求数据,method-override模块提供了DELETEPUT HTTP 动词的旧版本支持。要使用这些模块,您需要修改您的config/express.js文件,使其看起来像以下代码片段:

const express = require('express');
const morgan = require('morgan');
const compress = require('compression');
const bodyParser = require('body-parser');
const methodOverride = require('method-override');

module.exports = function() {
  const app = express();

 if (process.env.NODE_ENV === 'development') {
 app.use(morgan('dev'));
 } else if (process.env.NODE_ENV === 'production') {
 app.use(compress());
 }

 app.use(bodyParser.urlencoded({
 extended: true
 }));
 app.use(bodyParser.json());
 app.use(methodOverride());

  require('../app/routes/index.server.routes.js')(app);

  return app;
};

正如您所看到的,我们只是使用process.env.NODE_ENV变量来确定我们的环境,并相应地配置 Express 应用程序。我们只是使用app.use()方法在开发环境中加载morgan()中间件,在生产环境中加载compress()中间件。bodyParser.urlencoded()bodyParser.json()methodOverride()中间件将始终加载,无论环境如何。

要完成您的配置,您需要将您的server.js文件更改为以下代码片段:

process.env.NODE_ENV = process.env.NODE_ENV || 'development';

const configureExpress = require('./config/express');

const app = configureExpress();
app.listen(3000);
module.exports = app;

console.log('Server running at http://localhost:3000/');

请注意,如果不存在,process.env.NODE_ENV变量将设置为默认的development值。这是因为通常NODE_ENV环境变量没有正确设置。

提示

建议在运行应用程序之前在操作系统中设置 NODE_ENV 环境变量。

在 Windows 环境中,您可以通过在命令提示符中执行以下命令来执行此操作:

> set NODE_ENV=development

而在基于 Unix 的环境中,您应该简单地使用以下导出命令:

$ export NODE_ENV=development

要测试您的更改,请使用npm导航到应用程序的根文件夹,并安装应用程序依赖项,如下所示:

$ npm install

安装过程结束后,您只需使用 Node 的命令行工具启动应用程序:

$ node server

您的 Express 应用程序现在应该运行!要测试它,请导航到http://localhost:3000,您将能够在命令行输出中看到记录器的操作。但是,当处理更复杂的配置选项时,process.env.NODE_ENV环境变量可以以更复杂的方式使用。

环境配置文件

在应用程序开发过程中,您经常需要配置第三方模块以在各种环境中以不同方式运行。例如,当连接到 MongoDB 服务器时,您可能会在开发和生产环境中使用不同的连接字符串。在当前设置中这样做可能会导致您的代码充斥着无尽的if语句,这通常会更难以维护。为了解决这个问题,您可以管理一组环境配置文件来保存这些属性。然后,您将能够使用process.env.NODE_ENV环境变量来确定要加载哪个配置文件,从而使您的代码更短,更易于维护。让我们首先为我们的默认开发环境创建一个配置文件。为此,请在config/env文件夹内创建一个新文件,并将其命名为development.js。在新文件中,粘贴以下代码:

module.exports = {
  // Development configuration options
};

如您所见,您的配置文件目前只是一个空的 CommonJS 模块初始化。不用担心;我们很快将添加第一个配置选项,但首先,我们需要管理配置文件的加载。为此,请转到应用程序的config文件夹,并创建一个名为config.js的新文件。在新文件中,粘贴以下代码:

module.exports = require('./env/' + process.env.NODE_ENV + '.js');

如您所见,此文件只是根据process.env.NODE_ENV环境变量加载正确的配置文件。在接下来的章节中,我们将使用此文件,它将为我们加载正确的环境配置文件。要管理其他环境配置,您只需要添加一个专门的环境配置文件,并正确设置NODE_ENV环境变量。

渲染视图

Web 框架的一个非常常见的特性是渲染视图的能力。基本概念是将数据传递给模板引擎,该引擎将渲染最终的视图,通常是 HTML。在 MVC 模式中,控制器使用模型来检索数据部分,并使用视图模板来渲染 HTML 输出,如下图所示。Express 可扩展的方法允许使用许多 Node.js 模板引擎来实现此功能。在本节中,我们将使用 EJS 模板引擎,但您可以随后将其替换为其他模板引擎。以下图表显示了渲染应用视图的 MVC 模式:

渲染视图

Express 有两种方法来渲染视图:app.render()用于渲染视图然后将 HTML 传递给回调函数,更常见的是res.render(),它在本地渲染视图并将 HTML 作为响应发送。你将更频繁地使用res.render(),因为通常你希望将 HTML 输出为响应。不过,例如,如果你希望你的应用程序发送 HTML 电子邮件,你可能会使用app.render()。在我们开始探索res.render()方法之前,让我们先配置我们的视图系统。

配置视图系统

为了配置 Express 视图系统,你需要使用 EJS 模板引擎。让我们回到我们的示例并安装 EJS 模块。你应该首先更改你的package.json文件,使其看起来像以下代码片段:

{
  "name": "MEAN",
  "version": "0.0.3",
  "dependencies": {
    "body-parser": "1.15.2",
    "compression": "1.6.0",
 "ejs": "2.5.2",
    "express": "4.14.0",
    "method-override": "2.3.6",
    "morgan": "1.7.0"  }
}

现在,通过在命令行中导航到项目的根文件夹并发出以下命令来安装 EJS 模块:

$ npm update

在 npm 完成安装 EJS 模块后,你将能够配置 Express 将其用作默认模板引擎。要配置你的 Express 应用程序,回到config/express.js文件,并将其更改为以下代码行:

const express = require('express');
const morgan = require('morgan');
const compress = require('compression');
const bodyParser = require('body-parser');
const methodOverride = require('method-override');

module.exports = function() {
  const app = express();
  if (process.env.NODE_ENV === 'development') {
    app.use(morgan('dev'));
  } else if (process.env.NODE_ENV === 'production') {
    app.use(compress());
  }

  app.use(bodyParser.urlencoded({
    extended: true
  }));
  app.use(bodyParser.json());
  app.use(methodOverride());

  app.set('views', './app/views');
  app.set('view engine', 'ejs');

  require('../app/routes/index.server.routes.js')(app);

  return app;
};

注意我们如何使用app.set()方法来配置 Express 应用程序的view文件夹和模板引擎。让我们创建你的第一个视图。

渲染 EJS 视图

EJS 视图基本上由 HTML 代码和EJS标签混合而成。EJS 模板将驻留在app/views文件夹中,并具有.ejs扩展名。当你使用res.render()方法时,EJS 引擎将在views文件夹中查找模板,如果找到符合的模板,它将渲染 HTML 输出。要创建你的第一个 EJS 视图,转到你的app/views文件夹,并创建一个名为index.ejs的新文件,其中包含以下 HTML 代码片段:

<!DOCTYPE html>
<html>
  <head>
    <title><%= title %></title>
  </head>
  <body>
    <h1><%= title %></h1>
  </body>
</html>

这段代码对你来说应该大部分都很熟悉,除了<%= %>标签。这些标签是告诉 EJS 模板引擎在哪里渲染模板变量的方式——在这种情况下是title变量。你所要做的就是配置你的控制器来渲染这个模板,并自动将其输出为 HTML 响应。要做到这一点,回到你的app/controllers/index.server.controller.js文件,并将其更改为以下代码片段的样子:

exports.render = function(req, res) {
  res.render('index', {
    title: 'Hello World'
  });
};

注意res.render()方法的使用方式。第一个参数是你的 EJS 模板的名称,不包括.ejs扩展名,第二个参数是一个包含你的模板变量的对象。res.render()方法将使用 EJS 模板引擎在我们在config/express.js文件中设置的views文件夹中查找文件,然后使用模板变量渲染视图。要测试你的更改,使用你的命令行工具并发出以下命令:

$ node server

干得好,你刚刚创建了你的第一个 EJS 视图!通过访问http://localhost:3000来测试你的应用程序,在那里你将能够查看渲染的 HTML。

EJS 视图易于维护,并提供了一种简单的方式来创建你的应用程序视图。我们将在本书的后面详细介绍 EJS 模板,不过不会像你期望的那样多,因为在 MEAN 应用程序中,大部分的 HTML 渲染是在客户端使用 Angular 完成的。

提供静态文件

在任何 Web 应用程序中,总是需要提供静态文件。幸运的是,Express 的唯一内置中间件是express.static()中间件,它提供了这个功能。要将静态文件支持添加到前面的示例中,只需在你的config/express.js文件中进行以下更改:

const express = require('express');
const morgan = require('morgan');
const compress = require('compression');
const bodyParser = require('body-parser');
const methodOverride = require('method-override');

module.exports = function() {
  const app = express();
  if (process.env.NODE_ENV === 'development') {
    app.use(morgan('dev'));
  } else if (process.env.NODE_ENV === 'production') {
    app.use(compress());
  }

  app.use(bodyParser.urlencoded({
    extended: true
  }));
  app.use(bodyParser.json());
  app.use(methodOverride());

  app.set('views', './app/views');
  app.set('view engine', 'ejs'); 

  require('../app/routes/index.server.routes.js')(app); 

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

  return app;
};

express.static()中间件接受一个参数来确定static文件夹的位置。注意express.static()中间件放置在路由文件调用下面。这个顺序很重要,因为如果它在上面,Express 首先会尝试在static files文件夹中查找 HTTP 请求路径。这会使响应变得更慢,因为它必须等待文件系统的 I/O 操作。

为了测试你的静态中间件,将一个名为logo.png的图片添加到public/img文件夹中,然后在你的app/views/index.ejs文件中做以下更改:

<!DOCTYPE html>
<html>
  <head>
    <title><%= title %></title>
  </head>
  <body>
    <img src="img/logo.png" alt="Logo">
    <h1><%= title %></h1>
  </body>
</html>

现在,使用 Node 的命令行工具运行你的应用程序:

$ node server

为了测试结果,访问http://localhost:3000,观察 Express 如何将你的图片作为静态文件提供。

配置会话

会话是一种常见的 Web 应用程序模式,允许你跟踪用户访问应用程序时的行为。要添加这个功能,你需要安装和配置express-session中间件。首先,修改你的package.json文件如下:

{
  "name": "MEAN",
  "version": "0.0.3",
  "dependencies": {
    "body-parser": "1.15.2",
    "compression": "1.6.0",
    "ejs": "2.5.2",
    "express": "4.14.0",
 "express-session": "1.14.1",
    "method-override": "2.3.6",
    "morgan": "1.7.0"
  }
}

然后,通过在命令行中导航到项目的根文件夹并发出以下命令来安装express-session模块:

$ npm update

安装过程完成后,你将能够配置你的 Express 应用程序使用express-session模块。express-session模块将使用一个存储在 cookie 中的签名标识符来识别当前用户。为了签署会话标识符,它将使用一个秘密字符串,这将有助于防止恶意会话篡改。出于安全原因,建议每个环境的 cookie 秘密都不同,这意味着这将是使用我们的环境配置文件的合适地方。为此,将config/env/development.js文件更改为以下代码片段的样子:

module.exports = {
  sessionSecret: 'developmentSessionSecret'
};

由于这只是一个例子,可以随意更改秘密字符串。对于其他环境,只需在它们的环境配置文件中添加sessionSecret属性。要使用配置文件并配置你的 Express 应用程序,返回到你的config/express.js文件,并将其更改为以下代码片段的样子:

const config = require('./config');
const express = require('express');
const morgan = require('morgan');
const compress = require('compression');
const bodyParser = require('body-parser');
const methodOverride = require('method-override');
const session = require('express-session');

module.exports = function() {
  const app = express();

  if (process.env.NODE_ENV === 'development') {
    app.use(morgan('dev'));
  } else if (process.env.NODE_ENV === 'production') {
    app.use(compress());
  }

  app.use(bodyParser.urlencoded({
    extended: true
  }));
  app.use(bodyParser.json());
  app.use(methodOverride());

  app.use(session({
    saveUninitialized: true,
    resave: true,
    secret: config.sessionSecret
  }));

  app.set('views', './app/views');
  app.set('view engine', 'ejs');

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

  require('../app/routes/index.server.routes.js')(app); 

  return app;
};

注意配置对象是如何传递给express.session()中间件的。在这个配置对象中,使用之前修改过的配置文件定义了secret属性。会话中间件将会话对象添加到应用程序中的所有请求对象中。使用这个会话对象,你可以设置或获取任何你希望在当前会话中使用的属性。为了测试会话,将app/controller/index.server.controller.js文件更改如下:

exports.render = function(req, res) {
  if (req.session.lastVisit) {
    console.log(req.session.lastVisit);
  }

  req.session.lastVisit = new Date();

  res.render('index', {
    title: 'Hello World'
  });
};

你在这里做的基本上是记录最后一次用户请求的时间。控制器检查session对象中是否设置了lastVisit属性,如果设置了,就将最后访问日期输出到控制台。然后将lastVisit属性设置为当前时间。为了测试你的更改,使用 Node 的命令行工具运行你的应用程序,如下所示:

$ node server

现在,通过在浏览器中访问http://localhost:3000并观察命令行输出来测试你的应用程序。

总结

在本章中,你创建了你的第一个 Express 应用程序,并学会了如何正确配置它。你将文件和文件夹组织成了一个有组织的结构,并发现了替代的文件夹结构。你还创建了你的第一个 Express 控制器,并学会了如何使用 Express 的路由机制调用它的方法。你渲染了你的第一个 EJS 视图,并学会了如何提供静态文件。你还学会了如何使用express-session来跟踪用户的行为。在下一章中,你将学会如何使用 MongoDB 保存你应用程序的持久数据。

第四章:MongoDB 简介

MongoDB 是一种令人兴奋的新型数据库。作为 NoSQL 运动的领导者,它正在成为世界上最有用的数据库解决方案之一。Mongo 的高吞吐量、独特的 BSON 数据模型和易于扩展的架构为 Web 开发人员提供了更好的工具来存储他们的持久数据。从关系型数据库转移到 NoSQL 解决方案可能是一个令人不知所措的任务,但通过了解 MongoDB 的设计目标可以轻松简化。在本章中,我们将涵盖以下主题:

  • 了解 NoSQL 运动和 MongoDB 设计目标

  • MongoDB BSON 数据结构

  • MongoDB 集合和文档

  • MongoDB 查询语言

  • 使用 MongoDB shell

NoSQL 简介

在过去的几年里,Web 应用程序开发通常需要使用关系型数据库来存储持久数据。大多数开发人员已经非常习惯使用众多的 SQL 解决方案之一。因此,使用成熟的关系数据库存储规范化数据模型的方法成为了标准。对象关系映射器开始出现,为开发人员提供了适当的解决方案,以从其应用程序的不同部分整理数据。但随着 Web 的不断扩大,越来越多的开发人员面临更多的扩展问题。为了解决这个问题,社区创建了各种键值存储解决方案,旨在提供更好的可用性、简单的查询和水平扩展。这种新型数据存储变得越来越健壮,提供了许多关系数据库的功能。在这一演变过程中,出现了不同的存储设计模式,包括键值存储、列存储、对象存储和最流行的文档存储。

在常见的关系数据库中,您的数据存储在不同的表中,通常使用主键到外键的关系连接。您的程序将稍后使用各种 SQL 语句重新构建模型,以将数据排列成某种层次化对象表示。文档型数据库处理数据的方式不同。它们不使用表,而是以标准格式(如 JSON 和 XML)存储分层文档。

为了更好地理解这一点,让我们看一个典型博客文章的例子。要使用 SQL 解决方案构建此博客文章模型,您可能至少需要使用两个表。第一个表包含帖子信息,而第二个表包含帖子评论。下图显示了一个示例表结构:

NoSQL 简介

在您的应用程序中,您将使用对象关系映射库或直接 SQL 语句来选择博客文章记录和帖子评论记录,以创建您的博客文章对象。然而,在基于文档的数据库中,博客文章将完全存储为单个文档,以后可以进行查询。例如,在一个以 JSON 格式存储文档的数据库中,您的博客文章文档可能看起来像以下代码片段:

{
  "title": "First Blog Post",
  "comments": [{
    "title": "First Comment"
  }, {
    "title": "Second Comment"
  }]
}

这表明了文档型数据库和关系型数据库之间的主要区别。因此,在使用关系型数据库时,您的数据存储在不同的表中,您的应用程序使用表记录组装对象。将数据存储为整体文档将允许更快的读取操作,因为您的应用程序不必在每次读取时重新构建对象。此外,面向文档的数据库还有其他优势。

在开发应用程序时,您经常会遇到另一个问题:模型更改。假设您想要为每篇博客文章添加一个新属性。因此,您可以更改您的帖子表,然后转到应用程序数据层,并将该属性添加到您的博客文章对象中。由于您的应用程序已经包含了几篇博客文章,所有现有的博客文章对象也将发生变化,这意味着您必须在代码中添加额外的验证过程。然而,基于文档的数据库通常是无模式的,这意味着您可以在单个对象集合中存储不同的对象,而无需更改数据库中的任何内容。尽管这对一些有经验的开发人员来说可能听起来像是在寻求麻烦,但无模式存储的自由具有几个优点。

例如,想象一个销售二手家具的电子商务应用程序。在您的“产品”表中,椅子和壁橱可能具有一些共同的特征,比如木材的类型,但客户可能还对壁橱有多少个门感兴趣。将壁橱和椅子对象存储在同一个表中意味着它们可以存储在具有大量空列的表中,或者使用更实用的实体-属性-值模式,其中另一个表用于存储键-值属性。然而,使用无模式存储将允许您在同一集合中为不同的对象定义不同的属性,同时仍然可以使用常见属性查询该集合,比如木材类型。这意味着您的应用程序,而不是数据库,将负责强制执行数据结构,这可以帮助您加快开发过程。

虽然有许多 NoSQL 解决方案解决各种开发问题,通常围绕缓存和规模,但面向文档的数据库正在迅速成为该运动的领导者。文档导向数据库的易用性,以及其独立的持久存储功能,甚至威胁着在某些用例中取代传统的 SQL 解决方案。尽管有一些文档导向数据库,但没有一个像 MongoDB 那样受欢迎。

介绍 MongoDB

回到 2007 年,Dwight Merriman 和 Eliot Horowitz 成立了一家名为 10gen 的公司,以创建一个更好的平台来托管 Web 应用程序。他们的想法是创建一个作为服务的托管平台,让开发人员专注于构建他们的应用程序,而不是处理硬件管理和基础设施扩展。很快,他们发现社区不愿意放弃对他们应用程序基础设施的控制。因此,他们将平台的不同部分作为开源项目发布。

有一个这样的项目是一个名为 MongoDB 的基于文档的数据库解决方案。MongoDB 源自于“巨大”的单词,能够支持复杂的数据存储,同时保持其他 NoSQL 存储的高性能方法。社区欣然接受了这种新的范式,使 MongoDB 成为世界上增长最快的数据库之一。拥有 150 多名贡献者和超过 10,000 次提交,它也成为最受欢迎的开源项目之一。

MongoDB 的主要目标是创建一种新类型的数据库,将关系数据库的健壮性与分布式键值数据存储的快速吞吐量相结合。考虑到可扩展的平台,它必须支持简单的水平扩展,同时保持传统数据库的耐久性。另一个关键的设计目标是支持 Web 应用程序开发,以标准 JSON 输出的形式。这两个设计目标最终成为 MongoDB 相对于其他解决方案的最大优势,因为这些与 Web 开发中的其他趋势完美契合,比如几乎无处不在的云虚拟化托管的使用或向水平而不是垂直扩展的转变。

最初被认为是更可行的关系数据库上的另一个 NoSQL 存储层,MongoDB 发展到远远超出了它诞生的平台。它的生态系统发展到支持大多数流行的编程平台,拥有各种社区支持的驱动程序。除此之外,还形成了许多其他工具,包括不同的 MongoDB 客户端、性能分析和优化工具、管理和维护实用程序,以及一些风险投资支持的托管服务。甚至一些大公司,如 eBay 和纽约时报,开始在其生产环境中使用 MongoDB 数据存储。要了解为什么开发人员更喜欢 MongoDB,现在是时候深入了解它的一些关键特性了。

MongoDB 的关键特性

MongoDB 有一些关键特性,帮助它变得如此受欢迎。正如我们之前提到的,目标是在传统数据库功能和 NoSQL 存储的高性能之间创建一种新的品种。因此,它的大多数关键特性都是为了超越其他 NoSQL 解决方案的限制,同时整合一些关系数据库的能力而创建的。在本节中,您将了解为什么在处理现代 Web 应用程序开发时,MongoDB 可以成为您首选的数据库。

BSON 格式

MongoDB 最伟大的特性之一是其类似 JSON 的存储格式,名为 BSON。BSON 代表二进制 JSON,BSON 格式是 JSON 样式文档的二进制编码序列化,旨在在大小和速度上更高效,从而实现 MongoDB 的高读/写吞吐量。

与 JSON 一样,BSON 文档是对象和数组的简单数据结构表示,采用键值格式。文档由一系列元素组成,每个元素都有一个字符串类型的字段名和一个类型化的字段值。这些文档支持所有 JSON 特定的数据类型以及其他数据类型,例如Date类型。

BSON 格式的另一个重要优势是使用_id字段作为主键。_id字段值通常是一个名为ObjectId的唯一标识符类型,它可以由应用程序驱动程序或 mongod 服务生成。如果驱动程序未能提供带有唯一ObjectId_id字段,mongod 服务将自动添加它,使用以下方式:

  • 一个表示自 Unix 纪元以来的秒数的 4 字节值

  • 一个 3 字节的机器标识符

  • 一个 2 字节的进程 ID

  • 一个 3 字节的计数器,从一个随机值开始

因此,上一个示例中的博客文章对象的 BSON 表示将如下代码片段所示:

{
  "_id": ObjectId("52d02240e4b01d67d71ad577"),
  "title": "First Blog Post",
  "comments": [
  ...
  ]
}

BSON 格式使 MongoDB 能够在内部索引和映射文档属性,甚至嵌套文档,从而能够高效地扫描集合,并且更重要的是,能够将对象与复杂的查询表达式匹配。

MongoDB 的特点

MongoDB 的另一个设计目标是扩展普通键值存储的能力。常见键值存储的主要问题是其有限的查询能力,这通常意味着您的数据只能使用键字段进行查询,而更复杂的查询大多是预定义的。为了解决这个问题,MongoDB 从关系数据库动态查询语言中汲取了灵感。

支持即席查询意味着数据库将立即响应动态结构化的查询,无需预定义每个查询。它能够通过索引 BSON 文档并使用独特的查询语言来实现这一点。让我们看一下以下 SQL 语句示例:

SELECT * FROM Posts WHERE Title LIKE '%mongo%';

这个简单的语句是在要求数据库返回所有标题中包含单词mongo的帖子记录。在 MongoDB 中复制这个查询将如下所示:

db.posts.find({ title:/mongo/ });

在 MongoDB shell 中运行此命令将返回所有title字段包含单词mongo的帖子。您将在本章后面学习更多关于 MongoDB 查询语言的内容,但现在重要的是要记住它几乎与传统的关系型数据库一样可查询。MongoDB 查询语言很棒,但它引发了一个问题,即当数据库变得更大时,这些查询运行效率如何。像关系型数据库一样,MongoDB 使用称为索引的机制来解决这个问题。

MongoDB 索引

索引是一种独特的数据结构,使数据库引擎能够高效解析查询。当查询发送到数据库时,它将不得不扫描整个文档集合,以找到与查询语句匹配的文档。这种方式,数据库引擎处理了大量不必要的数据,导致性能不佳。

为了加快扫描速度,数据库引擎可以使用预定义的索引,它映射文档字段,并告诉引擎哪些文档与此查询语句兼容。为了理解索引的工作原理,我们假设我们想检索所有具有超过 10 条评论的帖子。在这种情况下,我们的文档定义如下:

{
  "_id": ObjectId("52d02240e4b01d67d71ad577"),
  "title": "First Blog Post",
  "comments": [
  …
  ],
  "commentsCount": 12
}

因此,一个请求超过 10 条评论的文档的 MongoDB 查询将如下所示:

db.posts.find({ commentsCount: { $gt: 10 } });

要执行此查询,MongoDB 必须遍历所有帖子,并检查帖子是否具有大于10commentCount属性。然而,如果定义了commentCount索引,那么 MongoDB 只需检查哪些文档具有大于10commentCount属性,然后检索这些文档。以下图表说明了commentCount索引的工作原理:

MongoDB 索引

使用commentsCount索引检索具有超过10条评论的文档

MongoDB 副本集

为了提供数据冗余和改善可用性,MongoDB 使用一种称为副本集的架构。数据库的复制有助于保护数据,以便从硬件故障中恢复并增加读取容量。副本集是一组承载相同数据集的 MongoDB 服务。一个服务被用作主服务,其他服务被称为次服务。所有的实例都支持读操作,但只有主实例负责写操作。当发生写操作时,主实例会通知次实例进行更改,并确保它们已将更改应用到其数据集的复制中。以下图表说明了一个常见的副本集:

MongoDB 副本集

具有一个主和两个次的副本集的工作流程

MongoDB 副本集的另一个强大功能是其自动故障转移。当副本集的一个成员无法在 10 秒内到达主实例时,副本集将自动选举并提升一个次实例为新的主实例。旧的主实例恢复在线后,它将作为次实例重新加入副本集。

副本集的另一个特性是能够添加仲裁节点。仲裁者不维护任何数据;它们的主要目的是在副本集中维护法定人数。这意味着它们参与选举新的主要过程,但不能作为次要功能或被选为主要功能。简而言之,仲裁者有助于以比常规数据节点更低的资源成本在副本集中提供一致性。以下图表说明了一个带有仲裁者的常见副本集:

MongoDB 副本集

具有主、次和仲裁者的副本集的工作流程

MongoDB 的复制是一个非常强大的功能,直接源自其平台起源,是使 MongoDB 达到生产就绪状态的主要功能之一。然而,这并不是唯一的功能。

注意

要了解更多关于 MongoDB 副本集的信息,请访问docs.mongodb.org/manual/replication/

MongoDB 分片

随着 Web 应用程序的增长,扩展性是一个常见的问题。解决这个问题的各种方法可以分为两组:垂直扩展和水平扩展。两者之间的区别在下图中有所说明:

MongoDB 分片

单台机器的垂直扩展与多台机器的水平扩展

垂直扩展更容易,包括增加单台机器的资源,如 RAM 和 CPU。然而,它有两个主要缺点:首先,在某个水平上,增加单台机器的资源相对于在几台较小的机器之间分配负载变得更加昂贵。其次,流行的云托管提供商限制了您可以使用的机器实例的大小。因此,垂直扩展应用程序只能在一定水平上进行。

水平扩展更加复杂,需要使用多台机器。每台机器将处理一部分负载,提供更好的整体性能。水平数据库扩展的问题在于如何正确地在不同的机器之间分配数据,以及如何管理它们之间的读/写操作。

幸运的是,MongoDB 支持水平扩展,它称之为分片。分片是将数据分割到不同的机器或分片的过程。每个分片保存一部分数据,并作为一个独立的数据库。几个分片的集合形成了一个单一的逻辑数据库。操作是通过称为查询路由器的服务执行的,它们询问配置服务器如何将每个操作委派给正确的分片。

注意

要了解更多关于 MongoDB 分片的信息,请访问docs.mongodb.org/manual/sharding/

MongoDB 3.0

2015 年初,MongoDB 团队推出了 MongoDB 数据库的第三个主要版本。最重要的是,这个版本标志着 MongoDB 正在向成为更大更复杂的生产环境的领先数据库解决方案迈进。或者,正如团队所描述的那样,使 MongoDB 成为每个组织的“默认数据库”。为了实现这一目标,团队提出了几个新功能:

  • 存储 API:在这个版本中,存储引擎层与更高级别的操作解耦。这意味着组织现在可以根据其应用程序需求选择使用哪种存储引擎,从而获得高达 10 倍的性能提升。

  • 增强的查询引擎内省:这使得数据库管理员能够更好地分析关键查询,确保性能得到优化。

  • 更好的身份验证和审计:这使得大型组织能够更安全地管理他们的 MongoDB 实例。

  • 更好的日志记录:更复杂的日志记录功能使开发人员能够更好地跟踪 MongoDB 的操作。

这些功能和许多其他功能使 MongoDB 如此受欢迎。尽管有许多良好的替代方案,但 MongoDB 在开发人员中变得越来越普遍,并且正在成为世界领先的数据库解决方案之一。让我们深入了解如何轻松开始使用 MongoDB。

MongoDB shell

如果您遵循了第一章, MEAN 简介,您应该在本地环境中拥有一个可用的 MongoDB 实例。要与 MongoDB 交互,您将使用 MongoDB shell,这是您在第一章中遇到的。MongoDB shell 是一个命令行工具,它使用 JavaScript 语法查询语言来执行不同的操作。

为了探索 MongoDB 的不同部分,让我们通过运行mongo可执行文件来启动 MongoDB shell,如下所示:

$ mongo

如果 MongoDB 已正确安装,您应该看到类似于以下截图所示的输出:

MongoDB shell

注意 shell 如何告诉您当前的 shell 版本,并且它已连接到默认的测试数据库。

MongoDB 数据库

每个 MongoDB 服务器实例可以存储多个数据库。除非特别定义,否则 MongoDB shell 将自动连接到默认的测试数据库。通过执行以下命令切换到另一个名为 mean 的数据库:

> use mean

您将看到一个命令行输出,告诉您 shell 已切换到 mean 数据库。请注意,您无需在使用数据库之前创建数据库,因为在 MongoDB 中,当您插入第一个文档时,数据库和集合会懒惰地创建。这种行为与 MongoDB 对数据的动态方法一致。使用特定数据库的另一种方法是以数据库名称作为参数运行 shell 可执行文件,如下所示:

$ mongo mean

shell 将自动连接到 mean 数据库。如果您想列出当前 MongoDB 服务器中的所有其他数据库,只需执行以下命令:

> show dbs

这将显示当前可用的至少存储了一个文档的数据库列表。

MongoDB 集合

MongoDB 集合是 MongoDB 文档的列表,相当于关系数据库表。当插入其第一个文档时,将创建一个集合。与表不同,集合不强制执行任何类型的模式,并且可以托管不同结构的文档。

要在 MongoDB 集合上执行操作,您需要使用集合方法。让我们创建一个名为 posts 的集合并插入第一篇文章。为了做到这一点,在 MongoDB shell 中执行以下命令:

> db.posts.insert({"title":"First Post", "user": "bob"})

执行上述命令后,它将自动创建 posts 集合并插入第一个文档。要检索集合文档,请在 MongoDB shell 中执行以下命令:

> db.posts.find()

您应该看到类似于以下截图所示的命令行输出:

MongoDB 集合

这意味着您已成功创建了 posts 集合并插入了第一个文档。

要显示所有可用的集合,请在 MongoDB shell 中发出以下命令:

> show collections

MongoDB shell 将输出可用集合的列表,您的情况下是 posts 集合和另一个名为 system.indexes 的集合,它保存了数据库索引的列表。

如果您想删除 posts 集合,您需要执行 drop()命令,如下所示:

> db.posts.drop()

shell 将通过输出 true 来通知您该集合已被删除。

MongoDB CRUD 操作

创建-读取-更新-删除(CRUD)操作是您与数据库执行的基本交互。为了对数据库实体执行 CRUD 操作,MongoDB 提供了各种集合方法。

创建新文档

您已经熟悉使用 insert()方法创建新文档的基本方法,就像您之前在早期示例中所做的那样。除了 insert()方法,还有两种方法叫做 update()和 save()来创建新对象。

使用 insert()创建文档

创建新文档的最常见方法是使用 insert()方法。insert()方法接受一个表示新文档的单个参数。要插入新的文章,只需在 MongoDB shell 中发出以下命令:

> db.posts.insert({"title":"Second Post", "user": "alice"})

使用 update()创建文档

update()方法通常用于更新现有文档。您还可以使用 upsert 标志来创建新文档,如果没有文档与查询条件匹配:

> db.posts.update({
 "user": "alice"
}, {
 "title": "Second Post",
 "user": "alice"
}, {
 upsert: true
})

在前面的例子中,MongoDB 将查找由alice创建的帖子并尝试更新它。考虑到posts集合没有由alice创建的帖子,以及您已经使用了upsert标志,MongoDB 将找不到适当的文档进行更新,而是创建一个新文档。

使用 save()创建文档

创建新文档的另一种方法是调用save()方法,传递一个没有_id字段或在集合中不存在的_id字段的文档:

> db.posts.save({"title":"Second Post", "user": "alice"})

这将产生与update()方法相同的效果,并将创建一个新文档而不是更新现有文档。

阅读文档

find()方法用于从 MongoDB 集合中检索文档列表。使用find()方法,您可以请求集合中的所有文档,或使用查询检索特定文档。

查找所有集合文档

要检索posts集合中的所有文档,应该将空查询传递给find()方法,或者根本不传递任何参数。以下查询将检索posts集合中的所有文档:

> db.posts.find()

此外,也可以使用以下查询执行相同的操作:

> db.posts.find({})

这两个查询基本上是相同的,将返回posts集合中的所有文档。

使用相等语句

要检索特定文档,可以使用相等条件查询,该查询将抓取符合该条件的所有文档。例如,要检索由alice创建的所有帖子,您需要在 shell 中发出以下命令:

> db.posts.find({ "user": "alice" })

这将检索具有user属性等于alice的所有文档。

使用查询操作符

使用相等语句可能不够。为了构建更复杂的查询,MongoDB 支持各种查询操作符。使用查询操作符,您可以查找不同类型的条件。例如,要检索由alicebob创建的所有帖子,可以使用以下$in操作符:

> db.posts.find({ "user": { $in: ["alice", "bob"] } })

注意

您可以通过访问docs.mongodb.org/manual/reference/operator/query/#query-selectors了解更多查询操作符。

构建 AND/OR 查询

构建查询时,可能需要使用多个条件。就像在 SQL 中一样,您可以使用AND/OR运算符来构建多条件查询语句。要执行AND查询,只需将要检查的属性添加到查询对象中。例如,看一下以下查询:

> db.posts.find({ "user": "alice", "commentsCount": { $gt: 10 }  })

它类似于您之前使用的find()查询,但添加了另一个条件,验证文档的commentCount属性,并且只会抓取由alice创建且评论数超过10的文档。OR查询稍微复杂,因为它涉及$or运算符。要更好地理解它,请看上一个例子的另一个版本:

> db.posts.find( { $or: [{ "user": "alice" }, { "user": "bob" }] })

与查询操作符示例一样,这个查询也会抓取由bobalice创建的所有帖子。

更新现有文档

使用 MongoDB,您可以使用update()save()方法更新文档。

使用 update()更新文档

update()方法需要三个参数来更新现有文档。第一个参数是选择条件,指示要更新哪些文档,第二个参数是update语句,最后一个参数是options对象。例如,在下面的例子中,第一个参数告诉 MongoDB 查找所有由alice创建的文档,第二个参数告诉它更新title字段,第三个参数强制它在找到的所有文档上执行update操作:

> db.posts.update({
 "user": "alice"
}, {
 $set: {
 "title": "Second Post"
 }
}, {
 multi: true
})

请注意multi属性已添加到options对象中。update()方法的默认行为是更新单个文档,因此通过设置multi属性,您告诉update()方法更新符合选择条件的所有文档。

使用 save()更新文档

更新现有文档的另一种方法是调用save()方法,将包含_id字段的文档传递给它。例如,以下命令将更新具有_id字段等于ObjectId("50691737d386d8fadbd6b01d")的现有文档:

> db.posts.save({
 "_id": ObjectId("50691737d386d8fadbd6b01d"),
 "title": "Second Post",
 "user": "alice"
})

重要的是要记住,如果save()方法无法找到合适的对象,它将创建一个新对象。

删除文档

要删除文档,您需要使用remove()方法。remove()方法最多可以接受两个参数。第一个是删除条件,第二个是一个布尔参数,指示是否删除多个文档。

删除所有文档

要从集合中删除所有文档,您需要调用remove()方法,而不需要任何删除条件。例如,要删除所有posts文档,您需要执行以下命令:

> db.posts.remove({})

请注意,remove()方法与drop()方法不同,因为它不会删除集合或其索引。要使用不同的索引重建集合,最好使用drop()方法。

删除多个文档

要从集合中删除符合条件的多个文档,您需要使用带有删除条件的remove()方法。例如,要删除alice发布的所有帖子,您需要执行以下命令:

> db.posts.remove({ "user": "alice" })

请注意,这将删除alice创建的所有文档,因此在使用remove()方法时要小心。

删除单个文档

要从集合中删除与条件匹配的单个文档,您需要使用带有删除条件和布尔值的remove()方法,指示您只想删除单个文档。例如,要删除alice发布的第一篇帖子,您需要执行以下命令:

> db.posts.remove({ "user": "alice" }, true)

这将删除由alice创建的第一个文档,并且即使它们符合删除条件,也会保留其他文档。

摘要

在本章中,您了解了 NoSQL 数据库以及它们在现代 Web 开发中的用途。您还了解了 NoSQL 运动的新兴领导者 MongoDB。您深入了解了使 MongoDB 成为强大解决方案的各种功能,并了解了其基本术语。最后,您一窥了 MongoDB 强大的查询语言以及如何执行所有四个 CRUD 操作。在下一章中,我们将讨论如何使用流行的 Mongoose 模块将 Node.js 和 MongoDB 连接在一起。

第五章:Mongoose 简介

Mongoose 是一个强大的 Node.js ODM 模块,为您的 Express 应用程序添加了 MongoDB 支持。它使用模式来对实体进行建模,提供预定义验证以及自定义验证,允许您定义虚拟属性,并使用中间件钩子来拦截操作。Mongoose 的设计目标是弥合 MongoDB 无模式方法与现实世界应用程序开发要求之间的差距。在本章中,您将了解 Mongoose 的以下基本特性:

  • Mongoose 模式和模型

  • 模式索引、修饰符和虚拟属性

  • 使用模型的方法和执行 CRUD 操作

  • 使用预定义和自定义验证器验证您的数据

  • 使用中间件拦截模型的方法

介绍 Mongoose

Mongoose 是一个 Node.js 模块,为开发人员提供了将对象建模并将其保存为 MongoDB 文档的能力。虽然 MongoDB 是一个无模式的数据库,但在处理 Mongoose 模型时,Mongoose 为您提供了享受严格和宽松模式方法的机会。与任何其他 Node.js 模块一样,在您的应用程序中开始使用它之前,您首先需要安装它。本章中的示例将直接从前几章中的示例继续进行;因此,在本章中,从第三章中复制最终示例,构建一个 Express Web 应用程序,然后从那里开始。

安装 Mongoose

安装并验证您的 MongoDB 本地实例正在运行后,您将能够使用 Mongoose 模块连接到它。首先,您需要在node_modules文件夹中安装 Mongoose,因此将您的package.json文件更改为以下代码片段所示的样子:

{
  "name": "MEAN",
  "version": "0.0.5",
  "dependencies": {
    "body-parser": "1.15.2",
    "compression": "1.6.0",
    "ejs": "2.5.2",
    "express": "4.14.0",
    "express-session": "1.14.1",
    "method-override": "2.3.6",
 "mongoose": "4.6.5",
    "morgan": "1.7.0"
}

要安装应用程序依赖项,请转到应用程序文件夹,并在命令行工具中发出以下命令:

$ npm install

这将在您的node_modules文件夹中安装最新版本的 Mongoose。安装过程成功完成后,下一步将是连接到您的 MongoDB 实例。

连接到 MongoDB

要连接到 MongoDB,您需要使用 MongoDB 连接 URI。MongoDB 连接 URI 是一个字符串 URL,告诉 MongoDB 驱动程序如何连接到数据库实例。MongoDB URI 通常构造如下:

mongodb://username:password@hostname:port/database

由于您正在连接到本地实例,可以跳过用户名和密码,使用以下 URI:

mongodb://localhost/mean-book

最简单的方法是直接在您的config/express.js配置文件中定义此连接 URI,并使用mongoose模块连接到数据库,如下所示:

const uri = 'mongodb://localhost/mean-book';
const db = require('mongoose').connect(uri);

但是,由于您正在构建一个真实的应用程序,直接在config/express.js文件中保存 URI 是一种不好的做法。存储应用程序变量的正确方法是使用您的环境配置文件。转到您的config/env/development.js文件,并将其更改为以下代码片段所示的样子:

module.exports = {
 db: 'mongodb://localhost/mean-book',
  sessionSecret: 'developmentSessionSecret'
};

现在在您的config文件夹中,创建一个名为mongoose.js的新文件,其中包含以下代码片段:

const config = require('./config');
const mongoose = require('mongoose');

module.exports = function() {
 const db = mongoose.connect(config.db);

  return db;
};

请注意,您需要mongoose模块并使用配置对象的db属性连接到 MongoDB 实例。要初始化 Mongoose 配置,请返回到您的server.js文件,并将其更改为以下代码片段所示的样子:

process.env.NODE_ENV = process.env.NODE_ENV || 'development';

const configureMongoose = require('./config/mongoose');
const configureExpress = require('./config/express');

const db = configureMongoose();
const app = configureExpress();
app.listen(3000);

module.exports = app;
console.log('Server running at http://localhost:3000/');

就是这样;您已经安装了 Mongoose,更新了配置文件,并连接到了 MongoDB 实例。要启动应用程序,请使用命令行工具并导航到应用程序文件夹,执行以下命令:

$ node server

您的应用程序应该正在运行并连接到 MongoDB 本地实例。

注意

如果您遇到任何问题或出现“错误:无法连接到[localhost:27017]”的输出,请确保您的 MongoDB 实例正常运行。

了解 Mongoose 模式

连接到您的 MongoDB 实例是第一步,但 Mongoose 模块的真正魔力在于其定义文档模式的能力。正如您已经知道的,MongoDB 使用集合来存储多个文档,这些文档不需要具有相同的结构。但是,在处理对象时,有时需要文档相似。Mongoose 使用模式对象来定义文档属性列表,每个属性都有自己的类型和约束,以强制执行文档结构。在指定模式之后,您将继续定义一个模型构造函数,用于创建 MongoDB 文档的实例。在本节中,您将学习如何定义用户模式和模型,以及如何使用模型实例来创建、检索和更新用户文档。

创建用户模式和模型

要创建您的第一个模式,请转到app/models文件夹并创建一个名为user.server.model.js的新文件。在此文件中,粘贴以下代码行:

const mongoose = require('mongoose');
const Schema = mongoose.Schema;

const UserSchema = new Schema({
  firstName: String,
  lastName: String,
  email: String,
  username: String,
  password: String
});

mongoose.model('User', UserSchema);

在上述代码片段中,您做了两件事:首先,使用Schema构造函数定义了您的UserSchema对象,然后使用模式实例定义了您的 User 模型。请注意,出于简单起见,我们将密码保存为明文;但是,在实际应用程序中,用户密码应该得到适当的加密。接下来,您将学习如何使用 User 模型在应用程序逻辑层执行 CRUD 操作。

注册 User 模型

在您可以开始使用 User 模型之前,您需要在 Mongoose 配置文件中包含user.server.model.js文件,以注册 User 模型。为此,请更改您的config/mongoose.js文件,使其看起来像以下代码片段中所示:

const config = require('./config');
const mongoose = require('mongoose');

module.exports = function() {
  const db = mongoose.connect(config.db);

 require('../app/models/user.server.model');

  return db;
};

确保在server.js文件中执行任何其他配置之前加载 Mongoose 配置文件。这很重要,因为在此模块之后加载的任何模块都将能够使用 User 模型,而无需自行加载它。

使用 save()创建新用户

您可以立即开始使用 User 模型,但为了保持组织有序,最好创建一个Users控制器,用于处理所有与用户相关的操作。在app/controllers文件夹中,创建一个名为users.server.controller.js的新文件,并粘贴以下代码行:

const User = require('mongoose').model('User');

exports.create = function(req, res, next) {
  const user = new User(req.body);

  user.save((err) => {
    if (err) {
      return next(err);
    } else {
      res.status(200).json(user);
    }
  });
};

让我们来看看这段代码。首先,您使用mongoose模块调用model方法,该方法将返回您之前定义的User模型。接下来,您创建了一个名为create()的控制器方法,稍后将用于创建新用户。使用new关键字,create()方法创建一个新的模型实例,该实例使用请求体进行填充。最后,您调用模型实例的save()方法,该方法要么保存用户并输出user对象,要么失败并将错误传递给下一个中间件。

要测试您的新控制器,让我们添加一组调用控制器方法的与用户相关的路由。首先,在app/routes文件夹中创建一个名为users.server.routes.js的文件。在这个新创建的文件中,粘贴以下代码行:

const users = require('../../app/controllers/users.server.controller');

module.exports = function(app) {
  app.route('/users').post(users.create);
};

由于您的 Express 应用程序主要将作为 AngularJS 应用程序的 RESTful API,因此最佳实践是根据 REST 原则构建路由。在这种情况下,创建新用户的正确方式是使用 HTTP POST 请求到您在此定义的基本users路由。更改您的config/express.js文件,使其看起来像以下代码片段中所示:

const config = require('./config');
const express = require('express');
const morgan = require('morgan');
const compress = require('compression');
const bodyParser = require('body-parser');
const methodOverride = require('method-override');
const session = require('express-session');

module.exports = function() {
  const app = express();

  if (process.env.NODE_ENV === 'development') {
    app.use(morgan('dev'));
  } else if (process.env.NODE_ENV === 'production') {
    app.use(compress());
  }

  app.use(bodyParser.urlencoded({
    extended: true
  }));
  app.use(bodyParser.json());
  app.use(methodOverride());

  app.use(session({
    saveUninitialized: true,
    resave: true,
    secret: config.sessionSecret
  }));

  app.set('views', './app/views');
  app.set('view engine', 'ejs');

  require('../app/routes/index.server.routes.js')(app);
  require('../app/routes/users.server.routes.js')(app);

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

  return app;
};

就是这样!要进行测试,请转到根应用程序文件夹并执行以下命令:

$ node server

您的应用程序应该正在运行。要创建新用户,请执行 HTTP POST 请求到基本的users路由,并确保请求体包含以下 JSON:

{
  "firstName": "First",
  "lastName": "Last",
  "email": "user@example.com",
  "username": "username",
  "password": "password"
}

另一种测试应用程序的方法是在命令行工具中执行以下curl命令:

$ curl -X POST -H "Content-Type: application/json" -d '{"firstName":"First", "lastName":"Last","email":"user@example.com","username":"username","password":"password"}' localhost:3000/users

提示

您将执行许多不同的 HTTP 请求来测试您的应用程序。对于 Mac OS X 和 Linux 用户,curl是一个有用的工具,但还有其他几种专门设计用于此任务的工具;我们建议您找到自己喜欢的工具并从现在开始使用它。

使用find()查找多个用户文档

find()方法是一个模型方法,它使用查询检索存储在同一集合中的多个文档,并且是 MongoDB find()集合方法的 Mongoose 实现。为了更好地理解这一点,请将以下list()方法添加到您的app/controllers/users.server.controller.js文件中:

exports.list = function(req, res, next) {
  User.find({}, (err, users) => {
    if (err) {
      return next(err);
    } else {
      res.status(200).json(users);
    }
  });
};

注意新的list()方法如何使用find()方法来检索users集合中所有文档的数组。要使用您创建的新方法,您需要为其注册一个路由,因此转到您的app/routes/users.server.routes.js文件并更改为以下代码片段所示:

const users = require('../../app/controllers/users.server.controller');

module.exports = function(app) {
  app.route('/users')
    .post(users.create)
    .get(users.list);
};

您需要做的就是通过执行以下命令运行应用程序:

$ node server

然后,您将能够通过在浏览器中访问http://localhost:3000/users来检索用户列表。

使用find()进行高级查询

在上面的代码示例中,find()方法接受了两个参数,一个是 MongoDB 查询对象,另一个是回调函数,但它最多可以接受四个参数:

  • Query:这是一个 MongoDB 查询对象

  • [Fields]:这是一个可选的字符串对象,表示要返回的文档字段

  • [Options]:这是一个可选的options对象

  • [Callback]:这是一个可选的回调函数

例如,为了仅检索用户的用户名和电子邮件,您需要修改调用,使其类似于以下代码行所示:

User.find({}, 'username email', (err, users) => {
  …
});

此外,当调用find()方法时,还可以传递一个options对象,该对象将操作查询结果。例如,要通过skiplimit选项分页浏览users集合并仅检索users集合的子集,可以使用以下方法:

User.find({}, 'username email', {
  skip: 10,
  limit: 10
}, (err, users) => {
  ...
});

这将返回最多 10 个用户文档的子集,同时跳过前 10 个文档。

注意

要了解更多有关查询选项的信息,建议您访问官方的 Mongoose 文档mongoosejs.com/docs/api.html

使用findOne()读取单个用户文档

使用findOne()方法检索单个用户文档,这与find()方法非常相似,但它仅检索子集的第一个文档。要开始处理单个用户文档,我们需要添加两个新方法。将以下代码行添加到您的app/controllers/users.server.controller.js文件的末尾:

exports.read = function(req, res) {
  res.json(req.user);
};

exports.userByID = function(req, res, next, id) {
  User.findOne({
    _id: id
  }, (err, user) => {
    if (err) {
      return next(err);
    } else {
      req.user = user;
      next();
    }
  });
};

read()方法很容易理解;它只是用req.user对象的 JSON 表示进行响应,但是是谁创建了req.user对象呢?嗯,userById()方法负责填充req.user对象。在执行读取、删除和更新操作时,您将使用userById()方法作为中间件来处理单个文档的操作。为此,您需要修改app/routes/users.server.routes.js文件,使其类似于以下代码行所示:

const users = require('../../app/controllers/users.server.controller');

module.exports = function(app) {
  app.route('/users')
     .post(users.create)
     .get(users.list);

 app.route('/users/:userId')
 .get(users.read);

 app.param('userId', users.userByID);
};

请注意,您添加了包含userId的请求路径的users.read()方法。在 Express 中,在路由定义中的子字符串前添加冒号意味着该子字符串将被处理为请求参数。为了处理req.user对象的填充,您使用app.param()方法,该方法定义了在使用该参数的任何其他中间件之前执行的中间件。在这里,users.userById()方法将在此情况下users.read()中注册的任何其他使用userId参数的中间件之前执行。在构建 RESTful API 时,此设计模式非常有用,其中您经常向路由字符串添加请求参数。

要测试这个,使用以下命令运行您的应用程序:

$ node server

然后,在浏览器中导航到http://localhost:3000/users,获取其中一个用户的_id值,并导航到http://localhost:3000/users/[id],将[id]部分替换为用户的_id值。

更新现有用户文档

Mongoose 模型有几种可用的方法来更新现有文档。其中包括update()findOneAndUpdate()findByIdAndUpdate()方法。每种方法在可能时都提供了不同级别的抽象,简化了update操作。在我们的情况下,由于我们已经使用了userById()中间件,更新现有文档的最简单方法是使用findByIdAndUpdate()方法。要做到这一点,返回到您的app/controllers/users.server.controller.js文件并添加一个新的update()方法:

exports.update = function(req, res, next) {
  User.findByIdAndUpdate(req.user.id, req.body, {
    'new': true
  }, (err, user) => {
    if (err) {
      return next(err);
    } else {
      res.status(200).json(user);
    }
  });
};

注意您如何使用用户的id字段来查找和更新正确的文档。请注意,默认的 Mongoose 行为是在更新文档之前将回调传递给文档;通过将new选项设置为true,我们确保我们收到更新后的文档。接下来您应该做的是在用户的路由模块中连接您的新的update()方法。返回到您的app/routes/users.server.routes.js文件并将其更改为以下代码片段所示的样子:

const users = require('../../app/controllers/users.server.controller');

module.exports = function(app) {
  app.route('/users')
     .post(users.create)
     .get(users.list);

  app.route('/users/:userId')
     .get(users.read)
     .put(users.update);

  app.param('userId', users.userByID);
};

注意您如何使用之前创建的路由,并如何使用路由的put()方法链接update()方法。要测试您的update()方法,请使用以下命令运行您的应用程序:

$ node server

然后,使用您喜欢的 REST 工具发出 PUT 请求,或者使用curl并执行此命令,将[id]部分替换为实际文档的_id属性:

$ curl -X PUT -H "Content-Type: application/json" -d '{"lastName": "Updated"}' localhost:3000/users/[id]

删除现有用户文档

Mongoose 模型有几种可用的方法来删除现有文档。其中包括remove()findOneAndRemove()findByIdAndRemove()方法。在我们的情况下,由于我们已经使用了userById()中间件,删除现有文档的最简单方法就是简单地使用remove()方法。要做到这一点,返回到您的app/controllers/users.server.controller.js文件并添加以下delete()方法:

exports.delete = function(req, res, next) {
  req.user.remove(err => {
    if (err) {
      return next(err);
    } else {
      res.status(200).json(req.user);
    }
  })
};

注意您如何使用user对象来删除正确的文档。接下来您应该做的是在用户的路由文件中使用您的新的delete()方法。转到您的app/routes/users.server.routes.js文件并将其更改为以下代码片段所示的样子:

const users = require('../../app/controllers/users.server.controller');

module.exports = function(app) { 
  app.route('/users')
    .post(users.create)
    .get(users.list);

  app.route('/users/:userId')
    .get(users.read)
    .put(users.update)
    .delete(users.delete);

  app.param('userId', users.userByID);
};

注意您如何使用之前创建的路由,并如何使用路由的delete()方法链接delete()方法。要测试您的delete方法,请使用以下命令运行您的应用程序:

$ node server

然后,使用您喜欢的 REST 工具发出DELETE请求,或者使用curl并执行以下命令,将[id]部分替换为实际文档的_id属性:

$ curl -X DELETE localhost:3000/users/[id]

这完成了四个 CRUD 操作的实现,让您简要了解了 Mongoose 模型的能力。然而,这些方法只是 Mongoose 包含的众多功能的示例。在下一节中,您将学习如何定义默认值,为模式字段提供动态功能,并验证您的数据。

扩展您的 Mongoose 模式

进行数据操作是很好的,但为了开发复杂的应用程序,您需要让您的 ODM 模块做更多的事情。幸运的是,Mongoose 支持各种其他功能,帮助您安全地对文档进行建模并保持数据的一致性。

定义默认值

定义默认字段值是数据建模框架的常见功能。您可以直接将此功能添加到应用程序的逻辑层,但这样会很混乱,通常是一种不好的做法。Mongoose 提供在模式级别定义默认值的功能,帮助您更好地组织代码并保证文档的有效性。

假设你想要向你的UserSchema添加一个创建日期字段。创建日期字段应该在创建时初始化,并且应该保存用户文档最初创建的时间,这是一个完美的例子,你可以利用默认值。为了做到这一点,你需要更改你的UserSchema;所以,回到你的app/models/user.server.model.js文件,并将其更改为以下代码片段所示的样子:

const mongoose = require('mongoose');
const Schema = mongoose.Schema;

const UserSchema = new Schema({
  firstName: String,
  lastName: String,
  email: String,
  username: String,
  password: String,
  created: {
    type: Date,
    default: Date.now
  }
});

mongoose.model('User', UserSchema);

注意created字段的添加和其默认值的定义。从现在开始,每个新的用户文档都将被创建一个默认的创建日期,代表文档创建的时刻。你还应该注意,在此模式更改之前创建的每个用户文档都将被分配一个创建字段,代表你查询它的时刻,因为这些文档没有初始化创建字段。

要测试你的新更改,使用以下命令运行你的应用程序:

$ node server

然后,使用你喜欢的 REST 工具发出一个 POST 请求,或者使用curl并执行以下命令:

$ curl -X POST -H "Content-Type: application/json" -d '{"firstName":"First", "lastName":"Last","email":"user@example.com","username":"username","password":"password"}' localhost:3000/users

将会创建一个新的用户文档,其中包含一个默认的创建字段,在创建时初始化。

使用模式修饰符

有时,你可能希望在保存或呈现给客户端之前对模式字段进行操作。为此,Mongoose 使用了一个称为修饰符的功能。修饰符可以在保存文档之前更改字段的值,也可以在查询时以不同的方式表示它。

预定义的修饰符

最简单的修饰符是 Mongoose 附带的预定义修饰符。例如,字符串类型的字段可以有一个修剪修饰符来去除空格,一个大写修饰符来将字段值大写,等等。为了理解预定义修饰符的工作原理,让我们确保你的用户的用户名不包含前导和尾随空格。要做到这一点,你只需要更改你的app/models/user.server.model.js文件,使其看起来像以下代码片段所示的样子:

const mongoose = require('mongoose');
const Schema = mongoose.Schema;

const UserSchema = new Schema({
  firstName: String,
  lastName: String,
  email: String,
  username: {
    type: String,
    trim: true
  },
  password: String,
  created: {
    type: Date,
    default: Date.now
  }
});

mongoose.model('User', UserSchema);

注意username字段中添加的trim属性。这将确保你的用户名数据将被保持修剪。

自定义 setter 修饰符

预定义的修饰符很棒,但你也可以定义自己的自定义 setter 修饰符来处理保存文档之前的数据操作。为了更好地理解这一点,让我们向你的用户模型添加一个新的website字段。website字段应该以http://https://开头,但是不要强迫你的客户在 UI 中添加这些前缀,你可以简单地编写一个自定义修饰符来验证这些前缀的存在,并在需要时添加它们。要添加你的自定义修饰符,你需要创建一个带有set属性的新的website字段,如下所示:

const UserSchema = new Schema({
  …
  website: {
    type: String,
    set: function(url) {
      if (!url) {
        return url;
      } else {
        if (url.indexOf('http://') !== 0   &&           url.indexOf('https://') !== 0) {
          url = 'http://' + url;
        }

        return url;
        }
    }
  },
  …
});

现在,每个创建的用户都将拥有一个在创建时修改的正确形式的网站 URL。然而,如果你已经有了一个大量的用户文档集合,你当然可以迁移你现有的数据,但是当处理大型数据集时,这将会对性能产生严重影响,所以你可以简单地使用 getter 修饰符。

自定义 getter 修饰符

Getter修饰符用于在将文档输出到下一层之前修改现有数据。例如,在我们之前的示例中,getter 修饰符有时会更好地通过在查询时修改网站字段来更改已经存在的用户文档,而不是遍历你的 MongoDB 集合并更新每个文档。要做到这一点,你只需要更改你的UserSchema,如下面的代码片段所示:

const UserSchema = new Schema({
  ...
  website: {
    type: String,
    get: function(url) {
      if (!url) {
        return url;
      } else {
        if (url.indexOf('http://') !== 0 &&           url.indexOf('https://') !== 0) {
            url = 'http://' + url;
          }

        return url;
     }
    }
  },
  …
});

UserSchema.set('toJSON', { getters: true });

你只需通过将set属性更改为get来将 setter 修改器更改为 getter 修改器。然而,这里需要注意的重要事情是如何使用UserSchema.set()配置了你的模式。这将强制 Mongoose 在将 MongoDB 文档转换为 JSON 表示时包含 getter,并允许使用res.json()输出文档以包含 getter 的行为。如果你没有包含这个,你的文档的 JSON 表示将忽略 getter 修改器。

注意

修改器非常强大,可以节省大量时间,但应谨慎使用,以防止出现意外的应用程序行为。建议您访问mongoosejs.com/docs/api.html获取更多信息。

添加虚拟属性

有时,你可能希望有动态计算的文档属性,这些属性实际上并不在文档中呈现。这些属性称为虚拟属性,它们可以用来满足几个常见的需求。例如,假设你想要添加一个新的fullName字段,它将表示用户的名和姓的连接。为此,你将需要使用virtual()模式方法;因此,修改后的UserSchema将包括以下代码片段:

UserSchema.virtual('fullName').get(function(){
  return this.firstName + ' ' + this.lastName;
});

UserSchema.set('toJSON', { getters: true, virtuals: true });

在上面的代码示例中,你向UserSchema添加了一个名为fullName的虚拟属性,为该虚拟属性添加了一个getter方法,然后配置了你的模式以在将 MongoDB 文档转换为 JSON 表示时包含虚拟属性。

然而,虚拟属性也可以有 setter,以帮助你保存你的文档,而不仅仅是添加更多字段属性。在这种情况下,假设你想要将输入的fullName字段分解为名和姓字段。为此,修改后的虚拟声明将如下代码片段所示:

UserSchema.virtual('fullName').get(function() {
  return this.firstName + ' ' + this.lastName;
}).set(function(fullName) {
 const splitName = fullName.split(' '); 
 this.firstName = splitName[0] || ''; 
 this.lastName = splitName[1] || ''; 
});

虚拟属性是 Mongoose 的一个很棒的特性,允许你在文档表示被传递到应用程序的各个层时修改它们,而不会被持久化到 MongoDB 中。

使用索引优化查询

正如我们之前讨论的,MongoDB 支持各种类型的索引来优化查询执行。Mongoose 也支持索引功能,甚至允许你定义次要索引。

索引的基本示例是唯一索引,它验证了集合中document字段的唯一性。在我们的示例中,保持用户名唯一是很常见的,因此为了传达这一点给 MongoDB,你需要修改你的UserSchema定义,包括以下代码片段:

const UserSchema = new Schema({
  ...
  username: {
    type: String,
    trim: true,
    unique: true
  },
  ...
});

这将告诉 MongoDB 为users集合的username字段创建一个唯一索引。Mongoose 还支持使用index属性创建次要索引。因此,如果你知道你的应用程序将使用大量涉及email字段的查询,你可以通过以下方式优化这些查询,创建一个电子邮件次要索引:

const UserSchema = new Schema({
  …
  email: {
    type: String,
    index: true
  },
  …
});

索引是 MongoDB 的一个很棒的特性,但你应该记住它可能会给你带来一些麻烦。例如,如果你在已经存储数据的集合上定义了唯一索引,你可能会在运行应用程序时遇到一些错误,直到你解决了集合数据的问题。另一个常见问题是 Mongoose 在应用程序启动时自动创建索引,这个特性可能会在生产环境中导致严重的性能问题。

定义自定义模型方法

Mongoose 模型中既包含静态方法又包含实例预定义方法,其中一些你已经使用过。然而,Mongoose 还允许你定义自己的自定义方法来增强你的模型,为你提供一个模块化的工具来正确分离你的应用程序逻辑。让我们来看一下定义这些方法的正确方式。

定义自定义静态方法

模型静态方法使您有自由进行模型级操作,例如添加额外的find方法。例如,假设您想通过他们的用户名搜索用户。当然,您可以在控制器中定义this方法,但那不是正确的地方。您要找的是静态模型方法。要添加静态方法,您需要将其声明为模式的statics属性的成员。在我们的情况下,添加一个findOneByUsername()方法将看起来像下面的代码片段所示:

UserSchema.statics.findOneByUsername = function(username, callback) {
    this.findOne({ username: new RegExp(username, 'i') }, 
  callback);
};

这种方法使用模型的findOne()方法来检索具有特定用户名的用户文档。使用新的findOneByUsername()方法类似于直接从User模型调用标准的static方法,如下所示:

User.findOneByUsername('username', (err, user) => {
  …
});

当开发应用程序时,您当然可以想出许多其他静态方法;您可能在开发应用程序时需要它们,所以不要害怕添加它们。

定义自定义实例方法

静态方法很棒,但如果您需要执行实例操作的方法怎么办?好吧,Mongoose 也支持这些方法,帮助您精简代码库并正确重用应用程序代码。要添加实例方法,您需要将其声明为模式的methods属性的成员。假设您想使用authenticate()方法验证用户的密码。添加此方法将类似于下面的代码片段所示:

UserSchema.methods.authenticate = function(password) {
  return this.password === password;
};

这将允许您从任何User模型实例调用authenticate()方法,如下所示:

user.authenticate('password');

正如您所看到的,定义自定义模型方法是保持项目正确组织并重用常见代码的好方法。在接下来的章节中,您将发现实例方法和静态方法都非常有用。

模型验证

在处理数据编组时的一个主要问题是验证。当用户向您的应用程序输入信息时,您经常需要在将信息传递给 MongoDB 之前验证该信息。虽然您可以在应用程序的逻辑层验证数据,但在模型级别进行此操作更有用。幸运的是,Mongoose 支持简单的预定义验证器和更复杂的自定义验证器。验证器在文档的字段级别定义,并在保存文档时执行。如果发生验证错误,则保存操作将被中止,并将错误传递给回调函数。

预定义验证器

Mongoose 支持不同类型的预定义验证器,其中大多数是特定于类型的。当然,任何应用程序的基本验证是值的存在。要在 Mongoose 中验证字段的存在,您需要在要验证的字段中使用required属性。假设您想在保存用户文档之前验证username字段的存在。为此,您需要对UserSchema进行以下更改:

const UserSchema = new Schema({
  ...
  username: {
    type: String,
    trim: true,
    unique: true,
    required: true
  },
  ...
});

这将在保存文档时验证username字段的存在,从而防止保存不包含该字段的任何文档。

除了required验证器之外,Mongoose 还包括基于类型的预定义验证器,例如用于字符串的enummatch验证器。例如,要验证您的email字段,您需要将UserSchema更改如下:

const UserSchema = new Schema({
  …
  email: {
    type: String,
    index: true,
    match: /.+\@.+\..+/
  },
  …
});

在这里使用match验证器将确保email字段值与给定的regex表达式匹配,从而防止保存任何不符合正确模式的电子邮件的文档。

另一个例子是enum验证器,它可以帮助您定义可用于该字段值的一组字符串。假设您添加了一个role字段。可能的验证如下所示:

const UserSchema = new Schema({
  ...
  role: {
    type: String,
    enum: ['Admin', 'Owner', 'User']
  },
  ...
});

前面的条件将只允许插入这三个可能的字符串,从而防止您保存文档。

注意

要了解更多关于预定义验证器的信息,建议您访问mongoosejs.com/docs/validation.html

自定义验证器

除了预定义的验证器,Mongoose 还允许您定义自己的自定义验证器。使用validate属性来定义自定义验证器。validate属性的值应该是一个包含验证函数和错误消息的数组。假设您想要验证用户密码的长度。为此,您需要在UserSchema中进行以下更改:

const UserSchema = new Schema({
  ...
  password: {
    type: String,
    validate: [
      function(password) {
        return password.length >= 6;
      },
      'Password should be longer'
    ]
  },
  ...
});

该验证器将确保您的用户密码至少为六个字符长,否则它将阻止文档的保存并将您定义的错误消息传递给回调函数。

Mongoose 验证是一个强大的功能,允许您控制模型并提供适当的错误处理,您可以用它来帮助用户理解出了什么问题。在接下来的章节中,您将学习如何使用 Mongoose 验证器来处理用户输入并防止常见的数据不一致性。

使用 Mongoose 中间件

Mongoose 中间件是可以拦截initvalidatesaveremove实例方法的函数。中间件在实例级别执行,并且有两种类型:预中间件和后中间件。

使用预中间件

预中间件在操作发生前执行。例如,一个预保存中间件将在保存文档之前执行。这个功能使得预中间件非常适合更复杂的验证和默认值分配。

使用pre()方法定义预中间件,因此使用预中间件验证模型将如下所示:

UserSchema.pre('save', function(next){
  if (...) {
    next()
  } else {
    next(new Error('An Error Occurred'));
  }
});

使用后中间件

后中间件在操作发生后执行。例如,一个后保存中间件将在保存文档后执行。这个功能使得后中间件非常适合记录应用程序逻辑。

使用post()方法定义后中间件,因此使用后中间件记录模型的save()方法将如下所示:

UserSchema.post('save', function(next){
    console.log('The user "' + this.username +  '" details were saved.');
});

Mongoose 中间件非常适合执行各种操作,包括日志记录、验证和执行各种数据一致性操作。如果您现在感到不知所措,不要担心,因为在本书的后面,您将更好地理解这些内容。

注意

要了解更多关于中间件的信息,建议您访问mongoosejs.com/docs/middleware.html

使用 Mongoose 的 ref 字段

尽管 MongoDB 不支持连接,但它支持使用名为DBRef的约定从一个文档到另一个文档的引用。DBRef 使得可以使用一个特殊字段来引用另一个文档,该字段包含集合名称和文档的ObjectId字段。Mongoose 实现了类似的行为,支持使用ObjectID模式类型和ref属性来支持文档引用。它还支持在查询数据库时将父文档与子文档进行关联。

为了更好地理解这一点,假设您为博客文章创建了另一个模式,称为PostSchema。因为用户是博客文章的作者,PostSchema将包含一个author字段,该字段将由User模型实例填充。因此,PostSchema将如下所示:

const PostSchema = new Schema({
  title: {
    type: String,
    required: true
  },
  content: {
    type: String,
    required: true
  },
  author: {
    type: Schema.ObjectId,
    ref: 'User'
  }
});

mongoose.model('Post', PostSchema);

注意ref属性告诉 Mongooseauthor字段将使用User模型来填充值。

使用这个新模式是一个简单的任务。要创建一个新的博客文章,您需要检索或创建一个User模型的实例,创建一个Post模型的实例,然后将post author属性分配给user实例。示例如下:

const user = new User();
user.save();

const post = new Post();
post.author = user;
post.save();

Mongoose 将在 MongoDBpost文档中创建一个引用,并稍后使用它来检索引用的用户文档。

由于它只是对真实文档的ObjectID引用,Mongoose 将不得不使用“populate()”方法来填充post实例中的user实例。为此,您将需要告诉 Mongoose 在检索文档时使用“populate()”方法。例如,一个填充author属性的“find()”方法将如下面的代码片段所示:

Post.find().populate('author').exec((err, posts) => {
  ...
});

然后,Mongoose 将检索posts集合中的所有文档,并填充它们的author属性。

Mongoose 对此功能的支持使您能够放心地依赖对象引用来保持数据模型的组织。在本书的后面,您将学习如何引用以支持您的应用程序逻辑。

注意

要了解更多关于引用字段和填充的信息,建议您访问mongoosejs.com/docs/populate.html

总结

在本章中,您已经了解了强大的 Mongoose 模型。您连接到了您的 MongoDB 实例,并创建了您的第一个 Mongoose 模式和模型。您还学会了如何验证您的数据,并使用模式修改器和 Mongoose 中间件进行修改。您发现了虚拟属性和修改器,并学会了如何使用它们来改变文档的表示。您还发现了如何使用 Mongoose 来实现文档之间的引用。在下一章中,我们将介绍 Passport 身份验证模块,它将使用您的User模型来处理用户身份验证。

第六章:使用护照管理用户身份验证

护照是一个强大的 Node.js 身份验证中间件,可帮助您对发送到 Express 应用程序的请求进行身份验证。护照使用策略来利用本地身份验证和 OAuth 身份验证提供程序,例如 Facebook、Twitter 和 Google。使用护照策略,您将能够无缝地为用户提供不同的身份验证选项,同时保持统一的用户模型。在本章中,您将了解护照的以下基本功能:

  • 了解护照策略

  • 将护照集成到用户的 MVC 架构中

  • 使用护照的本地策略来验证用户

  • 利用护照 OAuth 策略

  • 通过社交 OAuth 提供程序提供身份验证

介绍护照

身份验证是大多数 Web 应用程序的重要部分。处理用户注册和登录是一个重要的功能,有时可能会带来开发开销。Express 以其精简的方式缺少了这个功能,因此,与 node 一样,需要一个外部模块。护照是一个使用中间件设计模式来验证请求的 Node.js 模块。它允许开发人员使用称为策略的机制提供各种身份验证方法,这使您能够实现复杂的身份验证层,同时保持代码清晰简洁。与任何其他 Node.js 模块一样,在应用程序中开始使用它之前,您首先需要安装它。本章中的示例将直接从前几章中的示例继续。因此,在本章中,从第五章Mongoose 简介中复制最终示例,然后从那里开始。

安装护照

护照使用不同的模块,每个模块代表不同的身份验证策略,但所有这些模块都依赖于基本的护照模块。要安装护照基本模块,请更改您的package.json文件如下:

{
  "name": "MEAN",
  "version": "0.0.6",
  "dependencies": {
    "body-parser": "1.15.2",
    "compression": "1.6.0",
    "ejs": "2.5.2",
    "express": "4.14.0",
    "express-session": "1.14.1",
    "method-override": "2.3.6",
    "mongoose": "4.6.5",
    "morgan": "1.7.0",
 "passport": "0.3.2"
  }
}

在继续开发应用程序之前,请确保安装新的护照依赖项。要这样做,请转到应用程序的文件夹,并在命令行工具中发出以下命令:

$ npm install

这将在您的node_modules文件夹中安装指定版本的护照。安装过程成功完成后,您将需要配置应用程序以加载护照模块。

配置护照

配置护照需要几个步骤。要创建护照配置文件,请转到config文件夹并创建一个名为passport.js的新文件。现在先留空;我们一会儿会回来的。接下来,您需要引用刚刚创建的文件,因此更改您的server.js文件如下:

process.env.NODE_ENV = process.env.NODE_ENV || 'development';

const configureMongoose = require('./config/mongoose');
const configureExpress = require('./config/express');
const configurePassport = require('./config/passport');

const db = configureMongoose();
const app = configureExpress();
const passport = configurePassport();
app.listen(3000);

module.exports = app;

console.log('Server running at http://localhost:3000/');

接下来,您需要在 Express 应用程序中注册 Passport 中间件。要这样做,请更改您的config/express.js文件如下:

const config = require('./config');
const express = require('express');
const morgan = require('morgan');
const compress = require('compression');
const bodyParser = require('body-parser');
const methodOverride = require('method-override');
const session = require('express-session');
const passport = require('passport');

module.exports = function() {
  const app = express();

  if (process.env.NODE_ENV === 'development') {
    app.use(morgan('dev'));
  } else if (process.env.NODE_ENV === 'production') {
    app.use(compress());
  }

  app.use(bodyParser.urlencoded({
    extended: true
  }));
  app.use(bodyParser.json());
  app.use(methodOverride());

  app.use(session({
    saveUninitialized: true,
    resave: true,
    secret: config.sessionSecret
  }));
  app.set('views', './app/views');
  app.set('view engine', 'ejs');

 app.use(passport.initialize());
 app.use(passport.session());

  require('../app/routes/index.server.routes.js')(app);
  require('../app/routes/users.server.routes.js')(app);

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

  return app;
};

让我们回顾一下您刚刚添加的代码。首先,您需要引用护照模块,然后注册两个中间件:passport.initialize()中间件,负责引导护照模块,以及passport.session()中间件,使用 Express 会话来跟踪用户的会话。

护照现在已安装和配置,但要开始使用它,您将需要安装至少一个身份验证策略。我们将从本地策略开始,该策略提供了一个简单的用户名/密码身份验证层;但首先,让我们讨论一下护照策略的工作原理。

了解护照策略

为了提供各种身份验证选项,Passport 使用单独的模块来实现不同的身份验证策略。每个模块提供不同的身份验证方法,例如用户名/密码身份验证和 OAuth 身份验证。因此,为了提供 Passport 支持的身份验证,您需要安装和配置您想要使用的策略模块。让我们从本地身份验证策略开始。

使用 Passport 的本地策略

Passport 的本地策略是一个 Node.js 模块,允许您实现用户名/密码身份验证机制。您需要像安装其他模块一样安装它,并配置它以使用您的 User Mongoose 模型。让我们开始安装本地策略模块。

安装 Passport 的本地策略模块

要安装 Passport 的本地策略模块,您需要将passport-local添加到您的package.json文件中,如下所示:

{
  "name": "MEAN",
  "version": "0.0.6",
  "dependencies": {
    "body-parser": "1.15.2",
    "compression": "1.6.0",
    "ejs": "2.5.2",
    "express": "4.14.0",
    "express-session": "1.14.1",
    "method-override": "2.3.6",
    "mongoose": "4.6.5",
    "morgan": "1.7.0",
    "passport": "0.3.2",
 "passport-local": "1.0.0"
  }
}

然后,转到应用程序的文件夹,并在命令行工具中输入以下命令:

$ npm install

这将在您的node_modules文件夹中安装指定版本的本地策略模块。安装过程成功完成后,您需要配置 Passport 以使用本地策略。

配置 Passport 的本地策略

您将使用的每种身份验证策略基本上都是一个允许您定义该策略将如何使用的节点模块。为了保持逻辑的清晰分离,每个策略都应该在其自己的分离文件中进行配置。在您的config文件夹中,创建一个名为strategies的新文件夹。在这个新文件夹中,创建一个名为local.js的文件,其中包含以下代码片段:

const passport = require('passport');
const LocalStrategy = require('passport-local').Strategy;
const User = require('mongoose').model('User');

module.exports = function() {
  passport.use(new LocalStrategy((username, password, done) => {
    User.findOne({
      username: username
    }, (err, user) => {
      if (err) {
        return done(err);
      }

      if (!user) {
        return done(null, false, {
          message: 'Unknown user'
        });
      }

      if (!user.authenticate(password)) {
        return done(null, false, {
          message: 'Invalid password'
        });
      }

      return done(null, user);
      });
  }));
};

前面的代码首先需要Passport模块、本地策略模块的Strategy对象和您的User Mongoose 模型。然后,您可以使用passport.use()方法注册策略,该方法使用LocalStrategy对象的实例。请注意LocalStrategy构造函数将回调函数作为参数。稍后在尝试对用户进行身份验证时,它将调用此回调。

回调函数接受三个参数——用户名密码和一个完成回调——当认证过程结束时将被调用。在回调函数内部,您将使用User Mongoose 模型来查找具有该用户名的用户并尝试对其进行身份验证。在出现错误时,您将把error对象传递给done回调。当用户经过身份验证时,您将使用user Mongoose对象调用done回调。

还记得空的config/passport.js文件吗?现在您已经准备好本地策略,可以返回并使用它来配置本地身份验证。为此,请返回到您的config/passport.js文件并粘贴以下代码行:

const passport = require('passport');
const mongoose = require('mongoose');

module.exports = function() {
  const User = mongoose.model('User');

  passport.serializeUser((user, done) => {
    done(null, user.id);
  });

  passport.deserializeUser((id, done) => {
    User.findOne({
      _id: id
    }, '-password -salt', (err, user) => {
      done(err, user);
    });
  });

  require('./strategies/local.js')();
};

在前面的代码片段中,使用passport.serializeUser()passport.deserializeUser()方法来定义 Passport 将如何处理用户序列化。当用户经过身份验证时,Passport 将其_id属性保存到会话中。稍后,当需要user对象时,Passport 将使用_id属性从数据库中获取user对象。请注意,我们使用了字段选项参数来确保 Mongoose 不会获取用户的密码和salt属性。前面的代码的第二件事是包含本地策略配置文件。这样,您的server.js文件将加载 Passport 配置文件,然后加载其策略配置文件。接下来,您需要修改您的User模型以支持 Passport 的身份验证。

调整用户模型

在上一章中,我们开始讨论User模型并创建了其基本结构。为了在您的 MEAN 应用程序中使用User模型,您将需要修改它以满足一些认证流程的要求。这些变化将包括修改UserSchema,添加一些pre中间件和添加一些新的实例方法。要做到这一点,请转到您的app/models/user.js文件,并按照以下方式进行更改:

const mongoose = require('mongoose');
const crypto = require('crypto');
const Schema = mongoose.Schema;
const UserSchema = new Schema({
    firstName: String,
    lastName: String,
    email: {
        type: String,
        match: [/.+\@.+\..+/, "Please fill a valid e-mail address"]
    },
    username: {
        type: String,
        unique: true,
        required: 'Username is required',
        trim: true
    },
    password: {
        type: String,
        validate: [(password) => {
            return password && password.length > 6;
        }, 'Password should be longer']
    },
 salt: {
 type: String
 },
 provider: {
 type: String,
 required: 'Provider is required'
 },
 providerId: String,
 providerData: {},
    created: {
        type: Date,
        default: Date.now
    }
});

UserSchema.virtual('fullName').get(function() {
    return this.firstName + ' ' + this.lastName;
}).set(function(fullName) {
    const splitName = fullName.split(' ');
    this.firstName = splitName[0] || '';
    this.lastName = splitName[1] || '';
});

UserSchema.pre('save', function(next) {
 if (this.password) {
 this.salt = new
 Buffer(crypto.randomBytes(16).toString('base64'), 'base64');
 this.password = this.hashPassword(this.password);
 }
 next();
});

UserSchema.methods.hashPassword = function(password) {
 return crypto.pbkdf2Sync(password, this.salt, 10000,
 64).toString('base64');
};

UserSchema.methods.authenticate = function(password) {
 return this.password === this.hashPassword(password);
};

UserSchema.statics.findUniqueUsername = function(username, suffix,
 callback) {
 var possibleUsername = username + (suffix || '');
 this.findOne({
 username: possibleUsername
 }, (err, user) => {
 if (!err) {
 if (!user) {
 callback(possibleUsername);
 } else {
 return this.findUniqueUsername(username, (suffix || 0) +
 1, callback);
 }
 } else {
 callback(null);
 }
 });
};
UserSchema.set('toJSON', {
    getters: true,
    virtuals: true
});

mongoose.model('User', UserSchema);

让我们来看看这些变化。首先,您向UserSchema对象添加了四个字段:一个salt属性,用于对密码进行哈希处理;一个provider属性,用于指示注册用户所使用的策略;一个providerId属性,用于指示认证策略的用户标识符;以及一个providerData属性,稍后您将用它来存储从 OAuth 提供程序检索到的user对象。

接下来,您创建了一些pre-save中间件来处理用户密码的哈希处理。众所周知,存储用户密码的明文版本是一种非常糟糕的做法,可能导致用户密码泄露。为了解决这个问题,您的pre-save中间件执行了两个重要的步骤:首先,它创建了一个自动生成的伪随机哈希盐,然后使用hashPassword()实例方法将当前用户密码替换为哈希密码。

您还添加了两个实例方法:一个hashPassword()实例方法,用于通过利用 Node.js 的crypto模块对密码字符串进行哈希处理;以及一个authenticate()实例方法,它接受一个字符串参数,对其进行哈希处理,并将其与当前用户的哈希密码进行比较。最后,您添加了findUniqueUsername()静态方法,用于为新用户找到一个可用的唯一用户名。在本章后面处理 OAuth 认证时,您将使用这个方法。

这完成了对您的User模型的修改,但在您测试应用程序的认证层之前,还有一些其他事情需要处理。

创建认证视图

就像任何 Web 应用程序一样,您需要有注册和登录页面来处理用户认证。我们将使用EJS模板引擎创建这些视图,因此在您的app/views文件夹中,创建一个名为signup.ejs的新文件。在您新创建的文件中,粘贴以下代码片段:

<!DOCTYPE html>
<html>
<head>
  <title>
    <%=title %>
  </title>
</head>
<body>
  <% for(var i in messages) { %>
    <div class="flash"><%= messages[i] %></div>
  <% } %>
  <form action="/signup" method="post">
    <div>
      <label>First Name:</label>
      <input type="text" name="firstName" />
    </div>
    <div>
      <label>Last Name:</label>
      <input type="text" name="lastName" />
    </div>
    <div>
      <label>Email:</label>
      <input type="text" name="email" />
    </div>
    <div>
      <label>Username:</label>
      <input type="text" name="username" />
    </div>
    <div>
      <label>Password:</label>
      <input type="password" name="password" />
    </div>
    <div>
      <input type="submit" value="Sign up" />
    </div>
  </form>
</body>
</html>

signup.ejs视图只包含一个 HTML 表单;一个 EJS 标签,用于呈现title变量;以及一个 EJS 循环,用于呈现messages列表变量。返回到您的app/views文件夹,并创建另一个文件,命名为signin.ejs。在这个文件中,粘贴以下代码片段:

<!DOCTYPE html>
<html>
<head>
  <title>
    <%=title %>
  </title>
</head>
<body>
  <% for(var i in messages) { %>
    <div class="flash"><%= messages[i] %></div>
  <% } %>
  <form action="/signin" method="post">
    <div>
      <label>Username:</label>
      <input type="text" name="username" />
    </div>
    <div>
      <label>Password:</label>
      <input type="password" name="password" />
    </div>
    <div>
      <input type="submit" value="Sign In" />
    </div>
  </form>
</body>
</html>

如您所见,signin.ejs视图甚至更简单,也包含一个 HTML 表单;一个 EJS 标签,用于呈现title变量;以及一个 EJS 循环,用于呈现messages列表变量。现在您已经设置了模型和视图,是时候使用您的 Users 控制器将它们连接起来了。

修改 Users 控制器

要修改 Users 控制器,转到您的app/controllers/users.server.controller.js文件,并按照以下方式更改其内容:

const User = require('mongoose').model('User');
const passport = require('passport');

function getErrorMessage(err) {
  let message = '';

  if (err.code) {
    switch (err.code) {
      case 11000:
      case 11001:
        message = 'Username already exists';
        break;
      default:
        message = 'Something went wrong';
    }
  } else {
    for (var errName in err.errors) {
      if (err.errors[errName].message) message = err.errors[errName].message;
    }
  }

  return message;
};

exports.renderSignin = function(req, res, next) {
  if (!req.user) {
    res.render('signin', {
      title: 'Sign-in Form',
      messages: req.flash('error') || req.flash('info')
    });
  } else {
    return res.redirect('/');
  }
};

exports.renderSignup = function(req, res, next) {
  if (!req.user) {
    res.render('signup', {
      title: 'Sign-up Form',
      messages: req.flash('error')
    });
  } else {
    return res.redirect('/');
  }
};

exports.signup = function(req, res, next) {
  if (!req.user) {
    const user = new User(req.body);
    user.provider = 'local';

    user.save((err) => {
      if (err) {
        const message = getErrorMessage(err);

        req.flash('error', message);
        return res.redirect('/signup');
      }
      req.login(user, (err) => {
        if (err) return next(err);
        return res.redirect('/');
      });
    });
  } else {
    return res.redirect('/');
  }
};

exports.signout = function(req, res) {
  req.logout();
  res.redirect('/');
};

getErrorMessage()方法是一个私有方法,它从 Mongoose error对象返回统一的错误消息。值得注意的是这里有两种可能的错误:使用错误代码处理的 MongoDB 索引错误,以及使用err.errors对象处理的 Mongoose 验证错误。

接下来的两个控制器方法非常简单,将用于呈现登录和注册页面。signout()方法也很简单,使用了 Passport 模块提供的req.logout()方法来使认证会话失效。

signup()方法使用您的User模型来创建新用户。正如您所看到的,它首先从 HTTP 请求体创建一个用户对象。然后,尝试将其保存到 MongoDB。如果发生错误,signup()方法将使用getErrorMessage()方法为用户提供适当的错误消息。如果用户创建成功,将使用req.login()方法创建用户会话。req.login()方法由Passport模块公开,并用于建立成功的登录会话。登录操作完成后,用户对象将被签名到req.user对象中。

注意

req.login()方法将在使用passport.authenticate()方法时自动调用,因此在注册新用户时主要使用手动调用req.login()

在前面的代码中,使用了一个您尚不熟悉的模块。当身份验证过程失败时,通常会将请求重定向回注册或登录页面。当发生错误时,这里会这样做,但是您的用户如何知道到底出了什么问题?问题在于当重定向到另一个页面时,您无法将变量传递给该页面。解决方案是使用某种机制在请求之间传递临时消息。幸运的是,这种机制已经存在,以一个名为Connect-Flash的节点模块的形式存在。

显示闪存错误消息

Connect-Flash模块是一个允许您将临时消息存储在会话对象的flash区域中的节点模块。存储在flash对象上的消息在呈现给用户后将被清除。这种架构使Connect-Flash模块非常适合在将请求重定向到另一个页面之前传递消息。

安装 Connect-Flash 模块

要在应用程序的模块文件夹中安装Connect-Flash模块,您需要按照以下方式更改您的package.json文件:

{
  "name": "MEAN",
  "version": "0.0.6",
  "dependencies": {
    "body-parser": "1.15.2",
    "compression": "1.6.0",
 "connect-flash": "0.1.1",
    "ejs": "2.5.2",
    "express": "4.14.0",
    "express-session": "1.14.1",
    "method-override": "2.3.6",
    "mongoose": "4.6.5",
    "morgan": "1.7.0",
    "passport": "0.3.2",
    "passport-local": "1.0.0"
  }
}

通常,在继续开发应用程序之前,您需要安装新的依赖项。转到应用程序文件夹,并在命令行工具中发出以下命令:

$ npm install

这将在您的node_modules文件夹中安装指定版本的Connect-Flash模块。安装过程成功完成后,您的下一步是配置 Express 应用程序以使用Connect-Flash模块。

配置 Connect-Flash 模块

要配置您的 Express 应用程序以使用新的Connect-Flash模块,您需要在 Express 配置文件中要求新模块,并使用app.use()方法将其注册到 Express 应用程序中。为此,请在您的config/express.js文件中进行以下更改:

const config = require('./config');
const express = require('express');
const morgan = require('morgan');
const compress = require('compression');
const bodyParser = require('body-parser');
const methodOverride = require('method-override');
const session = require('express-session');
const flash = require('connect-flash');
const passport = require('passport');

module.exports = function() {
  const app = express();

  if (process.env.NODE_ENV === 'development') {
    app.use(morgan('dev'));
  } else if (process.env.NODE_ENV === 'production') {
    app.use(compress());
  }

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

  app.use(bodyParser.json());
  app.use(methodOverride());

  app.use(session({
    saveUninitialized: true,
    resave: true,
    secret: config.sessionSecret
  }));
  app.set('views', './app/views');
  app.set('view engine', 'ejs');

 app.use(flash());
  app.use(passport.initialize());
  app.use(passport.session());

  require('../app/routes/index.server.routes.js')(app);
  require('../app/routes/users.server.routes.js')(app);

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

  return app;

};

这将告诉您的 Express 应用程序使用Connect-Flash模块,并在应用程序会话中创建新的闪存区域。

使用 Connect-Flash 模块

安装后,Connect-Flash模块公开了req.flash()方法,允许您创建和检索闪存消息。为了更好地理解它,让我们观察您对用户控制器所做的更改。首先,让我们看看负责渲染登录和注册页面的renderSignup()renderSignin()方法:

exports.renderSignin = function(req, res, next) {
  if (!req.user) {
    res.render('signin', {
      title: 'Sign-in Form',
 messages: req.flash('error') || req.flash('info')
    });
  } else {
    return res.redirect('/');
  }
};

exports.renderSignup = function(req, res, next) {
  if (!req.user) {
    res.render('signup', {
      title: 'Sign-up Form',
 messages: req.flash('error')
    });
  } else {
    return res.redirect('/');
  }
};


如您所见,res.render()方法使用titlemessages变量执行。messages 变量使用req.flash()读取消息写入闪存。现在,如果您查看signup()方法,您会注意到以下代码行:

req.flash('error', message);

这是如何使用req.flash()方法将错误消息写入闪存的方式。在学习如何使用Connect-Flash模块之后,您可能已经注意到我们缺少一个signin()方法。这是因为 Passport 为您提供了一个身份验证方法,您可以直接在路由定义中使用。最后,让我们继续进行最后需要修改的部分:用户的路由定义文件。

连接用户路由

一旦您配置好模型、控制器和视图,剩下的就是定义用户的路由。为此,请在您的app/routes/users.server.routes.js文件中进行以下更改:

const users = require('../../app/controllers/users.server.controller');
const passport = require('passport');

module.exports = function(app) {
  app.route('/signup')
     .get(users.renderSignup)
     .post(users.signup);

  app.route('/signin')
     .get(users.renderSignin)
     .post(passport.authenticate('local', {
       successRedirect: '/',
       failureRedirect: '/signin',
       failureFlash: true
     }));

  app.get('/signout', users.signout);
};

正如您所看到的,这里的大多数路由定义基本上是指向您的用户控制器中的方法。唯一不同的路由定义是处理发送到/signin路径的任何 POST 请求时使用passport.authenticate()方法。

当执行passport.authenticate()方法时,它将尝试使用其第一个参数定义的策略来验证用户请求。在这种情况下,它将尝试使用本地策略来验证请求。此方法接受的第二个参数是一个options对象,其中包含三个属性:

  • successRedirect:此属性告诉 Passport 在成功验证用户后将请求重定向到何处

  • failureRedirect:此属性告诉 Passport 在未能验证用户时将请求重定向到何处

  • failureFlash:此属性告诉 Passport 是否使用闪存消息

您几乎已经完成了基本的身份验证实现。要测试它,请对app/controllers/index.server.controller.js文件进行以下更改:

exports.render = function(req, res) {
  res.render('index', {
    title: 'Hello World',
    userFullName: req.user ? req.user.fullName : ''
  });
};

这将向您的主页模板传递经过身份验证的用户的全名。您还需要对app/views/index.ejs文件进行以下更改:

<!DOCTYPE html>
<html>
  <head>
      <title><%= title %></title>
    </head>
    <body>
      <% if ( userFullName ) { %>
        <h2>Hello <%=userFullName%> </h2> 
        <a href="/signout">Sign out</a>
      <% } else { %>
        <a href="/signup">Signup</a>
        <a href="/signin">Signin</a>
    <% } %>
    <br>
      <img src="img/logo.png" alt="Logo">
    </body>
</html>

就是这样!一切都准备好测试您的新身份验证层。转到您的根应用程序文件夹,并使用 node 命令行工具运行您的应用程序,然后输入以下命令:

$ node server

通过访问http://localhost:3000/signinhttp://localhost:3000/signup来测试您的应用程序。尝试注册,然后登录,不要忘记返回到您的主页,查看用户详细信息如何通过会话保存。

了解 Passport OAuth 策略

OAuth 是一种身份验证协议,允许用户使用外部提供者注册您的 Web 应用程序,而无需输入其用户名和密码。OAuth 主要由社交平台(如 Facebook、Twitter 和 Google)使用,允许用户使用其社交账户注册其他网站。

提示

要了解有关 OAuth 的更多信息,请访问oauth.net/上的 OAuth 协议网站。

设置 OAuth 策略

Passport 支持基本的 OAuth 策略,这使您能够实现任何基于 OAuth 的身份验证。但是,它还支持通过主要的 OAuth 提供者进行用户身份验证,使用包装策略来帮助您避免自己实现复杂的机制。在本节中,我们将回顾顶级 OAuth 提供者以及如何实现其 Passport 身份验证策略。

注意

在开始之前,您需要联系 OAuth 提供者并创建一个开发者应用程序。此应用程序将具有 OAuth 客户端 ID 和 OAuth 客户端密钥,这将允许您对您的应用程序进行 OAuth 提供者的验证。

处理 OAuth 用户创建

OAuth 用户创建应该与本地signup()方法有些不同。由于用户是使用其他提供者的配置文件注册的,配置文件详细信息已经存在,这意味着您需要以不同的方式对它们进行验证。为此,请返回到您的app/controllers/users.server.controller.js文件,并添加以下方法:

exports.saveOAuthUserProfile = function(req, profile, done) {
  User.findOne({
    provider: profile.provider,
    providerId: profile.providerId
  }, (err, user) => {
    if (err) {
      return done(err);
    } else {
      if (!user) {
        const possibleUsername = profile.username || ((profile.email) ? profile.email.split(@''@')[0] : '');

        User.findUniqueUsername(possibleUsername, null, (availableUsername) => {
          const newUser = new User(profile);
          newUser.username = availableUsername;

          newUser.save((err) => {

            return done(err, newUser);
          });
        });
      } else {
        return done(err, user);
      }
    }
  });
};

该方法接受一个用户资料,然后查找具有这些providerIdprovider属性的现有用户。如果找到用户,它将使用用户的 MongoDB 文档调用done()回调方法。但是,如果找不到现有用户,它将使用 User 模型的findUniqueUsername()静态方法找到一个唯一的用户名,并保存一个新的用户实例。如果发生错误,saveOAuthUserProfile()方法将使用done()方法报告错误;否则,它将把用户对象传递给done()回调方法。一旦弄清楚了saveOAuthUserProfile()方法,就是时候实现第一个 OAuth 认证策略了。

使用 Passport 的 Facebook 策略

Facebook 可能是世界上最大的 OAuth 提供商。许多现代 Web 应用程序允许用户使用他们的 Facebook 资料注册 Web 应用程序。Passport 支持使用passport-facebook模块进行 Facebook OAuth 认证。让我们看看如何通过几个简单的步骤实现基于 Facebook 的认证。

安装 Passport 的 Facebook 策略

要在应用程序的模块文件夹中安装 Passport 的 Facebook 模块,你需要按照以下方式更改你的package.json文件:

{
  "name": "MEAN",
  "version": "0.0.6",
  "dependencies": {
    "body-parser": "1.15.2",
    "compression": "1.6.0",
    "connect-flash": "0.1.1",
    "ejs": "2.5.2",
    "express": "4.14.0",
    "express-session": "1.14.1",
    "method-override": "2.3.6",
    "mongoose": "4.6.5",
    "morgan": "1.7.0",
    "passport": "0.3.2",
 "passport-facebook": "2.1.1",
    "passport-local": "1.0.0"
  }
}

在继续开发应用之前,你需要安装新的 Facebook 策略依赖。为此,前往你应用的root文件夹,并在命令行工具中输入以下命令:

$ npm install

这将在你的node_modules文件夹中安装指定版本的 Passport 的 Facebook 策略。安装过程成功完成后,你需要配置 Facebook 策略。

配置 Passport 的 Facebook 策略

在开始配置 Facebook 策略之前,你需要前往 Facebook 的开发者主页developers.facebook.com/,创建一个新的 Facebook 应用,并将本地主机设置为应用域。配置完 Facebook 应用后,你将获得一个 Facebook 应用 ID 和密钥。你需要这些信息来通过 Facebook 对用户进行认证,所以让我们将它们保存在环境配置文件中。前往config/env/development.js文件,并进行以下更改:

module.exports = {
  db: 'mongodb://localhost/mean-book',
  sessionSecret: 'developmentSessionSecret',
 facebook: {
 clientID: 'Application Id',
 clientSecret: 'Application Secret',
 callbackURL: 'http://localhost:3000/oauth/facebook/callback'
  }
};

不要忘记用你的 Facebook 应用 ID 和密钥替换Application IdApplication SecretcallbackURL属性将被传递给 Facebook OAuth 服务,在认证过程结束后将重定向到该 URL。确保callbackURL属性与你在开发者主页设置的回调设置匹配。

现在,前往你的config/strategies文件夹,创建一个名为facebook.js的新文件,其中包含以下代码片段:

const passport = require('passport');
const url = require('url');
const FacebookStrategy = require('passport-facebook').Strategy;
const config = require('../config');
const users = require('../../app/controllers/users.server.controller');

module.exports = function() {
  passport.use(new FacebookStrategy({
    clientID: config.facebook.clientID,
    clientSecret: config.facebook.clientSecret,
    callbackURL: config.facebook.callbackURL,
    profileFields: ['id', 'name', 'displayName', 'emails'],
    passReqToCallback: true
  }, (req, accessToken, refreshToken, profile, done) => {
    const providerData = profile._json;
    providerData.accessToken = accessToken;
    providerData.refreshToken = refreshToken;

    const providerUserProfile = {
      firstName: profile.name.givenName,
      lastName: profile.name.familyName,
      fullName: profile.displayName,
      email: profile.emails[0].value,
      username: profile.name.givenName + profile.name.familyName,
      provider: 'facebook',
      providerId: profile.id,
      providerData: providerData
    };

    users.saveOAuthUserProfile(req, providerUserProfile, done);
  }));
};

让我们稍微回顾一下前面的代码片段。你首先需要引入passport模块、Facebook 策略对象、你的环境配置文件、你的User Mongoose 模型和 Users 控制器。然后,使用passport.use()方法注册策略,并创建一个FacebookStrategy对象的实例。FacebookStrategy构造函数接受两个参数:Facebook 应用信息和稍后在尝试认证用户时将调用的回调函数。

看一下你定义的回调函数。它接受五个参数:HTTP 请求对象,一个accessToken对象用于验证未来的请求,一个refreshToken对象用于获取新的访问令牌,一个包含用户资料的profile对象,以及在认证过程结束时调用的done回调函数。

在回调函数内部,你将使用 Facebook 资料信息创建一个新的用户对象,并使用控制器的saveOAuthUserProfile()方法对当前用户进行认证。

还记得config/passport.js文件吗?现在您已经配置了您的 Facebook 策略,您可以返回到该文件并加载策略文件。为此,返回config/passport.js文件并按以下方式更改它:

const passport = require('passport');
const mongoose = require('mongoose');

module.exports = function() {
  const User = mongoose.model('User');

  passport.serializeUser((user, done) => {
    done(null, user.id);
  });

  passport.deserializeUser((id, done) => {
    User.findOne({
      _id: id
    }, '-password -salt', (err, user) => {
      done(err, user);
    });
  });

  require('./strategies/local.js')();
 require('./strategies/facebook.js')();
};

这将加载您的 Facebook 策略配置文件。现在,剩下的就是设置通过 Facebook 对用户进行身份验证所需的路由,并在您的登录和注册页面中包含指向这些路由的链接。

连接 Passport 的 Facebook 策略路由

Passport OAuth 策略支持使用passport.authenticate()方法直接对用户进行身份验证的能力。要这样做,转到app/routes/users.server.routes.js,并在本地策略路由定义之后追加以下代码行:

app.get('/oauth/facebook', passport.authenticate('facebook', {
  failureRedirect: '/signin'
}));

app.get('/oauth/facebook/callback', passport.authenticate('facebook', {
  failureRedirect: '/signin',
  successRedirect: '/'
}));

第一个路由将使用passport.authenticate()方法启动用户身份验证过程,而第二个路由将在用户链接其 Facebook 个人资料后使用passport.authenticate()方法完成身份验证过程。

就是这样!一切都为您的用户通过 Facebook 进行身份验证设置好了。现在您只需要转到您的app/views/signup.ejsapp/views/signin.ejs文件,并在关闭的BODY标签之前添加以下代码行:

<a href="/oauth/facebook">Sign in with Facebook</a>

这将允许您的用户点击链接并通过其 Facebook 个人资料注册您的应用程序。

使用 Passport 的 Twitter 策略

另一个流行的 OAuth 提供程序是 Twitter,许多 Web 应用程序都提供用户使用其 Twitter 个人资料注册 Web 应用程序的功能。Passport 支持使用passport-twitter模块的 Twitter OAuth 身份验证方法。让我们看看如何通过几个简单的步骤实现基于 Twitter 的身份验证。

安装 Passport 的 Twitter 策略

要在应用程序的模块文件夹中安装 Passport 的 Twitter 策略模块,您需要按照以下步骤更改您的package.json文件:

{
  "name": "MEAN",
  "version": "0.0.6",
  "dependencies": {
    "body-parser": "1.15.2",
    "compression": "1.6.0",
    "connect-flash": "0.1.1",
    "ejs": "2.5.2",
    "express": "4.14.0",
    "express-session": "1.14.1",
    "method-override": "2.3.6",
    "mongoose": "4.6.5",
    "morgan": "1.7.0",
    "passport": "0.3.2",
    "passport-facebook": "2.1.1",
    "passport-local": "1.0.0",
 "passport-twitter": "1.0.4"
  }
}

在继续开发应用程序之前,您需要安装新的 Twitter 策略依赖项。转到您的应用程序的root文件夹,并在命令行工具中发出以下命令:

$ npm install

这将在您的node_modules文件夹中安装指定版本的 Passport 的 Twitter 策略。安装过程成功完成后,您需要配置 Twitter 策略。

配置 Passport 的 Twitter 策略

在开始配置 Twitter 策略之前,您需要转到 Twitter 开发者主页dev.twitter.com/并创建一个新的 Twitter 应用程序。配置 Twitter 应用程序后,您将获得 Twitter 应用程序 ID 和密钥。您需要它们来通过 Twitter 对用户进行身份验证,因此让我们将它们添加到我们的环境配置文件中。转到config/env/development.js文件,并按以下方式更改它:

module.exports = {
  db: 'mongodb://localhost/mean-book',
  sessionSecret: 'developmentSessionSecret',
  facebook: {
    clientID: 'Application Id',
    clientSecret: 'Application Secret',
    callbackURL: 'http://localhost:3000/oauth/facebook/callback'
  },
 twitter: {
 clientID: 'Application Id',
 clientSecret: 'Application Secret',
 callbackURL: 'http://localhost:3000/oauth/twitter/callback'
 }
};

不要忘记用您的 Twitter 应用程序的 ID 和密钥替换Application IdApplication SecretcallbackURL属性将被传递给 Twitter OAuth 服务,该服务将在认证过程结束后将用户重定向到该 URL。确保callbackURL属性与您在开发者主页中设置的回调设置匹配。

如前所述,在您的项目中,每个策略都应该在自己单独的文件中进行配置,这将帮助您保持项目的组织。转到您的config/strategies文件夹,并创建一个名为twitter.js的新文件,其中包含以下代码行:

const passport = require('passport');
const url = require('url');
const TwitterStrategy = require('passport-twitter').Strategy;
const config = require('../config');
const users = require('../../app/controllers/users.server.controller');

module.exports = function() {
  passport.use(new TwitterStrategy({
    consumerKey: config.twitter.clientID,
    consumerSecret: config.twitter.clientSecret,
    callbackURL: config.twitter.callbackURL,
    passReqToCallback: true
  }, (req, token, tokenSecret, profile, done) => {
    const providerData = profile._json;
    providerData.token = token;
    providerData.tokenSecret = tokenSecret;

    const providerUserProfile = {
      fullName: profile.displayName,
      username: profile.username,
      provider: 'twitter',
      providerId: profile.id,
      providerData: providerData
    };

    users.saveOAuthUserProfile(req, providerUserProfile, done);
  }));
};

您首先需要引入passport模块、Twitter Strategy对象、您的环境配置文件、您的User Mongoose 模型和 Users 控制器。然后,您使用passport.use()方法注册策略,并创建TwitterStrategy对象的实例。TwitterStrategy构造函数接受两个参数:Twitter 应用程序信息和稍后在尝试对用户进行身份验证时将调用的回调函数。

查看您定义的回调函数。它接受五个参数:HTTP 请求对象,一个token对象和一个tokenSecret对象来验证未来的请求,一个包含用户配置文件的profile对象,以及在身份验证过程结束时调用的done回调。

在回调函数中,您将使用 Twitter 配置文件信息创建一个新的用户对象,并使用您之前创建的控制器的saveOAuthUserProfile()方法来验证当前用户。

现在您已经配置了 Twitter 策略,您可以返回config/passport.js文件,并按照以下方式加载策略文件:

const passport = require('passport');
const mongoose = require('mongoose');

module.exports = function() {
  const User = mongoose.model('User');

  passport.serializeUser((user, done) => {
    done(null, user.id);
  });

  passport.deserializeUser((id, done) => {
    User.findOne({
      _id: id
    }, '-password -salt, ', (err, user) => {
      done(err, user);
    });
  });

  require('./strategies/local.js')();
  require('./strategies/facebook.js')();
 require('./strategies/twitter.js')();
};

这将加载您的 Twitter 策略配置文件。现在,您只需要设置所需的路由来通过 Twitter 对用户进行身份验证,并在登录和注册页面中包含指向这些路由的链接。

连接 Passport 的 Twitter 策略路由

要添加 Passport 的 Twitter 路由,请转到您的app/routes/users.server.routes.js文件,并在 Facebook 策略路由之后粘贴以下代码:

app.get('/oauth/twitter', passport.authenticate('twitter', {
  failureRedirect: '/signin'
}));

app.get('/oauth/twitter/callback', passport.authenticate('twitter', {
  failureRedirect: '/signin',
  successRedirect: '/'
}));

第一个路由将使用passport.authenticate()方法启动用户身份验证过程,而第二个路由将在用户使用其 Twitter 配置文件连接后使用passport.authenticate()方法完成身份验证过程。

就是这样!您的用户的 Twitter 身份验证已经设置好了。您需要做的就是转到您的app/views/signup.ejsapp/views/signin.ejs文件,并在关闭的BODY标签之前添加以下代码行:

<a href="/oauth/twitter">Sign in with Twitter</a>

这将允许您的用户点击链接,并通过其 Twitter 配置文件注册到您的应用程序。

使用 Passport 的 Google 策略

我们将实现的最后一个 OAuth 提供程序是 Google,因为许多 Web 应用程序都允许用户使用其 Google 配置文件注册 Web 应用程序。Passport 支持使用passport-google-oauth模块的 Google OAuth 身份验证方法。让我们看看如何通过几个简单的步骤实现基于 Google 的身份验证。

安装 Passport 的 Google 策略

要在应用程序的模块文件夹中安装 Passport 的 Google 策略模块,您需要更改您的package.json文件,如下所示:

{
  "name": "MEAN",
  "version": "0.0.6",
  "dependencies": {
    "body-parser": "1.15.2",
    "compression": "1.6.0",
    "connect-flash": "0.1.1",
    "ejs": "2.5.2",
    "express": "4.14.0",
    "express-session": "1.14.1",
    "method-override": "2.3.6",
    "mongoose": "4.6.5",
    "morgan": "1.7.0",
    "passport": "0.3.2",
    "passport-facebook": "2.1.1",    
 "passport-google-oauth": "1.0.0",
    "passport-local": "1.0.0",
    "passport-twitter": "1.0.4"
  }
}

在您继续开发应用程序之前,您需要安装新的谷歌策略依赖项。转到应用程序的“根”文件夹,并在命令行工具中输入以下命令:

$ npm install

这将在您的node_modules文件夹中安装 Passport 的 Google 策略的指定版本。安装过程成功完成后,您需要配置 Google 策略。

配置 Passport 的 Google 策略

在我们开始配置您的 Google 策略之前,您需要转到 Google 开发人员主页console.developers.google.com/并创建一个新的 Google 应用程序。在应用程序的设置中,将JAVASCRIPT ORIGINS属性设置为http://localhost,将REDIRECT URLs属性设置为http://localhost/oauth/google/callback。配置完您的 Google 应用程序后,您将获得 Google 应用程序 ID 和密钥。您需要它们来通过 Google 对用户进行身份验证,因此让我们将它们添加到我们的环境配置文件中。转到config/env/development.js文件,并更改如下:

module.exports = {
  db: 'mongodb://localhost/mean-book',
  sessionSecret: 'developmentSessionSecret',
  facebook: {
    clientID: 'Application Id',
    clientSecret: 'Application Secret',
    callbackURL: 'http://localhost:3000/oauth/facebook/callback'
  },
  twitter: {
    clientID: 'Application Id',
    clientSecret: 'Application Secret',
    callbackURL: 'http://localhost:3000/oauth/twitter/callback'
  },
 google: {
 clientID: 'Application Id',
 clientSecret: 'Application Secret',
 callbackURL: 'http://localhost:3000/oauth/google/callback'
 }
};

不要忘记用您的 Google 应用程序的 ID 和密钥替换Application IdApplication SecretcallbackURL属性将传递给 Google OAuth 服务,在身份验证过程结束后将用户重定向到该 URL。确保callbackURL属性与您在开发人员主页中设置的回调设置匹配。

要实现 Google 身份验证策略,请转到您的config/strategies文件夹,并创建一个名为google.js的新文件,其中包含以下代码行:

const passport = require('passport');
const url = require('url');,
const GoogleStrategy = require('passport-google-oauth').OAuth2Strategy;
const config = require(../config');
const users = require('../../app/controllers/users.server.controller');

module.exports = function() {
  passport.use(new GoogleStrategy({
    clientID: config.google.clientID,
    clientSecret: config.google.clientSecret,
    callbackURL: config.google.callbackURL,
    passReqToCallback: true
  }, (req, accessToken, refreshToken, profile, done) => {
    const providerData = profile._json;
    providerData.accessToken = accessToken;
    providerData.refreshToken = refreshToken;

    const providerUserProfile = {
      firstName: profile.name.givenName,
      lastName: profile.name.familyName,
      fullName: profile.displayName,
      email: profile.emails[0].value,
      username: profile.username,
      provider: 'google''google',
      providerId: profile.id,
      providerData: providerData
    };

    users.saveOAuthUserProfile(req, providerUserProfile, done);
  }));
};

让我们稍微回顾一下前面的代码片段。您首先需要引入passport模块、Google 策略对象、您的环境配置文件、User Mongoose 模型和用户控制器。然后,使用passport.use()方法注册策略,并创建一个GoogleStrategy对象的实例。GoogleStrategy构造函数接受两个参数:Google 应用程序信息和稍后在尝试对用户进行身份验证时将调用的回调函数。

查看您定义的回调函数。它接受五个参数:HTTP 请求对象,用于验证未来请求的accessToken对象,用于获取新访问令牌的refreshToken对象,包含用户配置文件的profile对象,以及在认证过程结束时调用的done回调。

在回调函数中,您将使用 Google 配置文件信息和控制器的saveOAuthUserProfile()方法创建一个新的用户对象,该方法是您之前创建的,用于验证当前用户。

现在您已经配置了 Google 策略,可以返回到config/passport.js文件并加载策略文件,如下所示:

const passport = require('passport');
const mongoose = require('mongoose');

module.exports = function() {
  const User = mongoose.model('User');

  passport.serializeUser((user, done) => {
    done(null, user.id);
  });

  passport.deserializeUser((id, done) => {
    User.findOne({
      _id: id
    }, '-password -salt', function(err, user) => {
      done(err, user);
    });
  });

  require('./strategies/local.js')();
  require('./strategies/facebook.js')();
  require('./strategies/twitter.js')();
 require('./strategies/google.js')();
};

这将加载您的 Google 策略配置文件。现在剩下的就是设置所需的路由来通过 Google 对用户进行身份验证,并在您的登录和注册页面中包含指向这些路由的链接。

连接 Passport 的 Google 策略路由

要添加 Passport 的 Google 路由,请转到您的app/routes/users.server.routes.js文件,并在 Twitter 策略路由之后粘贴以下代码行:

app.get('/oauth/google', passport.authenticate('google', {
  failureRedirect: '/signin',
  scope: [
    'https://www.googleapis.com/auth/userinfo.profile',
    'https://www.googleapis.com/auth/userinfo.email'
  ],
}));

app.get('/oauth/google/callback', passport.authenticate('google', {
  failureRedirect: '/signin',
  successRedirect: '/'
}));

第一个路由将使用passport.authenticate()方法启动用户身份验证过程,而第二个路由将使用passport.authenticate()方法在用户使用其 Google 配置文件连接后完成身份验证过程。

就是这样!一切都为您的用户基于 Google 的身份验证设置好了。您只需转到您的app/views/signup.ejsapp/views/signin.ejs文件,并在关闭的BODY标签之前添加以下代码行:

<a href="/oauth/google">Sign in with Google</a>

这将允许您的用户点击链接并通过其 Google 配置文件注册您的应用程序。要测试您的新身份验证层,转到应用程序的root文件夹,并使用 node 命令行工具运行您的应用程序:

$ node server

通过访问http://localhost:3000/signinhttp://localhost:3000/signup来测试您的应用程序。尝试使用新的 OAuth 方法进行注册和登录。不要忘记访问您的主页,查看用户详细信息在整个会话期间是如何保存的。

提示

Passport 还为许多其他 OAuth 提供程序提供类似的支持。要了解更多信息,建议您访问passportjs.org/guide/providers/

总结

在本章中,您了解了 Passport 身份验证模块。您了解了其策略以及如何处理其安装和配置。您还学会了如何正确注册用户以及如何验证其请求。您已经了解了 Passport 的本地策略,并学会了如何使用用户名和密码对用户进行身份验证,以及 Passport 如何支持不同的 OAuth 身份验证提供程序。在下一章中,我们将向您介绍 MEAN 拼图的最后一部分,即Angular

第七章:Angular 简介

MEAN 拼图的最后一块当然是 Angular。回到 2009 年,当开发人员 Miško Hevery 和 Adam Abrons 在构建他们的 JSON 作为平台服务时,他们注意到常见的 JavaScript 库并不够用。他们丰富的 Web 应用程序的性质引发了对更有结构的框架的需求,以减少冗余工作并保持项目代码有序。他们放弃了最初的想法,决定专注于开发他们的框架,将其命名为 AngularJS,并在开源许可下发布。这个想法是弥合 JavaScript 和 HTML 之间的差距,并帮助推广单页面应用程序的开发。在接下来的几年里,AngularJS—现在被称为 Angular—成为 JavaScript 生态系统中最受欢迎的框架之一,并彻底改变了前端开发世界。然而,在过去的几年里,发生了一些重大的范式转变。因此,当由谷歌赞助的团队决定开发 Angular 的下一个版本时,他们引入了一整套新的想法。在本章中,我们将涵盖以下主题:

  • 介绍 TypeScript

  • 介绍 Angular 2

  • 理解 Angular 2 的构建块

  • 安装和配置 TypeScript 和 Angular 2

  • 创建和组织 Angular 2 应用程序

  • 利用 Angular 的组件架构

  • 实现Authentication组件

介绍 Angular 2

AngularJS 是一个前端 JavaScript 框架,旨在使用类似 MVC 的架构构建单页面应用程序。Angular 的方法是通过特殊属性扩展 HTML 的功能,将 JavaScript 逻辑与 HTML 元素绑定在一起。AngularJS 扩展 HTML 的能力允许通过客户端模板化进行更清晰的 DOM 操作,并实现了无缝同步的双向数据绑定,使模型和视图之间无缝同步。AngularJS 还通过 MVC 和依赖注入改进了应用程序的代码结构和可测试性。AngularJS 1 是一个很棒的框架,但它是基于 ES5 的概念构建的,随着新的 ES2015 规范带来的巨大改进,团队不得不重新思考整个方法。

从 Angular 1.x 到 Angular 2.x

如果您已经熟悉 Angular 1,转向 Angular 2 可能看起来是一个很大的步骤。然而,Angular 团队确保保留了 Angular 1 的优点,同时利用 ES2015 的新功能,并保持了通向改进框架的更清晰的路径。以下是从 Angular 1 所做的更改的快速总结:

  • 语法:Angular 2 依赖于以前称为 ES6 的新 ECMAScript 规范,现在更名为 ES2015。然而,该规范仍在不断发展,浏览器支持仍然不足。为了解决这个问题,Angular 2 团队决定使用 TypeScript。

  • TypeScript:TypeScript 是 ES2015 的超集,这意味着它允许您编写强类型的 ES2015 代码,稍后将根据您的需求和平台支持编译为 ES5 或 ES2015 源代码。Angular 2 在其文档和代码示例中大力推动 TypeScript 的使用,我们也会这样做。不过,不用担心;尽管 TypeScript 可能看起来广泛而可怕,但在本章结束时,您将能够使用它。

  • 模块:Angular 1 引入了一个模块化架构,需要使用angular#module()自定义方法。然而,ES2015 引入了一个类似于 Node.js 中使用的内置模块系统。因此,Angular 2 模块更容易创建和使用。

  • 控制器:Angular 1 主要关注控制器。在本书的第一个版本中,本章主要关注 Angular 1 的 MVC 方法,但在 Angular 2 中,基本构建块是组件。这种转变也代表了 JavaScript 生态系统的更大转变,特别是关于 Web 组件。

  • 作用域:著名的$scope对象现在已经过时。在 Angular 2 中,组件模型更清晰、更可读。一般来说,ES2015 中引入类的概念及其在 TypeScript 中的支持允许更好的设计模式。

  • 装饰器:装饰器是 TypeScript 中实现的一种设计特性,可能会在 ES2016(ES7)中实现。装饰器允许开发人员注释类和成员,以添加功能或数据,而不扩展实体。Angular 2 依赖装饰器来实现某些功能,您将在本章后面处理它们。

  • 依赖注入:Angular 1 非常强调依赖注入范式。Angular 2 简化了依赖注入,现在支持多个注入器而不是一个。

所有这些特性标志着 Angular 和 JavaScript 的新时代,一切都始于 TypeScript。

TypeScript 简介

TypeScript 是由微软创建的一种类型化编程语言,它使用了 C#、Java 和现在的 ES2015 的面向对象基础。用 TypeScript 编写的代码会被转译成 ES3、ES5 或 ES2015 的 JavaScript 代码,并可以在任何现代 Web 浏览器上运行。它也是 ES2015 的超集,因此基本上任何 JavaScript 代码都是有效的 TypeScript 代码。其背后的想法是创建一个强类型的编程语言,用于大型项目,可以让大型团队更好地沟通其软件组件之间的接口。由于 TypeScript 中的许多特性已经在 ES2015 中实现,我们将介绍一些基本特性,这些特性是我们需要的,但在当前规范中没有得到。

类型

类型是每种编程语言的重要部分,包括 JavaScript。不幸的是,静态类型在 ES2015 中没有被引入;然而,TypeScript 支持基本的 JavaScript 类型,并允许开发人员创建和使用自己的类型。

基本类型

类型可以是 JavaScript 原始类型,如下面的代码所示:

let firstName: string = "John";
let lastName = 'Smith';
let height: number = 6;
let isDone: boolean = false;

此外,TypeScript 还允许您使用数组:

var numbers:number[] = [1, 2, 3];
var names:Array<string> = ['Alice', 'Helen', 'Claire'];

然后,这两种方式都被转译成熟悉的 JavaScript 数组声明。

任意类型

any类型表示任何自由形式的 JavaScript 值。any的值将通过转译器进行最小的静态类型检查,并支持作为 JavaScript 值的所有操作。可以访问any值上的所有属性,并且any值也可以作为带有参数列表的函数调用。实际上,any是所有类型的超类型,每当 TypeScript 无法推断类型时,将使用any类型。您可以显式或隐式地使用any类型:

var x: any;
var y;

接口

由于 TypeScript 是关于保持项目结构的,语言的重要部分是接口。接口允许您塑造对象并保持代码的稳固和清晰。类可以实现接口,这意味着它们必须符合接口中声明的属性或方法。接口还可以继承自其他接口,这意味着它们的实现类将能够实现扩展的接口。一个示例的 TypeScript 接口将类似于这样:

interface IVehicle {
  wheels: number;
  engine: string;
  drive();
}

在这里,我们有一个IVehicle接口,有两个属性和一个方法。一个实现类会是这样的:

class Car implements IVehicle  {
  wheels: number;
  engine: string;

  constructor(wheels: number, engine: string) {
    this.wheels = wheels;
    this.engine = engine;
  }

  drive() {
    console.log('Driving...');
  }
}

正如您所看到的,Car类实现了IVehicle接口,并遵循了其设置的结构。

注意

接口是 TypeScript 的一个强大特性,也是面向对象编程的重要部分。建议您继续阅读有关它们的内容:www.typescriptlang.org/docs/handbook/interfaces.html

装饰器

虽然对于新的 ES7 规范来说,它仍处于提案阶段,但 Angular 2 在装饰器上有很大的依赖。装饰器是一种特殊类型的声明,可以附加到各种实体上,比如类、方法或属性。装饰器为开发人员提供了一种可重用的方式来注释和修改类和成员。装饰器使用 @decoratorName 的形式,其中 decoratorName 参数必须是一个函数,在运行时将被调用以装饰实体。一个简单的装饰器如下所示:

function Decorator(target: any) {

}
@Decorator
class MyClass {

}

在运行时,装饰器将使用 MyClass 构造函数填充目标参数执行。此外,装饰器也可以带有参数,如下所示:

function DecoratorWithArgs(options: Object) {
  return (target: Object) => {

  }
}

@DecoratorWithArgs({ type: 'SomeType' })
class MyClass {

}

这种模式也被称为装饰器工厂。装饰器可能看起来有点奇怪,但一旦我们深入了解 Angular 2,你就会开始理解它们的强大。

总结

TypeScript 已经存在多年,并且由一个非常强大的团队开发。这意味着我们仅仅触及了它无尽的功能和能力的表面。然而,这个介绍将为我们提供进入 Angular 2 这个伟大框架所需的技能和知识。

Angular 2 架构

Angular 2 的目标很简单:以一种可管理和可扩展的方式将 HTML 和 JavaScript 结合起来,以构建客户端应用程序。为此,Angular 2 使用了基于组件的方法,支持实体,如服务和指令,在运行时注入到组件中。这种方法一开始可能有点奇怪,但它允许我们保持关注点的清晰分离,并通常保持更清晰的项目结构。为了理解 Angular 2 的基础知识,请看下面的图:

Angular 2 架构

上图展示了一个由两个组件组成的 Angular 2 应用程序的简单架构。中心实体是组件。每个组件都通过其模板执行数据绑定和事件处理,以向用户呈现交互式用户界面。服务用于执行任何其他任务,比如加载数据、执行计算等。然后组件消耗这些服务并委托这些任务。指令是组件模板的渲染指令。为了更好地理解这一点,让我们深入了解一下。

Angular 2 模块

Angular 2 应用通常是模块化的应用程序。这意味着 Angular 2 应用程序由多个模块组成,每个模块通常都是专门用于单个任务的一段代码。事实上,整个框架都是以模块化的方式构建的,允许开发人员只导入他们需要的功能。幸运的是,Angular 2 使用了我们之前介绍过的 ES2015 模块语法。我们的应用程序也将由自定义模块构建,一个示例应用程序模块如下所示:

import { NgModule }       from '@angular/core';
import { CommonModule }   from '@angular/common';
import { RouterModule }   from '@angular/router';

import { AppComponent }       from './app.component';
import { AppRoutes }       from './app.routes';

@NgModule({
  imports: [
    CommonModule,
    RouterModule.forRoot(AppRoutes),
  ],
  declarations: [
    AppComponent
  ],
  bootstrap: [AppComponent]
})
export class AppModule { }

如你所见,我们使用 @NgModule 装饰器来创建应用程序模块,该模块使用应用程序组件和路由来启动我们的应用程序。为了更好地理解这一点,让我们来看看 Angular 2 应用程序的第一个和最重要的构建块:组件。

Angular 2 组件

组件是 Angular 2 应用程序的基本构建块。它的工作是控制用户界面的一个专用部分,通常称为视图。大多数应用程序至少包含一个根应用程序组件,通常还包含多个控制不同视图的组件。组件通常被定义为一个常规的 ES2015 类,带有一个 @Component 装饰器,用于将其定义为组件并包含组件元数据。然后将组件类导出为一个模块,可以在应用程序的其他部分导入和使用。一个简单的应用程序组件如下所示:

import { Component } from '@angular/core';

@Component({
  selector: 'mean-app',
  template: '<h1>I AM AN APPLICATION COMPONENT</h1>'
})
export class AppComponent { 	}

注意我们如何从 @angular/core 模块库中导入 @Component 装饰器,然后使用它来定义我们的组件 DOM 选择器和我们想要使用的模板。最后,我们导出一个名为 AppComponent 的类。组件是视图管理的一方,另一方是模板。

Angular 2 模板

模板由组件用于呈现组件视图。它们由基本的 HTML 与 Angular 专用的注解组合而成,告诉组件如何呈现最终视图。在前面的例子中,你可以看到一个简单的模板直接传递给了 AppComponent 类。然而,你也可以将模板保存在外部模板文件中,并将组件更改为如下所示:

import { Component } from '@angular/core';

@Component({
  selector: 'mean-app',
  templateUrl: 'app.template.html'
})
export class AppComponent { 	}

如你所见,我们当前的模板是静态的,所以为了创建更有用的模板,现在是时候讨论数据绑定了。

Angular 2 数据绑定

Angular 最大的特点之一是其复杂的数据绑定能力。如果你习惯于在框架之外工作,你就知道在视图和数据模型之间管理数据更新是一种噩梦。幸运的是,Angular 的数据绑定为你提供了一种简单的方式来管理组件类和渲染视图之间的绑定。

插值绑定

将数据从组件类绑定到模板的最简单方法称为插值。插值使用双大括号语法将类属性的值与模板绑定。这种机制的一个简单例子如下:

import { Component } from '@angular/core';

@Component({
  selector: 'mean-app',
  template: '<h1>{{title}}</h1>'
})
export class AppComponent {
  title = 'MEAN Application';
}

注意我们如何在模板 HTML 中绑定了 AppComponent 类的 title 属性。

属性绑定

单向数据绑定的另一个例子是属性绑定,它允许你将 HTML 元素的属性值与组件属性值或任何其他模板表达式绑定。这是使用方括号来完成的,如下所示:

import { Component } from '@angular/core';

@Component({
  selector: 'mean-app',
  template: '<button [disabled]="isButtonDisabled">My Button</button>'
})
export class AppComponent {
  isButtonDisabled = true;
}

在这个例子中,Angular 会将按钮呈现为禁用状态,因为我们将 isButtonDisabled 属性设置为 true

事件绑定

为了使你的组件响应从视图生成的 DOM 事件,Angular 2 为你提供了事件绑定的机制。要将 DOM 事件绑定到组件方法,你只需要在圆括号内设置事件名称,如下例所示:

import { Component } from '@angular/core';

@Component({
  selector: 'mean-app',
  template: '<button (click)="showMessage()">Show Message</button>'
})
export class AppComponent {
  showMessage() {
    alert('This is a message!')
  }
}

在这个例子中,视图按钮的点击事件将调用我们的 AppComponent 类内的 showMessage() 方法。

双向绑定

到目前为止,我们只讨论了单向数据绑定,其中视图调用组件函数或组件改变视图。然而,当处理用户输入时,我们需要以一种无缝的方式进行双向数据绑定。这可以通过将 ngModel 属性添加到你的输入 HTML 元素并将其绑定到组件属性来完成。为了做到这一点,我们需要使用圆括号和方括号的组合语法,如下例所示:

import { Component } from '@angular/core';

@Component({
  selector: 'mean-app',
  template: '<h1>Hello {{name}}</h1><br><input [(ngModel)]="name">'
})
export class AppComponent {
  name = ''
}

在这个例子中,用户将看到一个标题元素,它将根据输入实时更新。输入双向绑定了名称属性,因此对输入值的每次更改都将更新到 AppComponent 类并呈现到视图中。我们在这里使用的 ngModel 属性被称为指令,因此自然而然地,现在是时候讨论指令了。

Angular 2 指令

Angular 的基本操作是使用一组通常是指令的指令将我们的动态模板转换为视图。有几种类型的指令,但最基本和令人惊讶的是组件。@Component 装饰器实际上通过向其添加模板来扩展了 @Directive 装饰器。还记得之前例子中的选择器属性吗?如果你在另一个组件内使用这个选择器作为标签,它将呈现我们的组件内部。但这只是一种指令的类型;另一种是我们在之前例子中使用的 ngModel 指令。总而言之,我们有三种类型的指令。

属性指令

属性指令改变 DOM 元素的行为或外观。我们将这些指令作为 HTML 属性应用于要更改的 DOM 元素上。Angular 2 包含了几个预定义的属性指令,例如以下内容:

  • ngClass:为元素绑定单个或多个类的方法

  • ngStyle:为元素绑定单个或多个内联样式的方法

  • ngModel:为表单元素创建双向数据绑定

这只是一些例子,但您应该记住,您可以并且应该编写自己的自定义指令。

结构指令

结构指令通过移除和添加 DOM 元素来改变我们应用程序的 DOM 布局。Angular 2 包含了三个您应该了解的主要结构指令:

  • ngIf:提供一种根据条件添加或移除元素的方法

  • ngFor:提供一种根据对象列表创建元素副本的方法

  • ngSwitch:提供一种根据属性值从元素列表中显示单个元素的方法

所有结构指令都使用一种称为 HTML5 模板的机制,它允许我们的 DOM 保留一个 HTML 模板,而不使用模板标签进行渲染。当我们使用这些指令时,这会产生一个我们将讨论的后果。

组件指令

正如之前所述,每个组件基本上都是一个指令。例如,假设我们有一个名为SampleComponent的组件:

import { Component } from '@angular/core';

@Component({
  selector: 'sample-component',
  template: '<h1>I'm a component</h1>'
})
export class SampleComponent {

}

我们可以在AppComponent类中将其作为指令使用,如下所示:

import { Component } from '@angular/core';
import { SampleComponent } from 'sample.component';

@Component({
  selector: 'mean-app',
  template: '<sample-component></sample-component>',
  directives: [SampleComponent]
})
export class AppComponent {

}

请注意我们如何在AppComponent类中使用sample-component标签并包含我们的SampleComponent模块在指令列表中。

总之,对于许多 Angular 1 开发人员来说,指令曾经是一个令人恐惧的概念,但现在它们变得简单、易于理解和有趣。在本书的后面,您将学习如何使用本节中介绍的大部分概念。

Angular 2 服务

服务是 Angular 2 的一个重要部分。它们基本上只是应用程序中单一目的或功能所需的类。由于我们希望保持组件的清晰并专注于用户体验,服务几乎包含了其他所有内容。例如,任何数据管理、日志记录、应用程序配置或其他不属于组件的功能都将作为服务实现。值得注意的是,Angular 2 服务并没有什么特别之处;它们只是具有定义功能的普通类。它们之所以特别,是因为我们可以使用一种称为依赖注入的机制将这些服务提供给组件。

Angular 2 依赖注入

依赖注入是一种软件设计模式,由软件工程师马丁·福勒(Martin Fowler)推广。依赖注入背后的主要原则是软件开发架构中的控制反转。为了更好地理解这一点,让我们来看一下以下的notifier示例:

const Notifier = function() {
  this.userService = new UserService();
};

Notifier.prototype.notify = function() {
  const user = this.userService.getUser();

  if (user.role === 'admin') {
    alert('You are an admin!');
  } else {
    alert('Hello user!');
  }
};

我们的Notifier类创建了一个userService的实例,当调用notify()方法时,它会根据用户角色发出不同的消息。现在这样做可能效果很好,但当您想要测试您的Notifier类时会发生什么呢?您将在测试中创建一个Notifier实例,但您将无法传递一个模拟的userService对象来测试notify方法的不同结果。依赖注入通过将创建userService对象的责任移交给Notifier实例的创建者来解决了这个问题,无论是另一个对象还是一个测试。这个创建者通常被称为注入器。这个示例的一个经过修订的、依赖注入的版本将如下所示:

const Notifier = function(userService) {
  this.userService = userService;
};

Notifier.prototype.notify = function() {
  const user = this.userService.getUser();

  if (user.role === 'admin') {
    alert('You are an admin!');
  } else {
    alert('Hello user!');
  }
};

现在,每当您创建Notifier类的实例时,注入器将负责将userService对象注入到构造函数中,从而使得在构造函数之外控制Notifier实例的行为成为可能,这种设计通常被描述为控制反转。

在 Angular 2 中使用依赖注入

在 Angular 2 中,依赖注入用于将服务注入到组件中。服务是在构造函数中注入到组件中的,如下所示:

import { Component } from '@angular/core';
import { SomeService } from '../users/services/some.service';

@Component({
  selector: 'some-component',
  template: 'Hello Services',
 providers: [SomeService]
})
export class SomeComponent {
  user = null;
  constructor (private _someService: SomeService) {
    this.user = _someService.user;
  }
}

当 Angular 2 创建组件类的实例时,它将首先请求一个注入器来解析所需的服务以调用构造函数。如果注入器包含服务的先前实例,它将提供它;否则,注入器将创建一个新实例。为此,您需要为组件注入器提供服务提供程序。这就是为什么我们在@Component装饰器中添加providers属性。此外,我们可以在组件树的任何级别注册提供程序,一个常见的模式是在应用程序启动时在根级别注册提供程序,这样服务的相同实例将在整个应用程序组件树中可用。

Angular 2 路由

在我们着手实现应用程序之前,我们最后一个主题将是导航和路由。使用 Web 应用程序,用户期望一定类型的 URL 路由。为此,Angular 团队创建了一个名为组件路由器的模块。组件路由器解释浏览器 URL,然后在其定义中查找并加载组件视图。支持现代浏览器的历史 API,路由器将响应来自浏览器 URL 栏或用户交互的任何 URL 更改。让我们看看它是如何工作的。

设置

由于 Angular 2 团队专注于模块化方法,您需要单独加载路由文件 - 无论是从本地文件还是使用 CDN。此外,您还需要在主 HTML 文件的头部设置<base href="/">标签。但现在不用担心这些。我们将在下一节中处理这些更改。

路由

每个应用程序将有一个路由器,因此当发生 URL 导航时,路由器将查找应用程序内部的路由配置,以确定要加载哪个组件。为了配置应用程序路由,Angular 提供了一个特殊的数组类,称为Routes,其中包括 URL 和组件之间的映射列表。这种机制的示例如下:

import { Routes } from '@angular/router';
import { HomeComponent } from './home.component';

export const HomeRoutes: Routes = [{
  path: '',
  component: HomeComponent,
}];

路由出口

组件路由器使用分层组件结构,这意味着每个由组件路由器装饰和加载的组件都可以配置子路径。因此,加载根组件并在主应用程序标签中呈现其视图;然而,当加载子组件时,它们将如何以及在哪里呈现?为了解决这个问题,路由器模块包括一个名为RouterOutlet的指令。要呈现您的子组件,您只需在父组件的模板中包含RouterOutlet指令。一个示例组件如下:

import { Component } from '@angular/core';

@Component({
  selector: 'mean-app',
  template: '<h1>Application Title</h1>
    <br>
 <router-outlet></router-outlet>'
})
export class AppComponent { ... }

请注意,router-outlet标签将被替换为您的子组件的视图。

路由链接

在我们配置应用程序路由之后,我们将能够通过更改浏览器 URL 或使用RouterLink指令来生成指向应用程序内部链接的锚标签来浏览我们的应用程序。RouterLink指令使用链接参数数组,路由器将稍后解析为与组件映射匹配的 URL。带有RouterLink指令的示例锚标签如下:

<a [routerLink]="['/about']">Some</a>

总结

随着我们在本章的进展,我们已经了解了 TypeScript 和 Angular 2。我们现在已经涵盖了我们在 MEAN 应用程序中创建 Angular 应用程序所需的一切。所以让我们开始设置我们的项目。

项目设置

为了在我们的项目中使用 Angular,我们需要安装 TypeScript 和 Angular。我们需要使用 TypeScript 转译器将我们的 TypeScript 文件转换为有效的 ES5 或 ES6 JavaScript 文件。此外,由于 Angular 是一个前端框架,安装它需要在应用程序的主页面中包含 JavaScript 文件。这可以通过各种方式完成,最简单的方式是下载你需要的文件并将它们存储在public文件夹中。另一种方法是使用 Angular 的 CDN 并直接从 CDN 服务器加载文件。虽然这两种方法都简单易懂,但它们都有一个严重的缺陷。加载单个第三方 JavaScript 文件是可读和直接的,但当你开始向项目中添加更多的供应商库时会发生什么?更重要的是,你如何管理你的依赖版本?

所有这些问题的答案都是 NPM!NPM 将允许我们在开发应用程序时安装所有依赖项并运行 TypeScript 转译器。为了做到这一点,你需要修改你的package.json文件,如下所示:

{
  "name": "MEAN",
  "version": "0.0.7",
 "scripts": {
 "tsc": "tsc",
 "tsc:w": "tsc -w",
 "app": "node server",
 "start": "concurrently \"npm run tsc:w\" \"npm run app\" ",
 "postinstall": "typings install"
 },
  "dependencies": {
 "@angular/common": "2.1.1",
 "@angular/compiler": "2.1.1",
 "@angular/core": "2.1.1",
 "@angular/forms": "2.1.1",
 "@angular/http": "2.1.1",
 "@angular/platform-browser": "2.1.1",
 "@angular/platform-browser-dynamic": "2.1.1",
 "@angular/router": "3.1.1",
    "body-parser": "1.15.2",
 "core-js": "2.4.1",
    "compression": "1.6.0",
    "connect-flash": "0.1.1",
    "ejs": "2.5.2",
    "express": "4.14.0",
    "express-session": "1.14.1",
    "method-override": "2.3.6",
    "mongoose": "4.6.5",
    "morgan": "1.7.0",
    "passport": "0.3.2",
    "passport-facebook": "2.1.1",
    "passport-google-oauth": "1.0.0",
    "passport-local": "1.0.0",
    "passport-twitter": "1.0.4",
 "reflect-metadata": "0.1.8",
 "rxjs": "5.0.0-beta.12",
 "systemjs": "0.19.39",
 "zone.js": "0.6.26"
  },
  "devDependencies": {
 "concurrently": "3.1.0",
 "traceur": "0.0.111",
    "typescript": "2.0.3",
    "typings": "1.4.0"
  }
}

在我们的新package.json文件中,我们做了一些事情;首先,我们添加了我们项目的 Angular 依赖,包括一些支持库:

  • CoreJS:这将为我们提供一些 ES6 polyfills

  • ReflectMetadata:这将为我们提供一些元数据反射 polyfill

  • Rx.JS:这是一个我们以后会使用的响应式框架

  • SystemJS:这将帮助加载我们的应用程序模块

  • Zone.js:这允许创建不同的执行上下文区域,并被 Angular 库使用

  • Concurrently:这将允许我们同时运行 TypeScript 转译器和我们的服务器

  • Typings:这将帮助我们下载预定义的外部库的 TypeScript 定义

在顶部,我们添加了一个 scripts 属性,其中我们定义了希望 npm 为我们运行的不同脚本。例如,我们有一个脚本用于安装第三方库的类型定义,另一个用于运行名为tsc的 TypeScript 编译器的脚本,一个名为app的脚本用于运行我们的节点服务器,以及一个名为start的脚本,使用并发工具同时运行这两个脚本。

接下来,我们将配置 TypeScript 编译器的运行方式。

配置 TypeScript

为了配置 TypeScript 的工作方式,我们需要在应用程序的根目录下添加一个名为tsconfig.json的新文件。在你的新文件中,粘贴以下 JSON:

{
  "compilerOptions": {
    "target": "es5",
    "module": "system",
    "moduleResolution": "node",
    "sourceMap": true,
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true,
    "removeComments": false,
    "noImplicitAny": false
  },
  "exclude": [
    "node_modules",
    "typings/main",
    "typings/main.d.ts"
  ]
}

在我们的tsconfig.json文件中,我们配置了 TypeScript 编译器:

  • 将我们的 TypeScript 代码编译成 ES5 代码

  • 将我们的模块编译成系统模块模式

  • 使用 Node 进行模块解析

  • 生成源映射

  • 包括装饰器并发出它们的元数据

  • 保留注释

  • 取消任何隐式声明的错误

  • 不包括node_modules文件夹和类型文件

当我们运行我们的应用程序时,TypeScript 将默认使用tsconfig.json配置文件。接下来,你需要在应用程序的根目录下添加一个名为typings.json的新文件。在你的新文件中,粘贴以下 JSON:

{
  "globalDependencies": {
  "core-js": "registry:dt/core-js#0.0.0+20160914114559",
    "jasmine": "registry:dt/jasmine#2.5.0+20161025102649",
    "socket.io-client": "registry:dt/socket.io-client#1.4.4+20160317120654",
    "node": "registry:dt/node#6.0.0+20161102143327"
  }
}

正如你所看到的,我们已经添加了所有我们需要的第三方库,以便让 TypeScript 转译器正确编译我们的代码。完成后,继续安装你的新依赖:

$ npm install

我们需要的所有包都将与我们需要的外部类型定义一起安装,以支持 TypeScript 编译。现在我们已经安装了新的包并配置了我们的 TypeScript 实现,是时候设置 Angular 了。

注意

建议你继续阅读 Typings 的官方文档github.com/typings/typings

配置 Express

要开始使用 Angular,你需要在我们的主 EJS 视图中包含新的 JavaScript 库文件。因此,我们将使用app/views/index.ejs文件作为主应用程序页面。然而,NPM 将所有依赖项安装在node_module文件夹中,这对我们的客户端不可访问。为了解决这个问题,我们将不得不修改我们的config/express.js文件如下:

const path = require('path'),
const config = require('./config'),
const express = require('express'),
const morgan = require('morgan'),
const compress = require('compression'),
const bodyParser = require('body-parser'),
const methodOverride = require('method-override'),
const session = require('express-session'),
const flash = require('connect-flash'),
const passport = require('passport');

module.exports = function() {
  const app = express();

  if (process.env.NODE_ENV === 'development') {
    app.use(morgan('dev'));
  } else if (process.env.NODE_ENV === 'production') {
    app.use(compress());
  }

  app.use(bodyParser.urlencoded({
    extended: true
  }));
  app.use(bodyParser.json());
  app.use(methodOverride());

  app.use(session({
    saveUninitialized: true,
    resave: true,
    secret: config.sessionSecret
  }));

  app.set('views', './app/views');
  app.set('view engine', 'ejs');

  app.use(flash());
  app.use(passport.initialize());
  app.use(passport.session());

  app.use('/', express.static(path.resolve('./public')));
 app.use('/lib', express.static(path.resolve('./node_modules')));

  require('../app/routes/users.server.routes.js')(app);
  require('../app/routes/index.server.routes.js')(app);

  return app;
};

这里的一个重大变化涉及创建一个指向我们node_modules文件夹的/lib静态路由。当我们在这里时,我们还切换了用户和索引路由的顺序。当我们开始处理 Angular 的路由机制时,这将非常方便。在这方面,我们还需要做一件事,那就是确保我们的 Express 应用程序在接收到未定义路由时始终返回主应用程序视图。这是为了处理浏览器初始请求使用的 URL 是由 Angular 路由器生成的,而不受我们的 Express 配置支持的情况。为此,返回到app/routes/index.server.routes.js文件,并进行如下更改:

module.exports = function(app) {
  const index = require('../controllers/index.server.controller');

  app.get('/*', index.render);
};

现在,我们已经配置了 TypeScript 和 Express,是时候设置 Angular 了,但在我们这样做之前,让我们稍微谈谈我们的应用程序结构。

重新构建应用程序

正如你可能记得的来自第三章,构建 Express Web 应用程序,你的应用程序结构取决于你的应用程序的复杂性。我们之前决定对整个 MEAN 应用程序使用水平方法;然而,正如我们之前所述,MEAN 应用程序可以以各种方式构建,而 Angular 应用程序结构是一个不同的话题,经常由社区和 Angular 开发团队讨论。有许多用于不同目的的原则,其中一些有点复杂,而其他一些则提供了更简单的方法。在本节中,我们将介绍一个推荐的结构。随着从 Angular 1 到 Angular 2 的转变,这个讨论现在变得更加复杂。对我们来说,最简单的方法是从我们 Express 应用程序的public文件夹开始,作为 Angular 应用程序的根文件夹,以便每个文件都可以静态地使用。

根据其复杂性,有几种选项可以结构化应用程序。简单的应用程序可以具有水平结构,其中实体根据其类型排列在文件夹中,并且主应用程序文件放置在应用程序的根文件夹中。这种类型的示例应用程序结构可以在以下截图中看到:

重新构建应用程序

正如你所看到的,这是一个非常舒适的解决方案,适用于具有少量实体的小型应用程序。然而,你的应用程序可能更复杂,具有多种不同的功能和更多的实体。这种结构无法处理这种类型的应用程序,因为它会混淆每个应用程序文件的行为,将会有一个文件过多的臃肿文件夹,并且通常会非常难以维护。为此,有一种不同的方法来以垂直方式组织文件。垂直结构根据其功能上下文定位每个文件,因此不同类型的实体可以根据其在功能或部分中的角色进行排序。这类似于我们在第三章中介绍的垂直方法,构建 Express Web 应用程序。然而,不同之处在于只有 Angular 的逻辑单元将具有独立的模块文件夹结构,通常包括组件和模板文件。Angular 应用程序垂直结构的示例可以在以下截图中看到:

重新构建应用程序

如你所见,每个模块都有自己的文件夹结构,这使你可以封装每个组件。我们还使用了我们在第三章中介绍的文件命名约定,构建 Express Web 应用程序

现在你知道了命名和结构化应用程序的基本最佳实践,让我们继续创建应用程序模块。

创建应用程序模块

首先,清空public文件夹的内容,并在其中创建一个名为app的文件夹。在你的新文件夹中,创建一个名为app.module.ts的文件。在你的文件中,添加以下代码:

import { NgModule }       from '@angular/core';
import { BrowserModule }  from '@angular/platform-browser';

import { AppComponent }       from './app.component';

@NgModule({
  imports: [
    BrowserModule
  ],
  declarations: [
    AppComponent
  ],
  bootstrap: [AppComponent]
})
export class AppModule { }

如你所见,我们基本上只是创建了一个声明应用程序组件并将其用于引导的简单模块。接下来我们需要创建应用程序组件。

创建应用程序组件

在你的public/app文件夹中,创建一个名为app.component.ts的新文件。在你的文件中,添加以下代码:

import { Component } from '@angular/core';

@Component({
  selector: 'mean-app',
  template: '<h1>Hello World</h1>',
})
export class AppComponent {}

如你所见,我们基本上只是创建了最简单的组件。接下来我们将学习如何引导我们的AppModule类。

引导应用程序模块

要引导你的应用程序模块,转到你的app文件夹并创建一个名为bootstrap.ts的新文件。在你的文件中,添加以下代码:

import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { AppModule } from './app.module';

platformBrowserDynamic().bootstrapModule(AppModule);

基本上,这段代码使用浏览器平台模块来为浏览器引导应用程序模块。一旦我们配置好这些,就是时候学习如何使用 SystemJS 模块加载器加载我们的引导代码了。

启动你的 Angular 应用程序

要使用 SystemJS 作为我们的模块加载器,我们将在public文件夹中创建一个名为systemjs.config.js的新文件。在你的新文件中,粘贴以下代码:

(function(global) {
  var packages = {
    app: {
      main: './bootstrap.js',
      defaultExtension: 'js'
    }
  };

  var map = {
    '@angular': 'lib/@angular',
    'rxjs': 'lib/rxjs'
  };

  var ngPackageNames = [
    'common',
    'compiler',
    'core',
    'forms',
    'http',
    'router',
    'platform-browser',
    'platform-browser-dynamic',
  ];

  ngPackageNames.forEach(function(pkgName) {	
    packages['@angular/' + pkgName] = { main: '/bundles/' + pkgName + '.umd.js', defaultExtension: 'js' };
  });

  System.config({
    defaultJSExtensions: true,
    transpiler: null,
    packages: packages,
    map: map
  });
})(this);

在这个文件中,我们告诉 SystemJS 关于我们的应用程序包以及从哪里加载 Angular 和 Rx 模块。然后我们描述了每个 Angular 包的主文件;在这种情况下,我们要求它加载每个包的 UMD 文件。然后我们使用System.config方法来配置 SystemJS。最后,我们重新访问我们的app/views/index.ejs文件并进行更改,如下所示:

<!DOCTYPE html>
<html>
<head>
  <title><%= title %></title>
 <base href="/">
</head>
<body>
  <mean-app>
    <h1>Loading...</h1>
  </mean-app>

 <script src="img/shim.min.js"></script>
 <script src="img/zone.js"></script>
 <script src="img/Reflect.js"></script>
 <script src="img/system.js"></script>

 <script src="img/systemjs.config.js"></script>
 <script>
 System.import('app').catch(function(err){ console.error(err); });
 </script>
</body>
</html>

如你所见,我们直接从node_modules包文件夹中加载我们的模块文件,并包括我们的 SystemJS 配置文件。最后一个脚本告诉 SystemJS 加载我们在配置文件中定义的应用程序包。

注意

要了解更多关于 SystemJS 的信息,建议你访问官方文档github.com/systemjs/systemjs

现在你所要做的就是在命令行中调用以下命令来运行你的应用程序:

$ npm start

当你的应用程序正在运行时,使用浏览器打开你的应用程序 URL,地址为http://localhost:3000。你应该看到一个标题标签显示Hello World。恭喜!你已经创建了你的第一个 Angular 2 模块和组件,并成功地引导了你的应用程序。接下来,我们将重构应用程序的身份验证部分并创建一个新的身份验证模块。

管理身份验证

管理 Angular 应用程序的身份验证是一个复杂的问题。问题在于,虽然服务器保存了关于经过身份验证的用户的信息,但 Angular 应用程序并不知道这些信息。一个解决方案是使用一个服务并向服务器询问身份验证状态;然而,这个解决方案存在缺陷,因为所有的 Angular 组件都必须等待响应返回,导致不一致和开发开销。这可以通过使用高级的 Angular 路由对象来解决;然而,一个更简单的解决方案是让 Express 应用程序直接在 EJS 视图中渲染user对象,然后使用 Angular 服务来提供该对象。

渲染用户对象

要渲染经过身份验证的user对象,你需要进行一些更改。让我们从更改app/controllers/index.server.controller.js文件开始,如下所示:

exports.render = function(req, res) {
  const user = (!req.user) ? null : {
    _id: req.user.id,
    firstName: req.user.firstName,
    lastName: req.user.lastName
  };

  res.render('index', {
    title: 'Hello World',
    user: JSON.stringify(user)
  });
};

接下来,转到你的app/views/index.ejs文件并进行以下更改:

<!DOCTYPE html>
<html>
<head>
  <title><%= title %></title>
  <base href="/">
</head>
<body>
  <mean-app>
    <h1>Loading...</h1>
  </mean-app>

 <script type="text/javascript">
 window.user = <%- user || 'null' %>;
 </script>

  <script src="img/shim.min.js"></script>
  <script src="img/zone.js"></script>
  <script src="img/Reflect.js"></script>
  <script src="img/system.js"></script>

  <script src="img/systemjs.config.js"></script>

  <script>
    System.import('app').catch(function(err){ console.error(err); });
  </script>
</body>
</html>

这将在您的主视图应用程序中以 JSON 表示形式呈现用户对象。当 Angular 应用程序启动时,身份验证状态将已经可用。如果用户已经通过身份验证,user对象将变为可用;否则,user对象将为 Null。

修改用户服务器控制器

为了支持我们的身份验证重构,我们需要确保我们的用户服务器控制器能够处理 Angular 服务请求。为此,您需要更改您的app/controllers/users.server.controller.js文件中的代码如下:

const User = require('mongoose').model('User'),
  passport = require('passport');

const getErrorMessage = function(err) {
  const message = '';

  if (err.code) {
    switch (err.code) {
      case 11000:
      case 11001:
      message = 'Username already exists';
      break;
      default:
      message = 'Something went wrong';
    }
  } else {
    for (let errName in err.errors) {
      if (err.errors[errName].message) message = err.errors[errName].message;
    }
  }

  return message;
};

exports.signin = function(req, res, next) {
  passport.authenticate('local', function(err, user, info) {
    if (err || !user) {
      res.status(400).send(info);
    } else {
      // Remove sensitive data before login
      user.password = undefined;
      user.salt = undefined;

      req.login(user, function(err) {
        if (err) {
          res.status(400).send(err);
        } else {
          res.json(user);
        }
      });
    }
  })(req, res, next);
};

exports.signup = function(req, res) {
  const user = new User(req.body);
  user.provider = 'local';

  user.save((err) => {
    if (err) {
      return res.status(400).send({
        message: getErrorMessage(err)
      });
    } else {
      // Remove sensitive data before login
      user.password = undefined;
      user.salt = undefined;

      req.login(user, function(err) {
        if (err) {
          res.status(400).send(err);
        } else {
          res.json(user);
        }
      });
    }
  });
};

exports.signout = function(req, res) {
  req.logout();
  res.redirect('/');
};

exports.saveOAuthUserProfile = function(req, profile, done) {
  User.findOne({
    provider: profile.provider,
    providerId: profile.providerId
  }, function(err, user) {
    if (err) {
      return done(err);
    } else {
      if (!user) {
        const possibleUsername = profile.username ||
        ((profile.email) ? profile.email.split('@')[0] : '');

        User.findUniqueUsername(possibleUsername, null,
        function(availableUsername) {
          profile.username = availableUsername;

          user = new User(profile);

          user.save((err) => {
            if (err) {
              const message = _this.getErrorMessage(err);

              req.flash('error', message);
              return res.redirect('/signup');
            }

            return done(err, user);
          });
        });
      } else {
        return done(err, user);
      }
    }
  });
};

我们基本上只是将身份验证逻辑封装在两个可以接受和响应 JSON 对象的方法中。现在让我们继续并按照以下方式更改app/routes/users.server.routes.js目录:

const users = require('../../app/controllers/users.server.controller'),
  passport = require('passport');

module.exports = function(app) {
  app.route('/api/auth/signup').post(users.signup);
  app.route('/api/auth/signin').post(users.signin);
  app.route('/api/auth/signout').get(users.signout);

  app.get('/api/oauth/facebook', passport.authenticate('facebook', {
    failureRedirect: '/signin'
  }));
  app.get('/api/oauth/facebook/callback', passport.authenticate('facebook', {
    failureRedirect: '/signin',
    successRedirect: '/'
  }));

  app.get('/api/oauth/twitter', passport.authenticate('twitter', {
     failureRedirect: '/signin'
  }));
  app.get('/api/oauth/twitter/callback', passport.authenticate('twitter', {
    failureRedirect: '/signin',
    successRedirect: '/'
  }));

  app.get('/api/oauth/google', passport.authenticate('google', {
    failureRedirect: '/signin',
    scope: [
      'https://www.googleapis.com/auth/userinfo.profile',
      'https://www.googleapis.com/auth/userinfo.email'
    ],
  }));
  app.get('/api/oauth/google/callback', passport.authenticate('google', {
    failureRedirect: '/signin',
    successRedirect: '/'
  }));

};

注意我们删除了用于渲染身份验证视图的路由。更重要的是,看看我们为所有路由添加了/api前缀的方式。将所有路由放在一个前缀下是一个很好的做法,因为我们希望 Angular 路由器能够拥有不干扰我们服务器路由的路由。现在我们的服务器端准备好了,是时候创建我们的 Angular 身份验证模块了。

创建身份验证模块

现在我们已经为我们的 Angular 应用程序奠定了基础,我们可以继续并将我们的身份验证逻辑重构为一个统一的身份验证模块。为此,我们将首先在我们的public/app文件夹内创建一个名为authentication的新文件夹。在我们的新文件夹中,创建一个名为authentication.module.ts的文件,并添加以下代码:

import { NgModule }       from '@angular/core';
import { FormsModule }    from '@angular/forms';
import { RouterModule } from '@angular/router';

import { AuthenticationRoutes } from './authentication.routes';
import { AuthenticationComponent } from './authentication.component';
import { SigninComponent } from './signin/signin.component';
import { SignupComponent } from './signup/signup.component';

@NgModule({
  imports: [
    FormsModule,
    RouterModule.forChild(AuthenticationRoutes),
  ],
  declarations: [
    AuthenticationComponent,
    SigninComponent,
    SignupComponent,
  ]
})
export class AuthenticationModule {}

我们的模块由三个组件组成:

  • 一个身份验证组件

  • 一个注册组件

  • 一个登录组件

我们还包括了一个身份验证路由配置和 Angular 的 Forms 模块来支持我们的登录和注册表单。让我们开始实现基本的身份验证组件。

创建身份验证组件

我们将首先创建我们的身份验证组件层次结构。然后,我们将把我们的服务器登录和注册视图转换为 Angular 模板,将身份验证功能添加到AuthenticationService中,并重构我们的服务器逻辑。让我们首先在我们的public/app/authentication文件夹内创建一个名为authentication.component.ts的文件。在新文件中,粘贴以下代码:

import { Component } from '@angular/core';
import { SigninComponent } from './signin/signin.component';
import { SignupComponent } from './signup/signup.component';

@Component({
  selector: 'authentication',
  templateUrl: 'app/authentication/authentication.template.html',
})
export class AuthenticationComponent { }

在这段代码中,我们实现了我们的新身份验证组件。我们首先导入了身份验证服务和注册和登录组件,这些组件我们还没有创建。另一个需要注意的是,这次我们为我们的组件使用了外部模板文件。接下来,我们将为我们的身份验证模块创建路由配置。

配置身份验证路由

为此,在我们的public/app/authentication文件夹内创建一个名为authentication.routes.ts的新文件。在新文件中,粘贴以下代码:

import { Routes } from '@angular/router';

import { AuthenticationComponent } from './authentication.component';
import { SigninComponent } from './signin/signin.component';
import { SignupComponent } from './signup/signup.component';

export const AuthenticationRoutes: Routes = [{
  path: 'authentication',
  component: AuthenticationComponent,
  children: [
    { path: 'signin', component: SigninComponent },
    { path: 'signup', component: SignupComponent },
  ],
}];

如您所见,我们创建了一个具有authentication父路由和signinsignup组件两个子路由的新Routes实例。接下来,我们将在我们的组件文件夹内创建名为authentication.template.html的模板文件。在新文件中,粘贴以下代码:

<div>
  <a href="/api/oauth/google">Sign in with Google</a>
  <a href="/api/oauth/facebook">Sign in with Facebook</a>
  <a href="/api/oauth/twitter">Sign in with Twitter</a>
  <router-outlet></router-outlet>
</div>

注意我们在代码中使用了RouterOutlet指令。这是我们的子组件将被渲染的地方。我们将继续创建这些子组件。

创建登录组件

要实现signin组件,请在您的public/app/authentication文件夹内创建一个名为signin的新文件夹。在您的新文件夹中,创建一个名为signin.component.ts的新文件,并添加以下代码:

import { Component } from '@angular/core';
import { Router } from '@angular/router';

import { AuthenticationService } from '../authentication.service';

@Component({
  selector: 'signin',
  templateUrl: 'app/authentication/signin/signin.template.html'
})
export class SigninComponent {
  errorMessage: string;
  credentials: any = {};

  constructor (private _authenticationService: AuthenticationService, private _router: Router) {	}

  signin() {
    this._authenticationService.signin(this.credentials).subscribe(result  => this._router.navigate(['/']),
      error =>  this.errorMessage = error );
  }
}

注意我们的signin组件如何使用身份验证服务来执行signin操作。不用担心,我们将在下一节中实现这一点。接下来,您需要在与您的组件相同的文件夹中创建一个名为signin.template.html的文件。在您的新文件中,添加以下代码:

<form (ngSubmit)="signin()">
  <div>
    <label>Username:</label>
    <input type="text" [(ngModel)]="credentials.username" name="username">
  </div>
  <div>
    <label>Password:</label>
    <input type="password" [(ngModel)]="credentials.password" name="password">
  </div>
  <div>
    <input type="submit" value="Sign In">
  </div>
  <span>{{errorMessage}}</span>
</form>

我们刚刚创建了一个新的组件来处理我们的身份验证登录操作!注册组件看起来会非常相似。

创建注册组件

要实现注册组件,请在您的public/app/authentication文件夹内创建一个名为signup的新文件夹。在您的新文件夹内,创建一个名为signup.component.ts的新文件,并包含以下代码:

import { Component } from '@angular/core';
import { Router } from '@angular/router';

import { AuthenticationService } from '../authentication.service';

@Component({
  selector: 'signup',
  templateUrl: 'app/authentication/signup/signup.template.html'
})
export class SignupComponent {
  errorMessage: string;
  user: any = {};

  constructor (private _authenticationService: 
    AuthenticationService,
    private _router: Router) {}

  signup() {
    this._authenticationService.signup(this.user)
    .subscribe(result  => this._router.navigate(['/']),
    error =>  this.errorMessage = error);
  }
}

请注意我们的注册组件如何使用身份验证服务来执行注册操作。接下来,您需要在与您的组件相同的文件夹中创建一个名为signup.template.html的文件。在您的新文件中,添加以下代码:

<form (ngSubmit)="signup()">
  <div>
  <label>First Name:</label>
    <input type="text" [(ngModel)]="user.firstName" name="firstName">
  </div>
  <div>
    <label>Last Name:</label>
    <input type="text" [(ngModel)]="user.lastName" name="lastName">
  </div>
  <div>
    <label>Email:</label>
    <input type="text" [(ngModel)]="user.email" name="email">
  </div>
  <div>
    <label>Username:</label>
    <input type="text" [(ngModel)]="user.username" name="username">
  </div>
  <div>
    <label>Password:</label>
    <input type="password" [(ngModel)]="user.password" name="password">
  </div>
  <div>
    <input type="submit" value="Sign up" />
  </div>
  <span>{{errorMessage}}</span>
</form>

现在我们已经有了我们的身份验证组件,让我们回过头来处理身份验证服务。

创建身份验证服务

为了支持我们的新组件,我们需要创建一个身份验证服务,以为它们提供所需的功能。为此,请在您的public/app/authentication文件夹内创建一个名为authentication.service.ts的新文件。在您的新文件中,粘贴以下代码:

import 'rxjs/Rx';
import { Injectable } from '@angular/core';
import { Http, Response, Headers, RequestOptions } from '@angular/http';
import { Observable } from 'rxjs/Observable';

@Injectable()
export class AuthenticationService {
  public user = window['user'];

  private _signinURL = 'api/auth/signin';
  private _signupURL = 'api/auth/signup';

  constructor (private http: Http) {

  }
  isLoggedIn(): boolean {
    return (!!this.user);
  }

  signin(credentials: any): Observable<any> {
    let body = JSON.stringify(credentials);
    let headers = new Headers({ 'Content-Type': 'application/json' });
    let options = new RequestOptions({ headers: headers });

    return this.http.post(this._signinURL, body, options)
    .map(res => this.user = res.json())
    .catch(this.handleError)
  }

  signup(user: any): Observable<any> {
    let body = JSON.stringify(user);
    let headers = new Headers({ 'Content-Type': 'application/json' });
    let options = new RequestOptions({ headers: headers });

    return this.http.post(this._signupURL, body, options)
    .map(res => this.user = res.json())
    .catch(this.handleError)
  }

  private handleError(error: Response) {
    console.error(error);
    return Observable.throw(error.json().message || 'Server error');
  }
}

请注意我们如何使用@Injectable装饰器装饰了AuthenticationService类。虽然在这种情况下不需要,但用这种装饰器装饰您的服务是一个好习惯。原因是,如果您想要用另一个服务来注入一个服务,您将需要使用这个装饰器,所以为了统一起见,最好是保险起见,装饰所有的服务。另一个需要注意的是我们如何从窗口对象中获取我们的用户对象。

我们还为我们的服务添加了三种方法:一个处理登录的方法,另一个处理注册的方法,以及一个用于错误处理的方法。在我们的方法内部,我们使用 Angular 提供的 HTTP 模块来调用我们的服务器端点。在下一章中,我们将进一步阐述这个模块,但与此同时,您需要知道的是,我们只是用它来向服务器发送 POST 请求。为了完成 Angular 部分,我们的应用程序将需要修改我们的应用程序模块,并添加一个简单的主页组件。

创建主页模块

为了扩展我们的简单示例,我们需要一个主页组件,它将为我们的基本根提供视图,并为已登录和未登录的用户呈现不同的信息。为此,请在您的public/app文件夹内创建一个名为home的文件夹。然后,在此文件夹内创建一个名为home.module.ts的文件,其中包含以下代码:

import { NgModule }       from '@angular/core';
import { CommonModule }   from '@angular/common';
import { RouterModule } from '@angular/router';

import { HomeRoutes } from './home.routes';
import { HomeComponent } from './home.component';

@NgModule({
  imports: [
    CommonModule,
    RouterModule.forChild(HomeRoutes),
  ],
  declarations: [
    HomeComponent,
  ]
})
export class HomeModule {}

正如您可能已经注意到的,我们的模块只导入了一个新的主页组件和路由配置。让我们继续创建我们的主页组件。

创建主页组件

接下来,我们将创建我们的主页组件。为此,请转到您的public/app/home文件夹,并创建一个名为home.component.ts的新文件,其中包含以下代码:

import { Component } from '@angular/core';
import { AuthenticationService } from '../authentication/authentication.service';

@Component({
  selector: 'home',
  templateUrl: './app/home/home.template.html'
})
export class HomeComponent {
  user: any;

  constructor (private _authenticationService: AuthenticationService) {
    this.user = _authenticationService.user;
  }
}

正如您所看到的,这只是一个简单的组件,它注入了身份验证服务,并用于为组件提供用户对象。接下来,我们需要创建我们的主页组件模板。为此,请转到您的public/app/home文件夹,并创建一个名为home.template.html的文件,其中包含以下代码:

<div *ngIf="user">
  <h1>Hello {{user.firstName}}</h1>
  <a href="/api/auth/signout">Signout</a>
</div>

<div *ngIf="!user">
  <a [routerLink]="['/authentication/signup']">Signup</a>
  <a [routerLink]="['/authentication/signin']">Signin</a>
</div>

这个模板的代码很好地演示了我们之前讨论过的一些主题。请注意我们在本章前面讨论过的ngIfrouterLink指令的使用。

配置主页路由

为了完成我们的模块,我们需要为我们的主页组件创建一个路由配置。为此,请在您的public/app/home文件夹内创建一个名为home.routes.ts的新文件。在您的新文件中,粘贴以下代码:

import { Routes } from '@angular/router';
import { HomeComponent } from './home.component';

export const HomeRoutes: Routes = [{
  path: '',
  component: HomeComponent,
}];

正如您所看到的,这只是一个简单的组件路由。为了完成我们的实现,我们需要稍微修改我们的应用程序模块。

重构应用程序模块

为了包含我们的身份验证和主页组件模块,我们需要修改我们的app.module.ts文件如下:

import { NgModule }       from '@angular/core';
import { BrowserModule }  from '@angular/platform-browser';
import { RouterModule }   from '@angular/router';
import { HttpModule } from '@angular/http';

import { AppComponent }       from './app.component';
import { AppRoutes }       from './app.routes';

import { HomeModule } from './home/home.module';
import { AuthenticationService } from './authentication/authentication.service';
import { AuthenticationModule } from './authentication/authentication.module';

@NgModule({
  imports: [
    BrowserModule,
    HttpModule,
 AuthenticationModule,
 HomeModule,
 RouterModule.forRoot(AppRoutes),
  ],
  declarations: [
    AppComponent
  ],
  providers: [
    AuthenticationService
  ],
  bootstrap: [AppComponent]
})
export class AppModule { }

正如您所看到的,这对我们的应用程序模块来说是一个相当大的改变。首先,我们导入了 HTTP 模块和我们的新主页和身份验证模块,以及我们的新应用程序路由配置。我们在providers属性中注入了身份验证服务,以便它对我们所有的子模块都可用。我们需要做的最后一件事就是实现我们的应用程序路由配置。

配置应用程序路由

配置我们的应用程序路由,我们需要在public/app文件夹内创建一个名为app.routes.ts的新文件。在新文件中,粘贴以下代码:

import { Routes } from '@angular/router';

export const AppRoutes: Routes = [{
  path: '**',
  redirectTo: '/',
}];

正如你所看到的,我们的应用程序由一个非常简单的单一配置组成,它将任何未知的路由请求重定向到我们的主页组件。

就是这样。您的应用程序已经准备好使用了!您需要做的就是在命令行中调用以下命令来运行它:

$ npm start

当您的应用程序正在运行时,请使用浏览器打开您的应用程序 URL,地址为http://localhost:3000。您应该会看到两个链接,用于注册和登录。尝试使用它们,看看会发生什么。尝试刷新您的应用程序,看看它如何保持其状态和路由。

总结

在本章中,您了解了 TypeScript 的基本原理。您学习了 Angular 的构建模块,并了解了它们如何适用于 Angular 2 应用程序的架构。您还学会了如何使用 NPM 安装前端库以及如何结构化和引导您的应用程序。您发现了 Angular 的实体以及它们如何协同工作。您还使用了 Angular 的路由器来配置您的应用程序路由方案。在本章的末尾,我们利用了所有这些知识来重构我们的身份验证模块。在下一章中,您将把迄今为止学到的所有内容连接起来,创建您的第一个 MEAN CRUD 模块。

第八章:创建一个 MEAN CRUD 模块

在之前的章节中,您学习了如何设置每个框架以及如何将它们全部连接在一起。在本章中,您将实现 MEAN 应用程序的基本操作构建模块,即 CRUD 模块。CRUD 模块由一个基本实体和创建、读取、更新和删除实体实例的基本功能组成。在 MEAN 应用程序中,您的 CRUD 模块是从服务器端 Express 组件和一个 Angular 客户端模块构建的。在本章中,我们将涵盖以下主题:

  • 设置 Mongoose 模型

  • 创建 Express 控制器

  • 连接 Express 路由

  • 创建和组织 Angular 模块

  • 理解 Angular 表单

  • 介绍 Angularhttp客户端

  • 实现 Angular 模块服务

  • 实现 Angular 模块组件

介绍 CRUD 模块

CRUD 模块是 MEAN 应用程序的基本构建模块。每个 CRUD 模块由支持 Express 和 Angular 功能的两个结构组成。Express 部分是建立在 Mongoose 模型、Express 控制器和 Express 路由文件之上的。Angular 模块稍微复杂,包含一组模板和一些 Angular 组件、服务和路由配置。在本章中,您将学习如何将这些组件组合起来,以构建一个示例的ArticleCRUD 模块。本章的示例将直接从前几章中的示例继续,因此请从第七章 Angular 简介中复制最终示例,然后从那里开始。

设置 Express 组件

让我们从模块的 Express 部分开始。首先,您将创建一个 Mongoose 模型,用于保存和验证您的文章。然后,您将继续创建处理模块业务逻辑的 Express 控制器。最后,您将连接 Express 路由,以生成控制器方法的 RESTful API。我们将从 Mongoose 模型开始。

创建 Mongoose 模型

Mongoose 模型将由四个简单的属性组成,代表我们的Article实体。让我们从在app/models文件夹中创建 Mongoose 模型文件开始;创建一个名为article.server.model.js的新文件,其中包含以下代码片段:

const mongoose = require('mongoose');
const Schema = mongoose.Schema;

const ArticleSchema = new Schema({
  created: {
    type: Date,
    default: Date.now
  },
  title: {
    type: String,
    default: '',
    trim: true,
    required: 'Title cannot be blank'
  },
  content: {
    type: String,
    default: '',
    trim: true
  },
  creator: {
    type: Schema.ObjectId,
    ref: 'User'
  }
});

mongoose.model('Article', ArticleSchema);

您应该熟悉这段代码片段,所以让我们快速浏览一下这个模型。首先,您包含了您的模型依赖项,然后使用 Mongoose 的Schema对象创建了一个新的ArticleSchemaArticleSchema定义了四个模型字段:

  • created:这是一个日期字段,表示文章创建的时间

  • title:这是一个字符串字段,表示文章标题;请注意如何使用了必需的验证,以确保所有文章都有标题

  • content:这是一个字符串字段,表示文章内容

  • creator:这是一个表示创建文章的用户的引用对象

最后,您注册了ArticleMongoose 模型,以便在ArticlesExpress 控制器中使用它。接下来,您需要确保您的应用程序正在加载模型文件,因此返回到config/mongoose.js文件,并进行以下更改:

const config = require('./config');
const mongoose = require('mongoose');

module.exports = function() {
  const db = mongoose.connect(config.db);

  require('../app/models/user.server.model');
  require('../app/models/article.server.model');

  return db;
};

这将加载您的新模型文件,并确保您的应用程序可以使用您的Article模型。一旦配置了模型,您就可以创建您的Articles控制器。

设置 Express 控制器

Express 控制器负责在服务器端管理与文章相关的功能。它旨在为 MongoDB 文章文档提供基本的 CRUD 操作。要开始编写 Express 控制器,请转到您的app/controllers文件夹,并创建一个名为articles.server.controller.js的新文件。在您新创建的文件中,添加以下依赖项:

const mongoose = require('mongoose');
const Article = mongoose.model('Article');

在前面的代码行中,你基本上只包含了你的Article mongoose 模型。现在,在开始创建 CRUD 方法之前,建议你为验证和其他服务器错误创建一个错误处理方法。

Express 控制器的错误处理方法

为了处理 Mongoose 错误,最好编写一个简单的错误处理方法,它将负责从 Mongoose 错误对象中提取简单的错误消息,并将其提供给你的控制器方法。回到你的app/controllers/articles.server.controller.js文件,并添加以下代码行:

function getErrorMessage (err) {
  if (err.errors) {
    for (let errName in err.errors) {
      if (err.errors[errName].message) return err.errors[errName].message;
    }
  } else {
    return 'Unknown server error';
  }
};

getErrorMessage()方法接收 Mongoose 错误对象作为参数,然后遍历错误集合并提取第一个消息。这样做是因为你不希望一次向用户展示多个错误消息。现在你已经设置好了错误处理,是时候编写你的第一个控制器方法了。

Express 控制器的create()方法

Express 控制器的create()方法将提供创建新文章文档的基本功能。它将使用 HTTP 请求体作为文档的 JSON 基对象,并使用模型的save()方法将其保存到 MongoDB。要实现create()方法,请将以下代码添加到你的app/controllers/articles.server.controller.js文件中:

exports.create = function(req, res) {
  const article = new Article(req.body);
  article.creator = req.user;

  article.save((err) => {
    if (err) {
      return res.status(400).send({
        message: getErrorMessage(err)
      });
    } else {
      res.status(200).json(article);
    }
  });
};

让我们来看一下create()方法的代码。首先,你使用 HTTP 请求体创建了一个新的Article模型实例。接下来,你将经过身份验证的passport用户添加为文章的creator。最后,你使用 Mongoose 实例的save()方法来保存文章文档。在save()回调函数中,值得注意的是你要么返回一个错误响应和适当的 HTTP 错误代码,要么返回新的article对象作为 JSON 响应。一旦你完成了create()方法,你将继续实现读取操作。读取操作包括两个方法:一个是检索文章列表的方法,另一个是检索特定文章的方法。让我们从列出文章集合的方法开始。

Express 控制器的list()方法

Express 控制器的list()方法将提供检索现有文章列表的基本功能。它将使用模型的find()方法来检索文章集合中的所有文档,然后输出这个列表的 JSON 表示。要实现list()方法,请将以下代码添加到你的app/controllers/articles.server.controller.js文件中:

exports.list = function(req, res) {
  Article.find().sort('-created').populate('creator', 'firstName lastName fullName').exec((err, articles) => {
    if (err) {
      return res.status(400).send({
        message: getErrorMessage(err)
      });
    } else {
      res.status(200).json(articles);
    }
  });
};

在这个控制器方法中,注意你如何使用 Mongoose 的find()函数来获取文章文档的集合,虽然我们可以添加一些 MongoDB 查询,但现在我们将检索集合中的所有文档。接下来,注意文章集合是如何使用created属性进行排序的。然后,你可以看到 Mongoose 的populate()方法是如何用来向articles对象的creator属性添加一些用户字段的。在这种情况下,你填充了creator用户对象的firstNamelastNamefullName属性。

CRUD 操作的其余部分涉及对单个现有文章文档的操作。当然,你可以在每个方法中实现对文章文档的检索,基本上重复这个逻辑。然而,Express 路由器有一个很好的特性用于处理路由参数,所以在实现 Express CRUD 功能的其余部分之前,你首先要学习如何利用路由参数中间件来节省一些时间和代码冗余。

Express 控制器的read()中间件

Express 控制器的 read() 方法将提供从数据库中读取现有文章文档的基本功能。由于您正在编写一种类似 RESTful API 的东西,因此这种方法的常见用法将通过将文章的 ID 字段作为路由参数来处理。这意味着您发送到服务器的请求将在其路径中包含一个 articleId 参数。

幸运的是,Express 路由器提供了 app.param() 方法来处理路由参数。该方法允许您为包含 articleId 路由参数的所有请求附加一个中间件。然后中间件本身将使用提供的 articleId 来查找适当的 MongoDB 文档,并将检索到的 article 对象添加到请求对象中。这将允许所有操作现有文章的控制器方法从 Express 请求对象中获取 article 对象。为了更清晰,让我们实现路由参数中间件。转到您的 app/controllers/articles.server.controller.js 文件并追加以下代码行:

exports.articleByID = function(req, res, next, id) {
  Article.findById(id).populate('creator', 'firstName lastName fullName').exec((err, article) => {
    if (err) return next(err);
    if (!article) return next(new Error('Failed to load article ' + id));

    req.article = article;
    next();
  });
};

如您所见,中间件函数签名包含所有 Express 中间件参数和一个 id 参数。然后使用 id 参数查找文章,并使用 req.article 属性引用它。请注意,Mongoose 模型的 populate() 方法用于向 article 对象的 creator 属性添加一些用户字段。在这种情况下,您填充了 creator 用户对象的 firstNamelastNamefullName 属性。

当您连接 Express 路由时,您将学习如何将 articleByID() 中间件添加到不同的路由,但现在让我们添加 Express 控制器的 read() 方法,它将返回一个 article 对象。要添加 read() 方法,请将以下代码行追加到您的 app/controllers/articles.server.controller.js 文件中:

exports.read = function(req, res) {
  res.status(200).json(req.article);
};

相当简单,不是吗?那是因为您已经在 articleByID() 中间件中处理了获取 article 对象的问题,所以现在您所需做的就是以 JSON 表示形式输出 article 对象。我们将在接下来的部分连接中间件和路由,但在此之前,让我们完成实现 Express 控制器的 CRUD 功能。

Express 控制器的 update() 方法

Express 控制器的 update() 方法将提供更新现有文章文档的基本操作。它将使用现有的 article 对象作为基础对象,然后使用 HTTP 请求体更新 titlecontent 字段。它还将使用模型的 save() 方法将更改保存到数据库。要实现 update() 方法,请转到您的 app/controllers/articles.server.controller.js 文件并追加以下代码行:

exports.update = function(req, res) {
  const article = req.article;

  article.title = req.body.title;
  article.content = req.body.content;

  article.save((err) => {
    if (err) {
      return res.status(400).send({
        message: getErrorMessage(err)
      });
    } else {
      res.status(200).json(article);
    }
  });
};

如您所见,update() 方法还假设您已经在 articleByID() 中间件中获取了 article 对象。因此,您所需做的就是更新 titlecontent 字段,保存文章,然后以 JSON 表示形式输出更新后的 article 对象。如果出现错误,它将使用您之前编写的 getErrorMessage() 方法和 HTTP 错误代码输出适当的错误消息。剩下要实现的最后一个 CRUD 操作是 delete() 方法;所以让我们看看如何向 Express 控制器添加一个简单的 delete() 方法。

Express 控制器的 delete() 方法

Express 控制器的 delete() 方法将提供删除现有文章文档的基本操作。它将使用模型的 remove() 方法从数据库中删除现有文章。要实现 delete() 方法,请转到您的 app/controllers/articles.server.controller.js 文件并追加以下代码行:

exports.delete = function(req, res) {
  const article = req.article;

  article.remove((err) => {
    if (err) {
      return res.status(400).send({
        message: getErrorMessage(err)
      });
    } else {
      res.status(200).json(article);
    }
  });
};

同样,您可以看到delete()方法也利用了已经获取的article对象,通过articleByID()中间件。因此,您所需做的就是调用 Mongoose 模型的remove()方法,然后输出已删除的article对象作为 JSON 表示。如果出现错误,它将使用您之前编写的getErrorMessage()方法输出适当的错误消息和 HTTP 错误代码。

恭喜!您刚刚完成了实现 Express 控制器的 CRUD 功能。在继续连接调用这些方法的 Express 路线之前,让我们花点时间来实现两个授权中间件。

实施身份验证中间件

在构建 Express 控制器时,您可能已经注意到大多数方法要求用户进行身份验证。例如,如果req.user对象未分配,create()方法将无法操作。虽然您可以在方法内部检查此分配,但这将强制您一遍又一遍地实施相同的验证代码。相反,您可以使用 Express 中间件链来阻止未经授权的请求执行您的控制器方法。您应该实施的第一个中间件将检查用户是否已经认证。由于这是一个与身份验证相关的方法,最好将其实施在 Expressusers控制器中,因此转到app/controllers/users.server.controller.js文件,并追加以下代码行:

exports.requiresLogin = function(req, res, next) {
  if (!req.isAuthenticated()) {
    return res.status(401).send({
      message: 'User is not logged in'
    });
  }

  next();
};

requiresLogin()中间件使用 Passport 启动的req.isAuthenticated()方法来检查用户当前是否已经认证。如果发现用户确实已登录,它将调用链中的下一个中间件;否则,它将以身份验证错误和 HTTP 错误代码进行响应。这个中间件很棒,但如果您想检查特定用户是否被授权执行某个操作,您需要实施一个特定于文章的授权中间件。

实施授权中间件

在您的 CRUD 模块中,有两种方法可以编辑现有的文章文档。通常,update()delete()方法应该受限,以便只有创建文章的用户才能使用它们。这意味着您需要授权对这些方法的任何请求,以验证当前文章是否正在被其创建者编辑。为此,您需要向Articles控制器添加一个授权中间件,因此转到app/controllers/articles.server.controller.js文件,并追加以下代码行:

exports.hasAuthorization = function(req, res, next) {
    if (req.article.creator.id !== req.user.id) {
        return res.status(403).send({
            message: 'User is not authorized'
        });
    }

    next();
};

hasAuthorization()中间件使用req.articlereq.user对象来验证当前用户是否是当前文章的创建者。该中间件还假定它仅对包含articleId路由参数的请求执行。现在,您已经将所有方法和中间件放置好,是时候连接启用它们的路线了。

连接 Express 路线

在我们开始连接 Express 路线之前,让我们快速回顾一下 RESTful API 的架构设计。RESTful API 提供了一个连贯的服务结构,代表了您可以在应用程序资源上执行的一组操作。这意味着 API 使用预定义的路由结构以及 HTTP 方法名称,以提供 HTTP 请求的上下文。虽然 RESTful 架构可以以不同的方式应用,但 RESTful API 通常遵守一些简单的规则:

  • 每个资源的基本 URI,在我们的情况下是http://localhost:3000/articles

  • 一个数据结构,通常是 JSON,传递到请求体中

  • 使用标准的 HTTP 方法(例如,GETPOSTPUTDELETE

使用这三条规则,您将能够正确地路由 HTTP 请求以使用正确的控制器方法。因此,您的文章 API 将包括五条路线:

  • GET http://localhost:3000/articles:这将返回一系列文章

  • POST http://localhost:3000/articles:这将创建并返回新文章

  • GET http://localhost:3000/articles/:articleId:这将返回单个现有文章

  • PUT http://localhost:3000/articles/:articleId:这将更新并返回单个现有文章

  • DELETE http://localhost:3000/articles/:articleId:这将删除并返回单篇文章

您可能已经注意到,这些路由已经有了相应的控制器方法。甚至已经实现了articleId路由参数中间件,因此剩下的就是实现 Express 路由。为此,请转到app/routes文件夹,并创建一个名为articles.server.routes.js的新文件。在您新创建的文件中,粘贴以下代码片段:

const users = require('../../app/controllers/users.server.controller');
const articles = require('../../app/controllers/articles.server.controller');

module.exports = function(app) {
  app.route('/api/articles')
     .get(articles.list)
     .post(users.requiresLogin, articles.create);

  app.route('/api/articles/:articleId')
     .get(articles.read)
     .put(users.requiresLogin, articles.hasAuthorization, articles.update)
     .delete(users.requiresLogin, articles.hasAuthorization, articles.delete);

  app.param('articleId', articles.articleByID);
};

在上述代码片段中,您做了几件事。首先,您需要了usersarticles控制器,然后使用 Express 的app.route()方法来定义 CRUD 操作的基本路由。您使用 Express 路由方法将每个控制器方法与特定的 HTTP 方法进行了连接。您可能还注意到POST方法如何使用users.requiresLogin()中间件,因为用户需要在创建新文章之前登录。同样,PUTDELETE方法使用了users.requiresLogin()articles.hasAuthorization()中间件,因为用户只能编辑和删除他们创建的文章。最后,您使用了app.param()方法来确保具有articleId参数的每个路由将首先调用articles.articleByID()中间件。接下来,您需要配置 Express 应用程序以加载您的新Article模型和路由文件。

配置 Express 应用程序

为了使用您的新的 Express 资源,您必须配置 Express 应用程序以加载您的路由文件。为此,请返回到您的config/express.js文件并进行更改,如下所示:

const path = require('path');
const config = require('./config');
const express = require('express');
const morgan = require('morgan');
const compress = require('compression');
const bodyParser = require('body-parser');
const methodOverride = require('method-override');
const session = require('express-session');
const flash = require('connect-flash');
const passport = require('passport');

module.exports = function() {
  const app = express();

  if (process.env.NODE_ENV === 'development') {
    app.use(morgan('dev'));
  } else if (process.env.NODE_ENV === 'production') {
    app.use(compress());
  }

  app.use(bodyParser.urlencoded({
    extended: true
  }));
  app.use(bodyParser.json());
  app.use(methodOverride());

  app.use(session({
    saveUninitialized: true,
    resave: true,
    secret: config.sessionSecret
  }));

  app.set('views', './app/views');
  app.set('view engine', 'ejs');

  app.use(flash());
  app.use(passport.initialize());
  app.use(passport.session());

  app.use('/', express.static(path.resolve('./public')));
  app.use('/lib', express.static(path.resolve('./node_modules')));

  require('../app/routes/users.server.routes.js')(app);  
  require('../app/routes/articles.server.routes.js')(app);
  require('../app/routes/index.server.routes.js')(app);

  return app;
}; 

就是这样;您的文章的 RESTful API 已经准备就绪!接下来,您将学习如何简单地使用HTTP客户端让您的 Angular 组件与其通信。

使用 HTTP 客户端

在第七章中,Angular 简介,我们提到http客户端作为 Angular 2 应用程序与后端 API 之间通信的手段。由于 REST 架构结构良好,因此很容易为我们的 Angular 模块实现一个服务,并通过 API 提供给我们的组件,以便与服务器通信。为此,Angular http 客户端利用 Observable 模式来处理其异步性质,因此在继续之前,最好快速回顾一下这个强大的模式。

响应式编程和 Observables

在编程中,我们大多数情况下期望事情按顺序运行,所有指令都按顺序发生。然而,从一开始,Web 应用程序开发就遭受了缺乏同步性的问题。当处理数据时,特别是在我们的情况下,从服务器检索到的数据时,这是一个特别大的问题。为了解决这个问题,创建了各种不同的模式,现在我们主要使用回调和 Promise 模式。回调在大部分 JavaScript 的生命周期中都是首选,而最近,Promise 开始受到一些关注。然而,Promise 的寿命很短。更准确地说,Promise 可以设置,但只能延迟一次,但我们的数据可能随着时间的推移而改变,所以我们需要创建更多的 Promise。举个例子,假设我们想跟踪对文本字段所做的所有更改并实现“撤销”功能;为此,我们可以使用回调来处理文本更改事件,然后记录所有更改并对其进行处理。这可能看起来很简单,但如果我们有数百个对象,或者如果我们的文本字段值是以编程方式更改的呢?这只是一个非常简单的例子,但这种情况在现代应用程序开发中以各种方式重复出现,为了解决这个问题,出现了一种新的方法论,称为响应式编程。您可能听说过响应式编程,也可能没有,但最容易理解它的方法是意识到它主要是跟踪随时间变化的异步数据,它通过使用 Observables 来实现这一点。Observables 是可以被一个或多个观察者观察的数据流。Observable 会随着时间发出值,并通过新值、错误或完成事件通知“订阅”的观察者。这种机制的可视化表示可以在下图中看到:

响应式编程和 Observables

在这个图表中,您可以看到 Observables 不断发出值的变化,一个错误,另一个值的变化,然后在 Observable 完成其生命周期时发出完成事件。响应式编程可能看起来很复杂,但幸运的是,ReactiveX 库允许我们以非常简单的方式处理 Observables。

注意

建议您继续阅读有关响应式编程的内容,因为它正在迅速成为现代 Web 应用程序开发的主要方法。

ReactiveX 库

Rx 库是一个跨平台库,它使用观察者模式来帮助开发人员管理随时间发生的异步数据更改。简而言之,ReactiveX 是一个允许我们创建和操作 Observable 对象的库。在 Angular 2 项目中,我们使用 RxJS 库,它基本上是 ReactiveX 库的 JavaScript 版本。如果您仔细观察前一章,您将看到我们已经设置了它,甚至在我们的身份验证服务中使用了它。我们通过使用npm安装它来实现这一点:

...
"rxjs": "5.0.0-beta.12",
...

我们在实体中导入它如下:

...
import 'rxjs/Rx';

我们不得不这样做是因为 Angular 团队选择广泛使用 Observables。我们第一次遇到它是在使用 http 客户端时。

使用 http 客户端

http模块为我们提供了与 RESTful 端点通信的标准化方式。要使用http客户端,我们需要将其导入并注入到我们的实体中,然后使用我们的http客户端实例执行不同的 HTTP 请求。在第七章中,我们展示了使用 http 客户端执行 POST 请求的简单示例,Angular 简介中我们在登录方法中使用了它:

signin(credentials: any): Observable<any> {
      let body = JSON.stringify(credentials);
      let headers = new Headers({ 'Content-Type': 'application/json' });
      let options = new RequestOptions({ headers: headers });

  return this.http.post(this._signinURL, body, options)
                        .map(res => this.user = res.json())
                        .catch(this.handleError)
  }

正如您所看到的,我们创建了一个 JSON 字符串,并在调用http客户端的post()方法之前使用RequestOptions对象设置了请求头。http客户端方法返回一个 Observable 对象,跟踪 HTTP 响应对象。但是由于我们希望我们的服务提供数据,我们使用map()方法提取响应的 JSON 对象。

注意

我们需要使用json()方法,因为 Angular 遵循 HTTP 响应对象的 ES2015 规范。

请注意,我们还使用我们的handleError()方法捕获任何错误。那么我们如何使用从这个方法返回的 Observable 对象?如果您回顾一下我们的signin组件,您将能够看到我们如何使用我们的认证服务:

signin() {
    this._authenticationService.signin(this.credentials).subscribe(
    result  => this._router.navigate(['/']), 
    error =>  this.errorMessage = error );
  }
}

在这个方法中,我们调用了认证服务的登录方法,然后订阅返回的 Observable。然后我们用第一个箭头函数处理任何值事件,用第二个箭头函数处理任何错误。这基本上是我们使用 HTTP 客户端的方式!

HTTP 客户端提供了各种方法来处理不同的 HTTP 请求:

  • request(url, options): 这个方法允许我们执行由选项对象定义的任何 HTTP 请求。

  • get(): 这个方法执行一个GET HTTP 请求。

  • post(): 这个方法执行一个POST HTTP 请求。

  • put(): 这个方法执行一个PUT HTTP 请求。

  • delete(): 这个方法执行一个DELETE HTTP 请求。

所有这些方法都返回一个可订阅或可操作的响应 Observable 对象。

注意

一个重要的事情要注意的是,HTTP 客户端总是返回一个“冷”可观察对象。这意味着请求本身直到有人订阅可观察对象才会被发送。

在下一节中,您将学习如何使用http客户端与您的 Express API 进行通信。

实现 Angular 模块

您的 CRUD 模块的第二部分是 Angular 模块。这个模块将包含一个 Angular 服务,该服务将使用http客户端与 Express API 进行通信,一个包含四个子组件的 Angular 文章组件,这些子组件具有一组模板,为您的用户提供执行 CRUD 操作的界面。在开始创建您的 Angular 实体之前,让我们首先创建初始模块结构。转到您的应用程序的public/app文件夹,并创建一个名为articles的新文件夹。在这个新文件夹中,创建名为articles.module.ts的模块文件,并粘贴以下代码行:

import { NgModule }       from '@angular/core';
import { CommonModule }   from '@angular/common';
import { FormsModule }    from '@angular/forms';
import { RouterModule } from '@angular/router';

import { ArticlesRoutes } from './articles.routes';
import { ArticlesComponent } from './articles.component';
import { CreateComponent } from './create/create.component';
import { ListComponent } from './list/list.component';
import { ViewComponent } from './view/view.component';
import { EditComponent } from './edit/edit.component';

@NgModule({
  imports: [
    CommonModule,
    FormsModule,
    RouterModule.forChild(ArticlesRoutes),
  ],
  declarations: [
    ArticlesComponent,
    CreateComponent,
    ListComponent,
    ViewComponent,
    EditComponent,
  ]
})
export class ArticlesModule {}

正如您所看到的,我们只是从 Angular 包中导入了我们需要的模块,以及我们新模块的组件、服务和路由定义。接下来,我们创建了一个新的 Angular 模块,它作为子路由导入了 Angular 模块和我们的路由配置,然后声明了我们新模块的组件。现在,我们可以继续创建我们的主组件文件。为此,在您的public/app文件夹中创建一个名为articles.component.ts的文件,并粘贴以下代码行:

import { Component } from '@angular/core';

import { ArticlesService } from './articles.service';

@Component({
  selector: 'articles',
  template: '<router-outlet></router-outlet>',
  providers: [ArticlesService]
})
export class ArticlesComponent {}

在这个文件中,我们导入了基本的 Angular 模块和我们即将创建的文章服务。然后我们创建了一个使用router-outlet并注入我们的服务的新组件。接下来,我们需要为我们的articles组件创建一个路由配置。为此,创建一个名为articles.routes.ts的文件,并粘贴以下代码行:

import { Routes } from '@angular/router';

import { ArticlesComponent } from './articles.component';
import { CreateComponent } from './create/create.component';
import { ListComponent } from './list/list.component';
import { ViewComponent } from './view/view.component';
import { EditComponent } from './edit/edit.component';

export const ArticlesRoutes: Routes = [{
  path: 'articles',
  component: ArticlesComponent,
  children: [
    {path: '', component: ListComponent},
    {path: 'create', component: CreateComponent},
    {path: ':articleId', component: ViewComponent},
    {path: ':articleId/edit', component: EditComponent}
  ],
}];

正如您所看到的,我们简单地为我们的组件及其子组件创建了一个路由配置。这段代码应该很熟悉,因为它类似于我们在上一章中实现的认证路由。此外,在我们的更新和查看路径中,我们定义了一个 URL 参数,形式为冒号后跟我们的参数名称,这种情况下是articleId参数。

接下来,您需要在我们的应用程序模块配置中导入我们的文章模块。为此,返回到您的public/app/app.module.ts文件,并将其更改如下:

import { NgModule }       from '@angular/core';
import { BrowserModule }  from '@angular/platform-browser';
import { FormsModule }    from '@angular/forms';
import { RouterModule }   from '@angular/router';
import { HttpModule, RequestOptions } from '@angular/http';
import { LocationStrategy, HashLocationStrategy } from '@angular/common';

import { AppComponent }       from './app.component';
import { AppRoutes }       from './app.routes';

import { HomeModule } from './home/home.module';
import { AuthenticationService } from './authentication/authentication.service';
import { AuthenticationModule } from './authentication/authentication.module';
import { ArticlesModule } from './articles/articles.module';

@NgModule({
  imports: [
    BrowserModule,
    HttpModule,
    FormsModule,
    AuthenticationModule,
    HomeModule,
    ArticlesModule,
    RouterModule.forRoot(AppRoutes),
  ],
  declarations: [
    AppComponent
  ],
  providers: [
    AuthenticationService
  ],
  bootstrap: [AppComponent]
})
export class AppModule { }

这就完成了我们新模块的配置。现在我们可以继续创建我们的模块实体。我们将从我们的模块服务开始。

创建 Angular 模块服务

为了使您的 CRUD 模块能够轻松与 API 端点通信,建议您使用一个单一的 Angular 服务,该服务将利用http客户端方法。为此,请转到您的public/app/articles文件夹,并创建一个名为articles.service.ts的新文件,其中包含以下代码行:

import 'rxjs/Rx';
import {Observable} from 'rxjs/Observable';

import {Injectable} from '@angular/core';
import {Http, Headers, Request, RequestMethod, Response} from '@angular/http';

@Injectable()
export class ArticlesService {
  private _baseURL = 'api/articles';

  constructor (private _http: Http) {}

  create(article: any): Observable<any> {
    return this._http
      .post(this._baseURL, article)
      .map((res: Response) => res.json())
      .catch(this.handleError);
    }

  read(articleId: string): Observable<any> {
    return this._http
      .get(`${this._baseURL}/${articleId}`)
      .map((res: Response) => res.json())
      .catch(this.handleError);
  }

  update(article: any): Observable<any> {
    return this._http
      .put(`${this._baseURL}/${article._id}`, article)
      .map((res: Response) => res.json())
      .catch(this.handleError);
    }

  delete(articleId: any): Observable<any> {
    return this._http
      .delete(`${this._baseURL}/${articleId}`)
      .map((res: Response) => res.json())
      .catch(this.handleError);
  }  

  list(): Observable<any> {
    return this._http
      .get(this._baseURL)
      .map((res: Response) => res.json())
      .catch(this.handleError);
  }

  private handleError(error: Response) {
    return Observable.throw(error.json().message || 'Server error');
  }
}

让我们来回顾一下。首先,我们从 Angular 库中导入了Observablerxjs库模块。您可能注意到我们导入了整个库,因为我们需要在 Observable 对象中使用各种操作符,例如map()方法。

接下来,我们从 Angular 库中导入了我们需要的模块,并使用@Injectable装饰器创建了我们的可注入服务。我们的服务有一个属性来保存我们的 API 基本 URL,并且有一个构造函数来注入 HTTP 客户端。它包含一个处理服务器错误的方法。我们的其他方法都很容易理解:

  • create(): 接受文章对象并使用 HTTP POST 请求将其发送到服务器

  • read(): 接受文章 ID字符串并使用 HTTP GET 请求向服务器请求文章对象

  • update(): 接受文章对象并使用 HTTP PUT 请求将其发送到服务器进行更新

  • delete(): 接受文章 ID字符串并尝试使用 HTTP DELETE 请求删除它

  • list(): 使用 HTTP GET 请求请求文章对象数组

注意我们如何将响应对象映射为只发送 JSON 对象,并且如何捕获任何错误以修改响应,以便我们的组件只需处理数据本身。

就是这样!我们的模块基础设施已经为我们的子组件准备好了。在接下来的章节中,您将能够看到我们如何利用之前的准备来轻松实现我们的实现。

实现创建子组件

我们的“创建”子组件将负责创建新文章。首先在public/app/articles文件夹内创建一个名为create的新文件夹。在此文件夹中,创建一个名为create.component.ts的新文件,并粘贴以下代码:

import { Component } from '@angular/core';
import { Router } from '@angular/router';

import { ArticlesService } from '../articles.service';

@Component({
  selector: 'create',
  templateUrl: 'app/articles/create/create.template.html'
})
export class CreateComponent {
  article: any = {};
  errorMessage: string;

  constructor(private _router:Router,
        private _articlesService: ArticlesService) {}

  create() {
    this._articlesService
      .create(this.article)
      .subscribe(createdArticle => this._router.navigate(['/articles', createdArticle._id]),
               error =>  this.errorMessage = error);
  }
}

让我们来回顾一下。我们首先从 Angular 库中导入了我们需要的模块以及我们的ArticlesService。然后,我们创建了一个带有空文章和errorMessage对象的组件。注意我们的组件构造函数如何注入了Router和我们的ArticlesService服务。然后,我们创建了一个create()方法,该方法使用ArticlesService来创建一个新的文章对象。在我们的可观察订阅中,我们使用Router服务导航到我们的视图组件以及新创建的文章 ID。在出现错误的情况下,我们将组件的errorMessage属性设置为该消息。为了完成我们的子组件,我们需要创建其模板。

添加模板

create模板将为您的用户提供一个创建新文章的界面。它将包含一个 HTML 表单,并且将使用您组件的create方法来保存新文章。要创建您的模板,请转到public/app/articles/create文件夹,并创建一个名为create.template.html的新文件。在您的新文件中,粘贴以下代码片段:

<h1>New Article</h1>
<form (ngSubmit)="create()" novalidate>
  <div>
    <label for="title">Title</label>
    <div>
      <input type="text" required [(ngModel)]="article.title" name="title" placeholder="Title">
    </div>
  </div>
  <div>
    <label for="content">Content</label>
    <div>
      <textarea type="text" required cols="30" rows="10" [(ngModel)]="article.content" name="content" placeholder="Content"></textarea>
    </div>
  </div>
  <div>
    <input type="submit">
  </div>

  <strong id="error">{{errorMessage}}</strong>
</form>

create模板包含一个简单的表单,其中包含两个文本输入字段和一个提交按钮。文本字段使用ngModel指令将用户输入绑定到我们组件的属性。还要注意在form元素中放置的ngSubmit指令。该指令告诉 Angular 在提交表单时调用特定的组件方法。在这种情况下,表单提交将执行您组件的create()方法。您应该注意到的最后一件事是表单末尾的错误消息,以防出现任何错误时会显示。接下来,我们将实现视图子组件。

实现视图子组件

我们的“查看”子组件将负责呈现单篇文章。我们的组件还将包含一组按钮,仅对文章创建者可见,这些按钮将允许创建者删除文章或导航到“编辑”路由。首先,在public/app/articles文件夹内创建一个名为view的新文件夹。在这个文件夹中,创建一个名为view.component.ts的新文件,并粘贴以下代码:

import { Component } from '@angular/core';
import { Router, ActivatedRoute } from '@angular/router';
import { AuthenticationService } from '../../authentication/authentication.service';
import { ArticlesService } from '../articles.service';

@Component({
  selector: 'view',
  templateUrl: 'app/articles/view/view.template.html',
})
export class ViewComponent {
  user: any;
  article: any;
  paramsObserver: any;
  errorMessage: string;
  allowEdit: boolean = false;

  constructor(private _router:Router, 
        private _route: ActivatedRoute, 
        private _authenticationService: AuthenticationService, 
        private _articlesService: ArticlesService) {}

  ngOnInit() {
    this.user = this._authenticationService.user

    this.paramsObserver = this._route.params.subscribe(params => {
      let articleId = params['articleId'];

      this._articlesService
        .read(articleId)
        .subscribe(
          article => {
            this.article = article;
            this.allowEdit = (this.user && this.user._id === this.article.creator._id);
           },
          error => this._router.navigate(['/articles'])
        );
    });
  }

  ngOnDestroy() {
    this.paramsObserver.unsubscribe();
  }

  delete() {
    this._articlesService.delete(this.article._id).subscribe(deletedArticle => this._router.navigate(['/articles']),
                                 error => this.errorMessage = error);
  }
}

我们从 Angular 库中导入我们需要的模块以及我们的ArticlesServiceAuthenticationService。然后,我们创建了一个具有文章属性、currentUser属性、paramsObserver属性、allowEdit标志和errorMessage属性的组件。请注意,我们的组件构造函数注入了RouterRouteParams和我们的ArticlesServiceAuthenticationService服务。我们的构造函数还使用AuthenticationService实例设置了currentUser属性。在我们的ngOnInit方法中,当组件初始化时被调用,我们从路由参数中读取文章 ID参数,然后使用ArticlesService来获取现有的文章。我们使用ActivatedRoute来完成这个操作,它为我们提供了一个params Observable。我们在组件的ngOnDestroy方法中取消了对这个 Observable 的订阅。在我们的 Observable 订阅中,我们设置了组件的article属性,并确定当前用户是否可以编辑文章。在出现错误时,我们使用Router服务来导航回到我们的List路由。最后,我们实现了一个delete()方法,该方法使用ArticlesService来删除查看的文章并返回到文章列表。要完成我们的子组件,我们需要创建它的模板。

添加模板

“视图”模板将为用户提供一个界面来“查看”现有文章。您的模板还将包含一组按钮,仅对文章创建者可见,这些按钮将允许创建者删除文章或导航到“编辑”路由。要创建模板,请转到public/app/articles/view文件夹,并创建一个名为view.template.html的新文件。在新文件中,粘贴以下代码片段:

<section *ngIf="article && article.creator">
  <h1>{{article.title}}</h1>

  <div *ngIf="allowEdit">
      <a [routerLink]="['/articles', article._id, 'edit']">edit</a>
      <button (click)="delete()">delete</button>
  </div>
  <small>
      <em>Posted on {{article.created}} by {{article.creator.fullName}}</em>
  </small>

  <p>{{article.content}}</p>
</section>

view模板包含一组简单的 HTML 元素,使用双大括号语法呈现文章信息。还要注意您如何使用ngIf指令,仅向文章的创建者呈现文章编辑链接和删除按钮。编辑链接将引导用户到edit子组件,而删除按钮将调用您的控制器的delete()方法。接下来,我们将实现我们的编辑组件。

实现编辑子组件

我们的“编辑”子组件将负责编辑现有文章。首先,在public/app/articles文件夹内创建一个名为edit的新文件夹。在这个文件夹中,创建一个名为edit.component.ts的新文件,并粘贴以下代码:

import { Component } from '@angular/core';
import { Router, ActivatedRoute } from '@angular/router';

import { ArticlesService } from '../articles.service';

@Component({
  selector: 'edit',
  templateUrl: 'app/articles/edit/edit.template.html'
})
export class EditComponent {
  article: any = {};
  errorMessage: string;
  paramsObserver: any;

  constructor(private _router:Router, 
        private _route: ActivatedRoute, 
        private _articlesService: ArticlesService) {}

  ngOnInit() {
    this.paramsObserver = this._route.params.subscribe(params => {
      let articleId = params['articleId'];

      this._articlesService.read(articleId).subscribe(article => {
                                this.article = article;
                               },
                              error => this._router.navigate(['/articles']));
    });
  }

  ngOnDestroy() {
    this.paramsObserver.unsubscribe();
  }

  update() {
    this._articlesService.update(this.article).subscribe(savedArticle => this._router.navigate(['/articles', savedArticle._id]),
                                  error =>  this.errorMessage = error);
  }
}

再次,我们从 Angular 库中导入我们需要的模块以及我们的ArticlesService。然后,我们创建了一个具有文章属性和errorMessage属性的组件。在我们的构造函数中,我们从路由参数中读取文章 ID,然后使用ArticlesService来获取现有的文章。在我们的 Observable 订阅中,我们设置了组件的文章属性,并在出现错误时,我们使用Router服务来导航回到我们的 List 路由。最后,我们实现了一个update()方法,该方法使用ArticlesService来更新查看的文章并返回到 View 路由。要完成我们的子组件,我们需要创建它的模板。

添加模板

edit 模板将为用户提供一个界面来更新现有文章。它将包含一个 HTML 表单,并使用你的组件的 update() 方法来保存更新后的文章。要创建这个模板,转到 public/app/articles/edit 文件夹并创建一个名为 edit.template.html 的新文件。在你的新文件中,粘贴以下 HTML 代码:

<h1>Edit Article</h1>
<form (ngSubmit)="update()" novalidate>
    <div>
        <label for="title">Title</label>
        <div>
            <input type="text" required [(ngModel)]="article.title" name="title" placeholder="Title">
        </div>
    </div>
    <div>
        <label for="content">Content</label>
        <div>
            <textarea type="text" required cols="30" rows="10" [(ngModel)]="article.content" name="content" placeholder="Content"></textarea>
        </div>
    </div>
    <div>
        <input type="submit" value="Update">
    </div>

    <strong>{{errorMessage}}</strong>
</form>

edit 模板包含一个简单的表单,其中有两个文本输入字段和一个提交按钮。文本字段使用 ngModel 指令将用户输入绑定到组件的 article 属性。还要注意在 form 元素中放置的 ngSubmit 指令。这次,该指令告诉 Angular 表单提交应执行组件的 update() 方法。你应该注意到的最后一件事是表单末尾的错误消息,在编辑错误的情况下会显示出来。我们的最终子组件是我们的 List 子组件。

实现 List 子组件

我们的 "List" 子组件将负责呈现文章列表。我们将首先在 public/app/articles 文件夹内创建一个名为 list 的新文件夹。在这个文件夹中,创建一个名为 list.component.ts 的新文件,并粘贴以下代码:

import { Component } from '@angular/core';
import { ArticlesService } from '../articles.service';

@Component({
  selector: 'list',
  templateUrl: 'app/articles/list/list.template.html'
})
export class ListComponent{
  articles: any;
  errorMessage: string;

  constructor(private _articlesService: ArticlesService) {}

  ngOnInit() {
    this._articlesService.list().subscribe(articles  => this.articles = articles);
  }
}

我们首先从 Angular 库中导入我们需要的模块以及我们的 ArticlesService。然后,我们创建了一个具有 articles 属性和 errorMessage 属性的组件。注意我们组件的构造函数如何注入 ArticlesService 并使用它来获取文章列表。在我们的 Observables 订阅中,我们设置了组件的 articles 属性。现在我们只剩下实现组件的模板了。

添加模板

list 模板将为用户提供一个查看现有文章列表的界面。我们的模板将使用 ngFor 指令来呈现一系列 HTML 元素,每个元素代表一篇文章。如果没有现有的文章,视图将提供用户导航到 create 路由。要创建你的视图,转到 public/app/articles/list 文件夹并创建一个名为 list.template.html 的新文件。在你的新文件中,粘贴以下代码片段:

<h1>Articles</h1>
<ul>
  <li *ngFor="let article of articles">
    <a [routerLink]="['/articles', article._id]">{{article.title}}</a>
    <br>
    <small>{{article.created}}/{{article.creator.fullName}}</small>
    <p>{{article.content}}</p>
  </li>
</ul>

<div *ngIf="articles && articles.length === 0">
  No articles yet, why don't you <a [routerLink]="['/articles/create']">create one</a>? 
</div>

list 模板包含一组简单的重复的 HTML 元素,代表文章列表。它使用 ngFor 指令为集合中的每篇文章复制列表项并显示每篇文章的信息。然后我们使用 routerLink 链接到单篇文章视图。还要注意我们如何使用 ngIf 指令来要求用户在没有现有文章的情况下创建一篇新文章。

通过实现你的 Angular 子组件,你实际上完成了你的第一个 CRUD 模块!现在剩下的就是向用户提供到我们新路由的链接。

总结

要完成我们的实现,最好是向用户提供到你的新 CRUD 模块路由的链接。为此,转到你的 public/app/home/home.template.html 文件并进行更改,如下所示:

<div *ngIf="user">
  <h1>Hello {{user.firstName}}</h1>
  <a href="/api/auth/signout">Signout</a>
  <ul>
    <li><a [routerLink]="['/articles']">List Articles</a></li>
 <li><a [routerLink]="['/articles/create']">Create Article</a></li>
 </ul>
</div>

<div *ngIf="!user">
  <a [routerLink]="['/authentication/signup']">Signup</a>
  <a [routerLink]="['/authentication/signin']">Signin</a>
</div>

这个改变将只在用户登录时向用户显示到新的 Articles 组件路由的链接,并在用户未登录时隐藏它。就是这样!一切都准备就绪,可以测试你的新的 CRUD 模块了。使用命令行工具导航到 MEAN 应用程序的根文件夹,然后运行你的应用程序:

$ npm start

当你的应用程序运行时,使用浏览器导航到 http://localhost:3000。你会看到注册和登录链接;尝试登录并观察主页视图的变化。然后,尝试导航到 http://localhost:3000/articles URL,并查看 list 组件如何建议你创建一个新文章。继续创建一个新文章,并尝试使用之前创建的组件编辑和删除它。你的 CRUD 模块应该是完全可操作的。

总结

在本章中,您学习了如何构建您的第一个 CRUD 模块。您首先定义了 Mongoose 模型和 Express 控制器,并学习了如何实现每个 CRUD 方法。您还使用 Express 中间件对控制器方法进行了授权。然后,您为模块方法定义了一个 RESTful API。您还学习了一些关于响应式编程和观察者模式的知识。您使用 HTTP 客户端与您的 API 进行通信。然后,您创建了您的 Angular 组件并实现了 Angular CRUD 功能。在连接 MEAN 应用程序的四个部分并创建您的第一个 CRUD 模块之后,在下一章中,您将使用 Socket.io 来实现服务器和客户端应用程序之间的实时连接。

第九章:使用 Socket.io 添加实时功能

在之前的章节中,您学习了如何构建您的 MEAN 应用程序以及如何创建 CRUD 模块。这些章节涵盖了 Web 应用程序的基本功能;然而,越来越多的应用程序需要服务器和浏览器之间的实时通信。在本章中,您将学习如何使用Socket.io模块实时连接您的 Express 和 Angular 应用程序。Socket.io 使 Node.js 开发人员能够在现代浏览器中支持使用WebSockets进行实时通信,并在旧版浏览器中支持回退协议。在本章中,我们将涵盖以下主题:

  • 设置 Socket.io 模块

  • 配置 Express 应用程序

  • 设置 Socket.io/Passport 会话

  • 连接 Socket.io 路由

  • 使用 Socket.io 客户端对象

  • 构建一个简单的聊天室

介绍 WebSockets

现代 Web 应用程序,如 Facebook、Twitter 和 Gmail,正在整合实时功能,使应用程序能够持续向用户呈现最新更新的信息。与传统应用程序不同,在实时应用程序中,浏览器和服务器的常见角色可以颠倒,因为服务器需要更新浏览器的新数据,而不管浏览器请求的状态如何。这意味着与常见的 HTTP 行为不同,服务器不会等待浏览器的请求。相反,它将在可用新数据时立即将新数据发送到浏览器。

这种反向方法通常被称为Comet,这个术语是由一位名叫 Alex Russel 的网页开发者在 2006 年创造的(这个术语是对 AJAX 术语的一个双关语;Comet 和 AJAX 都是美国常见的家用清洁剂)。过去,有几种方法可以使用 HTTP 协议实现 Comet 功能。

第一种最简单的方法是XMLHttpRequestXHR)轮询。在 XHR 轮询中,浏览器定期向服务器发出请求。然后服务器返回一个空响应,除非它有新数据要发送回来。在新事件发生时,服务器将新事件数据返回给下一个轮询请求。虽然这对大多数浏览器来说效果很好,但这种方法有两个问题。最明显的问题是,使用这种方法会产生大量没有特定原因的请求,因为很多请求都是空的。第二个问题是更新时间取决于请求周期。这意味着新数据只会在下一个请求时推送到浏览器,导致更新客户端状态的延迟。为了解决这些问题,引入了更好的方法:XHR 长轮询。

在 XHR 长轮询中,浏览器向服务器发出 XHR 请求,但除非服务器有新数据,否则不会发送响应。在事件发生时,服务器将以事件数据响应,并且浏览器会发出新的长轮询请求。这个循环可以更好地管理请求,因为每个会话只有一个请求。此外,服务器可以立即使用新信息更新浏览器,而无需等待浏览器的下一个请求。由于其稳定性和可用性,XHR 长轮询已成为实时应用程序的标准方法,并以各种方式实现,包括 Forever iFrame、多部分 XHR 和使用脚本标签的 JSONP 长轮询(用于跨域实时支持),以及常见的长期 XHR。

然而,所有这些方法实际上都是使用 HTTP 和 XHR 协议的黑客方法,这并不是它们本来的用途。随着现代浏览器的快速发展和新 HTML5 规范的广泛采用,出现了一种新的协议,用于实现实时通信:全双工WebSockets协议。

在支持WebSockets协议的浏览器中,服务器和浏览器之间的初始连接是通过 HTTP 进行的,称为 HTTP 握手。一旦建立了初始连接,浏览器和服务器将在 TCP 套接字上打开一个持续的通信通道。一旦套接字连接建立,它就可以实现浏览器和服务器之间的双向通信。这使双方能够通过单一通信通道发送和检索消息。这也有助于降低服务器负载,减少消息延迟,并统一使用独立连接进行 PUSH 通信。

然而,WebSockets仍然存在两个主要问题。首先是浏览器兼容性。WebSockets规范相当新,因此旧版浏览器不支持它,尽管大多数现代浏览器现在实现了该协议,但仍有大量用户在使用这些旧版浏览器。第二个问题是 HTTP 代理、防火墙和托管提供商。由于WebSockets使用与 HTTP 不同的通信协议,许多中介不支持它,因此阻止任何套接字通信。就像 Web 一直存在的问题一样,开发人员面临着碎片化问题,只能通过使用抽象库来解决,该库通过根据可用资源切换协议来优化可用性。幸运的是,一个名为 Socket.io 的流行库已经为此目的开发,并且可以免费提供给 Node.js 开发人员社区。

介绍 Socket.io

Socket.io 由 JavaScript 开发人员 Guillermo Rauch 于 2010 年创建,旨在抽象化 Node.js 实时应用程序开发。从那时起,它已经发展迅速,并在最新版本中分为两个不同的模块:engine.iosocket.io之前发布了九个主要版本。

Socket.io 的早期版本因首先尝试建立最先进的连接机制,然后回退到更原始的协议而受到批评。这导致在生产环境中使用 Socket.io 出现严重问题,并对将 Socket.io 作为实时库的采用构成威胁。为了解决这个问题,Socket.io 团队对其进行了重新设计,并将核心功能包装在一个名为 Engine.io 的基础模块中。

Engine.io 的理念是创建一个更稳定的实时模块,首先打开长轮询 XHR 通信,然后尝试升级连接到WebSockets通道。新版本的 Socket.io 使用 Engine.io 模块,并为开发人员提供各种功能,如事件、房间和自动连接恢复,否则您将自行实现。在本章的示例中,我们将使用新的 Socket.io 1.0,这是第一个使用 Engine.io 模块的版本。

注意

在 1.x 版本之前的旧版 Socket.io 不使用新的 Engine.io 模块,因此在生产环境中不太稳定。

当您包含socket.io模块时,它会为您提供两个对象:一个负责服务器功能的 socket 服务器对象,以及一个处理浏览器功能的 socket 客户端对象。我们将从检查服务器对象开始。

Socket.io 服务器对象

Socket.io 服务器对象是一切的开始。您首先需要引入socket.io模块,然后使用它创建一个新的 Socket.io 服务器实例,该实例将与 socket 客户端进行交互。服务器对象支持独立实现和与 Express 框架一起使用的能力。然后,服务器实例公开一组方法,允许您管理 Socket.io 服务器操作。一旦服务器对象被初始化,它还将负责为浏览器提供 socket 客户端 JavaScript 文件。

独立 Socket.io 服务器的简单实现如下所示:

const io = require('socket.io')();
io.on('connection', function(socket){ /* ... */ });
io.listen(3000);

这将在3000端口上打开一个 Socket.io,并在http://localhost:3000/socket.io/socket.io.js上提供套接字客户端文件。与 Express 应用程序一起实现 Socket.io 服务器将会有一些不同,如下面的代码所示:

const app = require('express')();
const server = require('http').Server(app);
const io = require('socket.io')(server);
io.on('connection', (socket) =>  { /* ... */ });
server.listen(3000);

这次,你首先使用 Node.js 的http模块创建一个服务器并包装 Express 应用程序。然后将服务器对象传递给socket.io模块,同时提供 Express 应用程序和 Socket.io 服务器。一旦服务器运行起来,它将可供套接字客户端连接。试图与 Socket.io 服务器建立连接的客户端将通过启动握手过程开始。

Socket.io 握手

当客户端想要连接到 Socket.io 服务器时,它首先会发送一个握手 HTTP 请求。服务器将分析请求以收集进行中通信所需的必要信息。然后它会查找在服务器上注册的配置中间件,并在触发连接事件之前执行它。当客户端成功连接到服务器时,连接事件监听器将被执行,暴露一个新的套接字实例。

一旦握手过程结束,客户端就连接到服务器了,并且所有与它的通信都通过套接字实例对象处理。例如,处理客户端的断开连接事件将如下所示:

const app = require('express')();
const server = require('http').Server(app);
const io = require('socket.io')(server);
io.on('connection', (socket) => { 
 socket.on('disconnect', () => {
 console.log('user has disconnected');
 });
});
server.listen(3000); 

注意socket.on()方法如何向断开连接事件添加事件处理程序。尽管断开连接事件是一个预定义事件,但这种方法对自定义事件也适用,后面你会看到。

虽然握手机制是完全自动的,Socket.io 提供了一种使用配置中间件拦截握手过程的方法。

Socket.io 配置中间件

虽然 Socket.io 配置中间件在以前的版本中存在,但在新版本中,它更简单,允许你在握手实际发生之前操纵套接字通信。要创建一个配置中间件,你需要使用服务器的use()方法,这与 Express 应用程序的use()方法非常相似:

const app = require('express')();
const server = require('http').Server(app);
const io = require('socket.io')(server);
io.use((socket, next) => {
 /* ... */
 next(null, true);
});
io.on('connection', (socket) => { 
  socket.on('disconnect', () => {
    console.log('user has disconnected');
  });
});
server.listen(3000); 

如你所见,io.use()方法的回调接受两个参数:socket对象和next回调。socket对象是将用于连接的相同套接字对象,它保存了一些连接属性。一个重要的属性是socket.request属性,它代表握手 HTTP 请求。在接下来的部分,你将使用握手请求来将 Passport 会话与 Socket.io 连接结合起来。

next参数是一个回调方法,接受两个参数:一个错误对象和一个布尔值。next回调告诉 Socket.io 是否继续握手过程,因此如果你向next方法传递一个错误对象或 false 值,Socket.io 将不会初始化套接字连接。现在你已经基本了解了握手的工作原理,是时候讨论 Socket.io 客户端对象了。

Socket.io 客户端对象

Socket.io 客户端对象负责实现浏览器与 Socket.io 服务器的套接字通信。首先要包含由 Socket.io 服务器提供的 Socket.io 客户端 JavaScript 文件。Socket.io JavaScript 文件公开了一个io()方法,用于连接到 Socket.io 服务器并创建客户端socket对象。套接字客户端的简单实现如下:

<script src="img/socket.io.js"></script>
<script>
  var socket = io();
  socket.on('connect', function() {
      /* ... */
  });
</script>

注意 Socket.io 客户端对象的默认 URL。虽然它可以被改变,但通常你可以保持默认的 Socket.io 路径并包含文件。另一件你应该注意的事情是,当没有参数执行io()方法时,它将自动尝试连接到默认的基本路径;但是,你也可以传递不同的服务器 URL 作为参数。

正如您所看到的,socket 客户端的实现要简单得多,因此我们可以继续讨论 Socket.io 如何使用事件处理实时通信。

Socket.io 事件

为了处理客户端和服务器之间的通信,Socket.io 使用了一种模仿WebSockets协议的结构,并在服务器和客户端对象之间触发事件消息。有两种类型的事件:系统事件,指示 socket 连接状态,和自定义事件,您将使用它们来实现业务逻辑。

socket 服务器上的系统事件如下:

  • io.on('connection', ...): 当一个新的 socket 连接时触发

  • socket.on('message', ...): 当使用socket.send()方法发送消息时触发

  • socket.on('disconnect', ...): 当 socket 断开连接时触发

客户端上的系统事件如下:

  • socket.io.on('open', ...): 当 socket 客户端与服务器建立连接时触发

  • socket.io.on('connect', ...): 当 socket 客户端连接到服务器时触发

  • socket.io.on('connect_timeout', ...): 当 socket 客户端与服务器的连接超时时触发

  • socket.io.on('connect_error', ...): 当 socket 客户端无法连接到服务器时触发

  • socket.io.on('reconnect_attempt', ...): 当 socket 客户端尝试重新连接到服务器时触发

  • socket.io.on('reconnect', ...): 当 socket 客户端重新连接到服务器时触发

  • socket.io.on('reconnect_error', ...): 当 socket 客户端无法重新连接到服务器时触发

  • socket.io.on('reconnect_failed', ...): 当 socket 客户端无法重新连接到服务器时触发

  • socket.io.on('close', ...): 当 socket 客户端关闭与服务器的连接时触发

处理事件

虽然系统事件帮助我们进行连接管理,但 Socket.io 的真正魔力在于使用自定义事件。为了做到这一点,Socket.io 在客户端和服务器对象上都公开了两种方法。第一种方法是on()方法,它将事件处理程序与事件绑定在一起,第二种方法是emit()方法,用于在服务器和客户端对象之间触发事件。

在 socket 服务器中实现on()方法非常简单:

const app = require('express')();
const server = require('http').Server(app);
const io = require('socket.io')(server);
io.on('connection', function(socket){ 
 socket.on('customEvent', (customEventData) => {
 /* ... */
 });
});
server.listen(3000); 

在上面的代码中,您绑定了一个事件监听器到customEvent事件。当 socket 客户端对象发出customEvent事件时,事件处理程序被调用。注意事件处理程序如何接受从 socket 客户端对象传递给事件处理程序的customEventData参数。

在 socket 客户端中实现on()方法也很简单:

<script src="img/socket.io.js"></script>
<script>
  var socket = io();
 socket.on('customEvent', function(customEventData) {
 /* ... */
 });
</script>

这次,事件处理程序在 socket 服务器发出customEvent事件并将customEventData发送到 socket 客户端事件处理程序时被调用。

一旦设置了事件处理程序,您就可以使用emit()方法从 socket 服务器向 socket 客户端发送事件,反之亦然。

发送事件

在 socket 服务器上,emit()方法用于向单个 socket 客户端或一组连接的 socket 客户端发送事件。emit()方法可以从连接的socket对象中调用,这将向单个 socket 客户端发送事件,如下所示:

io.on('connection', (socket) => { 
  socket.emit('customEvent', customEventData);
});

emit()方法也可以从io对象中调用,这将向所有连接的 socket 客户端发送事件,如下所示:

io.on('connection', (socket) => { 
  io.emit('customEvent', customEventData);
});

另一个选项是使用broadcast属性将事件发送给除发送方以外的所有连接的 socket 客户端,如下面的代码所示:

io.on('connection', (socket) => { 
  socket.broadcast.emit('customEvent', customEventData);
});

在 socket 客户端上,情况要简单得多。由于 socket 客户端只连接到 socket 服务器,emit()方法只会将事件发送到 socket 服务器:

const socket = io();
socket.emit('customEvent', customEventData);

尽管这些方法允许您在个人和全局事件之间切换,但它们仍然缺乏向一组连接的套接字客户端发送事件的能力。Socket.io 提供了两种选项来将套接字分组:命名空间和房间。

Socket.io 命名空间

为了更容易地控制套接字管理,Socket.io 允许开发人员根据其目的将套接字连接分割成不同的命名空间。因此,您可以使用相同的服务器来创建不同的连接端点,而不是为不同的连接创建不同的套接字服务器。这意味着套接字通信可以被分成组,然后分别处理。

Socket.io 服务器命名空间

要创建套接字服务器命名空间,您需要使用套接字服务器的of()方法,该方法返回一个套接字命名空间。一旦保留了套接字命名空间,您可以像使用套接字服务器对象一样使用它:

const app = require('express')();
const server = require('http').Server(app);
const io = require('socket.io')(server);

io.of('/someNamespace').on('connection', (socket) => { 
  socket.on('customEvent', (customEventData) => {
    /* ... */
  });
});

io.of('/someOtherNamespace').on('connection', (socket) => { 
  socket.on('customEvent', (customEventData) => {
    /* ... */
  });
});
server.listen(3000);

实际上,当您使用io对象时,Socket.io 实际上使用一个默认的空命名空间,如下所示:

io.on('connection', (socket) => { 
/* ... */
});

前面的代码行实际上等同于这个:

io.of('').on('connection', (socket) => { 
/* ... */
});

Socket.io 客户端命名空间

在套接字客户端上,实现略有不同:

<script src="img/socket.io.js"></script>
<script>
  var someSocket = io('/someNamespace');
  someSocket.on('customEvent', function(customEventData) {
    /* ... */
  });
 var someOtherSocket = io('/someOtherNamespace');
  someOtherSocket.on('customEvent', function(customEventData) {
    /* ... */
  });
</script>

如您所见,您可以在同一个应用程序中轻松使用多个命名空间。然而,一旦套接字连接到不同的命名空间,您将无法同时向所有这些命名空间发送事件。这意味着命名空间对于更动态的分组逻辑并不是很好。为此,Socket.io 提供了一个称为 rooms 的不同功能。

Socket.io 房间

Socket.io 房间允许您以动态方式将连接的套接字分成不同的组。连接的套接字可以加入和离开房间,Socket.io 为您提供了一个清晰的界面来管理房间并向房间中的套接字子集发出事件。房间功能完全由套接字服务器处理,但可以轻松地暴露给套接字客户端。

加入和离开房间

使用套接字join()方法处理加入房间,而使用leave()方法处理离开房间。因此,可以实现一个简单的订阅机制,如下所示:

io.on('connection', (socket) => {
    socket.on('join', (roomData) => {
 socket.join(roomData.roomName);
    })
    socket.on('leave', (roomData) => {
 socket.leave(roomData.roomName);
    })
});

请注意,join()leave()方法都将房间名称作为第一个参数。

向房间发出事件

要向房间中的所有套接字发出事件,您需要使用in()方法。因此,向已加入房间的所有套接字客户端发送事件非常简单,并且可以通过以下代码片段的帮助实现:

io.on('connection', (socket) => { 
  io.in('someRoom').emit('customEvent', customEventData);
});

另一个选项是使用broadcast属性和to()方法将事件发送到房间中除发送者之外的所有连接的套接字客户端:

io.on('connection', (socket) => { 
  socket.broadcast.to('someRoom').emit('customEvent', customEventData);
});

这基本上涵盖了 Socket.io 的简单而强大的 room 功能。在下一节中,您将学习如何在 MEAN 应用程序中实现 Socket.io,更重要的是,如何使用 Passport 会话来识别 Socket.io 会话中的用户。本章中的示例将直接从前几章中的示例继续,因此请从第八章中复制最终示例,创建一个 MEAN CRUD 模块,然后从那里开始。

注意

虽然我们已经介绍了大部分 Socket.io 的功能,但您可以通过访问官方项目页面socket.io/来了解更多关于 Socket.io 的信息。

安装 Socket.io

在您可以使用socket.io模块之前,您需要使用npm进行安装。为此,请按照以下更改您的package.json文件:

{
  "name": "MEAN",
  "version": "0.0.9",
  "scripts": {
    "tsc": "tsc",
    "tsc:w": "tsc -w",
    "app": "node server",
    "start": "concurrently \"npm run tsc:w\" \"npm run app\" ",
    "postinstall": "typings install"
  },
  "dependencies": {
    "@angular/common": "2.1.1",
    "@angular/compiler": "2.1.1",
    "@angular/core": "2.1.1",
    "@angular/forms": "2.1.1",
    "@angular/http": "2.1.1",
    "@angular/platform-browser": "2.1.1",
    "@angular/platform-browser-dynamic": "2.1.1",
    "@angular/router": "3.1.1",
    "body-parser": "1.15.2",
    "core-js": "2.4.1",
    "compression": "1.6.0",
    "connect-flash": "0.1.1",
    "ejs": "2.5.2",
    "express": "4.14.0",
    "express-session": "1.14.1",
    "method-override": "2.3.6",
    "mongoose": "4.6.5",
    "morgan": "1.7.0",
    "passport": "0.3.2",
    "passport-facebook": "2.1.1",
    "passport-google-oauth": "1.0.0",
    "passport-local": "1.0.0",
    "passport-twitter": "1.0.4",
    "reflect-metadata": "0.1.8",
    "rxjs": "5.0.0-beta.12",
 "socket.io": "1.4.5",
    "systemjs": "0.19.39",
    "zone.js": "0.6.26"
  },
  "devDependencies": {
    "concurrently": "3.1.0",
    "traceur": "0.0.111",
    "typescript": "2.0.3",
    "typings": "1.4.0"
  }
}

要安装socket.io模块,请转到应用程序的根文件夹,并在命令行工具中发出以下命令:

$ npm install

像往常一样,这将在您的node_modules文件夹中安装指定版本的 Socket.io。安装过程成功完成后,您需要配置 Express 应用程序以与socket.io模块配合工作,并启动套接字服务器。

配置 Socket.io 服务器

在安装了socket.io模块之后,你需要启动 Socket 服务器以及 Express 应用程序。为此,你需要在你的config/express.js文件中进行以下更改:

const path = require('path');
const config = require('./config');
const http = require('http');
const socketio = require('socket.io');
const express = require('express');
const morgan = require('morgan');
const compress = require('compression');
const bodyParser = require('body-parser');
const methodOverride = require('method-override');
const session = require('express-session');
const flash = require('connect-flash');
const passport = require('passport');

module.exports = function() {
  const app = express();
 const server = http.createServer(app);
 const io = socketio.listen(server);

  if (process.env.NODE_ENV === 'development') {
    app.use(morgan('dev'));
  } else if (process.env.NODE_ENV === 'production') {
    app.use(compress());
  }

  app.use(bodyParser.urlencoded({
    extended: true
  }));
  app.use(bodyParser.json());
  app.use(methodOverride());

  app.use(session({
    saveUninitialized: true,
    resave: true,
    secret: config.sessionSecret
  }));

  app.set('views', './app/views');
  app.set('view engine', 'ejs');

  app.use(flash());
  app.use(passport.initialize());
  app.use(passport.session());

  app.use('/', express.static(path.resolve('./public')));
  app.use('/lib', express.static(path.resolve('./node_modules')));

  require('../app/routes/users.server.routes.js')(app);
  require('../app/routes/articles.server.routes.js')(app);
  require('../app/routes/index.server.routes.js')(app);

 return server;
};

让我们来看看你对 Express 配置所做的更改。在包含新的依赖项之后,你使用了http核心模块来创建一个包装你的 Express app对象的server对象。然后你使用了socket.io模块及其listen()方法将 Socket.io 服务器附加到你的server对象上。最后,你返回了新的server对象而不是 Express 应用程序对象。当服务器启动时,它将运行你的 Socket.io 服务器以及 Express 应用程序。

虽然你已经可以开始使用 Socket.io,但是这个实现还存在一个主要问题。由于 Socket.io 是一个独立的模块,发送到它的请求与 Express 应用程序分离。这意味着 Express 会话信息在 socket 连接中不可用。在应用程序的 socket 层处理 Passport 身份验证时,这将带来严重的障碍。为了解决这个问题,你需要配置一个持久的会话存储,这将允许你在 Express 应用程序和 Socket.io 握手请求之间共享会话信息。

配置 Socket.io 会话

要配置你的 Socket.io 会话与 Express 会话一起工作,你必须找到一种方法在 Socket.io 和 Express 之间共享会话信息。由于 Express 会话信息目前存储在内存中,Socket.io 将无法正确访问它。因此,一个更好的解决方案是将会话信息存储在你的 MongoDB 中。幸运的是,有一个名为connect-mongo的节点模块,它几乎无缝地允许你将会话信息存储在 MongoDB 实例中。为了检索 Express 会话信息,你需要一种方法来解析已签名的会话 cookie 信息。为此,你还需要安装cookie-parser模块,它用于解析 cookie 头并将 HTTP 请求对象填充为与 cookie 相关的属性。

在你可以使用connect-mongocookie-parser模块之前,你需要使用npm安装它们。为此,请按照以下方式更改你的package.json文件:

{
  "name": "MEAN",
  "version": "0.0.9",
  "scripts": {
    "tsc": "tsc",
    "tsc:w": "tsc -w",
    "app": "node server",
    "start": "concurrently \"npm run tsc:w\" \"npm run app\" ",
    "postinstall": "typings install"
  },
  "dependencies": {
    "@angular/common": "2.1.1",
    "@angular/compiler": "2.1.1",
    "@angular/core": "2.1.1",
    "@angular/forms": "2.1.1",
    "@angular/http": "2.1.1",
    "@angular/platform-browser": "2.1.1",
    "@angular/platform-browser-dynamic": "2.1.1",
    "@angular/router": "3.1.1",
    "body-parser": "1.15.2",
    "core-js": "2.4.1",
    "compression": "1.6.0",
    "connect-flash": "0.1.1",
 "connect-mongo": "1.3.2",
 "cookie-parser": "1.4.3",
    "ejs": "2.5.2",
    "express": "4.14.0",
    "express-session": "1.14.1",
    "method-override": "2.3.6",
    "mongoose": "4.6.5",
    "morgan": "1.7.0",
    "passport": "0.3.2",
    "passport-facebook": "2.1.1",
    "passport-google-oauth": "1.0.0",
    "passport-local": "1.0.0",
    "passport-twitter": "1.0.4",
    "reflect-metadata": "0.1.8",
    "rxjs": "5.0.0-beta.12",
    "socket.io": "1.4.5",
    "systemjs": "0.19.39",
    "zone.js": "0.6.26"
  },
  "devDependencies": {
    "concurrently": "3.1.0",
    "traceur": "0.0.111",
    "typescript": "2.0.3",
    "typings": "1.4.0"
  }
}

要安装新的模块,进入你的应用程序根目录,并在命令行工具中输入以下命令:

$ npm install

像往常一样,这将在你的node_modules文件夹中安装指定版本的connect-mongocookie-parser模块。当安装过程顺利完成后,你的下一步将是配置你的 Express 应用程序使用connect-mongo作为会话存储。

配置 connect-mongo 模块

要配置你的 Express 应用程序使用connect-mongo模块存储会话信息,你需要做一些更改。首先,你需要更改你的config/express.js文件,如下所示:

const path = require('path');
const config = require('./config');
const http = require('http');
const socketio = require('socket.io');
const express = require('express');
const morgan = require('morgan');
const compress = require('compression');
const bodyParser = require('body-parser');
const methodOverride = require('method-override');
const session = require('express-session');
const MongoStore = require('connect-mongo')(session);
const flash = require('connect-flash');
const passport = require('passport');

module.exports = function(db) {
  const app = express();
  const server = http.createServer(app);
  const io = socketio.listen(server);

  if (process.env.NODE_ENV === 'development') {
    app.use(morgan('dev'));
  } else if (process.env.NODE_ENV === 'production') {
    app.use(compress());
  }

  app.use(bodyParser.urlencoded({
    extended: true
  }));
  app.use(bodyParser.json());
  app.use(methodOverride());

  const mongoStore = new MongoStore({
 mongooseConnection: db.connection
 });

 app.use(session({
 saveUninitialized: true,
 resave: true,
 secret: config.sessionSecret,
 store: mongoStore
 }));

  app.set('views', './app/views');
  app.set('view engine', 'ejs');

  app.use(flash());
  app.use(passport.initialize());
  app.use(passport.session());

  app.use('/', express.static(path.resolve('./public')));
  app.use('/lib', express.static(path.resolve('./node_modules')));

  require('../app/routes/users.server.routes.js')(app);
  require('../app/routes/articles.server.routes.js')(app);
  require('../app/routes/index.server.routes.js')(app);

  return server;
};

在上述代码片段中,你配置了一些东西。首先,你加载了connect-mongo模块,然后将 Express 会话模块传递给它。然后,你创建了一个新的connect-mongo实例,并将你的 Mongoose 连接对象传递给它。最后,你使用了 Express 会话存储选项,让 Express 会话模块知道在哪里存储会话信息。

正如你所看到的,你的 Express 配置方法需要一个db参数。这个参数是 Mongoose 连接对象,它将从server.js文件传递给 Express 配置方法,当它需要express.js文件时。因此,去你的server.js文件并按照以下方式更改它:

process.env.NODE_ENV = process.env.NODE_ENV || 'development';
const configureMongoose = require('./config/mongoose');
const configureExpress = require('./config/express');
const configurePassport = require('./config/passport');

const db = configureMongoose();
const app = configureExpress(db);
const passport = configurePassport();
app.listen(3000);

module.exports = app;

console.log('Server running at http://localhost:3000/'); 

一旦 Mongoose 连接创建完成,server.js文件将调用express.js模块方法并将 Mongoose 数据库属性传递给它。这样,Express 将会将会话信息持久存储在您的 MongoDB 数据库中,以便为 Socket.io 会话提供。接下来,您需要配置您的 Socket.io 握手中间件以使用connect-mongo模块并检索 Express 会话信息。

配置 Socket.io 会话

要配置 Socket.io 会话,您需要使用 Socket.io 配置中间件并检索您的会话用户。首先,在您的config文件夹中创建一个名为socketio.js的新文件,以存储所有与 Socket.io 相关的配置。在您的新文件中,添加以下代码行:

const config = require('./config');
const cookieParser = require('cookie-parser');
const passport = require('passport');

module.exports = function(server, io, mongoStore) {
  io.use((socket, next) => {
    cookieParser(config.sessionSecret)(socket.request, {}, (err) => {
      const sessionId = socket.request.signedCookies['connect.sid'];

      mongoStore.get(sessionId, (err, session) => {
        socket.request.session = session;

        passport.initialize()(socket.request, {}, () => {
          passport.session()(socket.request, {}, () => {
            if (socket.request.user) {
              next(null, true);
            } else {
              next(new Error('User is not authenticated'), false);
            }
          })
        });
      });
    });
  });
  io.on('connection', (socket) => {
    /* ... */
  });
};

让我们来看一下新的 Socket.io 配置文件。首先,您需要引入必要的依赖项,然后使用io.use()配置方法来拦截握手过程。在配置函数中,您使用 Express 的cookie-parser模块来解析握手请求的 cookie 并检索 Express 的sessionId。然后,您使用connect-mongo实例从 MongoDB 存储中检索会话信息。

一旦检索到会话对象,您使用passport.initialize()passport.session()中间件来根据会话信息填充会话的user对象。如果用户经过身份验证,握手中间件将调用next()回调并继续进行套接字初始化;否则,它将使用next()回调以一种方式通知 Socket.io 套接字连接无法打开。这意味着只有经过身份验证的用户才能与服务器建立套接字通信,并防止未经授权的连接到您的 Socket.io 服务器。

要完成您的 Socket.io 服务器配置,您需要从express.js文件中调用 Socket.io 配置模块。转到您的config/express.js文件并进行更改,如下所示:

const path = require('path');
const config = require('./config');
const http = require('http');
const socketio = require('socket.io');
const express = require('express');
const morgan = require('morgan');
const compress = require('compression');
const bodyParser = require('body-parser');
const methodOverride = require('method-override');
const session = require('express-session');
const MongoStore = require('connect-mongo')(session);
const flash = require('connect-flash');
const passport = require('passport');
const configureSocket = require('./socketio');

module.exports = function(db) {
  const app = express();
  const server = http.createServer(app);
  const io = socketio.listen(server);

  if (process.env.NODE_ENV === 'development') {
    app.use(morgan('dev'));
  } else if (process.env.NODE_ENV === 'production') {
    app.use(compress());
  }

  app.use(bodyParser.urlencoded({
    extended: true
  }));
  app.use(bodyParser.json());
  app.use(methodOverride());

  const mongoStore = new MongoStore({
    mongooseConnection: db.connection
  });

  app.use(session({
    saveUninitialized: true,
    resave: true,
    secret: config.sessionSecret,
    store: mongoStore
  }));

  app.set('views', './app/views');
  app.set('view engine', 'ejs');

  app.use(flash());
  app.use(passport.initialize());
  app.use(passport.session());

  app.use('/', express.static(path.resolve('./public')));
  app.use('/lib', express.static(path.resolve('./node_modules')));

  require('../app/routes/users.server.routes.js')(app);
  require('../app/routes/articles.server.routes.js')(app);
  require('../app/routes/index.server.routes.js')(app);

 configureSocket(server, io, mongoStore);

  return server;
};

这将执行您的 Socket.io 配置方法,并负责设置 Socket.io 会话。现在您已经配置好了一切,让我们看看如何使用 Socket.io 和 MEAN 轻松构建一个简单的聊天。

构建一个 Socket.io 聊天

为了测试您的 Socket.io 实现,构建一个简单的聊天应用程序。您的聊天将由几个服务器事件处理程序构成,但大部分实现将在您的 Angular 应用程序中进行。我们将从设置服务器事件处理程序开始。

设置聊天服务器的事件处理程序

在您的 Angular 应用程序中实现聊天客户端之前,您首先需要创建一些服务器事件处理程序。您已经有了一个合适的应用程序结构,因此不会直接在配置文件中实现事件处理程序。相反,最好通过在app/controllers文件夹中创建一个名为chat.server.controller.js的新文件来实现聊天逻辑。在您的新文件中,粘贴以下代码行:

module.exports = function(io, socket) {
  io.emit('chatMessage', {
    type: 'status',
    text: 'connected',
    created: Date.now(),
    username: socket.request.user.username
  });

  socket.on('chatMessage', (message) => {
    message.type = 'message';
    message.created = Date.now();
    message.username = socket.request.user.username;

    io.emit('chatMessage', message);
  });

  socket.on('disconnect', () => {
    io.emit('chatMessage', {
    type: 'status',
    text: 'disconnected',
    created: Date.now(),
    username: socket.request.user.username
    });
  });
};

在这个文件中,您实现了一些内容。首先,您使用io.emit()方法通知所有连接的套接字客户端有新连接的用户。这是通过发出chatMessage事件并传递带有用户信息和消息文本、时间和类型的聊天消息对象来完成的。由于您在套接字服务器配置中处理了用户身份验证,用户信息可以从socket.request.user对象中获取。

接下来,您实现了chatMessage事件处理程序,负责处理从套接字客户端发送的消息。事件处理程序将添加消息类型、时间和用户信息,并使用io.emit()方法将修改后的消息对象发送给所有连接的套接字客户端。

我们的最后一个事件处理程序将负责处理disconnect系统事件。当某个用户从服务器断开连接时,事件处理程序将使用io.emit()方法通知所有连接的 socket 客户端关于这个事件。这将允许聊天视图向其他用户呈现断开连接的信息。

您现在已经实现了服务器处理程序,但是如何配置 socket 服务器以包含这些处理程序呢?为此,您需要返回到您的config/socketio.js文件并稍微修改它:

const config = require('./config');
const cookieParser = require('cookie-parser');
const passport = require('passport');
const configureChat = require('../app/controllers/chat.server.controller');

module.exports = function(server, io, mongoStore) {
  io.use((socket, next) => {
    cookieParser(config.sessionSecret)(socket.request, {}, (err) => {
      const sessionId = socket.request.signedCookies['connect.sid'];

      mongoStore.get(sessionId, (err, session) => {
        socket.request.session = session;

        passport.initialize()(socket.request, {}, () => {
          passport.session()(socket.request, {}, () => {
            if (socket.request.user) {
              next(null, true);
            } else {
              next(new Error('User is not authenticated'), false);
            }
          })
        });
      });
    });
  });

  io.on('connection', (socket) => {
 configureChat(io, socket);
  });
};

注意 socket 服务器connection事件是如何用来调用聊天控制器的。这将允许您直接将事件处理程序与连接的 socket 绑定。

恭喜!您已成功完成了服务器的实现!接下来,您将看到实现 Angular 聊天组件是多么容易。

创建聊天 Angular 模块

为了完成我们的聊天实现,我们将创建一个新的 Angular 聊天模块。我们的模块将包括我们的组件和模板、路由配置以及包装socket.io客户端功能的服务。Socket.io 为我们提供了一个客户端库来处理 socket 通信;然而,最佳实践是使用我们自己的 Angular 服务来混淆它。我们将从配置 Socket.io 客户端库开始。

设置 Socket.io 客户端库

为了设置 Socket.io 客户端库,我们需要在我们的index.ejs模板中包含库的 JavaScript 文件。为此,转到app/views/index.ejs文件并进行以下更改:

<!DOCTYPE html>
<html>
<head>
  <title><%= title %></title>
  <base href="/">
</head>
<body>
  <mean-app>
    <h1>Loading...</h1>
  </mean-app>

  <script type="text/javascript">
    window.user = <%- user || 'null' %>;
  </script>

  <script src="img/socket.io.js"></script>
  <script src="img/shim.min.js"></script>
  <script src="img/zone.js"></script>
  <script src="img/Reflect.js"></script>
  <script src="img/system.js"></script>

  <script src="img/systemjs.config.js"></script>

  <script>
    System.import('app').catch(function(err){ console.error(err); });
  </script>
</body>
</html>

如您所见,我们在这里所做的就是在我们的主应用页面中添加脚本标签以包含 Socket.io 的客户端文件。接下来,我们需要创建我们的Chat模块。

创建聊天模块

完成了客户端 Socket.io 实现的基本声明设置后,我们可以继续进行聊天实现。首先,在public/app文件夹内创建一个名为chat的文件夹。然后,在这个文件夹内创建一个名为chat.module.ts的文件,其中包含以下代码:

import { NgModule }       from '@angular/core';
import { CommonModule }   from '@angular/common';
import { FormsModule }    from '@angular/forms';
import { RouterModule } from '@angular/router';

import { ChatRoutes } from './chat.routes';
import { ChatService } from './chat.service';
import { ChatComponent } from './chat.component';

@NgModule({
  imports: [
    CommonModule,
    FormsModule,
    RouterModule.forChild(ChatRoutes),
  ],
  declarations: [
    ChatComponent,
  ],
  providers: [
    ChatService
  ]
})
export class ChatModule {}

正如您可能注意到的,我们的模块导入了一个新的聊天组件和路由配置,并注入了聊天服务。让我们继续创建我们的聊天服务。

创建聊天服务

为了混淆我们的组件与 Socket.io 客户端库的通信,我们需要创建一个 Angular 服务。为此,在public/app/chat文件夹内创建一个名为chat.service.ts的文件。在新文件中,粘贴以下代码:

import 'rxjs/Rx';
import { Observable } from 'rxjs/Observable';
import { Injectable } from '@angular/core';
import { Router } from '@angular/router';

import { AuthenticationService } from '../authentication/authentication.service';

@Injectable()
export class ChatService {
  private socket: any;

  constructor(private _router:Router, private _authenticationService: AuthenticationService) {
    if (this._authenticationService.isLoggedIn()) {
      this.socket = io();
    } else {
      this._router.navigate(['Home']);
    }
  }

  on(eventName, callback) {
    if (this.socket) {
      this.socket.on(eventName, function(data) {
        callback(data);
      });
    }
  };

  emit(eventName, data) {
    if (this.socket) {
      this.socket.emit(eventName, data);
    }
  };

  removeListener(eventName) {
    if (this.socket) {
      this.socket.removeListener(eventName);
    }
  };
}

让我们暂停一下来审查我们的新代码。基本结构应该看起来很熟悉,因为它基本上是一个常规的 Angular 服务。在构造函数中注入了 Authentication 和 Router 服务后,您使用Authentication服务检查用户是否经过身份验证。如果用户未经过身份验证,则使用Router服务将请求重定向回主页。由于 Angular 服务是惰性加载的,Socket 服务只有在请求时才会加载。这将阻止未经身份验证的用户使用 Socket 服务。如果用户经过身份验证,则通过调用 Socket.io 的io()方法设置服务socket属性。

接下来,您使用兼容的服务方法包装了 socket 的emit()on()removeListener()方法。为了保持我们的示例简单,我们称之为ChatService服务。然而,正如您可能通过其结构注意到的那样,这个服务很容易成为我们应用程序不同组件中使用的通用 Socket 服务。现在聊天服务已经准备好了,我们所要做的就是实现聊天组件和模板。让我们开始定义聊天组件。

创建聊天组件

我们的聊天组件将包含基本的客户端聊天功能。要实现它,转到您的public/app/chat文件夹,并创建一个名为char.component.ts的文件。在新文件中,粘贴以下代码:

import { Component } from '@angular/core';
import { ChatService } from './chat.service';

@Component({
  selector: 'chat',
  templateUrl: 'app/chat/chat.template.html',
  providers: [ChatService]
})
export class ChatComponent {
  messageText: string;
  messages: Array<any>;

  constructor(private _chatService: ChatService) {}

  ngOnInit() {
    this.messages = new Array();

    this._chatService.on('chatMessage', (msg) => {
 this.messages.push(msg);
 });
  }

  sendMessage() {
    const message = {
      text: this.messageText,
    };

 this._chatService.emit('chatMessage', message);
    this.messageText = ''
  }

  ngOnDestroy() {
 this._chatService.removeListener('chatMessage');
  }
}

在我们的组件中,您首先创建了一个消息数组,然后使用ChatService on()方法来实现chatMessage事件监听器,将检索到的消息添加到此数组中。接下来,您创建了一个sendMessage()方法,通过向套接字服务器发出chatMessage事件来发送新消息。最后,您使用内置的ngOnInit指令来从套接字客户端中删除chatMessage事件监听器。当控制器实例被拆除时,ngOnDestroy方法将被触发。这很重要,因为除非您将其删除,否则事件处理程序仍将被执行。

创建聊天模板

聊天模板将由一个简单的表单和一个聊天消息列表构成。要实现您的聊天模板,请转到您的public/app/chat文件夹,并创建一个名为chat.template.html的新文件,其中包含以下代码片段:

<div *ngFor="let message of messages" [ngSwitch]="message.type">
    <strong *ngSwitchCase="'status'">
      <span>{{message.created}}</span>
      <span>{{message.username}}</span>
      <span>is</span>
      <span>{{message.text}}</span>
    </strong>
    <span *ngSwitchDefault>
      <span>{{message.created}}</span>
      <span>{{message.username}}:</span>
      <span>{{message.text}}</span>
    </span>
</div>
<form (ngSubmit)="sendMessage()">
    <input type="text" name= "messageText" [(ngModel)]="messageText">
    <input type="submit">
</form>

在您的模板中,您使用了ngFor指令来渲染消息列表,使用了ngSwitch指令来区分状态消息和常规消息。模板以一个简单的表单结束,该表单使用ngSubmit指令来调用sendMessage()方法。就是这样!您只需要通过将聊天模块添加到我们的应用程序模块中来完成您的实现。

添加聊天路由配置

要添加聊天组件路由,请返回到您的public/app/chat文件夹,并创建一个名为chat.routes.ts的新文件,其中包含以下代码片段:

import { Routes } from '@angular/router';
import { ChatComponent } from './chat.component';

export const ChatRoutes: Routes = [{
  path: 'chat',
  component: ChatComponent
}];

正如您所看到的,我们为我们的聊天组件创建了一个简单的路由。我们要做的就是在我们的应用程序模块中包含我们的聊天模块。

使用聊天模块

要完成我们的聊天实现,我们需要将我们的模块包含在应用程序模块中。为此,请转到您的public/app/app.module.ts,如下所示:

import { NgModule }       from '@angular/core';
import { BrowserModule }  from '@angular/platform-browser';
import { FormsModule }    from '@angular/forms';
import { RouterModule }   from '@angular/router';
import { HttpModule, RequestOptions } from '@angular/http';
import { LocationStrategy, HashLocationStrategy } from '@angular/common';

import { AppComponent } from './app.component';
import { AppRoutes } from './app.routes';

import { HomeModule } from './home/home.module';
import { AuthenticationService } from './authentication/authentication.service';
import { AuthenticationModule } from './authentication/authentication.module';
import { ArticlesModule } from './articles/articles.module';
import { ChatModule } from './chat/chat.module';

@NgModule({
  imports: [
    BrowserModule,
    HttpModule,
    FormsModule,
    AuthenticationModule,
    HomeModule,
    ArticlesModule,
 ChatModule,
    RouterModule.forRoot(AppRoutes),
  ],
  declarations: [
    AppComponent
  ],
  providers: [
    AuthenticationService
  ],
  bootstrap: [AppComponent]
})
export class AppModule { }

现在,您需要在主页组件中添加一个链接到我们的聊天组件。为此,请转到您的public/app/home/home.template.html文件并进行以下更改:

<div *ngIf="user">
  <h1>Hello {{user.firstName}}</h1>
  <a href="/api/auth/signout">Signout</a>
  <ul>
    <li><a [routerLink]="['/articles']">List Articles</a></li>
    <li><a [routerLink]="['/articles/create']">Create Article</a></li>
 <li><a [routerLink]="['/chat']">Chat</a></li>
  </ul>
</div>

<div *ngIf="!user">
  <a [routerLink]="['/authentication/signup']">Signup</a>
  <a [routerLink]="['/authentication/signin']">Signin</a>
</div>

完成这些更改后,您的新聊天组件应该已经准备好使用了!使用命令行工具并导航到 MEAN 应用程序的根文件夹。然后,通过输入以下命令来运行您的应用程序:

$ npm start

一旦您的应用程序运行起来,打开两个不同的浏览器并使用两个不同的用户进行注册。然后,导航到http://localhost:3000/并点击您的新的聊天链接。尝试在两个客户端之间发送聊天消息,您将能够看到聊天消息是如何实时更新的。您的 MEAN 应用程序现在支持实时通信!

总结

在本章中,您学习了socket.io模块的工作原理。您了解了 Socket.io 的关键特性,并学习了服务器和客户端如何通信。您配置了 Socket.io 服务器,并学习了如何将其与 Express 应用程序集成。您还使用了 Socket.io 握手配置来集成 Passport 会话。最后,您构建了一个完全功能的聊天示例,并学习了如何使用 Angular 服务包装 Socket.io 客户端。在下一章中,您将学习如何编写和运行测试来覆盖您的应用程序代码。

第十章:测试 MEAN 应用程序

在之前的章节中,你学会了如何构建实时的 MEAN 应用程序。你学习了 Express 和 Angular 的基础知识,并学会了将所有部分连接在一起。然而,当你的应用程序变得更大更复杂时,你很快就会发现手动验证你的代码非常困难。然后,你需要开始自动测试你的应用程序。幸运的是,借助新工具和适当的测试框架,测试 Web 应用程序,这曾经是一项复杂的任务,现在变得更加容易。在本章中,你将学习如何使用现代测试框架和流行工具来覆盖你的 MEAN 应用程序代码。我们将涵盖以下主题:

  • 介绍 JavaScript TDD 和 BDD

  • 设置你的测试环境

  • 安装和配置 Mocha 测试框架

  • 编写 Express 模型和控制器测试

  • 安装和配置 Karma 测试运行器

  • 使用 Jasmine 来对你的 Angular 实体进行单元测试

  • 编写和运行端到端的 Angular 测试

介绍 JavaScript 测试

正如你已经知道的,在过去的几年里,JavaScript 发展迅速。它曾经是一个简单的脚本语言,用于小型 Web 应用程序,但现在它是复杂架构的骨干,无论是在服务器还是浏览器中。然而,这种发展让开发人员陷入了一个境地,他们需要手动管理一个大型的代码库,而这些代码在自动化测试方面仍然没有覆盖到。虽然我们的 Java、.NET 或 Ruby 开发人员一直安全地编写和运行他们的测试,但 JavaScript 开发人员仍然处于未知的领域,需要弄清楚如何正确地测试他们的应用程序。最近,这个空白已经被由才华横溢的 JavaScript 社区成员编写的新工具和测试框架填补。在本章中,我们将介绍一些流行的工具,但请记住,这个领域是相当新的,不断变化,所以你也需要密切关注新出现的解决方案。

在本章中,我们将讨论两种主要类型的测试:单元测试和端到端E2E)测试。单元测试是为了验证孤立的代码单元的功能。这意味着开发人员应该努力编写每个单元测试来覆盖应用程序中最小的可测试部分。例如,开发人员可能会编写单元测试来验证 ORM 方法是否正常工作,并且输出正确的验证错误。然而,开发人员通常会选择编写验证更大代码单元的单元测试,主要是因为这些单元一起执行孤立的操作。如果开发人员想要测试包括许多软件组件的过程,他将编写一个 E2E 测试。E2E 测试是为了验证跨应用程序功能。这些测试通常会迫使开发人员使用多个工具,并在同一个测试中覆盖应用程序的不同部分,包括 UI、服务器和数据库组件。一个例子是验证注册过程的 E2E 测试。确定正确的测试是编写应用程序的适当测试套件的关键步骤之一。然而,为开发团队设置适当的约定可以使这个过程变得更加容易。

在我们开始讨论特定于 JavaScript 的工具之前,让我们首先快速了解一下 TDD 范式的概述以及它如何影响我们日常的开发周期。

TDD、BDD 和单元测试

测试驱动开发TDD)是由软件工程师和敏捷方法倡导者 Kent Beck 开发的一种软件开发范式。在 TDD 中,开发人员首先编写一个(最初失败的)测试,定义了对代码的孤立单元的期望。然后,开发人员需要实现最少量的代码来通过测试。

当测试成功通过时,开发人员清理代码并验证所有测试是否通过。下图说明了 TDD 循环:

TDD,BDD 和单元测试

重要的是要记住,尽管 TDD 已经成为现代软件开发中流行的方法,但在其纯粹形式下实施是非常困难的。为了简化这个过程并改善团队沟通,TDD 的基础上开发了一种新的方法,称为行为驱动开发BDD)。BDD 范式是 TDD 的一个子集,由 Dan North 创建,帮助开发人员确定其单元测试的范围,并用行为术语表达其测试过程。基本上,TDD 为编写测试提供了框架,而 BDD 提供了塑造测试编写方式的词汇。通常,BDD 测试框架为开发人员提供了一组自解释的方法来描述测试过程。

尽管 BDD 为我们提供了编写测试的机制,但在 JavaScript 环境中运行这些测试仍然是一个复杂的任务。您的应用程序可能会在不同的浏览器甚至同一浏览器的不同版本上运行。因此,在单个浏览器上运行您编写的测试将无法提供适当的覆盖范围。为解决这个问题,JavaScript 社区开发了一系列多样化的工具,用于编写、评估和正确运行测试。

测试框架

虽然您可以开始使用自己的库编写测试,但很快就会发现这种方法不够可扩展,并且需要您构建一个复杂的基础设施。幸运的是,已经付出了相当大的努力来解决这个问题,这导致了几个流行的测试框架,允许您以结构化和通用的方式编写测试。这些测试框架通常提供一组方法来封装测试。测试框架通常还提供一些 API,使您能够运行测试并将结果与开发周期中的其他工具集成。

断言库

尽管测试框架为开发人员提供了一种创建和组织测试的方式,但它们通常缺乏实际测试表示测试结果的布尔表达式的能力。例如,Mocha 测试框架(我们将在下一节介绍)不提供开发人员断言工具。为此,社区开发了几个断言库,允许您检查特定的谓词。开发人员使用断言表达式来指示在测试上下文中应为真的谓词。运行测试时,将评估断言,如果结果为假,则测试失败。

测试运行器

测试运行器是一种实用工具,可以让开发人员轻松地运行和评估测试。测试运行器通常使用一个定义好的测试框架以及一组预配置的属性来在不同的上下文中评估测试结果。例如,测试运行器可以配置为在不同的环境变量下运行测试,或者在不同的测试平台(通常是浏览器)上运行相同的测试。我们将在测试您的 Angular 应用程序部分看到两种不同的测试运行器。

现在您已经了解了与测试相关的一组术语,最终可以学习如何测试您的 MEAN 应用程序的不同部分。尽管您的代码完全是用 JavaScript 编写的,但它在不同的平台上以不同的场景运行。为了简化测试过程,我将其分为两个不同的部分:测试 Express 组件和测试 Angular 组件。让我们从测试您的 Express 应用程序组件开始。

测试您的 Express 应用程序

在您的 MEAN 应用程序的 Express 部分中,您的业务逻辑主要封装在控制器中;但是,您还有 Mongoose 模型,它们模糊了许多任务,包括数据操作和验证。因此,为了正确覆盖 Express 应用程序代码,您需要编写覆盖模型和控制器的测试。为此,您将使用 Mocha 作为测试框架,Should.js作为模型的断言库,SuperTest HTTP作为控制器的断言库。您还需要创建一个新的测试环境配置文件,该文件将为您提供用于测试目的的特殊配置选项,例如专用的 MongoDB 连接字符串。在本节结束时,您将学会使用 Mocha 命令行工具来运行和评估测试结果。我们将从介绍 Mocha 测试框架开始。

介绍 Mocha

Mocha 是由 Express 的创始人 TJ Holowaychuk 开发的多功能测试框架。它支持 BDD 和 TDD 单元测试,使用 Node.js 运行测试,并允许开发人员运行同步和异步测试。由于 Mocha 的结构很简洁,它不包括内置的断言库;相反,它支持流行的断言框架的集成。它配备了一系列不同的报告器来呈现测试结果,并包括许多功能,如挂起测试、排除测试和跳过测试。与 Mocha 的主要交互是通过提供的命令行工具完成的,该工具允许您配置测试的执行和报告方式。

Mocha 测试的 BDD 接口包括几种描述性方法,使开发人员能够轻松描述测试场景。这些方法如下:

  • describe(description, callback): 这是一个基本方法,用于为每个测试套件添加描述。回调函数用于定义测试规范或子套件。

  • it(description, callback): 这是一个基本方法,用于为每个测试规范添加描述。回调函数用于定义实际的测试逻辑。

  • before(callback): 这是一个钩子函数,在测试套件中的所有测试之前执行一次。

  • beforeEach(callback): 这是一个钩子函数,在测试套件中的每个测试规范执行前执行一次。

  • after(callback): 这是一个钩子函数,在测试套件中的所有测试执行后执行一次。

  • afterEach(callback): 这是一个钩子函数,在测试套件中的每个测试规范执行后执行一次。

使用这些基本方法将允许您利用 BDD 范式定义单元测试。然而,没有包含确定开发人员对覆盖的代码的期望的断言表达式,没有测试可以得出结论。为了支持断言,您需要使用一个断言库。

注意

您可以通过访问官方文档了解更多关于 Mocha 的特性github.com/mochajs/mocha

介绍 Should.js

Should.js库也是由 TJ Holowaychuk 开发的,旨在帮助开发人员编写可读性强且表达力强的断言表达式。使用Should.js,您将能够更好地组织测试代码并生成有用的错误消息。Should.js库通过一个不可枚举的 getter 扩展了Object.prototype,允许您表达对象应该如何行为。Should.js的一个强大功能是每个断言都返回一个包装对象,因此可以链接断言。这意味着您可以编写可读的表达式,几乎描述了与被测试对象相关的断言。例如,链接的断言表达式如下所示:

user.should.be.an.Object.and.have.property('name', 'tj');

注意

请注意每个辅助属性如何返回一个 Should.js 对象,可以使用另一个辅助属性(beanhave等)链接,或者使用断言属性和方法(Objectproperty())进行测试。您可以通过阅读官方文档了解更多关于 Should.js 的功能:github.com/shouldjs/should.js

虽然 Should.js 在测试对象方面做得很好,但它无法帮助您测试 HTTP 端点。为此,您需要使用不同类型的断言库。这就是 Mocha 的最小模块化的地方派上用场。

介绍 SuperTest

SuperTest 是由 TJ Holowaychuk 开发的另一个断言库,与其他断言库不同之处在于它提供了一个抽象层,用于进行 HTTP 断言。这意味着它将帮助您创建断言表达式来测试 HTTP 端点,而不是测试对象。在您的情况下,它将帮助您测试控制器端点,从而覆盖暴露给浏览器的代码。为此,它将利用 Express 应用程序对象并测试从 Express 端点返回的响应。一个 SuperTest 断言表达式示例如下:

request(app).get('/user')
  .set('Accept', 'application/json')
  .expect('Content-Type', /json/)
  .expect(200, done);

注意

请注意每个方法如何可以链接到另一个断言表达式。这将允许您使用expect()方法对同一响应进行多个断言。您可以通过访问官方文档了解更多关于 SuperTest 的功能:github.com/visionmedia/supertest

在接下来的部分中,您将学习如何利用 Mocha、Should.js 和 SuperTest 来测试您的模型和控制器。让我们开始安装这些依赖项并正确配置测试环境。本章中的示例将直接从前几章中的示例继续,因此请复制第九章中的最终示例,使用 Socket.io 添加实时功能,然后从那里开始。

安装 Mocha

Mocha 基本上是一个 Node.js 模块,提供了运行测试的命令行功能。使用 Mocha 的最简单方法是首先将其作为全局 node 模块使用npm进行安装。为此,只需在命令行工具中输入以下命令:

$ npm install –g mocha

通常情况下,这将在全局node_modules文件夹中安装 Mocha 的最新版本。安装过程成功完成后,您将能够从命令行中使用 Mocha 实用程序。接下来,您需要在项目中安装 Should.js 和 SuperTest 断言库。

注意

您可能会在安装全局模块时遇到一些问题。这通常是一个权限问题,所以在运行全局安装命令时,请使用sudosuper user

安装 Should.js 和 SuperTest 模块

在开始编写测试之前,您需要使用npm安装Should.jsSuperTest。为此,请按照以下步骤更改项目的package.json文件:

{
  "name": "MEAN",
  "version": "0.0.10",
  "scripts": {
    "tsc": "tsc",
    "tsc:w": "tsc -w",
    "app": "node server",
    "start": "concurrently \"npm run tsc:w\" \"npm run app\" ",
    "postinstall": "typings install"
  },
  "dependencies": {
    "@angular/common": "2.1.1",
    "@angular/compiler": "2.1.1",
    "@angular/core": "2.1.1",
    "@angular/forms": "2.1.1",
    "@angular/http": "2.1.1",
    "@angular/platform-browser": "2.1.1",
    "@angular/platform-browser-dynamic": "2.1.1",
    "@angular/router": "3.1.1",
    "body-parser": "1.15.2",
    "core-js": "2.4.1",
    "compression": "1.6.0",
    "connect-flash": "0.1.1",
    "connect-mongo": "1.3.2",
    "cookie-parser": "1.4.3",
    "ejs": "2.5.2",
    "express": "4.14.0",
    "express-session": "1.14.1",
    "method-override": "2.3.6",
    "mongoose": "4.6.5",
    "morgan": "1.7.0",
    "passport": "0.3.2",
    "passport-facebook": "2.1.1",
    "passport-google-oauth": "1.0.0",
    "passport-local": "1.0.0",
    "passport-twitter": "1.0.4",
    "reflect-metadata": "0.1.8",
    "rxjs": "5.0.0-beta.12",
    "socket.io": "1.4.5",
    "systemjs": "0.19.39",
    "zone.js": "0.6.26"
  },
  "devDependencies": {
    "concurrently": "3.1.0",
 "should": "11.1.1",
 "supertest": "2.0.1",
    "traceur": "0.0.111",
    "typescript": "2.0.3",
    "typings": "1.4.0"
  }
}

要安装新的依赖项,请转到应用程序的根文件夹,并在命令行工具中输入以下命令:

$ npm install

这将在您项目的node modules文件夹中安装指定版本的Should.jsSuperTest。安装过程成功完成后,您将能够在测试中使用这些模块。接下来,您需要通过创建新的环境配置文件并设置测试环境来为测试准备项目。

配置您的测试环境

由于您将运行包括数据库操作的测试,因此最好使用不同的配置文件来运行测试。幸运的是,您的项目已经配置为根据NODE_ENV变量使用不同的配置文件。虽然应用程序在运行测试环境时会自动使用config/env/development.js文件,但我们将确保将NODE_ENV变量设置为测试。您需要做的就是在config/env文件夹中创建一个名为test.js的新配置文件。在这个新文件中,粘贴以下代码片段:

module.exports = {
 db: 'mongodb://localhost/mean-book-test',
  sessionSecret: 'Your Application Session Secret',
  viewEngine: 'ejs',
  facebook: {
    clientID: 'APP_ID',
    clientSecret: 'APP_SECRET',
    callbackURL: 'http://localhost:3000/oauth/facebook/callback'
  },
  twitter: 
  {
    clientID: 'APP_ID',
    clientSecret: 'APP_SECRET',
    callbackURL: 'http://localhost:3000/oauth/twitter/callback'
  },
  google: {
    clientID: 'APP_ID',
    clientSecret: 'APP_SECRET',
    callbackURL: 'http://localhost:3000/oauth/google/callback'
  }
};

正如您所注意到的,我们已经更改了db属性,以使用不同的 MongoDB 数据库。其他属性保持不变,但您可以稍后更改它们以测试应用程序的不同配置。

现在,您需要为测试文件创建一个新文件夹。要这样做,请转到您的应用程序文件夹并创建一个名为tests的新文件夹。设置环境完成后,您可以继续下一节并编写您的第一个测试。

编写您的第一个 Mocha 测试

在开始编写测试之前,您首先需要识别和分解 Express 应用程序的组件为可测试单元。由于大多数应用程序逻辑已经分为模型和控制器,显而易见的方法是分别测试每个模型和控制器。下一步将是将此组件分解为逻辑代码单元,并分别测试每个单元。例如,对控制器中的每个方法进行测试。当每个方法本身不执行任何重要操作时,您还可以决定一起测试控制器的一些方法。另一个例子是对 Mongoose 模型进行测试并测试每个模型方法。

在 BDD 中,每个测试都以自然语言描述测试目的开始。这是使用describe()方法完成的,它允许您定义测试场景的描述和功能。描述块可以嵌套,这使您能够进一步阐述每个测试。一旦您准备好测试的描述结构,您将能够使用it()方法定义测试规范。每个it()块将被测试框架视为单个单元测试。每个测试还将包括一个或多个断言表达式。断言表达式基本上将作为布尔测试指示器,用于测试您的测试假设。当断言表达式失败时,它通常会为测试框架提供可追踪的错误对象。

虽然这基本上解释了您将遇到的大多数测试,但您还可以使用支持方法,在测试上下文中执行某些功能。这些支持方法可以配置为在一组测试之前或之后运行,甚至可以在每个测试执行之前或之后运行。

在接下来的示例中,您将学习如何轻松使用每个方法来测试您在第八章中创建的文章模块,创建一个 MEAN CRUD 模块。为简单起见,我们将仅为每个组件实现一个基本的测试套件。这个测试套件可以和应该大大扩展,以最终提供体面的代码覆盖率。

注意

尽管 TDD 明确规定在开始编写功能代码之前应编写测试,但本书的结构迫使我们编写检查现有代码的测试。如果您希望在开发过程中实施真正的 TDD,您应该意识到开发周期应该从首先编写适当的测试开始。

测试 Express 模型

在模型的测试示例中,我们将编写两个测试,以验证模型的save方法。要开始测试您的Article Mongoose 模型,您需要在app/tests文件夹中创建一个名为article.server.model.tests.js的新文件。在新文件中,粘贴以下代码行:

const app = require('../../server.js');
const should = require('should');
const mongoose = require('mongoose');
const User = mongoose.model('User');
const Article = mongoose.model('Article');

let user, article;

describe('Article Model Unit Tests:', () => {
  beforeEach((done) => {
    user = new User({
      firstName: 'Full',
      lastName: 'Name',
      displayName: 'Full Name',
      email: 'test@test.com',
      username: 'username',
      password: 'password'
    });

    user.save(() => {
      article = new Article({
        title: 'Article Title',
        content: 'Article Content',
        user: user
      });

      done();
    });
  });

  describe('Testing the save method', () => {
 it('Should be able to save without problems', () => {
 article.save((err) => {
 should.not.exist(err);
 });
 });

 it('Should not be able to save an article without a title', () => {
 article.title = '';

 article.save((err) => {
 should.exist(err);
 });
 });
 });

  afterEach((done) => {
    Article.remove(() => {
      User.remove(() => {
        done();
      });
    });
  });
});

让我们开始分解测试代码。首先,您需要引入模块依赖项并定义全局变量。然后,您使用describe()方法开始测试,该方法通知测试工具将要检查Article模型。在describe块内,我们首先使用beforeEach()方法创建新的userarticle对象。beforeEach()方法用于定义在执行每个测试之前运行的代码块。您还可以用before()方法替换它,它只会在执行所有测试之前执行一次。注意beforeEach()方法如何通过调用done()回调通知测试框架可以继续执行测试。这将允许数据库操作在实际执行测试之前完成。

接下来,您创建了一个新的describe块,表明您将要测试模型保存方法。在这个块中,您使用it()方法创建了两个测试。第一个测试使用article对象保存了一篇新文章。然后,您使用Should.js断言库来验证没有发生错误。第二个测试通过将无效值赋给title属性来检查Article模型的验证。这次,使用Should.js断言库来验证在尝试保存无效的article对象时确实发生了错误。

您通过使用afterEach()方法清理ArticleUser集合来完成测试。与beforeEach()方法类似,这段代码将在每个测试执行后运行,并且也可以用after()方法替换。done()方法在这里也以相同的方式使用。

恭喜,您创建了您的第一个单元测试!正如我们之前所述,您可以继续扩展此测试套件,以覆盖更多模型代码,当处理更复杂的对象时,您可能会这样做。接下来,我们将看到在覆盖控制器代码时如何编写更高级的单元测试。

测试 Express 控制器

在控制器测试示例中,我们将编写两个测试来检查控制器检索文章的方法。在开始编写这些测试时,我们有两个选择:直接测试控制器的方法,或者在测试中使用定义的控制器 Express 路由。虽然最好是分别测试每个单元,但由于我们的路由定义非常简单,所以我们选择第二个选项,这样我们可以从编写更全面的测试中受益。

要开始测试文章控制器,您需要在app/tests文件夹中创建一个名为articles.server.controller.tests.js的新文件。在新文件中,粘贴以下代码片段:

const app = require('../../server');
const request = require('supertest');
const should = require('should');
const mongoose = require('mongoose');
const User = mongoose.model('User');
const Article = mongoose.model('Article');

let user, article;

describe('Articles Controller Unit Tests:', () => {
  beforeEach((done) => {
    user = new User({
      firstName: 'Full',
      lastName: 'Name',
      displayName: 'Full Name',
      email: 'test@test.com',
      username: 'username',
      password: 'password'
    });

    user.save(() => {
      article = new Article({
        title: 'Article Title',
        content: 'Article Content',
        user: user
      });

      article.save((err) => {
        done();
      });
    });
  });

  describe('Testing the GET methods', () => {
 it('Should be able to get the list of articles', (done) => {
 request(app).get('/api/articles/')
 .set('Accept', 'application/json')
 .expect('Content-Type', /json/)
 .expect(200)
 .end((err, res) => {
 res.body.should.be.an.Array().and.have.lengthOf(1);
 res.body[0].should.have.property('title', article.title);
 res.body[0].should.have.property('content',article.content);

 done();
 });
 });

 it('Should be able to get the specific article', (done) => {
 request(app).get('/api/articles/' + article.id)
 .set('Accept', 'application/json')
 .expect('Content-Type', /json/)
 .expect(200)
 .end((err, res) => {
 res.body.should.be.an.Object().and.have.property('title',article.title);
 res.body.should.have.property('content', article.content);

 done();
 });
 });
  });

  afterEach((done) => {
    Article.remove().exec();
    User.remove().exec();

    done();
  });
});

就像您的模型测试一样,首先需要引入模块依赖项并定义全局变量。然后,您使用describe()方法开始测试,该方法通知测试工具将要检查Articles控制器。在describe块内,我们首先使用beforeEach()方法创建新的userarticle对象。这次,在初始化测试之前保存了文章,然后通过调用done()回调继续测试执行。

接下来,您创建了一个新的describe块,表明您将要测试控制器的GET方法。在这个块中,您使用it()方法创建了两个测试。第一个测试使用SuperTest断言库在返回文章列表的端点发出 HTTP GET请求。然后检查 HTTP 响应变量,包括content-type头和 HTTP 响应代码。当它验证响应正确返回时,它使用三个Should.js断言表达式来测试响应主体。响应主体应该是包含一篇文章的文章数组,这篇文章应该类似于您在beforeEach()方法中创建的文章。

第二个测试使用SuperTest断言库在返回单个文章的端点发出 HTTP GET请求。然后,它检查 HTTP 响应变量,包括content-type头和 HTTP 响应代码。一旦验证响应正确返回,它使用三个Should.js断言表达式来测试响应主体。响应主体应该是一个单独的article对象,并且应该类似于您在beforeEach()方法中创建的文章。

就像以前一样,您可以通过使用afterEach()方法清理ArticleUser集合来完成测试。完成测试环境的设置和创建测试后,您所剩的就是使用 Mocha 的命令行工具运行它们。

运行您的 Mocha 测试

要运行您的 Mocha 测试,您需要使用先前安装的 Mocha 命令行实用程序。为此,请使用命令行工具并导航到项目的基本文件夹。然后,发出以下命令:

$ NODE_ENV=test mocha --reporter spec app/tests

Windows 用户应首先执行以下命令:

> set NODE_ENV=test

然后使用以下命令运行 Mocha:

> mocha --reporter spec app/tests

上述命令将执行一些操作。首先,它将将NODE_ENV变量设置为test,强制您的 MEAN 应用程序使用测试环境的配置文件。然后,它将使用--reporter标志执行 Mocha 命令行实用程序,告诉 Mocha 使用spec报告和您的测试文件夹的路径。测试结果应该在您的命令行工具中报告,并且类似于以下截图:

运行您的 Mocha 测试

Mocha 的测试结果

这结束了对 Express 应用程序的测试覆盖。您可以使用这些方法来扩展您的测试套件,并显着改进应用程序开发。建议您从开发过程的开始设置测试约定;否则,编写测试可能会成为一种令人不知所措的体验。接下来,您将学习如何测试您的 Angular 组件并编写 E2E 测试。

测试您的 Angular 应用程序

多年来,测试前端代码是一项复杂的任务。在不同浏览器和平台上运行测试是复杂的,由于大多数应用程序代码是无结构的,测试工具主要集中在 UI E2E 测试上。然而,向 MVC 框架的转变使社区能够创建更好的测试工具,改进了开发人员编写单元测试和 E2E 测试的方式。事实上,Angular 团队非常注重测试,团队开发的每个功能都是以可测试性为目标设计的。

此外,平台碎片化还创建了一个称为测试运行器的新工具层,允许开发人员轻松地在不同的上下文和平台上运行他们的测试。在本节中,我们将重点关注与 Angular 应用程序相关的工具和框架,解释如何最好地使用它们来编写和运行单元测试和 E2E 测试。我们将从将在两种情况下为我们提供服务的测试框架开始:Jasmine 测试框架。

注意

尽管我们可以使用 Mocha 或任何其他测试框架,但在测试 Angular 应用程序时,使用 Jasmine 目前是最简单和最常见的方法。

介绍 Jasmine 框架

Jasmine 是由 Pivotal 组织开发的一种有见地的 BDD 框架。方便的是,Jasmine 使用与 Mocha 的 BDD 接口相同的术语,包括describe()it()beforeEach()afterEach()方法。然而,与 Mocha 不同,Jasmine 预先捆绑了断言功能,使用与Matchers相关的expect()方法链接的断言方法。Matchers 基本上是实现实际对象和预期值之间的布尔比较的函数。例如,使用toBe()匹配器的简单测试如下:

describe('Matchers Example', function() {
  it('Should present the toBe matcher example', function() {
    var a = 1;
    var b = a;

    expect(a).toBe(b);
    expect(a).not.toBe(null);
  });
});

toBe()匹配器使用===运算符来比较对象。Jasmine 还包括许多其他匹配器,甚至使开发人员能够添加自定义匹配器。Jasmine 还包括其他强大的功能,以允许更高级的测试套件。在下一节中,我们将重点介绍如何使用 Jasmine 轻松测试您的 Angular 组件。

注意

您可以通过访问官方文档了解更多有关 Jasmine 功能的信息jasmine.github.io/2.5/introduction.html

Angular 单元测试

过去,想要编写单元测试以覆盖其前端代码的 Web 开发人员必须努力确定其测试范围并正确组织其测试套件。然而,Angular 中的关注点内在分离迫使开发人员编写独立的代码单元,使测试过程变得更加简单。开发人员现在可以快速识别他们需要测试的单元,因此组件、服务、指令和任何其他 Angular 实体都可以作为独立单元进行测试。此外,Angular 中广泛使用的依赖注入使开发人员能够切换上下文并轻松地使用广泛的测试套件覆盖其代码。但是,在开始为您的 Angular 应用程序编写测试之前,您首先需要准备好测试环境,从 Karma 测试运行器开始。

介绍 Karma 测试运行器

Karma 测试运行器是由 Angular 团队开发的实用工具,可帮助开发人员在不同的浏览器中执行测试。它通过启动一个运行源代码和测试代码的 Web 服务器在选定的浏览器上运行,将测试结果报告给命令行实用程序。Karma 为真实设备和浏览器提供真实的测试结果,为 IDE 和命令行提供流程控制,并提供与框架无关的可测试性。它还为开发人员提供了一组插件,使他们能够使用最流行的测试框架运行测试。团队还提供了称为浏览器启动器的特殊插件,使 Karma 能够在选定的浏览器上运行测试。

在我们的情况下,我们将使用 Jasmine 测试框架以及 PhantomJS 浏览器启动器。但是,测试真实应用程序将需要您扩展 Karma 的配置以包括更多的启动器,并在您打算支持的浏览器上执行测试。

注意

PhantomJS 是一个无头的 WebKit 浏览器,通常用于不需要视觉输出的可编程场景;这就是为什么它非常适用于测试目的。您可以通过访问官方文档了解更多关于 PhantomJS 的信息phantomjs.org/documentation/

安装 Karma 命令行工具

开始使用 Karma 的最简单方法是使用npm提供的命令行工具进行全局安装。要这样做,只需在命令行工具中输入以下命令:

$ npm install -g karma-cli

这将在全局node_modules文件夹中安装 Karma 命令行实用程序的最新版本。安装过程成功完成后,您将能够从命令行使用 Karma 实用程序。接下来,您需要安装 Karma 的项目依赖项。

注意

您可能会在安装全局模块时遇到一些问题。这通常是权限问题,因此在运行全局安装命令时,请使用sudo或超级用户。

安装 Karma 的依赖项

在您开始编写测试之前,您需要使用npm安装 Karma 的依赖项。要这样做,请按照以下步骤更改您的package.json文件:

{
  "name": "MEAN",
  "version": "0.0.10",
  "scripts": {
    "tsc": "tsc",
    "tsc:w": "tsc -w",
    "app": "node server",
    "start": "concurrently \"npm run tsc:w\" \"npm run app\" ",
    "postinstall": "typings install"
  },
  "dependencies": {
    "@angular/common": "2.1.1",
    "@angular/compiler": "2.1.1",
    "@angular/core": "2.1.1",
    "@angular/forms": "2.1.1",
    "@angular/http": "2.1.1",
    "@angular/platform-browser": "2.1.1",
    "@angular/platform-browser-dynamic": "2.1.1",
    "@angular/router": "3.1.1",
    "body-parser": "1.15.2",
    "core-js": "2.4.1",
    "compression": "1.6.0",
    "connect-flash": "0.1.1",
    "connect-mongo": "1.3.2",
    "cookie-parser": "1.4.3",
    "ejs": "2.5.2",
    "express": "4.14.0",
    "express-session": "1.14.1",
    "method-override": "2.3.6",
    "mongoose": "4.6.5",
    "morgan": "1.7.0",
    "passport": "0.3.2",
    "passport-facebook": "2.1.1",
    "passport-google-oauth": "1.0.0",
    "passport-local": "1.0.0",
    "passport-twitter": "1.0.4",
    "reflect-metadata": "0.1.8",
    "rxjs": "5.0.0-beta.12",
    "socket.io": "1.4.5",
    "systemjs": "0.19.39",
    "zone.js": "0.6.26"
  },
  "devDependencies": {
    "concurrently": "3.1.0",
 "jasmine": "2.5.2",
 "jasmine-core": "2.5.2",
 "karma": "1.3.0",
 "karma-jasmine": "1.0.2",
 "karma-phantomjs-launcher": "1.0.2",
    "should": "11.1.1",
    "supertest": "2.0.1",
    "traceur": "0.0.111",
    "typescript": "2.0.3",
    "typings": "1.4.0"
  }
}

如您所见,您已将 Karma 和 Jasmine 核心包、Karma 的 Jasmine 插件以及 Karma 的 PhantomJS 启动器添加到了devDependencies属性中。要安装新的依赖项,请转到应用程序的root文件夹,并在命令行工具中输入以下命令:

$ npm install

这将在你的项目的node_modules文件夹中安装指定版本的 Karma 核心包、Karma 的 Jasmine 插件和 Karma 的 PhantomJS 启动器。当安装过程成功完成时,你将能够使用这些模块来运行你的测试。接下来,你需要通过添加一个 Karma 配置文件来配置 Karma 的执行。

配置 Karma 测试运行器

为了控制 Karma 的测试执行,你需要使用一个特殊的配置文件来配置 Karma,该文件放置在应用程序的root文件夹中。当执行时,Karma 将自动查找默认配置文件,命名为karma.conf.js,位于应用程序的root文件夹中。你也可以使用命令行标志指示你的配置文件的名称,但出于简单起见,我们将使用默认文件名。要开始配置 Karma,在你的应用程序文件夹中创建一个新文件,并将其命名为karma.conf.js。在你的新文件中,粘贴以下代码片段:

module.exports = function(config) {
  config.set({
    basePath: '',
    frameworks: ['jasmine'],
    files: [
      'node_modules/systemjs/dist/system.js',
      'node_modules/systemjs/dist/system-polyfills.js',
      'node_modules/core-js/client/shim.min.js',
      'node_modules/reflect-metadata/Reflect.js',
      'node_modules/zone.js/dist/zone.js',
      'node_modules/zone.js/dist/long-stack-trace-zone.js',
      'node_modules/zone.js/dist/proxy.js',
      'node_modules/zone.js/dist/sync-test.js',
      'node_modules/zone.js/dist/jasmine-patch.js',
      'node_modules/zone.js/dist/async-test.js',
      'node_modules/zone.js/dist/fake-async-test.js',

      { pattern: 'public/systemjs.config.js', served: true,included: false, watched: false },
      { pattern: 'public/app/**/*.*', served: true, included:false, watched: false },
      { pattern: 'node_modules/rxjs/**/*.js', served: true,included: false, watched: false },
      { pattern: 'node_modules/@angular/**/*.js', served:true,included: false, watched: false },

      'karma.shim.js',
    ],
    proxies: {
      '/lib/': '/base/node_modules/',
      '/app/': '/base/public/app/',
    },
    reporters: ['progress'],
    browsers: ['PhantomJS'],
    captureTimeout: 60000,
    singleRun: true
  });
};

如你所见,Karma 的配置文件用于设置 Karma 执行测试的方式。在这种情况下,我们使用了以下设置:

  • basePath:这告诉 Karma 使用空的基本路径。

  • frameworks:这告诉 Karma 使用 Jasmine 框架。

  • files:这设置了 Karma 将包含在其测试中的文件列表。请注意,你可以使用 glob 模式来指示文件模式。在这种情况下,我们包括了所有的库文件和模块文件,但不包括我们的测试文件。此外,我们配置了我们的应用程序和库文件,以便它们由 Karma 服务器提供,即使它们并没有直接包含在页面中。

  • reporters:这设置了 Karma 报告其测试结果的方式。

  • browsers:这是 Karma 将在其上测试的浏览器列表。请注意,由于我们没有安装其他启动器插件,所以我们只能使用 PhantomJS 浏览器。

  • captureTimeout:这设置了 Karma 测试执行的超时时间。

  • singleRun:这强制 Karma 在完成测试执行后退出。

这些属性是项目导向的,这意味着它们将根据你的需求而改变。例如,在实际应用中,你可能会包含更多的浏览器启动器。

注意

你可以通过访问官方文档了解更多关于 Karma 配置的信息karma-runner.github.io/1.0/config/configuration-file.html

我们还有两件事要做,以完成我们的 Karma 配置。我们将首先修改System.js配置。为此,转到你的public/systemjs.config.js文件,并按以下方式更改它:

(function(global) {
  var packages = {
    app: {
        main: './bootstrap.js',
        defaultExtension: 'js'
      }
  };

  var map = {
    '@angular': 'lib/@angular',
      'rxjs': 'lib/rxjs'
  };

  var ngPackageNames = [
    'common',
    'compiler',
    'core',
    'forms',
    'http',
    'router',
    'platform-browser',
    'platform-browser-dynamic',
  ];

  ngPackageNames.forEach(function(pkgName) {
    packages['@angular/' + pkgName] = { main: '/bundles/' +pkgName + '.umd.js', defaultExtension: 'js' };
 map['@angular/' + pkgName + '/testing'] = 'lib/@angular/' + pkgName + '/bundles/' + pkgName + '-testing.umd.js';
  });

  System.config({
    defaultJSExtensions: true,
    transpiler: null,
    packages: packages,
    map: map
  });
})(this);

正如你所看到的,我们只告诉System.js将我们的 Angular 测试模块映射到正确的UMD模块文件。接下来,我们需要创建我们的 karma“shim”文件,实际上加载我们的测试。为此,在应用程序的root文件夹中创建一个名为karma.shim.js的新文件。在你的新文件中,粘贴以下代码:

__karma__.loaded = function () { };

System.import('/base/public/systemjs.config.js').then(loadTests);

function loadTests() {
  Promise.all([
    System.import('app/bootstrap.spec'),
    System.import('app/articles/articles.service.spec'),
    System.import('app/articles/list/list.component.spec'),
    System.import('app/app.routes.spec'),
    System.import('app/directive.spec'),
    System.import('app/pipe.spec')
  ]).then(__karma__.start, __karma__.error);
}

如你所见,我们的文件基本上通过覆盖加载钩子来阻止 Karma 在启动时自动运行测试。然后,它加载System.js配置文件并导入我们的测试文件。一旦加载了所有文件,它告诉 Karma 通过调用其 start hook 来运行测试。就是这样!我们唯一剩下的事情就是开始编写我们的测试。

编写 Angular 单元测试

一旦配置了测试环境,编写单元测试就变得很容易。虽然一般结构是相同的,但每个实体测试都有些不同,并涉及微妙的变化。在本节中,你将学习如何测试主要的 Angular 实体。让我们从测试一个组件开始。

测试组件

测试一个组件的复杂程度可能会有所不同。简单的组件测试起来相当容易,而更复杂的组件可能会有些棘手。一个很好的中间例子是测试我们的文章列表组件,因为它使用了一个服务,并为我们的文章呈现了一个简单的 DOM。要测试您的组件,请转到public/app/articles/list文件夹,并创建一个名为list.component.spec.ts的文件。在新文件中,粘贴以下代码:

import { Observable } from "rxjs/Rx";
import { Directive, Input }   from '@angular/core';
import { ComponentFixture, TestBed, async, fakeAsync } from '@angular/core/testing';
import { ArticlesService } from '../articles.service';
import { ListComponent } from './list.component';

class MockArticlesService {
  articles = [{
    _id: '12345678',
    title: 'An Article about MEAN',
    content: 'MEAN rocks!',
    created: new Date(),
    creator: {
      fullName: 'John Doe'
    }
  }];

  public list() {
    return Observable.of(this.articles);
  }
};

@Directive({
  selector: '[routerLink]',
  host: {
    '(click)': 'onClick()'
  }
})
export class RouterLinkStubDirective {
  @Input('routerLink') linkParams: any;
  navigatedTo: any = null;

  onClick() {
    this.navigatedTo = this.linkParams;
  }
}

describe('List component tests', () => {
  let componentFixture: ComponentFixture<ListComponent>;

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [ ListComponent, RouterLinkStubDirective ],
      providers:    [ {provide: ArticlesService, useClass: MockArticlesService } ]
    }).compileComponents();
  }));

    beforeEach(fakeAsync(() => {
        componentFixture = TestBed.createComponent(ListComponent);
    }));

 it('Should render list', () => {
 componentFixture.detectChanges();

 const mockArticleService = new MockArticlesService();
 const listComponentElement = componentFixture.nativeElement;

 const articleElements = listComponentElement.querySelectorAll('li');
 const articleElement = articleElements[0];
 const articleTitleElement = articleElement.querySelector('a');
 const articleContentElement = articleElement.querySelector('p');

 const mockArticleList = mockArticleService.articles;
 const mockArticle = mockArticleList[0];
 const mockArticleTitle = mockArticle.title;
 const mockArticleContent = mockArticle.content;

 expect(articleElements.length).toBe(mockArticleList.length);

 expect(articleTitleElement.innerHTML).toBe(mockArticleTitle);

 expect(articleContentElement.innerHTML).toBe(mockArticleContent);
 });
});

让我们来看看这个例子。我们首先导入所有测试所需的模块。接下来,我们创建MockArticlesService,它将替换我们的ArticlesService,以便为ListComponent提供一组文章。这是一个重要的步骤,因为在编写单元测试时,尽可能地隔离每个单元非常重要。在这种情况下,我们希望避免与真实的ArticlesService建立任何连接,因此我们将为我们的组件提供一个静态数据源。然后,我们创建一个模拟的routerLink,以便我们的组件测试可以呈现我们的链接。

接下来,我们使用describe关键字创建我们的测试套件,并使用 Angular 的TestBed对象来配置我们的测试模块。我们使用configureTestingModule方法提供我们模块中需要的声明和提供者,然后再次使用TestBed对象来创建我们的ListComponent的组件装置。然后我们使用it关键字创建我们的测试,并使用组件装置来获取我们的ListComponent的原生元素,这样我们就能够使用 Jasmine 的匹配器与MockArticlesService的数据进行比较。就是这样!接下来,我们将看到如何测试服务,但在我们这样做之前,我们需要学习如何模拟后端数据服务。

模拟后端数据

在测试 Angular 应用程序时,建议单元测试快速执行,并与后端服务器分开。这是因为我们希望单元测试尽可能地独立并以同步方式工作。这意味着我们需要控制依赖注入过程,并提供模拟组件来模拟真实组件的操作。例如,大多数与后端服务器通信的组件通常使用http服务或某种抽象层。此外,Http服务使用XHRBackend服务向服务器发送请求。这意味着通过注入不同的后端服务,我们可以发送不会命中真实服务器的假 HTTP 请求。正如我们之前所述,Angular 团队非常致力于测试,因此他们已经为我们创建了这些工具,以MockBackend类的形式提供。MockBackend类允许开发人员定义对 HTTP 请求的模拟响应。这个类可以被注入到任何使用Http服务的服务中,并配置为提供预定义数据的 HTTP 请求。让我们看看如何使用它来测试我们的ArticlesService

测试服务

测试服务与测试组件有些不同。正如我们之前讨论的,我们需要使用MockBackend类来模拟我们的服务 HTTP 请求。让我们看看如何在ArticlesService中应用这个方法。要创建一个服务的示例测试套件,请转到public/app/articles文件夹,并创建一个名为articles.service.spec.ts的文件。在新文件中,粘贴以下代码:

import { async, inject, TestBed } from '@angular/core/testing';
import { MockBackend, MockConnection } from '@angular/http/testing';
import { HttpModule, Http, XHRBackend, Response, ResponseOptions } from '@angular/http';
import { ArticlesService } from './articles.service';

let backend: MockBackend;
let service: ArticlesService;

const mockArticle = {
  title: 'An Article about MEAN',
  content: 'MEAN rocks!',
  creator: {
    fullName: 'John Doe'
  }
};

describe('Articles service tests', () => {
  beforeEach(async(() => {
    TestBed.configureTestingModule({
      imports: [ HttpModule ],
      providers: [
        ArticlesService,
 { provide: XHRBackend, useClass: MockBackend }
      ]
    })
    .compileComponents();
  }));

 beforeEach(inject([Http, XHRBackend], (_http: Http, _mockBackend: MockBackend) => {
 backend = _mockBackend;
 service = new ArticlesService(_http);
 }));

 it('Should create a single article', done => {
 const options = new ResponseOptions({ status: 200, body: mockArticle });
 const response = new Response(options);

 backend.connections.subscribe((connection: MockConnection) => connection.mockRespond(response));

 service.create(mockArticle).do(article => {
 expect(article).toBeDefined();

 expect(article.title).toEqual(mockArticle.title);
 expect(article.content).toEqual(mockArticle.content);

 done();
 }).toPromise();
 }); 
});

让我们来看看这个例子。我们首先导入所有必要的模块进行测试。接下来,我们使用describe关键字创建我们的测试套件,并利用 Angular 的TestBed对象来配置我们的测试模块。我们使用configureTestingModule方法提供ArticlesService提供程序和MockBackend作为我们的XHRBackend提供程序。然后我们将其与 HTTP 服务一起注入并创建我们的ArticlesService的实例。在我们的实际测试中,我们创建一个模拟响应,并告诉我们的MockBackend实例通过订阅其连接来响应我们的模拟响应。我们通过调用ArticlesServicecreate方法并期望它响应我们的模拟文章实例属性来完成我们的测试。就是这样!我们刚刚测试了ArticlesService的一个方法;要完成测试套件,您还需要测试其他方法。接下来,我们将学习如何测试我们的 Angular 路由定义。

测试路由

要测试我们的路由,我们需要确保我们的路由器能够导航到我们应用程序的 URL。在我们的情况下,我们可以测试在我们的AppComponent中创建的路由。要这样做,转到您的public/app文件夹并创建一个名为app.routes.spec.ts的文件。在新文件中,粘贴以下代码:

import { async, fakeAsync, TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { SpyLocation } from '@angular/common/testing';
import { Location } from '@angular/common';
import { AppModule } from './app.module';
import { AppComponent } from './app.component';

let router: Router;
let location: SpyLocation;

describe('AppComponent Routing', () => {
  beforeEach(async(() => {
    TestBed.configureTestingModule({
      imports: [ AppModule, RouterTestingModule ]
    }).compileComponents();
  }));

  beforeEach(fakeAsync(() => {
    const injector = TestBed.createComponent(AppComponent).debugElement.injector;
    location = injector.get(Location); 
  }));

 it('Should navigate to home', fakeAsync(() => {
 location.go('/');
 expect(location.path()).toEqual('/');
 }));

 it('Should navigate to signin', fakeAsync(() => {
 location.go('/authentication/signin');
 expect(location.path()).toEqual('/authentication/signin');
 }));

 it('Should navigate to signup', fakeAsync(() => {
 location.go('/authentication/signup');
 expect(location.path()).toEqual('/authentication/signup');
 }));
});

正如您所注意到的,测试路由非常简单。我们只是使用 Angular 的TestBed对象来创建我们的测试模块并导入RouterTestingModule。接下来,我们使用我们的组件注入器来获取location实例。在我们的测试中,我们只是使用location.go方法并检查位置路径是否相应地更改。接下来,我们将学习如何为指令编写单元测试。

测试指令

在 Angular 2 中测试指令基本上是测试结构和属性指令影响 DOM 的方式。例如,要测试ngIf指令,您可以转到您的public/app文件夹并创建一个名为directive.spec.ts的文件。在新文件中,粘贴以下代码:

import { Component }   from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';

@Component({ 
  template: 
  `<ul>
    <li *ngIf="shouldShow" name="One">1</li>
    <li *ngIf="!shouldShow" name="Two">2</li>
  </ul>`
})
class TestComponent {  
  shouldShow = true
}

describe('ngIf tests', () => {
  let componentFixture: ComponentFixture<TestComponent>;

  beforeEach(() => {
    componentFixture = TestBed.configureTestingModule({
      declarations: [TestComponent]
    }).createComponent(TestComponent);
  });  

 it('It should render the list properly', () => {
 componentFixture.detectChanges(); 

 let listItems = componentFixture.debugElement.queryAll(By.css('li'));
 expect(listItems.length).toBe(1);
 expect(listItems[0].attributes['name']).toBe('One');
 });

 it('It should rerender the list properly', () => {
 componentFixture.componentInstance.shouldShow = false;
 componentFixture.detectChanges();

 let listItems = componentFixture.debugElement.queryAll(By.css('li'));
 expect(listItems.length).toBe(1);
 expect(listItems[0].attributes['name']).toBe('Two');
 });
});

注意我们为指令创建了一个TestComponent,然后使用TestBed实用程序生成我们的组件实例,并测试ngIf指令改变 DOM 渲染的方式。

测试管道

与指令一样,我们还没有涉及管道的主题。然而,管道是 Angular 的一个非常简单但功能强大的组件,它可以帮助我们轻松地将数据转换为可读格式。Angular 的管道功能各不相同,从简单的大小写转换到日期和国际化,但最重要的是,您可以编写自己的管道。测试管道非常容易,因为您只需实例化Pipe类并为其提供输入和预期输出。例如,要测试 Angular 的LowerCasePipe类,您需要转到public/app文件夹并创建一个名为pipe.spec.ts的文件。在新文件中,粘贴以下代码:

import { LowerCasePipe } from '@angular/common';

describe('LowerCasePipe tests', () => {
  let pipe = new LowerCasePipe();

 it('should capitalise', () => {
 expect(pipe.transform('MEAN')).toEqual('mean');
 });
});

正如您所注意到的,我们只是导入了LowerCasePipe类,并使用其 transform 方法来检查其功能。

现在您已经有了一些单元测试,让我们看看如何使用 Karma 的命令行实用程序来执行它。

运行您的 Angular 单元测试

要运行您的 Angular 测试,您需要使用之前安装的 Karma 的命令行实用程序。在您完成测试设置之前,我们需要完成我们的测试设置。要这样做,转到您的public/app文件夹并创建一个名为bootstrap.spec.ts的文件。在新文件中,粘贴以下代码:

import { TestBed } from '@angular/core/testing';
import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing';

TestBed.initTestEnvironment(
  BrowserDynamicTestingModule,
  platformBrowserDynamicTesting()
);

这将为我们设置适当的平台模块的测试环境。现在您只需要转到项目的基本文件夹,然后发出以下命令:

$ npm run tsc

这将编译您的 TypeScript 文件,因此您现在可以使用以下命令运行 Karma:

$ NODE_ENV=test karma start

Windows 用户应首先执行以下命令:

> set NODE_ENV=test

然后,使用以下命令运行您的测试:

> karma start

上述命令将执行一些操作。首先,它将将NODE_ENV变量设置为test,强制您的 MEAN 应用程序使用测试环境配置文件。然后,它将执行 Karma 命令行实用程序。测试结果应该报告在您的命令行工具中,类似于以下屏幕截图:

运行您的 Angular 单元测试

Karma 的测试结果

这结束了您的 Angular 应用程序的单元测试覆盖范围。建议您使用这些方法来扩展您的测试套件并包含更多测试。在下一小节中,您将了解有关 Angular 端到端测试以及如何编写和运行跨应用程序端到端测试。

Angular 端到端测试

虽然单元测试作为保持应用程序覆盖的第一层,但有时需要编写涉及多个组件一起与某个接口交互的测试。Angular 团队经常将这些测试称为端到端测试。

为了更好地理解这一点,让我们假设 Bob 是一位优秀的前端开发人员,他保持他的 Angular 代码经过了充分的测试。Alice 也是一位优秀的开发人员,但她负责后端代码,确保她的 Express 控制器和模型都得到了覆盖。理论上,这两个人的团队做得很好,但当他们完成他们的 MEAN 应用程序的登录功能的编写时,他们突然发现它失败了。当他们深入挖掘时,他们发现 Bob 的代码发送了一个特定的JSON对象,而 Alice 的后端控制器期望一个略有不同的JSON对象。事实是,他们两个都做了自己的工作,但代码仍然失败了。你可能会说这是团队领导的错,但我们都曾经历过这种情况,虽然这只是一个小例子,但现代应用程序往往变得非常复杂。这意味着你不能只信任手动测试甚至单元测试。您需要找到一种方法来测试整个应用程序的功能,这就是端到端测试如此重要的原因。

介绍 Protractor 测试运行器

要执行端到端测试,您将需要一些模拟用户行为的工具。过去,Angular 团队推荐了一个称为Angular 场景测试运行器的工具。然而,他们决定放弃这个工具,并创建一个名为Protractor的新测试运行器。Protractor 是一个专门的端到端测试运行器,模拟人类交互并使用 Jasmine 测试框架运行测试。它基本上是一个使用名为WebDriver的不错的库的 Node.js 工具。WebDriver 是一个开源实用程序,允许对 Web 浏览器的行为进行可编程控制。正如我所说,Protractor 默认使用 Jasmine,因此测试看起来会非常类似于您之前编写的单元测试,但 Protractor 还为您提供了几个全局对象,如下所示:

  • browser:这是一个WebDriver实例包装器,允许您与浏览器通信。

  • element:这是一个辅助函数,用于操作 HTML 元素。

  • by:这是一组元素定位器函数。您可以使用它通过 CSS 选择器、其 ID 甚至通过其绑定到的模型属性来查找元素。

  • protractor:这是一个WebDriver命名空间包装器,包含一组静态类和变量。

使用这些实用程序,您将能够在测试规范中执行浏览器操作。例如,browser.get()方法将为您加载一个页面以进行测试。重要的是要记住,Protractor 是 Angular 应用程序的专用工具,因此如果它尝试加载的页面不包括 Angular 库,browser.get()方法将抛出错误。您将很快编写您的第一个端到端测试,但首先让我们安装 Protractor。

注意

Protractor 是一种比较年轻的工具,因此事情很可能会迅速发生变化。建议您通过访问官方存储库页面github.com/angular/protractor来了解更多关于 Protractor 的信息。

安装 Protractor 测试运行器

Protractor 是一个命令行工具,因此您需要使用npm全局安装它。只需在命令行工具中发出以下命令即可:

$ npm install -g protractor

这将在全局node_modules文件夹中安装最新版本的 Protractor 命令行实用程序。安装过程成功完成后,您将能够从命令行中使用 Protractor。

注意

在安装全局模块时可能会遇到一些问题。这通常是一个权限问题,所以在运行全局安装命令时使用sudosuper user

由于 Protractor 将需要一个可用的 WebDriver 服务器,您将需要使用 Selenium 服务器或安装一个独立的 WebDriver 服务器。您可以通过在命令行工具中发出以下命令来下载并安装一个独立的服务器:

$ webdriver-manager update

这将安装 Selenium 独立服务器,您稍后将用它来处理 Protractor 的测试。下一步是配置 Protractor 的执行选项。

注意

您可以通过访问官方项目页面www.seleniumhq.org/了解更多关于 WebDriver 的信息。

配置 Protractor 测试运行器

为了控制 Protractor 的测试执行,您需要在应用程序的root文件夹中创建一个 Protractor 配置文件。执行时,Protractor 将自动在应用程序的root文件夹中查找名为protractor.conf.js的配置文件。您也可以使用命令行标志指定配置文件名,但出于简单起见,我们将使用默认文件名。因此,请在应用程序的root文件夹中创建一个名为protractor.conf.js的新文件。在您的新文件中,粘贴以下代码行:

exports.config = {
  specs: ['public/tests/**/e2e/*.js'],
  useAllAngular2AppRoots: true
}

我们的 Protractor 配置文件非常基本。specs属性基本上告诉 Protractor 在哪里找到测试文件,useAllAngular2AppRoots属性告诉 Protractor 遍历页面中所有可用的 Angular 应用程序。这个配置是面向项目的,这意味着它会根据您的需求而改变。

注意

您可以通过查看示例配置文件github.com/angular/protractor/blob/master/lib/config.ts了解更多关于 Protractor 配置的信息。

编写您的第一个端到端测试

由于端到端测试编写和阅读起来相当复杂,我们将从一个简单的例子开始。在我们的例子中,我们将测试创建文章页面,并尝试创建一篇新文章。由于我们没有先登录,应该会出现错误并呈现给用户。要实现这个测试,转到您的public/tests文件夹,并创建一个名为e2e的新文件夹,在这个文件夹内,创建一个名为articles的新文件夹。在articles文件夹内,创建一个名为articles.client.e2e.tests.js的新文件。最后,在您的新文件中,粘贴以下代码片段:

describe('Articles E2E Tests:', function() {
  describe('New Article Page', function() {
    it('Should not be able to create a new article', function() {
      browser.get('http://localhost:3000/#!/articles/create');
      element(by.css('input[type=submit]')).click();
      element(by.id('error')).getText().then(function(errorText) {
        expect(errorText).toBe('User is not logged in');
      });
    });
  });
});

一般的测试结构应该已经很熟悉了;然而,测试本身是非常不同的。我们首先使用browser.get()方法请求创建文章页面。然后,我们使用element()by.css()方法提交表单。最后,我们使用by.id()找到错误消息元素并验证错误文本。虽然这是一个简单的例子,但它很好地说明了端到端测试的工作方式。接下来我们将使用 Protractor 来运行这个测试。

运行您的 Angular 端到端测试

运行 Protractor 与使用 Karma 和 Mocha 有些不同。Protractor 需要您的应用程序运行,以便它可以像真实用户一样访问它。因此,让我们从运行应用程序开始;转到应用程序的root文件夹,并使用命令行工具启动 MEAN 应用程序,如下所示:

$ NODE_ENV=test npm start

Windows 用户应首先执行以下命令:

> set NODE_ENV=test

然后,使用以下命令运行您的应用程序:

> npm start

这将使用测试环境的配置文件启动您的 MEAN 应用程序。现在,打开一个新的命令行窗口,并导航到您的应用程序的root文件夹。然后,通过发出以下命令启动 Protractor 测试运行器:

$ protractor

Protractor 应该在命令行窗口中运行您的测试并报告结果,如下面的屏幕截图所示:

运行您的 Angular E2E 测试

Protractor 的测试结果

恭喜!您现在知道如何用 E2E 测试覆盖您的应用程序代码。建议您使用这些方法来扩展您的测试套件并包括广泛的 E2E 测试。

摘要

在本章中,您学习了如何测试您的 MEAN 应用程序。您了解了一般的测试和常见的 TDD/BDD 测试范式。然后,您使用了 Mocha 测试框架,并创建了控制器和模型单元测试,其中您使用了不同的断言库。然后,我们讨论了测试 Angular 的方法,您了解了单元测试和 E2E 测试之间的区别。然后,我们使用 Jasmine 测试框架和 Karma 测试运行器对您的 Angular 应用程序进行了单元测试。然后,您学习了如何使用 Protractor 创建和运行 E2E 测试。一旦您构建并测试了您的实时 MEAN 应用程序,在下一章中,您将学习如何使用一些流行的自动化工具来提高您的开发周期时间。

第十一章:自动化和调试 MEAN 应用程序

在之前的章节中,您学会了如何构建和测试您的实时 MEAN 应用程序。您学会了如何连接所有的 MEAN 组件,以及如何使用测试框架来测试您的应用程序。虽然您可以继续使用前几章中使用的相同方法来开发您的应用程序,但您也可以通过使用支持性工具和框架来加快开发周期。这些工具将通过自动化和抽象为您提供一个稳固的开发环境。在本章中,您将学习如何使用不同的社区工具来加快您的 MEAN 应用程序的开发。我们将涵盖以下主题:

  • 使用 NPM 脚本

  • 介绍 Webpack

  • 介绍 ESLint

  • 介绍 Nodemon

  • 使用 V8 检查器调试您的 Express 应用程序

  • 使用 NPM 脚本

使用 NPM 脚本

正如您可能已经注意到的,开发我们的应用程序涉及同时执行多个任务。例如,为了运行我们的应用程序,我们需要转译我们的 Angular 文件,然后运行我们的 Express 应用程序。这种模式将重复并变得更加复杂。为了解决这个问题,开发人员倾向于自动化一些应用功能,并使用支持性工具来加快他们的工作。一些开发人员喜欢使用第三方工具,比如 Grunt 或 Gulp,也被称为任务运行器;然而,我们已经使用了一个允许我们运行脚本的工具,一个叫做 NPM 的工具。为了更好地理解这一点,请查看您的package.json文件的scripts属性:

...
"scripts": {
  "tsc": "tsc",
  "tsc:w": "tsc -w",
  "app": "node server",
  "start": "concurrently \"npm run tsc:w\" \"npm run app\" ",
  "postinstall": "typings install",
},
...

正如您所看到的,您已经有了五个脚本来管理您的应用程序开发。在接下来的章节中,我们将学习如何添加更多脚本,以及如何使用这个 NPM 功能来帮助您自动化您的日常工作。我们将从 Webpack 模块打包工具开始。

介绍 Webpack

Webpack 是由 Tobias Koppers 创建的流行模块打包工具。它已经占领了 JavaScript 世界,并成为我们生态系统中最常用的工具之一。作为其他模块打包工具的替代品,比如 SystemJS(我们到目前为止使用的),它有一个非常简单的动机:简化代码打包,将大型应用模块化,并进行代码拆分。然而,在经过几年的积极开发后,它现在可以做的更多,包括资源打包、预处理和优化等功能。在我们简单的介绍中,我们将学习如何简单地替换 SystemJS 来打包和加载我们的 Angular 模块。

注意

强烈建议您通过访问官方项目页面webpack.github.io/来了解更多关于 Webpack 的信息。

安装 Webpack

在我们开始配置我们的 Webpack 实现之前,我们需要使用npm安装 Webpack 的依赖项。为此,请按照以下方式更改您的package.json文件:

{
  "name": "MEAN",
  "version": "0.0.11",
  "scripts": {
    "tsc": "tsc",
    "tsc:w": "tsc -w",
    "app": "node server",
 "start": "concurrently \"npm run webpack\" \"npm run app\" ",
    "postinstall": "typings install",
 "webpack": "webpack --watch"
  },
  "dependencies": {
    "@angular/common": "2.1.1",
    "@angular/compiler": "2.1.1",
    "@angular/core": "2.1.1",
    "@angular/forms": "2.1.1",
    "@angular/http": "2.1.1",
    "@angular/platform-browser": "2.1.1",
    "@angular/platform-browser-dynamic": "2.1.1",
    "@angular/router": "3.1.1",
    "body-parser": "1.15.2",
    "core-js": "2.4.1",
    "compression": "~1.6.0",
    "connect-flash": "0.1.1",
    "connect-mongo": "1.3.2",
    "cookie-parser": "1.4.3",
    "ejs": "2.5.2",
    "es6-promise": "4.0.5",
    "express": "4.14.0",
    "express-session": "1.14.1",
    "method-override": "2.3.6",
    "mongoose": "4.6.5",
    "morgan": "1.7.0",
    "passport": "0.3.2",
    "passport-facebook": "2.1.1",
    "passport-google-oauth": "1.0.0",
    "passport-local": "1.0.0",
    "passport-twitter": "1.0.4",
    "phantomjs-prebuilt": "2.1.13",
    "reflect-metadata": "0.1.8",
    "rxjs": "5.0.0-beta.12",
    "socket.io": "1.4.5",
    "systemjs": "0.19.39",
    "zone.js": "0.6.26"
  },
  "devDependencies": {
 "awesome-typescript-loader": "2.2.4",
    "concurrently": "3.1.0",
    "jasmine": "2.5.2",
    "jasmine-core": "2.5.2",
    "karma": "1.3.0",
    "karma-jasmine": "1.0.2",
    "karma-phantomjs-launcher": "1.0.2",
    "should": "11.1.1",
    "supertest": "2.0.1",
    "traceur": "0.0.111",
    "typescript": "2.0.3",
    "typings": "1.4.0",
 "webpack": "1.13.3"
  }
}

如您所见,您已经将 Webpack 和 TypeScript 加载器添加到了您的devDependencies属性中。我们还添加了一个 Webpack 脚本,以便以“监视”模式运行 Webpack,这样我们文件的每次更改都会自动更新。然后,我们将我们的 NPM 启动脚本更改为使用 Webpack,而不是使用 TypeScript 命令行工具转译我们的 Angular 文件。要安装您的新依赖项,请转到您的应用程序的根文件夹,并在命令行工具中输入以下命令:

$ npm install

这将在您的项目的node_modules文件夹中安装指定版本的 Webpack 和 TypeScript 加载器。当安装过程成功完成后,您将能够使用这些模块来运行 Webpack 并打包您的 TypeScript 模块。接下来,您将学习如何配置 Webpack。

配置 Webpack

为了控制 Webpack 的执行,您需要使用一个特殊的配置文件来配置 Webpack,该文件放置在应用程序的根文件夹中。当执行时,Webpack 将自动查找应用程序根文件夹中名为webpack.config.js的默认配置文件。您也可以使用命令行标志指示您的配置文件名称,但为简单起见,我们将使用默认文件名。要开始配置 Webpack,创建一个新文件在您的应用程序根文件夹中,并将其命名为webpack.config.js。在您的新文件中,粘贴以下代码片段:

const webpack = require('webpack');

module.exports = {
  entry: {
    'polyfills': './public/polyfills',
    'vendor': './public/vendor',
    'bootstrap': './public/bootstrap'
  },
  devtool: 'source-map',
  resolve: {
    extensions: ['', '.js', '.ts']
  },
  output: {
    path: 'public/build',
    filename: '[name].js',
  },
  module: {
    loaders: [
      {
        test: /\.ts$/,
        loaders: ['awesome-typescript-loader']
      }
    ]
  },
  plugins: [
    new webpack.optimize.CommonsChunkPlugin({
      name: ['bootstrap', 'vendor', 'polyfills']
    })
  ]
};

正如您所看到的,Webpack 的配置文件用于设置 Webpack 构建我们的模块的方式。在这种情况下,我们使用了以下设置:

  • entry:这告诉 Webpack 我们应用程序的入口点是什么。如果您不认识这些文件,不要担心;我们将在下一步中创建它们。您需要理解的是,我们将我们的应用程序捆绑成三个不同的文件:我们的 polyfills 文件,其中将包括所有与 polyfills 相关的模块,我们的 vendor 文件,其中将包括所有第三方模块,如 Angular 核心模块,以及我们的应用程序文件,其中将包括我们的 Angular 应用程序文件。

  • devtool:这告诉 Webpack 要使用哪种开发工具;在这种情况下,我们希望 Webpack 为转译后的应用程序文件创建映射文件。

  • resolve:这告诉 Webpack 要解析什么样的模块扩展名;在这种情况下,它将包括没有扩展名的模块导入,TypeScript 和 JavaScript 文件。

  • output:这设置了 Webpack 保存输出文件的方式。在这里,我们告诉它我们要在public/build文件夹中创建捆绑文件,并使用 JavaScript 文件扩展名。

  • module:这是 Webpack 将使用的模块列表。在我们的情况下,我们告诉 Webpack 使用我们之前安装的 TypeScript 加载器加载所有 TypeScript 文件。

  • optimize:这设置了 Webpack 优化模块捆绑的方式。在我们的情况下,我们希望 Webpack 只捆绑每个模块一次。这意味着如果 Webpack 在 Bootstrap 文件和 Vendor 文件中找到一个常见的模块导入,它将只在 vendor 文件中捆绑一次。

请注意,这些属性是面向项目的,这意味着它将根据您的要求进行更改。我们将继续创建我们缺少的文件。首先,转到您的public文件夹并创建一个名为polyfills.ts的文件。在这个文件中,粘贴以下代码:

import 'core-js/es6/symbol';
import 'core-js/es6/object';
import 'core-js/es6/function';
import 'core-js/es6/parse-int';
import 'core-js/es6/parse-float';
import 'core-js/es6/number';
import 'core-js/es6/math';
import 'core-js/es6/string';
import 'core-js/es6/date';
import 'core-js/es6/array';
import 'core-js/es6/regexp';
import 'core-js/es6/map';
import 'core-js/es6/set';
import 'core-js/es6/weak-map';
import 'core-js/es6/weak-set';
import 'core-js/es6/typed';
import 'core-js/es6/reflect';
import 'core-js/es7/reflect';
import 'zone.js/dist/zone';
import 'zone.js/dist/long-stack-trace-zone';

正如您所看到的,我们包含了所有的 polyfills 库。接下来,我们将创建一个名为vendor.ts的文件;在这个文件中,粘贴以下代码:

import '@angular/common';
import '@angular/compiler';
import '@angular/core';
import '@angular/forms';
import '@angular/http';
import '@angular/router';
import '@angular/platform-browser';
import '@angular/platform-browser-dynamic';
import 'rxjs';

正如您所看到的,我们包含了 Angular 和 RXJS 库的所有核心模块。最后,我们将把之前的bootstrap.ts文件复制到public文件夹中。要做到这一点,转到您的public文件夹并创建一个名为bootstrap.ts的文件。在这个文件中,粘贴以下代码:

import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { AppModule } from './app/app.module';

platformBrowserDynamic().bootstrapModule(AppModule);

正如您所看到的,这是我们之前章节的应用程序引导文件。我们唯一剩下的事情就是更改我们的主应用程序页面。要做到这一点,转到app/views/index.ejs文件并进行以下更改:

<!DOCTYPE html>
<html>
<head>
  <title><%= title %></title>
  <base href="/">
</head>
<body>
  <mean-app>
    <h1>Loading...</h1>
  </mean-app>

  <script type="text/javascript">
    window.user = <%- user || 'null' %>;
  </script>

  <script src="img/socket.io.js"></script>

 <script src="img/polyfills.js"></script>
 <script src="img/vendor.js"></script>
 <script src="img/bootstrap.js"></script>
</body>
</html>

正如您所看到的,我们刚刚用新的捆绑脚本文件替换了旧的脚本。完成这些更改后,您的 Webpack 配置应该已经准备好使用了!使用您的命令行工具并导航到 MEAN 应用程序的根文件夹。然后,通过输入以下命令来运行您的应用程序:

$ npm start

一旦您的应用程序正在运行并且 Webpack 完成了代码的捆绑,导航到http://localhost:3000/并测试您的应用程序。当然,这只是一个基本的设置,因此强烈建议您继续学习 Webpack 的其他功能。

引入 ESLint

在软件开发中,linting 是使用专用工具识别可疑代码使用的过程。在 MEAN 应用程序中,linting 可以帮助您避免日常开发周期中的常见错误和编码错误;此外,它还可以让您在团队中设置统一的代码样式。在我们的生态系统中最常用的 linting 工具叫做 ESLint。ESLint 是一个可插拔的 linting 实用程序,最初由 Nicholas C. Zakas 于 2013 年创建。它允许我们使用一组规则和预设配置来 lint 我们的 JavaScript 代码。我们将从在我们的应用程序中安装 ESLint 包开始。

注意

强烈建议您访问官方项目页面eslint.org/,了解更多关于 ESLint 的信息。

安装 ESLint

在我们开始配置 ESLint 执行之前,我们需要使用npm安装 ESLint 包。为此,请按照以下更改您的package.json文件:

{
  "name": "MEAN",
  "version": "0.0.11",
  "scripts": {
    "tsc": "tsc",
    "tsc:w": "tsc -w",
    "app": "node server",
    "start": "concurrently \"npm run webpack\" \"npm run app\" ",
    "postinstall": "typings install",
    "webpack": "webpack --watch",
 "lint": "eslint --ext .js ./config ./app ./*.js"
  },
  "dependencies": {
    "@angular/common": "2.1.1",
    "@angular/compiler": "2.1.1",
    "@angular/core": "2.1.1",
    "@angular/forms": "2.1.1",
    "@angular/http": "2.1.1",
    "@angular/platform-browser": "2.1.1",
    "@angular/platform-browser-dynamic": "2.1.1",
    "@angular/router": "3.1.1",
    "body-parser": "1.15.2",
    "core-js": "2.4.1",
    "compression": "~1.6.0",
    "connect-flash": "0.1.1",
    "connect-mongo": "1.3.2",
    "cookie-parser": "1.4.3",
    "ejs": "2.5.2",
    "es6-promise": "4.0.5",
    "express": "4.14.0",
    "express-session": "1.14.1",
    "method-override": "2.3.6",
    "mongoose": "4.6.5",
    "morgan": "1.7.0",
    "passport": "0.3.2",
    "passport-facebook": "2.1.1",
    "passport-google-oauth": "1.0.0",
    "passport-local": "1.0.0",
    "passport-twitter": "1.0.4",
    "phantomjs-prebuilt": "2.1.13",
    "reflect-metadata": "0.1.8",
    "rxjs": "5.0.0-beta.12",
    "socket.io": "1.4.5",
    "systemjs": "0.19.39",
    "zone.js": "0.6.26"
  },
  "devDependencies": {
    "awesome-typescript-loader": "2.2.4",
    "concurrently": "3.1.0",
 "eslint": "3.10.2",
    "jasmine": "2.5.2",
    "jasmine-core": "2.5.2",
    "karma": "1.3.0",
    "karma-jasmine": "1.0.2",
    "karma-phantomjs-launcher": "1.0.2",
    "should": "11.1.1",
    "supertest": "2.0.1",
    "traceur": "0.0.111",
    "typescript": "2.0.3",
    "typings": "1.4.0",
    "webpack": "1.13.3"
  }
}

正如您所看到的,您已经将 ESLint 包添加到了devDependencies属性中。您还添加了一个lint脚本来运行 ESLint,并在您的appconfig文件夹中放置了一个 lint JavaScript 文件。要安装新的依赖项,请转到应用程序的根文件夹,并在命令行工具中输入以下命令:

$ npm install

这将在您的项目的node_modules文件夹中安装指定版本的 ESLint 包。接下来,您将学习如何配置 ESLint。

配置 ESLint

为了控制 ESLint 的执行,您需要使用放置在应用程序根文件夹中的特殊配置文件进行配置。当执行时,ESLint 会自动查找应用程序根文件夹中名为.eslintrc的默认配置文件。在应用程序的根文件夹中创建一个新文件,并将其命名为.eslintrc。在新文件中,粘贴以下 JSON 对象:

{
  "parserOptions": {
    "ecmaVersion": 6
  }
}

正如您所看到的,这个简单的配置基本上告诉 ESLint 我们的代码是用 ECMAScript 6 编写的。然而,ESLint 还可以做更多的事情;例如,您可以通过更改配置告诉 ESLint 验证我们的代码缩进,如下所示:

{
  "parserOptions": {
    "ecmaVersion": 6
  },
  "rules": {
    "indent": ["error", 2]
  }
}

这将告诉 ESLint 在我们的代码文件中期望两个空格的缩进。此外,通常情况下,您将使用extend属性来扩展现有的配置文件,如下所示:

{
  "extends": "eslint:recommended",
  "parserOptions": {
    "ecmaVersion": 6
  }
}

这将扩展 ESLint 的推荐规则集。然而,这些只是简单的例子,因此建议您继续学习 ESLint,以找到最佳的项目配置。要运行 lint 任务,请转到命令行工具并执行以下命令:

$ npm run lint

linting 结果应该在您的命令行工具中报告,并且与以下截图中显示的内容类似:

配置 ESLint

ESLint 结果

ESLint 是一个强大的工具。然而,在这种形式下,您需要手动运行lint任务。更好的方法是在修改文件时自动运行 lint 任务。

使用 Nodemon

使用 Node 的命令行工具运行应用程序可能看起来不是一个多余的任务。然而,当不断开发您的应用程序时,您很快会注意到您经常停止和启动应用程序服务器。为了帮助完成这项任务,有一个常用的工具叫做 Nodemon。Nodemon 是一个 Node.js 命令行工具,作为简单的 node 命令行工具的包装器,但它会监视您的应用程序文件的更改。当 Nodemon 检测到文件更改时,它会自动重新启动 node 服务器以更新应用程序。要使用 Nodemon,您需要修改项目的package.json文件,如下所示:

{
  "name": "MEAN",
  "version": "0.0.11",
  "scripts": {
    "tsc": "tsc",
    "tsc:w": "tsc -w",
    "app": "node server",
 "app:dev": "npm run lint && npm run app",
 "nodemon": "nodemon -w app -w config -w server.js --exec npm run app:dev",
 "start": "concurrently \"npm run webpack\" \"npm run nodemon\",
    "postinstall": "typings install",
    "webpack": "webpack --watch",
    "lint": "eslint --ext .js ./config ./app ./*.js"
  },
  "dependencies": {
    "@angular/common": "2.1.1",
    "@angular/compiler": "2.1.1",
    "@angular/core": "2.1.1",
    "@angular/forms": "2.1.1",
    "@angular/http": "2.1.1",
    "@angular/platform-browser": "2.1.1",
    "@angular/platform-browser-dynamic": "2.1.1",
    "@angular/router": "3.1.1",
    "body-parser": "1.15.2",
    "core-js": "2.4.1",
    "compression": "~1.6.0",
    "connect-flash": "0.1.1",
    "connect-mongo": "1.3.2",
    "cookie-parser": "1.4.3",
    "ejs": "2.5.2",
    "es6-promise": "4.0.5",
    "express": "4.14.0",
    "express-session": "1.14.1",
    "method-override": "2.3.6",
    "mongoose": "4.6.5",
    "morgan": "1.7.0",
    "passport": "0.3.2",
    "passport-facebook": "2.1.1",
    "passport-google-oauth": "1.0.0",
    "passport-local": "1.0.0",
    "passport-twitter": "1.0.4",
    "phantomjs-prebuilt": "2.1.13",
    "reflect-metadata": "0.1.8",
    "rxjs": "5.0.0-beta.12",
    "socket.io": "1.4.5",
    "systemjs": "0.19.39",
    "zone.js": "0.6.26"
  },
  "devDependencies": {
    "awesome-typescript-loader": "2.2.4",
    "concurrently": "3.1.0",
    "eslint": "3.10.2",
    "jasmine": "2.5.2",
    "jasmine-core": "2.5.2",
    "karma": "1.3.0",
    "karma-jasmine": "1.0.2",
    "karma-phantomjs-launcher": "1.0.2",
 "nodemon": "1.11.0",
    "should": "11.1.1",
    "supertest": "2.0.1",
    "traceur": "0.0.111",
    "typescript": "2.0.3",
    "typings": "1.4.0",
    "webpack": "1.13.3"
  }
}

正如你所看到的,我们将 Nodemon 包添加到我们的开发依赖项中。我们还添加了两个新的脚本,并更改了我们的start脚本。我们添加的第一个脚本是app:dev,它运行lintapp脚本。接下来,我们创建了一个nodemon脚本,它监视我们所有的服务器 JavaScript 文件,并在文件被修改时运行app:dev脚本。在我们的start脚本中,我们同时执行我们的 Webpack 和 Nodemon 脚本。就是这样!你所要做的就是在你的应用程序根文件夹中安装 Nodemon 包,并在命令行工具中发出以下命令:

$ npm install

然后,使用常规的start命令运行你的应用程序:

$ npm start

这将使用新的设置启动你的应用程序。尝试更改你的 Angular 或 Express 应用程序文件;注意,如果你更改了服务器文件,你的应用程序将重新启动,并且当你更改 Angular 文件时,Webpack 会自动编译你的代码的方式。

使用 V8 检查器调试 Express

使用 V8 检查器调试 Express 部分的 MEAN 应用程序可能是一个复杂的任务。幸运的是,有一个名为 V8 检查器的解决这个问题的好工具。V8 检查器是一个使用 Blink(一个 WebKit 分支)开发者工具的调试工具。事实上,使用 Google 的 Chrome 浏览器的开发人员已经熟悉它,以 Chrome 开发者工具界面的形式。V8 检查器支持一些非常强大的调试功能:

  • 源代码文件导航

  • 断点操作

  • 跳过、步入、步出和恢复执行

  • 变量和属性检查

  • 实时代码编辑

要调试你的应用程序,你需要使用兼容的网络浏览器访问 V8 检查器界面。然后你就可以使用它来调试你的应用程序代码,使用 Chrome 开发者工具界面。要做到这一点,你只需要在你的package.json文件中添加一个调试脚本,如下所示:

{
  "name": "MEAN",
  "version": "0.0.11",
  "scripts": {
    "tsc": "tsc",	
    "tsc:w": "tsc -w",
    "app": "node server",
    "app:dev": "npm run lint && npm run app",
    "nodemon": "nodemon -w app -w config -w server.js --exec npm run app:dev",
    "start": "concurrently \"npm run webpack\" \"npm run nodemon\",
 "debug": "node --inspect --debug-brk server.js",
    "postinstall": "typings install",
    "webpack": "webpack --watch",
    "lint": "eslint --ext .js ./config ./app ./*.js"
  },
  "dependencies": {
    "@angular/common": "2.1.1",
    "@angular/compiler": "2.1.1",
    "@angular/core": "2.1.1",
    "@angular/forms": "2.1.1",
    "@angular/http": "2.1.1",
    "@angular/platform-browser": "2.1.1",
    "@angular/platform-browser-dynamic": "2.1.1",
    "@angular/router": "3.1.1",
    "body-parser": "1.15.2",
    "core-js": "2.4.1",
    "compression": "~1.6.0",
    "connect-flash": "0.1.1",
    "connect-mongo": "1.3.2",
    "cookie-parser": "1.4.3",
    "ejs": "2.5.2",
    "es6-promise": "4.0.5",
    "express": "4.14.0",
    "express-session": "1.14.1",
    "method-override": "2.3.6",
    "mongoose": "4.6.5",
    "morgan": "1.7.0",
    "passport": "0.3.2",
    "passport-facebook": "2.1.1",
    "passport-google-oauth": "1.0.0",
    "passport-local": "1.0.0",
    "passport-twitter": "1.0.4",
    "phantomjs-prebuilt": "2.1.13",
    "reflect-metadata": "0.1.8",
    "rxjs": "5.0.0-beta.12",
    "socket.io": "1.4.5",
    "systemjs": "0.19.39",
    "zone.js": "0.6.26"
  },
  "devDependencies": {
    "awesome-typescript-loader": "2.2.4",
    "concurrently": "3.1.0",
    "eslint": "3.10.2",
    "jasmine": "2.5.2",
    "jasmine-core": "2.5.2",
    "karma": "1.3.0",
    "karma-jasmine": "1.0.2",
    "karma-phantomjs-launcher": "1.0.2",
    "nodemon": "1.11.0",
    "should": "11.1.1",
    "supertest": "2.0.1",
    "traceur": "0.0.111",
    "typescript": "2.0.3",
    "typings": "1.4.0",
    "webpack": "1.13.3"
  }
}

在你的新脚本中,你所做的就是用两个命令行标志运行你的应用程序。inspect标志允许将 Chrome 开发者工具附加到我们的 Node.js 实例,debug-brk标志阻止 Node.js 在附加调试器之前运行你的代码。接下来,我们将使用新脚本运行我们的应用程序,并看看我们如何调试它。

调试你的应用程序

要使用你的新的debug脚本,导航到你的应用程序根文件夹,并在命令行工具中发出以下命令:

$ npm run debug

这将以调试模式运行你的应用程序,并等待你附加 Chrome 开发者工具调试器。你的命令行工具中的输出应该类似于以下截图所示的内容:

调试你的应用程序

以调试模式运行

正如你所看到的,debug脚本邀请你通过使用兼容的浏览器访问chrome-devtools://…来开始调试应用程序。在 Google Chrome 中打开这个 URL,你应该会看到一个类似以下截图所示的界面:

调试你的应用程序

使用 V8 检查器调试

正如你所看到的,你会在左侧面板中得到你项目的文件列表,在中间面板中得到文件内容查看器,在右侧面板中得到调试仪表板。这意味着你的debug脚本正在正确运行,并且正在识别你的 Express 项目。你可以通过设置一些断点并测试你的应用程序行为来开始调试你的项目。

注意

节点检查只能在使用 Blink 引擎的浏览器上工作,例如 Google Chrome 或 Opera。此外,这个功能仍然是实验性的。因此,建议您随时关注官方的 Node.js 文档。

使用 Angular Augury 调试 Angular 应用程序

调试 MEAN 应用程序的大部分 Angular 部分通常在浏览器中完成。但是,调试 Angular 的内部操作可能有点棘手。出于这个目的,来自 Google 和 Rangle.io 的联合团队创建了一个名为 Angular Augury 的 Chrome 扩展。Angular Augury 通过一个新的选项卡扩展了 Chrome 开发者工具,您可以在其中调试您的 Angular 应用程序的不同方面。安装 Angular Augury 非常简单。您只需访问 Chrome 网络商店chrome.google.com/webstore/detail/augury/elgalmkoelokbchhkhacckoklkejnhcd并安装 Chrome 扩展即可。

注意

Angular Augury 只能在 Google Chrome 浏览器上运行。

使用 Angular Augury

安装完 Angular Augury 后,使用 Chrome 导航到您的应用程序 URL。然后,打开 Chrome 开发者工具面板,您应该会看到一个Angular选项卡。单击它,应该会打开一个与以下截图中显示的类似的面板:

使用 Angular Augury

Angular Augury

组件树

我们的 Angular 应用程序是以组件树的形式构建的。Augury 允许我们以分层方式检查这些组件;为了更好地理解这一点,请看以下截图:

组件树

Augury 组件树

正如您所看到的,我们正在检查文章模块中的ViewComponent。由于我们的组件路由基于简单的层次结构,您可以注意到我们还看到了AppComponentArticlesComponent。在右侧,您会注意到我们有两个选项卡:属性注入器图。在属性选项卡中,您将找到组件状态,包括articleuser属性以及组件依赖项。状态是可编辑的,因此它允许您更改组件状态并查看对ViewComponent的渲染的影响。此外,如果我们检查CreateComponent,您将能够看到 Augury 如何与表单一起工作:

组件树

带有表单的 Augury

正如您所看到的,您可以检查您的表单状态并了解其内部状态。如果您编辑表单输入的值,您还将能够在右侧窗格上实时看到其状态更新。如果单击注入器图选项,您将能够看到 Angular 的注入器是如何工作以及当前组件的注入提供程序:

组件树

Augury 注入器图

请注意,RouterArticlesService提供程序被注入到CreateComponent中。在更大的应用程序中,这将让您更好地了解项目状态。

路由树

要探索您的 Angular 应用程序路由,您可以单击路由树选项卡。在这样做之前,您需要在应用程序组件中注入路由提供程序,如下所示:

import { Component } from '@angular/core';
import { AuthenticationService } from './authentication/authentication.service';
import { Router } from '@angular/router';

@Component({
  selector: 'mean-app',
  template: '<router-outlet></router-outlet>',
})
export class AppComponent {
  constructor(private _authenticationService: AuthenticationService,
 private router: Router) {}
}

一旦您这样做,您将能够看到与以下截图中显示的类似的面板:

路由树

Augury 路由树

正如您所看到的,路由树选项卡可以让您以易于理解的图形方式了解您的应用程序路由方案。

Angular Augury 是一个简单而强大的工具。正确使用,它可以节省您大量时间,而不是不断地四处查找和使用控制台日志。确保您了解每个选项卡,并尝试自己探索应用程序。

总结

在本章中,您学习了如何自动化您的 MEAN 应用程序的开发,以及如何调试应用程序的 Express 和 Angular 部分。我们从简要解释 NPM 脚本开始。然后我们讨论了 Webpack 及其强大的功能,您学会了如何自动重新启动和 lint 您的应用程序。然后,您学会了如何使用 V8 检查器工具来调试您的 Express 代码。在本章的最后,您了解了 Angular Augury Chrome 扩展程序。您了解了 Angular Augury 的功能,并了解了如何调试您的 Angular 内部。

由于这是本书的最后一章,您现在应该知道如何构建、运行、测试、调试和轻松开发您的 MEAN 应用程序。

接下来的步骤就看您了。

posted @ 2024-05-23 15:56  绝不原创的飞龙  阅读(3)  评论(0编辑  收藏  举报