MEAN-蓝图-全-

MEAN 蓝图(全)

原文:zh.annas-archive.org/md5/7fbede1e27be02d3d720ab3e0d820ee8

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

当我们学习新技术或甚至提高我们现有的技能集时,我们总是对完整的应用程序感兴趣:应用程序是如何从概念阶段到实际运行的应用程序构建的,不同的技术可以解决哪些痛点,以及我们需要遵循哪些指南来简化我们的开发周期。

说到技术,其中最普遍的可能是 JavaScript。我认为 JavaScript 的好与坏在于它的普及。几十年来,我们没有一种技术像它那样广泛传播,并准备好在不同平台上集成。

除了在浏览器中运行 JavaScript,这是我们所有人都非常习惯的,在过去的几年里,我们可以轻松地在服务器上运行我们的 JS 代码,使用 Node.js。但这并没有结束,因为我们还可以仅使用 JavaScript 开始开发强大的物联网项目。

多年来,我们看到了一种趋势,即有一种技术统治每一个平台。我认为目前的 JavaScript 技术栈,是的,技术栈,是一个巨大的变革者。你可能现在在想,它并不完美或丰富多彩;是的,我非常同意你的看法,但进化是如此迅速——每天都有新事物被推动,每分钟都有新事物出现——我们正站在技术革命的边缘。

等等!什么?是的,我们可以成为革命的一部分,我们可以成为潮流的引领者,我们可以塑造 Web、移动和物联网(也许整个宇宙?但当然)。我们可以突破极限,证明我们可以做大事,我们可以不断进步,并改善我们周围的人。

书背后的想法

写这本书背后的想法是提供一个指南和更高层次的应用程序构建指导。由于我在工作的 Evozon 公司与很多人互动,我看到人们有很大的愿望去触摸和感受如何使用不同的技术栈构建应用程序。

尤其是在 JavaScript、Node.js 和 MongoDB 等流行技术中。但通常,找到完整的端到端示例应用是困难的。请别误会;互联网上充满了伟大的事物、杰出的人和许多令人惊叹的事物,但它也充满了噪音和不确定性,很难知道哪条路是正确的或错误的,以及在特定场景下应该做什么。

正因如此,我们想展示使用 MEAN 栈构建应用程序的不同场景。可能这并不是做事情的唯一方式,但它应该为你提供一个起点,或者给你一些关于应用程序某些部分是如何构建的洞察。

这不是一本关于 Node.js 和 Angular 2 入门的书。它将直接进入行动,展示六个从头开始构建的应用程序,每个应用程序都有不同的用例,解决一个高层次的问题。

一点小转折

写一本书需要时间,大量的精力,还有一点点的恐惧。我曾担心我永远无法完成这本书。这是一段漫长的旅程,最有趣的部分是前三章是在 AngularJS 的早期版本中编写的。别担心!所有内容都是使用 Angular 2 发布的。

如你所猜,我们经历了一段漫长的旅程,并使用目前处于 beta 发布的 Angular 2 重写了所有内容。为什么?因为,正如我之前告诉你的,我们想要挑战自己的极限。我们想要成长并掌握构建现代应用的新方法。

我尝试涵盖使用 MEAN 堆栈构建的各种应用程序,从简单的联系人管理器和实时聊天应用程序到完整的拍卖网站。

本书涵盖内容

第一章, 联系人管理器,将涵盖构建一个用于在 MondoDB 中保存联系人的入门级应用程序的过程。本章将向你介绍为你的 Node.js 应用程序进行 TDD(即测试驱动开发)。你将学习如何构建一个 Angular 2 应用程序,该程序将访问 Express API 中的数据。

第二章, 费用追踪器,将深入探讨在 JavaScript 中处理货币数据并在 MongoDB 中使用精确精度方法存储这些数据。此外,你还将学习如何通过扩展 Angular 中的内置 HTTP 服务来为客户端应用程序中的每个请求添加令牌进行身份验证。除此之外,你还将看到如何使用 MongoDB 的聚合框架并在你的 Angular 应用程序中显示结果。

第三章, 招聘板,将专注于构建一个更面向消费者的应用程序,使用户能够定义具有动态数据的自定义配置文件。你将使用 Angular 中的响应式扩展来创建应用程序中的不同通信层。在后台,我们将使用 Node.js 构建 RESTful API,并设置从之前构建的应用程序中设置的样板应用程序,这些应用程序将在本书的后续内容中使用。

第四章, 聊天应用程序,将开始重用前一章中构建的样板代码。但最有趣的部分将从我们创建一个使用 SocketIO 的聊天服务层开始。这将使后端和前端 Angular 应用程序能够实时通信发送消息。聊天服务将以易于扩展的方式构建,除了即时消息外,还可以扩展到新模块,例如当用户在线或离线时。

第五章,“电子商务应用”,将反映在 MongoDB 中存储非结构化数据的简便性。我们将详细讨论如何在 NoSQL 数据库中存储您的产品目录。此外,我们前几章的初始架构将得到新的形式,我们将尝试微应用,每个微应用都有自己的职责。微应用将使用封装所有业务逻辑的核心电子商务模块。此外,本章还将涵盖两个客户端应用程序,一个完全使用不同的技术构建,另一个使用 Angular 2 构建的管理应用程序。

第六章,“拍卖应用”,将更多地是前一章的扩展;换句话说,它将使用电子商务 API 获取产品信息并验证用户。这将不仅推动我们重用现有代码,而且在构建产品时依赖其他服务以实现更快的原型设计。此外,我们将更深入地研究 RxJs,并探讨我们如何在 Angular 拍卖应用程序中使用 SocketIO 在服务器端构建实时竞标系统。

您需要为此书准备的内容

您需要任何现代网络浏览器(例如 Chrome 的最新版本或 IE 10+),在您的机器上安装 Node.js 平台,以及 MongoDB 的 3.2 或更高版本。可选地,您可以安装任何 Web 服务器,例如 Nginx、Apache、IIS 或 lighttpd,以代理请求到您的 Node.js 应用程序。

本书面向对象

如果您是一位对 MEAN 堆栈有基本了解的 Web 开发者,有使用 JavaScript 开发应用程序的经验,以及使用 NoSQL 数据库的基本经验,那么这本书就是为您准备的。

术语

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

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称将如下所示:“创建一个名为app/models/token.js的文件。”

代码块应如下设置:

'use strict';

const LEN = 256;
const SALT_LEN = 64;
const ITERATIONS = 10000;
const DIGEST = 'sha256';
const crypto = require('crypto');

module.exports.hash = hashPassword;
module.exports.verify = verify;

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

require('./config/express').init(app);
require('./config/routes').init(app);

任何命令行输入或输出都应如下所示:

 Authentication
 Basic authentication
 √ should authenticate a user and return a new token
 √ should not authenticate a user with invalid credentials
 2 passing

新术语重要词汇将以粗体显示。屏幕上显示的单词,例如在菜单或对话框中,将在文本中如下显示:“当点击保存按钮并提交表单时,它将触发一个事件。”

注意

警告或重要注意事项将如下所示。

小贴士

小技巧和窍门如下所示。

读者反馈

我们始终欢迎读者的反馈。请告诉我们您对这本书的看法——您喜欢或不喜欢什么。读者反馈对我们很重要,因为它帮助我们开发出您真正能从中获得最大价值的标题。

要向我们发送一般反馈,请简单地通过电子邮件发送到 <feedback@packtpub.com>,并在邮件的主题中提及书籍的标题。

如果您在某个主题上具有专业知识,并且您有兴趣撰写或为书籍做出贡献,请参阅我们的作者指南www.packtpub.com/authors

客户支持

现在,您已经成为 Packt 图书的骄傲拥有者,我们有一些事情可以帮助您从您的购买中获得最大价值。

下载示例代码

您可以从www.packtpub.com上的账户下载本书的示例代码文件。如果您在其他地方购买了此书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。

您可以通过以下步骤下载代码文件:

  1. 使用您的电子邮件地址和密码登录或注册我们的网站。

  2. 将鼠标指针悬停在顶部的支持标签上。

  3. 点击代码下载与错误清单

  4. 搜索框中输入书籍的名称。

  5. 选择您想要下载代码文件的书籍。

  6. 从下拉菜单中选择您购买此书的来源。

  7. 点击代码下载

一旦文件下载完成,请确保您使用最新版本的软件解压缩或提取文件夹:

  • WinRAR / 7-Zip for Windows

  • Zipeg / iZip / UnRarX for Mac

  • 7-Zip / PeaZip for Linux

该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/MEAN-Blueprints。我们还有来自我们丰富的图书和视频目录的其他代码包可供在github.com/PacktPublishing/上找到。查看它们吧!

错误清单

尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在我们的书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进此书的后续版本。如果您发现任何错误清单,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击错误清单提交表单链接,并输入您的错误清单详情。一旦您的错误清单得到验证,您的提交将被接受,错误清单将被上传到我们的网站或添加到该标题的错误清单部分。

要查看之前提交的勘误表,请访问www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将显示在勘误部分。

盗版

互联网上对版权材料的盗版是一个跨所有媒体的持续问题。在 Packt,我们非常重视保护我们的版权和许可证。如果您在互联网上发现任何形式的非法副本,请立即提供位置地址或网站名称,以便我们可以追究补救措施。

请通过链接<版权@packtpub.com>与我们联系,并提供涉嫌盗版材料的链接。

我们感谢您在保护我们作者和我们为您提供有价值内容的能力方面的帮助。

问题

如果您对本书的任何方面有问题,请通过链接questions@packtpub.com与我们联系,我们将尽力解决问题。

第一章. 联系人管理器

在这一章中,你将学习如何构建联系人管理器应用程序。该应用程序将分为两个独立的部分:一部分是后端,我们使用 Express 编写的 Node.js API,另一部分是使用 Angular 2 精心制作的客户端应用程序。

别担心!这一章将更多地作为一个指南,设置一个基础项目,并在 Node.js 中了解 TDD(即测试驱动开发)。我们还将看到 Angular 2 的实际应用。我们不会在客户端编写测试,因为一章节中已经有很多东西要积累。

设置基本应用程序

开始的最佳方式是建立一个坚实的基础。这就是为什么我们将专注于构建我们应用程序的基础结构。一个好的基础给你模块化和灵活性,文件也应该很容易被你和你团队成员找到。

总是以简单的东西开始,然后围绕它构建。随着你的应用程序增长,你可能会超出你最初的应用程序结构,所以提前思考会给你带来长远的好处。

文件夹结构

在直接开始构建你的功能之前,你应该花点时间勾勒出你初始应用程序的结构。在规划过程中,一支笔和一张纸总是足够的,但我已经节省了一些时间并提出了一个初始版本:

app/
--controllers/
--middlewares/
--models/
--routes/
config/
--environments/
--strategies/
tests/
--integration/
--unit/
public/
--app/
--src/
--assets/
--typings/
--package.json
--tsconfig.json
--typings.json
package.json
server.js

让我们看看我们对文件夹结构的更详细解释:

  • app: 这个文件夹包含应用程序中使用的所有服务器文件:

    • controllers: 这个文件夹将存储应用程序控制器,主要是后端业务逻辑。

    • middlewares: 在这个文件夹中,我们将存储所有将操作请求和响应对象的函数片段。一个很好的例子就是一个身份验证中间件。

    • models: 这个文件夹将存储所有后端模型。

    • routes: 这个文件夹将包含所有路由文件,这是我们定义所有 Express 路由的地方。

  • config: 所有应用程序配置文件都放在这里:

    • environments: 这个文件夹包含根据当前环境加载的文件

    • strategies: 所有你的身份验证策略都应该放在这里

  • tests: 这个文件夹包含测试应用程序后端逻辑所需的所有测试:

    • integration: 如果某些东西使用了外部模块,创建一个集成测试是个好习惯

    • unit: 这应该包含对小型代码单元的测试,例如密码散列

  • public: 这个文件夹应该包含我们应用程序提供的所有静态文件。我喜欢这种分离,因为它很容易让另一个 Web 服务器来处理我们的静态文件。比如说,你想让 nginx 来处理静态文件服务:

    • app: 这是我们的客户端应用程序文件夹。所有编译后的 TypeScript 文件都将放在这里。这个文件夹应该自动填充。

    • src:这个文件夹包含所有用于构建我们应用程序的客户端文件。我们将使用 TypeScript 来构建我们的 Angular 应用程序。

    • typings:这包含 TypeScript 定义。

服务器端 package.json

在设置初始文件夹结构之后,接下来要做的事情是创建 package.json 文件。这个文件将包含所有应用程序的元数据和依赖项。package.json 文件将放置在我们的项目文件夹根目录。路径应该是 contact-manager/package.json

{
  "name": "mean-blueprints-contact-manager",
  "version": "0.0.9",
  "repository": {
    "type": "git",
    "url": "https://github.com/robert52/mean-blueprints-cm.git"
  },
  "engines": {
    "node": ">=4.4.3"
  },
  "scripts": {
    "start": "node app.js",
    "unit": "node_modules/.bin/mocha tests/unit/ --ui bdd --recursive --reporter spec --timeout 10000 --slow 900",
    "integration": "node_modules/.bin/mocha tests/integration/ --ui bdd --recursive --reporter spec --timeout 10000 --slow 900",
    "less": "node_modules/.bin/autoless public/assets/less public/assets/css --no-watch",
    "less-watch": "node_modules/.bin/autoless public/assets/less public/assets/css"
  },
  "dependencies": {
    "async": "⁰.9.2",
    "body-parser": "¹.15.0",
    "connect-mongo": "¹.1.0",
    "express": "⁴.13.4",
    "express-session": "¹.13.0",
    "lodash": "³.10.1",
    "method-override": "².3.5",
    "mongoose": "⁴.4.12",
    "passport": "⁰.2.2",
    "passport-local": "¹.0.0",
    "serve-static": "¹.10.2"
  },
  "devDependencies": {
    "autoless": "⁰.1.7",
    "chai": "².3.0",
    "chai-things": "⁰.2.0",
    "mocha": "².4.5",
    "request": "².71.0"
  }
}

我们在我们的 package.json 文件中添加了一些脚本以运行我们的单元和集成测试以及编译 Less 文件。你始终可以使用 npm 直接运行不同的脚本,而不是使用构建工具,如 Grunt 或 Gulp。

在撰写本书时,我们正在使用定义的依赖项及其版本。现在这应该足够了。让我们使用以下命令安装它们:

$ npm install

你应该看到 npm 正在拉取一些文件并将必要的依赖项添加到 node_modules 文件夹中。耐心等待直到一切安装完成。你将返回到命令提示符。现在你应该看到已创建的 node_modules 文件夹,并且所有依赖项都已就位。

第一个应用程序文件

在做任何事情之前,我们需要为我们的环境创建一个简单的配置文件。让我们在 config 文件夹中创建文件,位于 contact-manager/config/environments/development.js,并添加以下内容:

'use strict';

module.exports = {
  port: 3000,
  hostname: '127.0.0.1',
  baseUrl: 'http://localhost:3000',
  mongodb: {
    uri: 'mongodb://localhost/cm_dev_db'
  },
  app: {
    name: 'Contact manager'
  },
  serveStatic: true,
  session: {
    type: 'mongo',
    secret: 'u+J%E⁹!hx?piXLCfiMY.EDc',
    resave: false,
    saveUninitialized: true
  }
};

现在,让我们为我们的应用程序创建主要的 server.js 文件。这个文件将是我们的应用程序的核心。该文件应位于我们的文件夹根目录,contact-manager/server.js。从以下代码行开始:

'use strict';

// Get environment or set default environment to development
const ENV = process.env.NODE_ENV || 'development';
const DEFAULT_PORT = 3000;
const DEFAULT_HOSTNAME = '127.0.0.1';

const http = require('http');
const express = require('express');
const config = require('./config');
const app = express();

var server;

// Set express variables
app.set('config', config);
app.set('root', __dirname);
app.set('env', ENV);

require('./config/mongoose').init(app);
require('./config/models').init(app);
require('./config/passport').init(app);
require('./config/express').init(app);
require('./config/routes').init(app);

// Start the app if not loaded by another module
if (!module.parent) {
  server = http.createServer(app);
  server.listen(
    config.port || DEFAULT_PORT,
    config.hostname || DEFAULT_HOSTNAME,
    () => {
      console.log(`${config.app.name} is running`);
      console.log(`   listening on port: ${config.port}`);
      console.log(`   environment: ${ENV.toLowerCase()}`);
    }
  );
}

module.exports = app;

我们定义了一些主要依赖项并初始化了应用程序必要的模块。为了模块化,我们将把我们的堆栈中的每个包放入单独的配置文件中。这些配置文件中会包含一些逻辑。我喜欢称它们为智能配置文件。

别担心!我们将逐个查看每个配置文件。最后,我们将导出我们的 Express 应用程序实例。如果我们的模块没有被另一个模块加载,例如,一个测试用例,那么我们可以安全地开始监听传入的请求。

创建 Express 配置文件

我们需要为 Express 创建一个配置文件。该文件应创建在 config 文件夹中,位于 contact-manager/config/express.js,并且我们必须添加以下代码行:

'use strict';

const path = require('path');
const bodyParser = require('body-parser');
const methodOverride = require('method-override');
const serveStatic = require('serve-static');
const session = require('express-session');
const passport = require('passport');
const MongoStore = require('connect-mongo')(session);
const config = require('./index');

module.exports.init = initExpress;

function initExpress(app) {
  const root = app.get('root');
  const sessionOpts = {
    secret: config.session.secret,
    key: 'skey.sid',
    resave: config.session.resave,
    saveUninitialized: config.session.saveUninitialized
  };

  //common express configs
  app.use(bodyParser.urlencoded({ extended: true }));
  app.use(bodyParser.json());
  app.use(methodOverride());
  app.disable('x-powered-by');

  if (config.session.type === 'mongo') {
    sessionOpts.store = new MongoStore({
      url: config.mongodb.uri
    });
  }

  app.use(session(sessionOpts));
  app.use(passport.initialize());
  app.use(passport.session());

  app.use(function(req, res, next) {
    res.locals.app = config.app;

    next();
  });

  // always load static files if dev env
  if (config.serveStatic) {
    app.use(serveStatic(path.join(root, 'public')));
  }
};

到现在为止,你应该已经熟悉了前面代码中的许多行,例如,设置我们 Express 应用的期望体解析器。此外,我们还设置了会话管理,以防万一我们还需要设置服务器静态文件,我们定义了服务器文件的路径。

在生产环境中,你应该使用与默认内存存储不同的东西来存储会话。这就是为什么我们添加了一个特殊的会话存储,它将在 MongoDB 中存储数据。

获取全局环境配置文件的一个好做法是设置一个根配置文件,所有应用程序文件都将加载,创建一个名为contact-manager/config/index.js的新文件,并将以下代码添加到其中:

'use strict';

var ENV = process.env.NODE_ENV || 'development';
var config = require('./environments/'+ENV.toLowerCase());

module.exports = config;

前面的代码将仅根据NODE_ENV进程环境变量加载必要的环境配置文件。如果环境变量不存在,则将考虑应用程序的默认开发状态。这是一个好的做法,以免我们犯错误并连接到错误的数据库。

通常,可以在启动您的 node 服务器时设置NODE_ENV变量;例如,在 Unix 系统中,您可以运行以下命令:

$ NODE_ENV=production node server.js

设置 mocha 进行测试

在我们实现任何功能之前,我们将为其编写测试。Mocha 是一个基于 Node.js 的测试框架。这种方法将使我们能够知道我们将要编写的代码,并在编写客户端应用程序的任何一行代码之前测试我们的 Node.js API。

如果您没有 Mocha,您可以在全局范围内安装它。如果您希望 Mocha 在您的命令行中全局可用,请运行以下命令:

$ npm install -g mocha

设置 Mongoose

为了在 MongoDB 中存储数据,我们将使用 Mongoose。Mongoose 提供了一个定义用于模型应用程序数据的模式的方法。我们已经在package.json文件中包含了 mongoose,因此它应该已经安装。

我们需要为我们的 mongoose 库创建一个配置文件。让我们创建我们的配置文件contact-manager/config/mongoose.js。首先,我们开始加载 Mongoose 库,获取适当的环境配置,并与数据库建立连接。将以下代码添加到mongoose.js文件中:

'use strict';

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

module.exports.init = initMongoose;

function initMongoose(app) {
  mongoose.connect(config.mongodb.uri);

  // If the Node process ends, cleanup existing connections
  process.on('SIGINT', cleanup);
  process.on('SIGTERM', cleanup);
  process.on('SIGHUP', cleanup);

  if (app) {
    app.set('mongoose', mongoose);
  }

  return mongoose;
};

function cleanup() {
  mongoose.connection.close(function () {
    console.log('Closing DB connections and stopping the app. Bye bye.');
    process.exit(0);
  });
}

此外,我们正在使用cleanup()函数来关闭与 MongoDB 数据库的所有连接。前面的代码将导出在主server.js文件中使用的必要init()函数。

管理联系人

现在我们有了启动开发和添加功能所需的文件,我们可以开始实现所有与联系人管理相关的业务逻辑。为此,我们首先需要定义联系人的数据模型。

创建联系人 mongoose 模式

我们的系统需要某种功能来存储可能的客户或只是其他公司的联系人。为此,我们将创建一个表示存储 MongoDB 中所有联系人的相同集合的联系人模式。我们将保持我们的联系人模式简单。让我们在contact-manager/app/models/contact.js中创建一个模型文件,它将包含模式,并将以下代码添加到其中:

'use strict';

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

var ContactSchema = new Schema({
  email:  {
    type: String
  },
  name: {
    type: String
  },
  city: {
    type: String
  },
  phoneNumber: {
    type: String
  },
  company: {
    type: String
  },
  createdAt: {
    type: Date,
    default: Date.now
  }
});

// compile and export the Contact model
module.exports = mongoose.model('Contact', ContactSchema);

以下表格给出了模式中字段的描述:

字段 描述
email 联系人的电子邮件地址
name 联系人的全名
company 联系人工作的公司的名称
phoneNumber 人员的完整电话号码或公司的电话号码
city 联系人的位置
createdAt 联系人对象被创建的日期

我们所有的模型文件都将注册在以下配置文件中,该文件位于 contact-manager/config/models.js 下。该文件的最终版本将类似于以下内容:

'use strict';

module.exports.init = initModels;

function initModels(app) {
  let modelsPath = app.get('root') + '/app/models/';

  ['user', 'contact'].forEach(function(model) {
    require(modelsPath + model);
  });
};

描述联系人路由

为了与服务器通信,我们需要为客户端应用程序提供路由以供消费。这些将是响应客户端请求的端点(URI)。主要,我们的路由将发送 JSON 响应。

我们将首先描述联系人模块的 CRUD 功能。路由应公开以下功能:

  • 创建新的联系人

  • 通过 ID 获取联系人

  • 获取所有联系人

  • 更新联系人

  • 通过 ID 删除联系人

在这个应用程序中,我们不会涵盖批量插入和删除。

以下表格显示了这些操作如何映射到 HTTP 路由和动词:

路由 动词 描述 数据
/contacts POST 创建新的联系人 emailnamecompanyphoneNumbercity
/contacts GET 从系统中获取所有联系人
/contacts/<id> GET 获取特定的联系人
/contacts/<id> PUT 更新特定的联系人 emailnamecompanyphoneNumbercity
/contacts/<id> DELETE 删除特定的联系人

按照前面的表格作为指南,我们将描述我们的主要功能和使用 Mocha 进行测试。Mocha 允许我们通过提供封装我们期望的 describe() 函数的能力来描述我们正在实施的功能。函数的第一个参数是一个简单的字符串,描述了功能。第二个参数是一个函数体,代表了描述。

您已经创建了一个名为 contact-manger/tests 的文件夹。在您的 tests 文件夹中,创建另一个名为 integration 的文件夹。创建一个名为 contact-manager/tests/integration/contact_test.js 的文件,并添加以下代码:

'use strict';

/**
 * Important! Set the environment to test
 */
process.env.NODE_ENV = 'test';

const http = require('http');
const request = require('request');
const chai = require('chai');
const userFixture = require('../fixtures/user');
const should = chai.should();

let app;
let appServer;
let mongoose;
let User;
let Contact;
let config;
let baseUrl;
let apiUrl;

describe('Contacts endpoints test', function() {

  before((done) => {
    // boot app
    // start listening to requests
  });

  after(function(done) {
    // close app
    // cleanup database
    // close connection to mongo
  });

  afterEach((done) => {
    // remove contacts
  });

  describe('Save contact', () => {});

  describe('Get contacts', () => {});

  describe('Get contact', function() {});

  describe('Update contact', function() {});

  describe('Delete contact', function() {});
});

在我们的测试文件中,我们要求了依赖项,并使用 Chai 作为我们的断言库。正如您所看到的,除了 describe() 函数外,mocha 还为我们提供了额外的方法:before()after()beforeEach()afterEach()

这些是钩子,可以是异步或同步的,但我们将使用它们的异步版本。钩子对于在运行测试之前准备先决条件非常有用;例如,您可以用模拟数据填充您的数据库或清理它。

在主要描述主体中,我们使用了三个钩子:before()after()afterEach()。在 before() 钩子中,它将在任何 describe() 函数之前运行,我们设置服务器监听指定的端口,并在服务器开始监听时调用 done() 函数。

after() 函数将在所有 describe() 函数运行完毕后执行,并将停止服务器运行。现在,afterEach() 钩子将在每个 describe() 函数之后运行,并允许我们在每个测试运行后从数据库中删除所有联系人。

最终版本可以在应用程序的代码包中找到。您仍然可以了解我们如何添加所有必要的描述。

创建联系人

我们还添加了四到五个单独的描述,这些描述将定义之前表格中的 CRUD 操作。首先,我们希望能够创建一个新的联系人。将以下代码添加到测试用例中:

  describe('Create contact', () => {
    it('should create a new contact', (done) => {
      request({
        method: 'POST',
        url: `${apiUrl}/contacts`,
        form: {
          'email': 'jane.doe@test.com',
          'name': 'Jane Doe'
        },
        json:true
      }, (err, res, body) => {
        if (err) throw err;

        res.statusCode.should.equal(201);
        body.email.should.equal('jane.doe@test.com');
        body.name.should.equal('Jane Doe');
        done();
      });
    });
  });

获取联系人

接下来,我们希望从系统中获取所有联系人。以下代码应描述此功能:

  describe('Get contacts', () => {
    before((done) => {
      Contact.collection.insert([
        { email: 'jane.doe@test.com' },
        { email: 'john.doe@test.com' }
      ], (err, contacts) => {
        if (err) throw err;

        done();
      });
    });

    it('should get a list of contacts', (done) => {
      request({
        method: 'GET',
        url: `${apiUrl}/contacts`,
        json:true
      }, (err, res, body) => {
        if (err) throw err;

        res.statusCode.should.equal(200);
        body.should.be.instanceof(Array);
        body.length.should.equal(2);
        body.should.contain.a.thing.with.property('email', 'jane.doe@test.com');
        body.should.contain.a.thing.with.property('email', 'john.doe@test.com');
        done();
      });
    });
  });

如您所见,我们在描述中添加了一个 before() 钩子。这是绝对正常的,并且可以这样做。Mocha 允许这种行为以便轻松设置先决条件。我们在获取所有联系人之前,使用批量插入 Contact.collection.insert() 将数据添加到 MongoDB 中。

通过 ID 获取联系人

当通过 ID 获取联系人时,我们还想检查插入的 ID 是否符合我们的 ObjectId 标准。如果未找到联系人,我们希望返回 404 HTTP 状态码:

  describe('Get contact', function() {
    let _contact;

    before((done) => {
      Contact.create({
        email: 'john.doe@test.com'
      }, (err, contact) => {
        if (err) throw err;

        _contact = contact;
        done();
      });
    });

    it('should get a single contact by id', (done) => {
      request({
        method: 'GET',
        url: `${apiUrl}/contacts/${_contact.id}`,
        json:true
      }, (err, res, body) => {
        if (err) throw err;

        res.statusCode.should.equal(200);
        body.email.should.equal(_contact.email);
        done();
      });
    });

    it('should not get a contact if the id is not 24 characters', (done) => {
      request({
        method: 'GET',
        url: `${apiUrl}/contacts/U5ZArj3hjzj3zusT8JnZbWFu`,
        json:true
      }, (err, res, body) => {
        if (err) throw err;

        res.statusCode.should.equal(404);
        done();
      });
    });
  });

我们使用了 .create() 方法。对于单条插入,使用它更为方便,可以预先将数据填充到数据库中。当我们通过 ID 获取单个联系人时,我们想要确保这是一个有效的 ID,因此我们添加了一个测试来反映这一点,如果 ID 无效或未找到联系人,则应返回 404 Not Found 响应。

更新联系人

我们还希望能够使用给定的 ID 更新现有的联系人。添加以下代码来描述此功能:

  describe('Update contact', () => {
    let _contact;

    before((done) => {
      Contact.create({
        email: 'jane.doe@test.com'
      }, (err, contact) => {
        if (err) throw err;

        _contact = contact;
        done();
      });
    });

    it('should update an existing contact', (done) => {
      request({
        method: 'PUT',
        url: `${apiUrl}/contacts/${_contact.id}`,
        form: {
          'name': 'Jane Doe'
        },
        json:true
      }, (err, res, body) => {
        if (err) throw err;

        res.statusCode.should.equal(200);
        body.email.should.equal(_contact.email);
        body.name.should.equal('Jane Doe');
        done();
      });
    });
  });

删除联系人

最后,我们将通过添加以下代码来描述删除联系人操作(CRUD 中的 DELETE):

  describe('Delete contact', () => {
    var _contact;

    before((done) => {
      Contact.create({
        email: 'jane.doe@test.com'
      }, (err, contact) => {
        if (err) throw err;

        _contact = contact;
        done();
      });
    });

    it('should update an existing contact', (done) => {
      request({
        method: 'DELETE',
        url: `${apiUrl}/contacts/${_contact.id}`,
        json:true
      }, (err, res, body) => {
        if (err) throw err;

        res.statusCode.should.equal(204);
        should.not.exist(body);
        done();
      });
    });
  });

在删除联系人后,服务器应响应 HTTP 204 No Content 状态码,这意味着服务器已成功解析请求并处理它,但由于联系人已成功删除,不应返回任何内容。

运行我们的测试

假设我们运行以下命令:

$ mocha test/integration/contact_test.js

在这一点上,我们将收到大量的 HTTP 404 Not Found 状态码,因为我们的路由尚未实现。输出应类似于以下内容:

 Contact
 Save contact
 1) should save a new contact
 Get contacts
 2) should get a list of contacts
 Get contact
 3) should get a single contact by id
 √ should not get a contact if the id is not 24 characters
 Update contact
 4) should update an existing contact
 Delete contact
 5) should update an existing contact

 1 passing (485ms)
 5 failing
 1) Contact Save contact should save a new contact:

 Uncaught AssertionError: expected 404 to equal 201
 + expected - actual

 +201
 -404

实现联系人路由

现在,我们将开始实现联系人 CRUD 操作。我们将首先创建我们的控制器。创建一个新的文件,contact-manager/app/controllers/contact.js,并添加以下代码:

'use strict';

const _ = require('lodash');
const mongoose = require('mongoose');
const Contact = mongoose.model('Contact');
const ObjectId = mongoose.Types.ObjectId;

module.exports.create = createContact;
module.exports.findById = findContactById;
module.exports.getOne = getOneContact;
module.exports.getAll = getAllContacts;
module.exports.update = updateContact;
module.exports.remove = removeContact;

function createContact(req, res, next) {
  Contact.create(req.body, (err, contact) => {
    if (err) {
      return next(err);
    }

    res.status(201).json(contact);
  });
}

前面的代码所做的是导出控制器中所有用于 CRUD 操作的方法。为了创建一个新的联系人,我们使用 Contact 架构中的 create() 方法。

我们返回一个包含新创建联系人的 JSON 响应。如果发生错误,我们只需使用错误对象调用 next() 函数。我们将在稍后添加一个特殊处理程序来捕获所有错误。

让我们为我们的路由创建一个新的文件,contact-manager/app/routes/contacts.js。以下代码行应该是我们路由器的良好开端:

'use strict';

const express = require('express');
const router = express.Router();
const contactController = require('../controllers/contact');

router.post('/contacts', auth.ensured, contactController.create);

module.exports = router;

假设我们现在使用这个运行测试,如下所示:

$ mocha tests/integration/contact_test.js

我们应该得到类似以下的内容:

Contact
 Create contact
 √ should save a new contact
 Get contacts
 1) should get a list of contacts
 Get contact
 2) should get a single contact by id
 √ should not get a contact if the id is not 24 characters
 Update contact
 3) should update an existing contact
 Delete contact
 4) should update an existing contact

 2 passing (502ms)
 4 failing

添加所有端点

接下来,我们将添加其余的路由,通过将以下代码添加到contact-manager/app/routes/contact.js文件中:

router.param('contactId', contactController.findById);

router.get('/contacts', auth.ensured, contactController.getAll);
router.get('/contacts/:contactId', auth.ensured, contactController.getOne);
router.put('/contacts/:contactId', auth.ensured, contactController.update);
router.delete('/contacts/:contactId', auth.ensured, contactController.remove);

我们定义了所有路由,并为contactId路由参数添加了回调触发。在 Express 中,我们可以使用名为参数名称的param()方法添加回调触发。

回调函数类似于任何正常的路由回调,但它会多一个参数,表示路由参数的值。一个具体的例子如下:

app.param('contactId', function(req, res, next, id) { 
  // do something with the id ...
});

按照前面的示例,当:contactId在路由路径中存在时,我们可以映射联系人加载逻辑,并将联系人提供给下一个处理器。

通过 ID 查找联系人

我们将在位于contact-manager/app/controllers/contact.js的控制器文件中添加其余缺失的功能:

function findContactById(req, res, next, id) {
  if (!ObjectId.isValid(id)) {
    res.status(404).send({ message: 'Not found.'});
  }

  Contact.findById(id, (err, contact) => {
    if (err) {
      next(err);
    } else if (contact) {
      req.contact = contact;
      next();
    } else {
      next(new Error('failed to find contact'));
    }
  });
}

前面的函数是一个特殊情况。它将获取四个参数,最后一个将是与触发参数值匹配的 ID。

获取联系人信息

要获取所有联系人,我们将查询数据库。我们将根据创建日期对结果进行排序。一个好的做法是始终限制返回的数据集的大小。为此,我们使用MAX_LIMIT常量:

function getAllContacts(req, res, next) {
  const limit = +req.query.limit || MAX_LIMIT;
  const skip = +req.query.offset || 0;
  const query = {};

  if (limit > MAX_LIMIT) {
    limit = MAX_LIMIT;
  }

  Contact
  .find(query)
  .skip(skip)
  .limit(limit)
  .sort({createdAt: 'desc'})
  .exec((err, contacts) => {
    if (err) {
      return next(err);
    }

    res.json(contacts);
  });
}

要返回单个联系人,你可以使用以下代码:

function getOneContact(req, res, next) {
  if (!req.contact) {
    return next(err);
  }

  res.json(req.contact);
}

理论上,我们将在路由定义中拥有:contactId参数。在这种情况下,param回调会被触发,将请求的联系人填充到req对象中。

更新联系人

当更新联系人时,也应用了相同的原则;请求的实体应由param回调填充。我们只需将传入的数据分配给联系人对象,并将更改保存到 MongoDB 中:

function updateContact(req, res, next) {
  let contact = req.contact;
  _.assign(contact, req.body);

  contact.save((err, updatedContact) => {
    if (err) {
      return next(err);
    }

    res.json(updatedContact);
  });
}

删除联系人

删除联系人应该相当简单,因为它没有依赖的文档。因此,我们可以简单地从数据库中删除文档,使用以下代码:

function removeContact(req, res, next) {
  req.contact.remove((err) => {
    if (err) {
      return next(err);
    }

    res.status(204).json();
  });
}

运行联系人测试

到目前为止,我们应该已经实现了后端管理联系人的所有要求。为了测试一切,我们运行以下命令:

$ mocha tests/integration/contact.test.js

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

 Contact

 Save contact
 √ should save a new contact
 Get contacts
 √ should get a list of contacts
 Get contact
 √ should get a single contact by id
 √ should not get a contact if the id is not 24 characters
 Update contact
 √ should update an existing contact
 Delete contact
 √ should update an existing contact

 6 passing (576ms)

这意味着所有测试都成功通过,并且我们已经实现了所有要求。

保护应用程序路由

你可能不希望让任何人看到你的联系人,所以是时候保护你的端点了。我们可以使用许多策略来在应用程序中验证受信任的用户。我们将使用经典的、基于状态的全局电子邮件和密码验证。这意味着会话将存储在服务器端。

记得我们在本章开头讨论了我们将如何将我们的会话存储在服务器端吗?我们选择了两个集成,一个是默认的内存会话管理,另一个是将会话存储在 MongoDB 中。所有这些都可以从环境配置文件中进行配置。

当涉及到在 Node.js 中处理认证时,一个好的选择是 Passport 模块,它是一块认证中间件。Passport 提供了一套全面的认证策略,使用简单的用户名和密码组合来支持 Facebook、Google、Twitter 等许多服务。

我们已经将此依赖项添加到我们的应用程序中,并在 express 配置文件中进行了必要的初始化。我们仍然需要添加一些内容,但在那之前,我们必须在我们的后端应用程序中创建一些可重用的组件。我们将创建一个辅助文件,这将简化我们与密码的交互。

描述密码辅助器

在我们深入探讨认证机制之前,我们需要能够在 MongoDB 中存储密码哈希而不是明文密码。我们希望创建一个辅助器来完成这个任务,使我们能够执行与密码相关的操作。

tests文件夹中创建一个新的文件夹,命名为unit。添加一个新文件,contact-manager/tests/unit/password.test.js,然后向其中添加以下代码:

'use strict';

const chai = require('chai');
const should = chai.should();
const passwordHelper = require('../../app/helpers/password');

describe('Password Helper', () => {
});

在我们的主要描述体中,我们将添加代表我们功能的详细段。添加以下代码:

describe('#hash() - password hashing', () => {
});
describe('#verify() - compare a password with a hash', () => {
});

Mocha 还提供了一个it()函数,我们将使用它来设置一个具体的测试。it()函数与describe()非常相似,只不过我们只放置了功能应该执行的内容。对于断言,我们将使用 Chai 库。将以下代码添加到tests/unit/password.test.js文件中:

  describe('#hash() - password hashing', () => {
    it('should return a hash and a salt from a plain string', (done) => {
      passwordHelper.hash('P@ssw0rd!', (err, hash, salt) => {
        if (err) throw err;

        should.exist(hash);
        should.exist(salt);
        hash.should.be.a('string');
        salt.should.be.a('string');
        hash.should.not.equal('P@ssw0rd!');
        done();
      });
    });

    it('should return only a hash from a plain string if salt is given', (done) => {
      passwordHelper.hash('P@ssw0rd!', 'secret salt', (err, hash, salt) => {
        if (err) throw err;

        should.exist(hash);
        should.not.exist(salt);
        hash.should.be.a('string');
        hash.should.not.equal('P@ssw0rd!');
        done();
      });
    });

    it('should return the same hash if the password and salt ar the same', (done) => {
      passwordHelper.hash('P@ssw0rd!', (err, hash, salt) => {
        if (err) throw err;

        passwordHelper.hash('P@ssw0rd!', salt, function(err, hashWithSalt) {
          if (err) throw err;

          should.exist(hash);
          hash.should.be.a('string');
          hash.should.not.equal('P@ssw0rd!');
          hash.should.equal(hashWithSalt);
          done();
        });
      });
    });
  });

passwordHelper还应测试密码是否与给定的哈希和盐组合匹配。为此,我们将添加以下描述方法:

  describe('#verify() - compare a password with a hash', () => {
    it('should return true if the password matches the hash', (done) => {
      passwordHelper.hash('P@ssw0rd!', (err, hash, salt) => {
        if (err) throw err;

        passwordHelper.verify('P@ssw0rd!', hash, salt, (err, result) => {
          if (err) throw err;

          should.exist(result);
          result.should.be.a('boolean');
          result.should.equal(true);
          done();
        });
      });
    });

    it('should return false if the password does not matches the hash', (done) => {
      passwordHelper.hash('P@ssw0rd!', (err, hash, salt) => {
        if (err) throw err;

        passwordHelper.verify('password!', hash, salt, (err, result) => {
          if (err) throw err;

          should.exist(result);
          result.should.be.a('boolean');
          result.should.equal(false);
          done();
        });
      });
    });
  });

实现密码辅助器

我们将在以下文件中实现我们的密码辅助器:contact-manager/app/helpers/password.js

我们密码辅助器的第一个描述描述了一个从明文密码创建哈希的函数。在我们的实现中,我们将使用一个密钥派生函数,它将从我们的密码计算哈希,也称为密钥拉伸。

我们将使用内置的 Node.js crypto库中的pbkdf2函数。该函数的异步版本接受一个明文密码并应用 HMAC 摘要函数。我们将使用sha256来获取给定长度的派生密钥,并通过多次迭代与盐结合。

我们希望对于两种情况都使用相同的哈希函数:当我们已经有一个密码哈希和一个盐时,以及当我们只有明文密码时。让我们看看我们的哈希函数的最终代码。添加以下内容:

'use strict';

const crypto = require('crypto');
const len = 512;
const iterations = 18000;
const digest = 'sha256';

module.exports.hash = hashPassword;
module.exports.verify = verify;

function hashPassword(password, salt, callback) {
  if (3 === arguments.length) {
    crypto.pbkdf2(password, salt, iterations, len, digest, (err, derivedKey) => {
      if (err) {
        return callback(err);
      }

      return callback(null, derivedKey.toString('base64'));
    });
  } else {
    callback = salt;
    crypto.randomBytes(len, (err, salt) => {
      if (err) {
        return callback(err);
      }

      salt = salt.toString('base64');
      crypto.pbkdf2(password, salt, iterations, len, digest, (err, derivedKey) => {
        if (err) {
          return callback(err);
        }

        callback(null, derivedKey.toString('base64'), salt);
      });
    });
  }
}

让我们看看现在运行我们的测试会得到什么。运行以下命令:

$ mocha tests/unit/password.test.js

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

 Password Helper
 #hash() - password hashing
 √ should return a hash and a salt from a plain string (269ms)
 √ should return only a hash from a plain string if salt is given (274ms)
 √ should return the same hash if the password and salt are the same (538ms)

3 passing (2s)

如您所见,我们已经成功实现了我们的哈希函数。所有测试用例的要求都已通过。请注意,运行测试可能需要最多 2 秒钟。不用担心这个问题;这是因为密钥拉伸函数需要时间从密码生成哈希值。

接下来,我们将实现 verify() 函数,该函数检查密码是否与现有用户的密码-哈希-盐组合匹配。根据我们的测试描述,此函数接受四个参数:明文密码、使用第三个盐参数生成的哈希值,以及回调函数。

回调函数接收两个参数:errresultresult 可以是 truefalse。这将反映密码是否与现有的哈希值匹配。考虑到测试的约束和前面的解释,我们可以在 password.helpr.js 文件中添加以下代码:

function verify(password, hash, salt, callback) {
  hashPassword(password, salt, (err, hashedPassword) => {
    if (err) {
      return callback(err);
    }

    if (hashedPassword === hash) {
      callback(null, true);
    } else {
      callback(null, false);
    }
  });
}

到目前为止,我们应该已经实现了测试中所有的规范。

创建用户 Mongoose 模式

为了授予应用程序中用户的访问权限,我们需要将它们存储在 MongoDB 集合中。我们将创建一个名为 contact-manager/app/models/user.model.js 的新文件,并添加以下代码:

'use strict';

const mongoose = require('mongoose');
const passwordHelper = require('../helpers/password');
const Schema = mongoose.Schema;
const _ = require('lodash');

var UserSchema = new Schema({
  email:  {
    type: String,
    required: true,
    unique: true
  },
  name: {
    type: String
  },
  password: {
    type: String,
    required: true,
    select: false
  },
  passwordSalt: {
    type: String,
    required: true,
    select: false
  },
  active: {
    type: Boolean,
    default: true
  },
  createdAt: {
    type: Date,
    default: Date.now
  }
});

以下表格描述了模式中的字段:

字段 描述
email 用户的电子邮件。这用于识别用户。电子邮件在系统中将是唯一的。
name 用户的完整姓名。
password 这是用户提供的密码。它不会以明文形式存储在数据库中,而是以哈希形式存储。
passwordSalt 每个密码都将为给定用户生成一个唯一的盐。
active 这指定了用户的状态。可以是活跃的或非活跃的。
createdAt 用户创建的日期。

从用户模型描述身份验证方法

我们将描述一个用户身份验证方法。它将检查用户是否有有效的凭据。以下文件 contact-manager/tests/integration/user.model.test.js 应包含有关 User 模型的所有测试用例。以下代码行将测试 authenticate() 方法:

  it('should authenticate a user with valid credentials', done => {
    User.authenticate(newUserData.email, newUserData.password, (err, user) => {
      if (err) throw err;

      should.exist(user);
      should.not.exist(user.password);
      should.not.exist(user.passwordSalt);
      user.email.should.equal(newUserData.email);
      done();
    });
  });

  it('should not authenticate user with invalid credentials', done => {
    User.authenticate(newUserData.email, 'notuserpassowrd', (err, user) => {
      if (err) throw err;

      should.not.exist(user);
      done();
    });
  });

实现身份验证方法

Mongoose 允许我们从模式中添加静态方法到编译后的模型。authenticate() 方法将通过电子邮件在数据库中搜索用户,并使用密码辅助器的 verify() 函数检查发送的密码是否匹配。

将以下代码行添加到 contact-manager/app/models/user.js 文件中:

UserSchema.statics.authenticate = authenticateUser;

function authenticateUser(email, password, callback) {
  this
  .findOne({ email: email })
  .select('+password +passwordSalt')
  .exec((err, user) => {
    if (err) {
      return callback(err, null);
    }

    // no user found just return the empty user
    if (!user) {
      return callback(err, user);
    }

    // verify the password with the existing hash from the user
    passwordHelper.verify(
      password,
      user.password,
      user.passwordSalt,
      (err, result) => {
        if (err) {
          return callback(err, null);
        }

        // if password does not match don't return user
        if (result === false) {
          return callback(err, null);
        }

        // remove password and salt from the result
        user.password = undefined;
        user.passwordSalt = undefined;
        // return user if everything is ok
        callback(err, user);
      }
    );
  });
}

在前面的代码中,当从 MongoDB 中选择用户时,我们明确选择了密码和 passwordSalt 字段。这是必要的,因为我们设置了密码和 passwordSalt 字段,使其不在查询结果中选中。另外,需要注意的是,我们希望在返回用户时从结果中移除密码和盐。

身份验证路由

为了在我们的系统中进行认证,我们需要公开一些端点来执行必要的业务逻辑以验证具有有效凭证的用户。在深入任何代码之前,我们将描述期望的行为。

描述认证路由

我们只将查看认证功能集成测试的部分代码,该代码位于contact-manager/tests/integration/authentication.test.js中。它应该看起来像这样:

  describe('Sign in user', () => {
    it('should sign in a user with valid credentials', (done) => {
      request({
        method: 'POST',
        url: baseUrl + '/auth/signin',
        form: {
          'email': userFixture.email,
          'password': 'P@ssw0rd!'
        },
        json:true
      }, (err, res, body) => {
        if (err) throw err;

        res.statusCode.should.equal(200);
        body.email.should.equal(userFixture.email);
        should.not.exist(body.password);
        should.not.exist(body.passwordSalt);
        done();
      });
    });

    it('should not sign in a user with invalid credentials', (done) => {
      request({
        method: 'POST',
        url: baseUrl + '/auth/signin',
        form: {
          'email': userFixture.email,
          'password': 'incorrectpassword'
        },
        json:true
      }, (err, res, body) => {
        if (err) throw err;

        res.statusCode.should.equal(400);
        body.message.should.equal('Invalid email or password.');
        done();
      });
    });
  });

因此,我们描述了一个auth/signin端点;它将使用电子邮件和密码组合来验证用户。我们正在测试两种场景。第一种是用户具有有效凭证,第二种是发送了错误的密码。

集成 Passport

我们在章节中提到了 Passport 并添加了一些基本逻辑,但我们仍然需要正确集成。Passport 模块应该已经安装,会话管理也已经就绪。因此,接下来我们需要创建一个适当的配置文件,contact-manager/config/passport.js,并添加以下内容:

'use strict';

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

module.exports.init = initPassport;

function initPassport(app) {
  passport.serializeUser((user, done) => {
    done(null, user.id);
  });

  passport.deserializeUser((id, done) => {
    User.findById(id, done);
  });

  // load strategies
  require('./strategies/local').init();
}

对于每个后续请求,我们需要将用户实例序列化和反序列化到会话中。我们只将用户的 ID 序列化到会话中。当后续请求被发起时,用户的 ID 被用来找到匹配的用户并在req.user中恢复数据。

Passport 为我们提供了使用不同策略来验证用户的能力。我们只将使用电子邮件和密码来验证用户。为了保持模块化,我们将策略移动到单独的文件中。所谓的本地策略,将用于使用电子邮件和密码验证用户,它将位于contact-manager/config/strategies/local.js文件中:

'use strict';

const passport = require('passport');
const LocalStrategy = require('passport-local').Strategy;
const User = require('mongoose').model('User');

module.exports.init = initLocalStrategy;

function initLocalStrategy() {
  passport.use('local', new LocalStrategy({
      usernameField: 'email',
      passwordField: 'password'
    },
    (email, password, done) => {
      User.authenticate(email, password, (err, user) => {
        if (err) {
          return done(err);
        }

        if (!user) {
          return done(null, false, { message: 'Invalid email or password.' });
        }

        return done(null, user);
      });
    }
  ));
}

实现认证路由

现在我们已经启动了 passport,我们可以定义我们的认证控制器逻辑和适当的登录用户路由。创建一个名为contact-manager/app/controllers/authentication.js的新文件:

'use strict';

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

module.exports.signin = signin;

function signin(req, res, next) {
  passport.authenticate('local', (err, user, info) => {
    if (err) {
      return next(err);
    }

    if (!user) {
      return res.status(400).send(info);
    }

    req.logIn(user, (err) => {
      if (err) {
        return next(err);
      }

      res.status(200).json(user);
    });
  })(req, res, next);
}

在这里,我们使用 Passport 的.authenticate()函数来检查用户使用之前实现的本地策略的凭证。接下来,我们将添加认证路由,创建一个名为contact-manager/app/routes/auth.js的新文件,并添加以下代码行:

'use strict';

var express = require('express');
var router = express.Router();
var authCtrl = require('../controllers/authentication');

router.post('/signin', authCtrl.signin);
router.post('/register', authCtrl.register);

module.exports = router;

注意,我们跳过了注册用户的功能,但别担心!最终的捆绑项目源代码将包含所有必要的逻辑。

限制对联系人路由的访问

我们创建了所有验证用户的要求。现在,我们需要限制对一些路由的访问,因此技术上我们将创建一个简单的 ACL。为了限制访问,我们将使用一个中间件来检查用户是否已认证。

让我们创建我们的中间件文件,contact-manager/app/middlewares/authentication.js。这个文件应该包含以下精心编写的代码:

'use strict';

module.exports.ensured = ensureAuthenticated;

function ensureAuthenticated(req, res, next) {
  if (req.isAuthenticated()) {
    return next();
  }

  res.status(401).json({
    message: 'Please authenticate.'
  });
}

我们已经添加了必要的逻辑来限制用户访问联系人路由;那是在我们首次创建它们时。我们成功添加了所有必要的代码来管理联系人和限制对端点的访问。现在我们可以继续并开始构建我们的 Angular 2 应用程序。

将 Angular 2 集成到我们的应用程序中

前端应用程序将使用 Angular 2 构建。在撰写本书时,该项目仍在测试版,但开始尝试使用 Angular 并对环境有良好理解将很有帮助。大部分代码将遵循官方文档中工具和集成方法的视图。

当我们首次描述我们的文件夹结构时,我们看到了客户端应用程序的 package.json 文件。让我们看一下它,位于 contact-manager/public/package.json 路径下:

{
  "private": true,
  "name": "mean-blueprints-contact-manager-client",
  "dependencies": {
    "systemjs": "⁰.19.25",
    "es6-shim": "⁰.35.0",
    "es6-promise": "³.0.2",
    "rxjs": "⁵.0.0-beta.2",
    "reflect-metadata": "⁰.1.2",
    "zone.js": "⁰.6.6",
    "angular2": "².0.0-beta.14"
  },
  "devDependencies": {
    "typings": "⁰.7.12",
    "typescript": "¹.8.9"
  }
}

要安装必要的依赖项,只需使用以下命令:

$ npm install

您将看到 npm 正在拉取 package.json 文件中指定的不同包。

如您所见,我们将在客户端应用程序中使用 TypeScript。如果您已全局安装它,可以使用以下命令编译并监视 .ts 文件的变化:

$ tsc -w

只会讨论应用程序最重要的部分。其余必要的文件和文件夹可以在最终的打包源代码中找到。

授予我们的应用程序访问权限

我们已经限制了我们对 API 端点的访问,因此现在我们必须从客户端应用程序中授予用户登录功能。我喜欢根据它们的领域上下文分组 Angular 2 应用程序文件。例如,所有我们的身份验证、注册和业务逻辑都应该放入一个单独的文件夹;我们可以称它为 auth

如果您的模块目录增长,根据上下文按类型将其拆分为单独的文件夹是一种良好的做法。没有固定的文件数量。通常,当您需要移动文件时,您会有一个很好的感觉。您的文件应该始终易于定位,并从其在特定上下文中的位置提供足够的信息。

AuthService

我们将使用 AuthService 来实现数据访问层并调用后端。这个服务将成为我们 API 的登录和注册功能之间的桥梁。创建一个名为 contact-manager/src/auth/auth.service.ts 的新文件,并将以下 TypeScript 代码添加到其中:

import { Injectable } from 'angular2/core';
import { Http, Response, Headers } from 'angular2/http';
import { contentHeaders } from '../common/headers';

@Injectable()
export class AuthService {
  private _http: Http;

  constructor(http: Http) {
    this._http = http;
  }
}

我们导入必要的模块,定义 AuthService 类,并导出它。Injectable 标记元数据将标记我们的类以使其可供注入。为了与后端通信,我们使用 HTTP 服务。不要忘记在启动应用程序时添加 HTTP_PROVIDERS,以便服务在整个应用程序中可用。

要登录用户,我们将添加以下方法:

  public signin(user: any) {
    let body = this._serialize(user);

    return this._http
    .post('/auth/signin', body, { headers: contentHeaders })
    .map((res: Response) => res.json());
  }

我们可以使用 .map() 操作符将响应转换为 JSON 文件。在执行 HTTP 请求时,这将返回一个 Observable。你可能已经猜到了——我们将大量使用 RxJs响应式扩展),这是一个由 Angular 喜爱的第三方库。

RxJs 实现了异步可观察模式。换句话说,它使你能够处理异步数据流并应用不同的操作符。在 Angular 应用程序中广泛使用 Observables。在撰写本书时,Angular 2 提供了 RxJs 的 Observable 模块的简化版本。

不要担心;随着我们进一步深入本书,我们将熟悉这项技术和它的好处。现在让我们继续实现我们想要公开的其他缺失方法:

  public register(user: any) {
    let body = this._serialize(user);

    return this._http
    .post('/auth/register', body, { headers: contentHeaders })
    .map((res: Response) => res.json());
  }

  private _serialize(data) {
    return JSON.stringify(data);
  }

我们在我们的服务中添加了 register() 方法,它将处理用户注册。此外,请注意,我们将序列化移动到了一个单独的私有方法中。我保留了此方法在同一类中,以便更容易理解,但你也可以将其移动到辅助类中。

用户登录组件

首先,我们将实现登录组件。让我们创建一个名为 contact-manager/public/src/auth/sigin.ts 的新文件,并添加以下 TypeScript 代码行:

import { Component } from 'angular2/core';
import { Router, RouterLink } from 'angular2/router';
import { AuthService } from './auth.service';

export class Signin {
  private _authService: AuthService;
  private _router: Router;

  constructor(
    authService: AuthService,
    router: Router
  ) {
    this._authService = authService;
    this._router = router;
  }

  signin(event, email, password) {
    event.preventDefault();

    let data = { email, password };

    this._authService
    .signin(data)
    .subscribe((user) => {
      this._router.navigateByUrl('/');
    }, err => console.error(err));
  }
}

我们仍然需要在 Signin 类之前添加 Component 注解:

@Component({
    selector: 'signin',
    directives: [
      RouterLink
    ],
    template: `
      <div class="login jumbotron center-block">
        <h1>Login</h1>
        <form role="form" (submit)="signin($event, email.value, password.value)">
          <div class="form-group">
            <label for="email">E-mail</label>
            <input type="text" #email class="form-control" id="email" placeholder="enter your e-mail">
          </div>
          <div class="form-group">
            <label for="password">Password</label>
            <input type="password" #password class="form-control" id="password" placeholder="now your password">
          </div>
          <button type="submit" class="button">Submit</button>
          <a href="#" [routerLink]="['Register']">Click here to register</a>
        </form>
      </div>
    `
})

Signin 组件将成为我们的登录表单,并使用 AuthService 与后端通信。在组件的模板中,我们使用带有 # 符号的局部变量标记电子邮件和密码字段。

正如我们之前所说的,HTTP 服务在发起请求时返回一个 Observable。这就是为什么我们可以订阅由我们的 AuthService 发起的请求生成的响应。在成功认证后,用户将被重定向到默认的主路径。

Register 组件将与 Signin 组件看起来相似,因此没有必要详细说明这一场景。auth 模块的最终版本将在源代码中提供。

自定义 HTTP 服务

为了限制对我们的 API 端点的访问,我们必须确保,如果请求未经授权,我们将用户重定向到登录页面。Angular 2 不支持拦截器,我们也不想为每个集成到我们服务中的请求添加处理器。

一个更方便的解决方案是在内置的 HTTP 服务之上构建我们自己的自定义服务。我们可以称它为 AuthHttp,用于授权 HTTP 请求。它的目的是检查请求是否返回了 401 未授权 HTTP 状态码

我想进一步思考这个问题,并引入一些响应式编程的提示,因为我们已经在使用 RxJS。因此,我们可以从它提供的完整功能集中受益。响应式编程围绕数据展开。数据流在你的应用程序中传播,并对这些变化做出反应。

让我们开始工作,开始构建我们的自定义服务。创建一个名为 contact-manager/public/src/auth/auth-http.ts 的文件。我们将添加几行代码:

import { Injectable } from 'angular2/core';
import { Http, Response, Headers, BaseRequestOptions, Request, RequestOptions, RequestOptionsArgs, RequestMethod } from 'angular2/http';
import { Observable } from 'rxjs/Observable';
import { Subject } from 'rxjs/Subject';
import { BehaviorSubject } from 'rxjs/Subject/BehaviorSubject';

@Injectable()
export class AuthHttp {
  public unauthorized: Subject<Response>;
  private _http: Http;

  constructor(http: Http) {
    this._http = http;
    this.unauthorized = new BehaviorSubject<Response>(null);
  }
}

我们在文件顶部导入了一些东西。我们将在本模块中需要所有这些。我们定义了一个名为 unauthorized 的公共属性,它是一个 Subject。Subject 既是 Observable 也是 Observer。这意味着我们可以将我们的主题订阅到后端数据源,并且所有观察者都可以订阅主题。

在我们的案例中,主题将作为我们的数据源和所有已订阅观察者之间的代理。如果一个请求未经授权,所有订阅者都会收到变更通知。这使得我们只需订阅主题,当我们检测到未经授权的请求时,就可以将用户重定向到登录页面。

要成功做到这一点,我们必须向我们的 AuthHttp 服务添加几个更多的方法:

  private request(requestArgs: RequestOptionsArgs, additionalArgs?: RequestOptionsArgs) {
    let opts = new RequestOptions(requestArgs);

    if (additionalArgs) {
      opts = opts.merge(additionalArgs);
    }

    let req:Request = new Request(opts);

    return this._http.request(req).catch((err: any) => {
      if (err.status === 401) {
        this.unauthorized.next(err);
      }

      return Observable.throw(err);
    });
  }

上述方法创建了一个带有所需 RequestOptions 的新请求,并从基本 HTTP 服务中调用 request 方法。此外,catch 方法捕获所有状态码非 200 级别的请求。

使用这种技术,我们可以通过我们的 unauthorized 主题向所有订阅者发送未经授权的请求。现在我们已经有了我们的私有 request 方法,我们只需要添加其余的公共 HTTP 方法:

  public get(url: string, opts?: RequestOptionsArgs) {
    return this.request({ url: url, method: RequestMethod.Get}, opts);
  }

  public post(url: string, body?: string, opts?: RequestOptionsArgs) {
    return this.request({ url: url, method: RequestMethod.Post, body: body}, opts);
  }

  public put(url: string, body?: string, opts?: RequestOptionsArgs) {
    return this.request({ url: url, method: RequestMethod.Put, body: body}, opts);
  }

  // rest of the HTTP methods ...

我只添加了最常用的方法;其余的可以在完整版本中找到。前面的代码调用了我们的请求方法,并为每种请求类型设置了必要的选项。理论上,我们创建了一个外观来处理未经授权的请求。

我认为我们已经取得了很好的进展,现在是时候继续我们的联系管理应用程序的其他模块了。

联系模块

此模块将包含管理联系所需的所有文件。正如我们之前讨论的,我们根据上下文将文件分组,与它们的领域相关。我们模块的起点将是数据层,这意味着我们将开始实现必要的服务。

联系服务

我们的联系服务将具有基本的 CRUD 操作和可订阅的 Observable 流。此实现将使用 Node.js 和 Express 构建的后端 API,但可以随时稍作努力转换为基于 WebSocket 的 API。

创建一个名为 contact-manager/src/contact/contact.service.ts 的新服务文件,并添加以下代码:

import { Injectable } from 'angular2/core';
import { Response, Headers } from 'angular2/http';
import { Observable } from 'rxjs/Observable';
import { contentHeaders } from '../common/headers';
import { AuthHttp } from '../auth/auth-http';
import { Contact } from '../contact';

type ObservableContacts = Observable<Array<Contact>>;
type ObservableContact = Observable<Contact>;

const DEFAULT_URL = '/api/contacts';

@Injectable()
export class ContactService {
  public contact: ObservableContact;
  public contacts: ObservableContacts;

  private _authHttp: AuthHttp;
  private _dataStore: { contacts: Array<Contact>, contact: Contact };
  private _contactsObserver: any;
  private _contactObserver: any;
  private _url: string;

  constructor(authHttp: AuthHttp) {
    this._authHttp = authHttp;
    this._url = DEFAULT_URL;
    this._dataStore = { contacts: [], contact: new Contact() };
    this.contacts = new Observable(
      observer => this._contactsObserver = observer
    ).share();
    this.contact = new Observable(
      observer => this._contactObserver = observer
    ).share();
  }
}

在联系服务中,我们有几个动态部分。首先,我们定义了我们的 Observables,以便任何其他组件或模块都可以订阅并开始获取数据流。

其次,我们声明了一个私有数据存储。这就是我们将存储我们的联系信息的地方。这是一个好的实践,因为你可以轻松地从内存中返回所有资源。

此外,在我们的服务中,当生成新的 Observables 实例时,我们将保留返回的 Observers。使用 Observers,我们可以将新的数据流推送到我们的 Observables。

在我们的公共方法中,我们将公开获取所有联系人、获取一个、更新和删除的功能。为了获取所有联系人,我们将向我们的ContactService添加以下方法:

  public getAll() {
    return this._authHttp
    .get(`${this._url}`, { headers: contentHeaders} )
    .map((res: Response) => res.json())
    .map(data => {
      return data.map(contact => {
        return new Contact(
          contact._id,
          contact.email,
          contact.name,
          contact.city,
          contact.phoneNumber,
          contact.company,
          contact.createdAt
        )
      });
    })
    .subscribe((contacts: Array<Contact>) => {
      this._dataStore.contacts = contacts;
      this._contactsObserver.next(this._dataStore.contacts);
    }, err => console.error(err));
  }

我们使用自定义构建的AuthHttp服务从我们的 Express 应用程序加载数据。当收到响应时,我们将它转换成 JSON 文件,然后,我们为数据集中的每个实体实例化一个新的联系人。

我们不是从 HTTP 服务返回整个Observable,而是使用我们的内部数据存储来持久化所有联系人。在我们成功更新数据存储的新数据后,我们将更改推送到我们的contactsObserver

任何订阅我们联系人流的组件都将从Observable数据流中获得新值。这样,我们总是通过一个单一的入口点保持我们的组件同步。

我们公共方法的逻辑大部分相同,但我们仍然有一些独特的元素,例如,更新方法:

  public update(contact: Contact) {
    return this._authHttp
    .put(
      `${this._url}/${contact._id}`,
      this._serialize(contact),
      { headers: contentHeaders}
    )
    .map((res: Response) => res.json())
    .map(data => {
      return new Contact(
        data._id,
        data.email,
        data.name,
        data.city,
        data.phoneNumber,
        contact.company,
        data.createdAt
      )
    })
    .subscribe((contact: Contact) => {
      // update the current list of contacts
      this._dataStore.contacts.map((c, i) => {
        if (c._id === contact._id) {
          this._dataStore.contacts[i] = contact;
        }
      });
      // update the current contact
      this._dataStore.contact = contact;
      this._contactObserver.next(this._dataStore.contact);
      this._contactsObserver.next(this._dataStore.contacts);
    }, err => console.error(err));
  }

update方法几乎与create()方法相同,但它将联系人的 ID 作为 URL 参数。我们不是将新值推送到数据流中,而是从Http服务返回Observable,以便从调用模块应用操作。

现在,如果我们想直接在datastore上做出更改并通过contacts数据流推送新值,我们可以在删除联系人的方法中展示这一点:

  public remove(contactId: string) {
    this._authHttp
    .delete(`${this._url}/${contactId}`)
    .subscribe(() => {
      this._dataStore.contacts.map((c, i) => {
        if (c._id === contactId) {
          this._dataStore.contacts.splice(i, 1);
        }
      });
      this._contactsObserver.next(this._dataStore.contacts);
    }, err => console.error(err));
  }

我们简单地使用map()函数来找到我们删除的联系人并将其从内部存储中移除。之后,我们向订阅者发送新数据。

联系人组件

随着我们已将所有与接触域相关的功能移动到一起,我们可以在模块中定义一个主要组件。让我们称它为contact-manager/public/src/contact/contact.component.ts。添加以下代码行:

import { Component } from 'angular2/core';
import { RouteConfig, RouterOutlet } from 'angular2/router';
import { ContactListComponent } from './contact-list.component';
import { ContactCreateComponent } from './contact-create.component';
import { ContactEditComponent } from './contact-edit.component';

@RouteConfig([
  { path: '/', as: 'ContactList', component: ContactListComponent, useAsDefault: true },
  { path: '/:id', as: 'ContactEdit', component: ContactEditComponent },
  { path: '/create', as: 'ContactCreate', component: ContactCreateComponent }
])
@Component({
    selector: 'contact',
    directives: [
      ContactListComponent,
      RouterOutlet
    ],
    template: `
      <router-outlet></router-outlet>
    `
})
export class ContactComponent {
  constructor() {}
}

我们组件没有与之关联的逻辑,但我们使用了RouterConfig注解。路由配置装饰器接受一个路由数组。配置中指定的每个路径都将与浏览器的 URL 匹配。每个路由将加载挂载的组件。为了在模板中引用路由,我们需要给它们一个名称。

现在,最吸引人的部分是我们可以将这个组件和配置的路由一起使用,并将其挂载到另一个组件上以拥有Child/Parent路由。在这种情况下,它变成了嵌套路由,这是 Angular 2 中添加的一个非常强大的功能。

我们应用程序的路由将具有树状结构;其他组件将加载它们配置的路由中的组件。我对这个功能感到非常惊讶,因为它使我们能够真正模块化我们的应用程序并创建令人惊叹的可重用模块。

列出联系人组件

在先前的组件中,我们使用了三个不同的组件并将它们挂载到不同的路由上。我们不会讨论每个组件,所以我们将选择其中一个。因为我们已经在Signin组件中处理过表单,让我们尝试做一些不同的事情并实现列出联系人的功能。

创建一个名为contact-manager/public/src/contact/contact-list.component.ts的新文件,并为你的组件添加以下代码:

import { Component, OnInit } from 'angular2/core';
import { RouterLink } from 'angular2/router';
import { ContactService } from '../contact.service';
import { Contact } from '../contact';

@Component({
    selector: 'contact-list',
    directives: [RouterLink],
    template: `
      <div class="row">
        <h4>
          Total contacts: <span class="muted">({{contacts.length}})</span>
          <a href="#" [routerLink]="['ContactCreate']">add new</a>
        </h4>
        <div class="contact-list">
          <div class="card-item col col-25 contact-item"
            *ngFor="#contact of contacts">
            <img src="img/{{ contact.image }}" />
            <h3>
              <a href="#" [routerLink]="['ContactEdit', { id: contact._id }]">
                {{ contact.name }}
              </a>
            </h3>
            <p>
              <span>{{ contact.city }}</span>
              <span>·</span>
              <span>{{ contact.company }}</span>
            </p>
            <p><span>{{ contact.email }}</span></p>
            <p><span>{{ contact.phoneNumber }}</span></p>
          </div>
        </div>
      </div>
    `
})
export class ContactListComponent implements OnInit {
  public contacts: Array<Contact> = [];
  private _contactService: ContactService;

  constructor(contactService: ContactService) {
    this._contactService = contactService;
  }

  ngOnInit() {
    this._contactService.contacts.subscribe(contacts => {
      this.contacts = contacts;
    });
    this._contactService.getAll();
  }
}

在我们的组件的ngOnInit()中,我们订阅联系人数据流。之后,我们从后端检索所有联系人。在模板中,我们使用ngFor遍历数据集并显示每个联系人。

创建联系人组件

现在我们可以在应用程序中列出联系人,我们也应该能够添加新条目。记住,我们之前使用RouterLink来导航到CreateContact路由。

之前的路由将加载CreateContactComponent,这将使我们能够通过 Express API 将新的联系人条目添加到我们的数据库中。让我们创建一个新的组件文件public/src/contact/components/contact-create.component.ts

import { Component, OnInit } from 'angular2/core';
import { Router, RouterLink } from 'angular2/router';
import { ContactService } from '../contact.service';
import { Contact } from '../contact';

@Component({
    selector: 'contact-create,
    directives: [RouterLink],
    templateUrl: 'src/contact/components/contact-form.html'
})
export class ContactCreateComponent implements OnInit {
  public contact: Contact;
  private _router: Router;
  private _contactService: ContactService;

  constructor(
    contactService: ContactService,
    router: Router
  ) {
    this._contactService = contactService;
    this._router = router;
  }

  ngOnInit() {
    this.contact = new Contact();
  }

  onSubmit(event) {
    event.preventDefault();

    this._contactService
    .create(this.contact)
    .subscribe((contact) => {
      this._router.navigate(['ContactList']);
    }, err => console.error(err));
  }
}

我们不是使用内嵌模板,而是使用外部模板文件,该文件通过组件注解中的templateUrl属性进行配置。每种情况都有其优缺点。使用外部模板文件的优点是您可以重复使用同一文件为多个组件。

写这本书的时候,Angular 2 的一个缺点是难以使用相对路径到模板文件,这会使你的组件不太便携。此外,我喜欢保持我的模板简短,这样它们可以轻松地放在组件内部,所以在大多数情况下,我可能会使用内嵌模板。

让我们看看在进一步讨论组件之前的模板,public/src/contact/components/contact-form.html

<div class="row contact-form-wrapper">
  <a href="#" [routerLink]="['ContactList']">&lt; back to contacts</a>
  <h2>Add new contact</h2>
  <form role="form"
    (submit)="onSubmit($event)">

    <div class="form-group">
      <label for="name">Full name</label>
      <input type="text" [(ngModel)]="contact.name"
        class="form-control" id="name" placeholder="Jane Doe">
    </div>
    <div class="form-group">
      <label for="email">E-mail</label>
      <input type="text" [(ngModel)]="contact.email"
        class="form-control" id="email" placeholder="jane.doe@example.com">
    </div>
    <div class="form-group">
      <label for="city">City</label>
      <input type="text"
        [(ngModel)]="contact.city"
        class="form-control" id="city" placeholder="a nice place ...">
    </div>
    <div class="form-group">
      <label for="company">Company</label>
      <input type="text"
        [(ngModel)]="contact.company"
        class="form-control" id="company" placeholder="working at ...">
    </div>
    <div class="form-group">
      <label for="phoneNumber">Phone</label>
      <input type="text"
        [(ngModel)]="contact.phoneNumber"
        class="form-control" id="phoneNumber" placeholder="mobile or landline">
    </div>

    <button type="submit" class="button">Submit</button>
  </form>
</div>

在模板中,我们使用组件的onSubmit()方法来处理表单提交,并在这种情况下创建一个新的联系人并将数据存储在 MongoDB 中。当我们成功创建联系人时,我们希望导航到ContactList路由。

我们没有使用局部变量,而是使用ngModel进行双向数据绑定,每个输入映射到联系人对象的属性。现在,每次用户更改输入值时,这些值都会存储在联系人对象中,并在提交时通过网络发送到后端。

RouterLink用于从模板中构建导航到ContactList组件。我留下了一个小的改进,即视图标题在创建和编辑时都将相同,更确切地说,“添加新联系人”,我会让你自己想出来。

编辑现有联系人

在编辑联系人时,我们希望从后端 API 通过 ID 加载特定资源,并对该联系人进行更改。幸运的是,在 Angular 中实现这一点相当简单。创建一个新的文件public/src/contact/components/contact-edit.component.ts

import { Component, OnInit } from 'angular2/core';
import { RouteParams, RouterLink } from 'angular2/router';
import { ContactService } from '../contact.service';
import { Contact } from '../contact';

@Component({
    selector: 'contact-edit',
    directives: [RouterLink],
    templateUrl: 'src/contact/components/contact-form.html'
})
export class ContactEditComponent implements OnInit {
  public contact: Contact;
  private _contactService: ContactService;
  private _routeParams: RouteParams;

  constructor(
    contactService: ContactService,
    routerParams: RouteParams
  ) {
    this._contactService = contactService;
    this._routeParams = routerParams;
  }

  ngOnInit() {
    const id: string = this._routeParams.get('id');
    this.contact = new Contact();
    this._contactService
    .contact.subscribe((contact) => {
      this.contact = contact;
    });
    this._contactService.getOne(id);
  }

  onSubmit(event) {
    event.preventDefault();

    this._contactService
    .update(this.contact)
    .subscribe((contact) => {
      this.contact = contact;
    }, err => console.error(err));
  }
}

我们离ContactCreateComponent并不远,类的结构几乎相同。我们不是使用Router,而是使用RouteParams从 URL 中加载 ID,并从 Express 应用程序中检索所需的联系人。

我们订阅ContactService返回的联系人Observable以获取新数据。换句话说,我们的组件将响应数据流,当数据可用时,它将显示给用户。

当提交表单时,我们更新 MongoDB 中持久化的联系人,并使用从后端新鲜接收的数据更改视图的contact对象。

最后的润色

我们已经将所有必要的模块添加到我们的应用程序中。我们还应该最终检查我们的主应用程序组件,该组件位于以下路径下—contact-manager/public/src/app.component.ts

import { Component } from 'angular2/core';
import { RouteConfig, RouterOutlet } from 'angular2/router';
import { Router } from 'angular2/router';
import { AuthHttp } from './auth/auth-http';
import { Signin } from './auth/signin';
import { Register } from './auth/register';
import { ContactComponent } from './contact/components/contact.component';

@RouteConfig([
  { path: '/signin', as: 'Signin', component: Signin },
  { path: '/register', as: 'Register', component: Register },
  { path: '/contacts/...', as: 'Contacts', component: ContactComponent, useAsDefault: true }
])
@Component({
    selector: 'cm-app',
    directives: [
      Signin,
      Register,
      ContactComponent,
      RouterOutlet
    ],
    template: `
      <div class="app-wrapper col card whiteframe-z2">
        <div class="row">
          <h3>Contact manager</h3>
        </div>
        <router-outlet></router-outlet>
      </div>
    `
})
export class AppComponent {
  private _authHttp: AuthHttp;
  private _router: Router;

  constructor(authHttp: AuthHttp, router: Router) {
    this._authHttp = authHttp;
    this._router = router;
    this._authHttp.unauthorized.subscribe((res) => {
      if (res) {
        this._router.navigate(['./Signin']);
      }
    });
  }
}

我们将所有组件挂载到它们特定的路由上。此外,当我们挂载Contact组件时,我们将引入组件中配置的所有路由。

为了在请求未授权时得到通知,我们订阅了AuthHttp服务的unauthorized数据流。如果请求需要身份验证,我们将用户重定向到登录页面。

我们应用程序的启动文件看起来可能如下所示:

import { bootstrap } from 'angular2/platform/browser';
import { provide } from 'angular2/core';
import { HTTP_PROVIDERS } from 'angular2/http';
import { ROUTER_PROVIDERS, LocationStrategy, HashLocationStrategy } from 'angular2/router';
import { AuthHttp } from './auth/auth-http';
import { AuthService } from './auth/auth.service';
import { ContactService } from './contact/contact.service';
import { AppComponent } from './app.component';

import 'rxjs/add/operator/map';
import 'rxjs/add/operator/share';
import 'rxjs/add/operator/debounceTime';
import 'rxjs/add/operator/catch';
import 'rxjs/add/observable/throw';

bootstrap(AppComponent, [
  ROUTER_PROVIDERS,
  HTTP_PROVIDERS,
  AuthService,
  AuthHttp,
  ContactService,
  provide(LocationStrategy, {useClass: HashLocationStrategy})
]);

我们导入并定义了必要的提供者,并添加了我们从 RxJs 中使用的操作符。这是因为 Angular 默认情况下只使用 Observable 模块的简化版本。

通过联系模块,我们使用了一个名为Contact的自定义类,它扮演着Contact模型的角色。每次我们想要确保我们正在处理一个联系人实体时,它都会被实例化。此外,TypeScript 的另一个优点是它使我们能够使用结构化代码。

当我们需要初始值时,类非常有用,例如,在我们的组件中,我们使用了contact.image属性来显示联系人的个人资料图片。这还没有在后端实现,所以我们使用一个模拟的 URL 作为图片。让我们看看Contact类,public/src/contact/contact.ts

export class Contact {
  _id: string;
  email: string;
  name: string;
  city: string;
  phoneNumber: string;
  company: string;
  image: string;
  createdAt: string;

  constructor(
    _id?: string,
    email?: string,
    name?: string,
    city?: string,
    phoneNumber?: string,
    company?: string,
    createdAt?: string
  ) {
    this._id = _id;
    this.email = email;
    this.name = name;
    this.city = city;
    this.phoneNumber = phoneNumber;
    this.company = company;
    this.image = 'http://placehold.it/171x100';
    this.createdAt = createdAt;
  }
}

如你所见,我们只定义了联系人实例可以拥有的属性,并为image属性创建了一个默认值。标记为?的构造函数参数是可选的。

在这个时候,我们应该已经一切就绪;如果你错过了什么,你可以查看代码的最终版本。

本章的关键要点如下:

  • 使用 Node.js、Express 和 MongoDB 构建后端 Web 服务

  • 在实际实现功能之前先编写测试

  • 使用 Passport 保护我们的 API 路由

  • 使 Angular 2 和 Express 通信并协同工作

  • 进入反应式扩展和反应式编程

  • 构建自定义的 Angular HTTP 服务

摘要

这就结束了这一相当入门性的章节。

我们从实现后端逻辑到学习在实际实现之前编写测试,全面地进行了全栈开发。我们从 MongoDB 资源公开了 RESTful 路由。我们还构建了一个小的 Angular 2 前端应用程序,该应用程序与 Web 服务器进行交互。

在下一章中,我们将更深入地探讨 MongoDB,并开始处理货币数据。这将是一次有趣的旅程!

第二章。支出跟踪器

在本章中,我们将看到如何构建支出跟踪器应用程序。它将存储给定类别的所有支出。我们将能够看到我们支出的汇总余额或按类别划分的支出。每个用户都将有一个单独的账户来管理他们的支出。

我们将涵盖的一些有趣的主题包括:

  • 创建多用户系统

  • 处理货币数据

  • 使用 MongoDB 聚合框架

  • 不同的身份验证策略,例如 HTTP 基本身份验证和基于令牌的身份验证

设置基本应用程序

让我们设置应用程序的基本结构和文件。项目的全部源代码将以捆绑包的形式在 www.packtpub.com/ 上提供。因此,我们只将详细说明设置基本应用程序的最重要部分。

安装依赖项

让我们从在项目根目录中创建我们的 package.json 文件并添加以下代码开始:

{
  "name": "mean-blueprints-expensetracker",
  "version": "0.0.1",
  "repository": {
    "type": "git",
    "url": "https://github.com/robert52/mean-blueprints-expensetracker.git"
  },
  "engines": {
    "node": ">=0.12.0"
  },
  "scripts": {
    "start": "node app.js",
    "unit": "mocha tests/unit/ --ui bdd --recursive --reporter spec --timeout 10000 --slow 900",
    "integration": "mocha tests/integration/ --ui bdd --recursive --reporter spec --timeout 10000 --slow 900"
  },
  "dependencies": {
    "async": "⁰.9.0",
    "body-parser": "¹.12.3",
    "express": "⁴.12.4",
    "express-session": "¹.11.2",
    "lodash": "³.7.0",
    "method-override": "².3.2",
    "mongoose": "⁴.0.2",
    "passport": "⁰.2.1",
    "passport-local": "¹.0.0",
    "serve-static": "¹.9.2"
  },
  "devDependencies": {
    "chai": "².3.0",
    "chai-things": "⁰.2.0",
    "mocha": "².2.4",
    "request": "².55.0"
  }
}

定义 package.json 文件后的下一步是安装必要的依赖项。运行以下命令:

npm install

npm 拉取所有必要的文件后,你应该会返回到命令提示符。

创建基本配置文件

我们将重用之前联系人管理器项目中的大量代码。我们创建了一个文件来根据当前运行的 Node 环境加载必要的环境配置文件。添加一个新的配置文件。

创建一个名为 config/environments/development.js 的文件,并添加以下代码:

'use strict';

module.exports = {
  port: 3000,
  hostname: 'localhost',
  baseUrl: 'http://localhost:3000',
  mongodb: {
    uri: 'mongodb://localhost/expense_dev_db'
  },
  app: {
    name: 'Expense tracker'
  },
  serveStatic: true,
  session: {
    session: {
      type: 'mongo',
      secret: 'someVeRyN1c3S#cr3tHer34U',
      resave: false,
      saveUninitialized: true
    }
  }
};

接下来,我们将为 Express 创建配置文件,并将以下代码行添加到 config/express.js 文件中:

'use strict';

const path = require('path');
const bodyParser = require('body-parser');
const methodOverride = require('method-override');
const serveStatic = require('serve-static');
const session = require('express-session');
const MongoStore = require('connect-mongo')(session);
const passport = require('passport');
const config = require('./index');

module.exports.init = initExpress

function initExpress(app) {
  const env = app.get('env');
  const root = app.get('root');
  const sessionOpts = {
    secret: config.session.secret,
    key: 'skey.sid',
    resave: config.session.resave,
    saveUninitialized: config.session.saveUninitialized
  };

  app.use(bodyParser.urlencoded({ extended: true }));
  app.use(bodyParser.json());
  app.use(methodOverride());
  app.disable('x-powered-by');

  if (config.session.type === 'mongo') {
    sessionOpts.store = new MongoStore({
      url: config.mongodb.uri
    });
  }

  app.use(session(sessionOpts));
  app.use(passport.initialize());
  app.use(passport.session());

  if (config.serveStatic) {
    app.use(serveStatic(path.join(root, 'public')));
  }
}

最后,我们将添加一个名为 config/mongoose.js 的文件来连接到 MongoDB,内容如下:

'use strict';

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

module.exports.init = initMongoose;

function initMongoose(app) {
  mongoose.connect(config.mongodb.uri);

  // If the Node process ends, cleanup existing connections
  process.on('SIGINT', cleanup);
  process.on('SIGTERM', cleanup);
  process.on('SIGHUP', cleanup);

  if (app) {
    app.set('mongoose', mongoose);
  }

  return mongoose;
}

function cleanup() {
  mongoose.connection.close(function () {
    console.log('Closing DB connections and stopping the app. Bye bye.');
    process.exit(0);
  });
}

创建主 server.js 文件

我们应用程序的主要入口点是 server.js 文件。在项目的根目录中创建它。此文件启动网络服务器并引导所有逻辑。添加以下代码行:

'use strict';

// Get process environment or set default environment to development
const ENV = process.env.NODE_ENV || 'development';
const DEFAULT_PORT = 3000;
const DEFAULT_HOSTNAME = 'localhost';

const http = require('http');
const express = require('express');
const config = require('./config');
const app = express();
let server;

/**
 * Set express (app) variables
 */
app.set('config', config);
app.set('root', __dirname);
app.set('env', ENV);

require('./config/mongoose').init(app);
require('./config/models').init(app);
require('./config/passport').init(app);
require('./config/express').init(app);
require('./config/routes').init(app);

app.use((err, req, res, next) => {
  res.status(500).json(err);
});

/**
 * Start the app if not loaded by another module
 */
 if (!module.parent) {
   server = http.createServer(app);
   server.listen(
     config.port || DEFAULT_PORT,
     config.hostname || DEFAULT_HOSTNAME,
     () => {
       console.log(`${config.app.name} is running`);
       console.log(`   listening on port: ${config.port}`);
       console.log(`   environment: ${ENV.toLowerCase()}`);
     }
   );
 }

module.exports = app;

设置用户部分

在上一章中,我们也有一个应用程序的用户部分。在本章中,我们将通过添加注册和更改密码功能来扩展这些功能。我们将重用现有的代码库并添加新功能。

描述用户模型

我们将创建一个专门针对用户模型的测试文件。这将有助于在不启动整个应用程序的情况下测试其所有功能。创建一个名为 test/integration/user.model.test.js 的文件,并添加以下内容:

'use strict';

/**
 * Important! Set the environment to test
 */
process.env.NODE_ENV = 'test';

const chai = require('chai');
const should = chai.should();
consst config = require('../../config/environments/test');

describe('User model', function() {
  const mongoose;
  const User;
  const _user;
  const newUserData = {
    email: 'jane.doe@test.com',
    password: 'user_password',
    name: 'Jane Doe'
  };

  before(function(done) {
    mongoose = require('../../config/mongoose').init();
    User = require('../../app/models/user');
    done();
  });

  after(function(done) {
    User.remove({}).exec(function(err) {
      if (err) throw err;

      mongoose.connection.close(function() {
        setTimeout(function() { done(); }, 1000);
      });
    });
  });
});

我们已经为测试文件定义了基础。现在我们将逐个添加测试用例,在最后一个闭合括号之前:

  1. 用户应该能够使用我们的系统进行注册。我们可以用以下代码行进行测试:

      it('should register a user', function(done) {
        User.register(newUserData, function(err, user) {
          if (err) throw err;
    
          should.exist(user);
          user.email.should.equal(newUserData.email);
          should.not.exist(user.password);
          should.not.exist(user.passwordSalt);
          should.exist(user.createdAt);
          user.active.should.equal(true);
    
          _user = user;
          done();
        });
      });
    
  2. 同一用户不能使用相同的电子邮件地址进行两次注册:

      it('should not register a user if already exists', function(done) {
        User.register(newUserData, function(err, user) {
          should.exist(err);
          err.code.should.equal(11000); // duplicate key error
          should.not.exist(user);
          done();
        });
      });
    
  3. 注册成功后,用户应该能够对我们的系统进行身份验证:

      it('should authenticate a user with valid credentials', function(done) {
        User.authenticate(newUserData.email, 'user_password', function(err, user) {
          if (err) throw err;
    
          should.exist(user);
          should.not.exist(user.password);
          should.not.exist(user.passwordSalt);
          user.email.should.equal(newUserData.email);
          done();
        });
      });
    
  4. 如果用户提供了无效的凭据,则不应成功认证:

      it('should not authenticate user with invalid credentials', function(done) {
        User.authenticate(newUserData.email, 'notuserpassowrd', function(err, user) {
          if (err) throw err;
    
          should.not.exist(user);
          done();
        });
      });
    
  5. 用户应该能够更改当前密码:

      it('should change the password of a user', function(done) {
        _user.changePassword('user_password', 'new_user_password', function(err, result) {
          if (err) throw err;
    
          should.exist(result);
          result.success.should.equal(true);
          result.message.should.equal('Password changed successfully.');
          result.type.should.equal('password_change_success');
    
          // run a check credential with the new password
          User.authenticate(_user.email, 'new_user_password', function(err, user) {
            if (err) throw err;
    
            should.exist(user);
            user.email.should.equal(_user.email);
            done();
          });
        });
      });
    
  6. 必须通过旧密码挑战才能设置新密码:

      it('should not change password if old password does not match', function(done) {
        _user.changePassword('not_good', 'new_user_password', function(err, result) {
          should.not.exist(result);
          should.exist(err);
          err.type.should.equal('old_password_does_not_match');
    
          // run a check credential with the old password
          User.authenticate(_user.email, 'new_user_password', function(err, user) {
            if (err) throw err;
    
            should.exist(user);
            user.email.should.equal(_user.email);
            done();
          });
        });
      });
    

使用前面的测试套件,我们描述并将要测试我们实现的方法的功能。

实现用户模型

user 模型将使用与 第一章 中相同的密码辅助原则,即 联系人管理器。让我们创建一个名为 app/helpers/password.js 的文件。该文件应包含以下代码:

'use strict';

const LEN = 256;
const SALT_LEN = 64;
const ITERATIONS = 10000;
const DIGEST = 'sha256';
const crypto = require('crypto');

module.exports.hash = hashPassword;
module.exports.verify = verify;

现在添加 hashPassword() 函数:

function hashPassword(password, salt, callback) {
  let len = LEN / 2;

  if (3 === arguments.length) {
    generateDerivedKey(password, salt, ITERATIONS, len, DIGEST, callback);
  } else {
    callback = salt;
    crypto.randomBytes(SALT_LEN / 2, (err, salt) => {
      if (err) {
        return callback(err);
      }

      salt = salt.toString('hex');
      generateDerivedKey(password, salt, ITERATIONS, len, DIGEST, callback);
    });
  }
}

我们添加了一个额外的函数,称为 generateDerivedKey(),以避免重复代码块:

function generateDerivedKey(password, salt, iterations, len, digest, callback) {
  crypto.pbkdf2(password, salt, ITERATIONS, len, DIGEST, (err, derivedKey) => {
    if (err) {
      return callback(err);
    }

    return callback(null, derivedKey.toString('hex'), salt);
  });
}

最后,添加 verify() 函数:

function verify(password, hash, salt, callback) {
  hashPassword(password, salt, (err, hashedPassword) => {
    if (err) {
      return callback(err);
    }

    if (hashedPassword === hash) {
      callback(null, true);
    } else {
      callback(null, false);
    }
  });
}

接下来,让我们在模型文件中创建一个用户模式。创建一个名为 app/models/user.js 的新文件,并添加以下内容:

'use strict';

const _ = require('lodash');
const mongoose = require('mongoose');
const passwordHelper = require('../helpers/password');
const Schema = mongoose.Schema;

const UserSchema = new Schema({
  email:  {
    type: String,
    required: true,
    unique: true
  },
  name: {
    type: String
  },
  password: {
    type: String,
    required: true,
    select: false
  },
  passwordSalt: {
    type: String,
    required: true,
    select: false
  },
  phoneNumber: {
    type: String
  },
  active: {
    type: Boolean,
    default: true
  },
  createdAt: {
    type: Date,
    default: Date.now
  }
});

UserSchema.statics.register = registerUser;
UserSchema.statics.authenticate = authenticateUser;
UserSchema.methods.changePassword = changeUserPassword;

现在,让我们逐个添加测试中所需的方法。我们将从 register() 方法开始。将这些代码行追加到用户模型文件中:

function registerUser(opts, callback) {
  let data = _.cloneDeep(opts);

  //hash the password
  passwordHelper.hash(opts.password, (err, hashedPassword, salt) => {
    if (err) {
      return callback(err);
    }

    data.password = hashedPassword;
    data.passwordSalt = salt;

    //create the user
    this.model('User').create(data, (err, user) => {
      if (err) {
        return callback(err, null);
      }

      // remove password and salt from the result
      user.password = undefined;
      user.passwordSalt = undefined;
      // return user if everything is ok
      callback(err, user);
    });
  });
}

这是一个简单的函数,它将在 MongoDB 中保存用户。在保存用户之前,我们希望从给定的密码中构建一个哈希值,并将该哈希值与盐一起保存在数据库中,而不是明文密码字符串。Mongoose 还会在保存之前根据用户模式验证用户数据。

对于 authenticate() 方法,我们将追加以下代码行:

function authenticateUser(email, password, callback) {
  this
  .findOne({ email: email })
  .select('+password +passwordSalt')
  .exec((err, user) => {
    if (err) {
      return callback(err, null);
    }

    // no user found just return the empty user
    if (!user) {
      return callback(err, user);
    }

    // verify the password with the existing hash from the user
    passwordHelper.verify(
      password,
      user.password,
      user.passwordSalt,
      (err, result) => {
        if (err) {
          return callback(err, null);
        }

        // if password does not match don't return user
        if (result === false) {
          return callback(err, null);
        }

        // remove password and salt from the result
        user.password = undefined;
        user.passwordSalt = undefined;
        // return user if everything is ok
        callback(err, user);
      }
    );
  });
}

认证方法将通过电子邮件查找用户。对于此查询,passwordpasswordSalt 字段被明确设置为仅从数据库中读取。将调用密码验证函数来匹配现有的密码哈希与发送给认证方法的密码。

最后,我们将添加一个 changePassword() 方法。此方法仅对用户实例可用。Mongoose 允许我们在模式上使用 methods 属性来附加新函数。追加以下代码:

function changeUserPassword(oldPassword, newPassword, callback) {
  this
  .model('User')
  .findById(this.id)
  .select('+password +passwordSalt')
  .exec((err, user) => {
    if (err) {
      return callback(err, null);
    }

    // no user found just return the empty user
    if (!user) {
      return callback(err, user);
    }

    passwordHelper.verify(
      oldPassword,
      user.password,
      user.passwordSalt,
      (err, result) => {
        if (err) {
          return callback(err, null);
        }

        // if password does not match don't return user
        if (result === false) {
          let PassNoMatchError = new Error('Old password does not match.');
          PassNoMatchError.type = 'old_password_does_not_match';
          return callback(PassNoMatchError, null);
        }

        // generate the new password and save the user
        passwordHelper.hash(newPassword, (err, hashedPassword, salt) => {
          this.password = hashedPassword;
          this.passwordSalt = salt;

          this.save((err, saved) => {
            if (err) {
              return callback(err, null);
            }

            if (callback) {
              return callback(null, {
                success: true,
                message: 'Password changed successfully.',
                type: 'password_change_success'
              });
            }
          });
        });
      }
    );
  });
}

更改密码功能是使用三个小步骤构建的。第一步是从数据库中获取用户的密码和盐。返回的数据用于验证用户输入的旧密码与现有的密码哈希和盐。如果一切顺利,则使用生成的盐对新的密码进行哈希处理,并将用户实例保存到 MongoDB 中。

不要忘记将以下代码行移动到文件末尾,以便编译用户模型:

module.exports = mongoose.model('User', UserSchema);

假设我们使用以下命令运行我们的用户模型测试:

mocha tests/integration/user.mode.test.js

我们应该看到所有测试都通过:

 User Model Integration
 #register()
 √ should create a new user (124ms)
 √ should not create a new user if email already exists (100ms)
 #authenticate()
 √ should return the user if the credentials are valid (63ms)
 √ should return nothing if the credential of the user are invalid (62ms)
 #changePassword()
 √ should change the password of a user (223ms)
 √ should not change password if old password does not match (146ms)

 6 passing (1s)

用户认证

在上一章中,我们使用了基于会话的认证。对于本章,我们将探索不同的解决方案——使用访问令牌来认证我们的用户。

访问令牌在 RESTful API 中被广泛使用。因为我们构建应用程序的前提是它不仅可以用作我们的 Angular 应用,还可以用于许多其他客户端应用程序,我们需要依赖某种可以用来识别用户的东西。

访问令牌是一个标识用户(甚至是一个应用程序)的字符串,它可以用来对我们的系统进行 API 调用。令牌可以通过多种方式发放。例如,可以使用 OAuth 2.0 简单地发放令牌。

对于本章,我们将构建一个自定义模块,该模块负责创建令牌。这将使我们能够轻松切换到任何其他可用的解决方案。

我们将实现两种策略来认证我们的用户。其中之一将是一个 HTTP Basic 认证策略,它将使用简单的用户名(在我们的情况下是电子邮件)和密码组合来认证用户,并生成一个用于后续 API 调用的令牌。第二种策略是 HTTP Bearer 认证,它将使用 Basic 认证发放的访问令牌来授予用户访问资源的权限。

描述认证策略

在实现任何代码之前,我们应该创建一个测试来描述关于用户认证的期望行为。创建一个名为 tests/integration/authentication.test.js 的文件,并描述主要测试用例:

  1. 第一个测试用例应考虑一个积极场景,即当用户尝试使用有效凭据进行认证时。这看起来如下所示:

        it('should authenticate a user and return a new token', function(done) {
          request({
            method: 'POST',
            url: baseUrl + '/auth/basic',
            auth: {
              username: userFixture.email,
              password: 'P@ssw0rd!'
            },
            json:true
          }, function(err, res, body) {
            if (err) throw err;
    
            res.statusCode.should.equal(200);
            body.email.should.equal(userFixture.email);
            should.not.exist(body.password);
            should.not.exist(body.passwordSalt);
            should.exist(body.token);
            should.exist(body.token.hash);
            should.exist(body.token.expiresAt);
            done();
          });
        });
    
  2. 如果用户尝试使用无效凭据进行认证,系统应返回一个错误请求消息:

        it('should not authenticate a user with invalid credentials', function(done) {
          request({
            method: 'POST',
            url: baseUrl + '/auth/basic',
            auth: {
              username: userFixture.email,
              password: 'incorrectpassword'
            },
            json:true
          }, function(err, res, body) {
            if (err) throw err;
    
            res.statusCode.should.equal(400);
            body.message.should.equal('Invalid email or password.');
            done();
          });
        });
    

我们描述了基本策略。我们考虑了用户必须通过 POST 调用 /api/auth 端点发送电子邮件作为用户名和密码,并获取用户详情和有效令牌的事实。

提示

request 库有一个名为 auth 的特殊属性,它将使用 base64 对用户名和密码元组进行编码,并设置适当的 HTTP Basic 认证头。

如您所见,我们的假设是当用户成功登录到我们的系统时,将生成一个有效的令牌。因此,在继续前进之前,我们将实现令牌生成功能。

实现令牌生成

令牌可以通过多种方式生成。在本章中,我们将使用 Node.js 的内置加密库。我们可以使用 randomBytes() 方法生成指定长度的随机字符串。需要注意的是,如果累积的熵不足,randomBytes() 将抛出错误。这意味着如果熵源中信息不足,无法生成随机数,它将抛出错误。

让我们创建一个名为 app/helpers/token.js 的新文件,并添加以下代码行:

'use strict';

const LEN = 16;
const crypto = require('crypto');

module.exports.generate = generateToken;

function generateToken(size, callback) {
  if (typeof size === 'function') {
    callback = size;
    size = LEN;
  }

  // we will return the token in `hex`
  size = size / 2;

  crypto.randomBytes(size, (err, buf) => {
    if (err) {
      return callback(err);
    }

    const token = buf.toString('hex');

    callback(null, token);
  });
} 

我们创建了一个辅助函数,它将为我们生成一个随机令牌。该函数接受两个参数:随机字节数,这是可选的,以及一个回调函数。

在 MongoDB 中持久化令牌

为了检查用户发送的访问令牌——即它是否有效——我们应该将其存储在某个地方。为此,我们将使用 MongoDB 作为我们的令牌存储引擎。

提示

注意,你应该像对待用户密码一样对待你的令牌,因为令牌将提供对系统功能的访问。为了进一步提高安全性,可以考虑将令牌加密存储在数据库中,或者甚至将它们存储在单独的令牌存储中。

在做任何事情之前,让我们为令牌模型创建一个测试。创建一个名为 tests/integration/token.model.js 的文件,并添加以下代码:

process.env.NODE_ENV = 'test';

const chai = require('chai');
const should = chai.should();
const mongoose = require('../../config/mongoose').init();
const Token = require('../../app/models/token');

describe('Token Model Integration', function() {
  after(function(done) {
    mongoose.connection.db.dropDatabase(function(err) {
      if (err) throw err;

      setTimeout(done, 200);
    });
  });

  describe('#generate() - Token class method', function() {
    var _userId = new mongoose.Types.ObjectId();

    it('should generate a new token for a user', function(done) {
      Token.generate({
        user: _userId
      }, function(err, token) {
        if (err) throw err;

        should.exist(token);
        should.exist(token.id);
        token.hash.length.should.equal(32);
        token.user.toString().should.equal(_userId.toString());
        done();
      });
    });
  });

});

我们将向 Token 模型添加一个 generate() 方法,该方法将返回一个加密强度高的令牌。

创建一个名为 app/models/token.js 的文件。它将包含 Token Mongoose 模式以及前面的方法:

'use strict';

const EXPIRATION = 30; // in days
const LEN = 32;

const mongoose = require('mongoose');
const tokenHelper = require('../helpers/token');
const Schema = mongoose.Schema;
const ObjectId = Schema.ObjectId;

const TokenSchema = new Schema({
  user: {
    type: ObjectId,
    ref: 'User',
    required: true
  },
  hash: {
    type: String,
  },
  expiresAt: {
    type: Date,
    default: function() {
      var now = new Date();
      now.setDate(now.getDate() + EXPIRATION);

      return now;
    }
  },
  createdAt: {
    type: Date,
    default: Date.now
  }
});

TokenSchema.statics.generate = generateToken

function generateToken(opts, callback) {
  tokenHelper.generate(opts.tokenLength || LEN, (err, tokenString) => {
    if (err) {
      return callback(err);
    }

    opts.hash = tokenString;

    this.model('Token').create(opts, callback);
  });
};

// compile Token model
module.exports = mongoose.model('Token', TokenSchema); 

如您所见,我们为我们的令牌添加了一个过期日期。这可以用来在指定时间后自动使令牌失效。通常,在应用程序中,您不希望有没有过期日期的令牌。如果需要这样的令牌,应该添加另一层通过 API 密钥的授权,以授权第三方客户端使用系统。

使用 HTTP 基本认证进行认证

在生成令牌之前,我们需要对用户进行认证。一个简单的解决方案可能是使用简单的用户名和密码认证,如果输入的信息有效,则生成令牌。

我们可以公开一个处理 HTTP 基本认证的路由。这是强制执行资源访问控制的最简单技术。在我们的例子中,资源将是一个令牌,它不需要 cookies 或标识会话。HTTP 基本认证使用 HTTP 请求头中的标准字段。

此方法不会以任何方式添加加密或散列;只需要简单的 base64 编码即可。因此,它通常在 HTTPS 上使用。如果客户端想要向服务器发送用于认证的必要凭证,它可以使用 Authorization 头字段。

我们将使用 passport-http 模块来实现基本认证策略。让我们创建一个名为 app/config/strategies/basic.js 的文件,并添加以下代码行:

'use strict';

const passport = require('passport');
const BasicStrategy = require('passport-http').BasicStrategy;
const mongoose = require('mongoose');
const User = mongoose.model('User');

module.exports.init = initBasicStrategy;

function initBasicStrategy() {
  passport.use('basic', new BasicStrategy((username, password, done) => {
    User.authenticate(username, password, (err, user) => {
      if (err) {
        return done(err);
      }

      if (!user) {
        return done(null, false);
      }

      return done(null, user);
    });
  }));
} 

该策略使用 authenticate() 方法来检查凭证是否有效。如您所见,我们在这里没有添加任何额外的逻辑。

接下来,我们将创建一个控制器来处理基本认证。创建一个名为 app/controllers/authentication.js 的文件,并添加以下内容:

'use strict';

const _ = require('lodash');
const passport = require('passport');
const mongoose = require('mongoose');
const Token = mongoose.model('Token');

module.exports.basic = basicAuthentication;

function basicAuthentication(req, res, next) {
  passport.authenticate('basic', (err, user, info) => {
    if (err || !user) {
      return res.status(400).send({ message: 'Invalid email or password.' });
    }

    Token.generate({
      user: user.id
    }, (err, token) => {
      if (err || !token) {
        return res.status(400).send({ message: 'Invalid email or password.' });
      }

      var result = user.toJSON();
      result.token = _.pick(token, ['hash', 'expiresAt']);

      res.json(result);
    });

  })(req, res, next);
} 

Passport 有一个 authenticate() 方法,使我们能够调用一个给定的策略。我们使用自定义回调来生成并持久化 MongoDB 中的令牌。当将令牌返回给客户端时,我们只需要存储数据中的少数几个东西,例如值和过期日期。

添加身份验证路由

创建一个名为 app/routes/authentication.js 的文件,并添加以下代码行:

'use strict';

const express = require('express');
const router = express.Router();
const authCtrl = require('../controllers/authentication');

router.post('/basic, authCtrl.basic);

module.exports = router;

auth 路由将允许用户使用基本策略进行 POST 调用并验证。为了创建可重用的路由,我们不会直接将路由挂载到 Express 应用实例上。相反,我们使用 Router 类来实例化一个新的路由器。

为了能够配置我们在 Express 应用程序上挂载哪些路由,我们可以创建一个名为 config/routes.js 的文件,包含以下代码行:

'use strict';

module.exports.init = function(app) {
  var routesPath = app.get('root') + '/app/routes';

  app.use('/auth, require(routesPath + '/auth));
};

上述代码行应该很简单。我们正在定义路由的基本路径并将它们挂载到我们的应用程序上。需要注意的是,我们正在向身份验证路由添加一个前缀。

将以下高亮显示的代码添加到主 server.js 文件中,以初始化路由配置文件:

require('./config/express').init(app);
require('./config/routes').init(app);

使用以下命令运行我们的身份验证测试:

mocha tests/integration/authentication.test.js

这应该有一个类似的积极输出:

 Authentication
 Basic authentication
 √ should authenticate a user and return a new token
 √ should not authenticate a user with invalid credentials
 2 passing

使用携带者身份验证验证用户

对于每个请求,应使用令牌来确定请求者是否有权访问系统。我们只有在用户发送有效凭据时才使用基本策略颁发令牌。Passport 有一个 passport-http-bearer 模块。通常,这用于保护 API 端点,就像在我们的案例中一样。令牌通常使用 OAuth 2.0 颁发,但,在我们的案例中,我们构建了一个自定义解决方案来颁发令牌。

在我们的案例中,令牌是系统向客户端颁发的访问授权键的字符串表示。客户端应用程序,Angular 应用程序,将使用访问令牌从 RESTful API 获取受保护资源。

让我们描述一个简单的用例,使用访问令牌检索信息。将以下代码行追加到 tests/integration/authentication.test.js,在基本身份验证测试套件之后:

  describe('Bearer authentication', function() {
    var _token;

    before(function() {
      Token.generate({
        user: _user.id
      }, function(err, token) {
        if (err) throw err;

        _token = token;
        done();
      });
    });

    it('should authenticate a user using an access token', function(done) {
      request({
        method: 'GET',
        url: baseUrl + '/auth/info',
        auth: {
          bearer: _token.value
        },
        json:true
      }, function(err, res, body) {
        if (err) throw err;

        res.statusCode.should.equal(200);
        body.email.should.equal(userFixture.email);
        should.not.exist(body.password);
        should.not.exist(body.passwordSalt);
        done();
      });
    });

    it('should not authenticate a user with an invalid access token', function(done) {
      request({
        method: 'GET',
        url: baseUrl + '/auth/info',
        auth: {
          bearer: _token.value + 'a1e'
        },
        json:true
      }, function(err, res, body) {
        if (err) throw err;

        res.statusCode.should.equal(401);
        body.should.equal('Unauthorized');
        done();
      });
    });
  });

我们假设存在一个 /auth/info 路由,如果进行 GET 调用,它将返回令牌的所有者凭据。如果令牌无效,将返回一个带有适当 401 HTTP 状态码的未授权消息。

携带者策略

让我们创建一个名为 config/strategies/bearer.js 的文件。添加以下代码段:

'use strict';

const passport = require('passport');
const BearerStrategy = require('passport-http-bearer').Strategy;
const mongoose = require('mongoose');
const Token = mongoose.model('Token');

module.exports.init = initBearerStrategy;

function initBearerStrategy() {
  passport.use('bearer', new BearerStrategy((token, done) => {
    Token
    .findOne({ hash: token })
    .populate('user')
    .exec((err, result) => {
      if (err) {
        return done(err);
      }

      if (!result) {
        return done(null, false, { message: 'Unauthorized.' });
      }

      if (!result.user) {
        return done(null, false, { message: 'Unauthorized.' });
      }

      done(null, result.user);
    });
  }));
} 

上述代码在数据库中搜索给定的令牌。为了检索令牌所有者,我们可以使用 Mongoose 的 populate() 方法结合一个常规查询方法,例如 findOne()。这是因为我们在 Token 模型中明确添加了对 User 模型的引用。

使用令牌保护资源

为了保护我们的资源,我们需要添加一个检查访问令牌存在的层。我们已经完成了 Bearer 策略的第一部分。现在我们只需要使用它;为此,我们可以创建一个中间件来验证令牌。

创建一个名为app/middlewares/authentication.js的新文件,并添加以下代码:

'use strict';

const passport = require('passport');

module.exports.bearer = function bearerAuthentication(req, res, next) {
  return passport.authenticate('bearer', { session: false });
};

上述代码相当简单。我们只是使用 passport 的内置authenticate()方法调用 Bearer 策略。我们不想在服务器上保存任何会话。这个中间件可以与任何其他应用程序逻辑结合使用,应用于每个路由。

将以下代码行追加到app/controllers/authentication.js。它将仅检查用户是否存在于请求对象中,并返回包含数据的 JSON:

module.exports.getAuthUser = getAuthUser;

function getAuthUser(req, res, next) {
  if (!req.user) {
    res.status(401).json({ message: 'Unauthorized.' });
  }

  res.json(req.user);
} 

现在,让我们回到我们的身份验证路由app/routes/authentication.js,并添加以下突出显示的代码行:

'use strict';

var express = require('express');
var router = express.Router();
var authCtrl = require('../controllers/authentication');
var auth = require('../middlewares/authentication');

router.post('/basic', authCtrl.basic);
router.get('/info', auth.bearer(), authCtrl.getAuthUser);

module.exports = router;

我们在控制器逻辑执行之前添加了身份验证中间件,以验证和检索令牌的所有者。我们的 Bearer 策略将处理这个问题,并在请求对象上设置用户;更确切地说,它可以在req.user上找到。

如果我们运行我们的身份验证测试:

mocha tests/integration/authentication.test.js

应该打印以下输出:

 Authentication
 Basic authentication
 √ should authenticate a user and return a new token
 √ should not authenticate a user with invalid credentials 
 Bearer authentication
 √ should authenticate a user using an access token
 √ should not authenticate a user with an invalid access token

 4 passing

通过这种方式,我们最终添加了所有必要的身份验证方法,以授予用户访问我们的系统。

跟踪支出

我们应用程序的主要功能是跟踪用户的支出。用户应该能够插入支出,使其在系统中持久化,并查看其账户的确切余额。

总是应该有一个清晰的视图来了解想要实现的目标。让我们从高层次的角度看看我们想要实现的目标:

  • 用户应该能够在系统中持久化一笔支出

  • 用户应该能够获取其所有的支出

  • 用户应该能够获取其支出的余额

  • 用户应该能够定义一个类别来保存支出,例如,杂货

货币价值

在我们的案例中,一笔支出将存储花费的确切货币价值。在某些情况下,处理货币数据可能会变得复杂。通常,处理货币数据的应用程序需要处理货币的分数单位。

我们可以将数据存储为浮点数。然而,在 JavaScript 中,浮点运算通常不符合货币运算。换句话说,像三分之一和十分之一这样的值在二进制浮点数中没有确切的表示。

例如,MongoDB 将数值数据存储为 IEEE 754 标准 64 位浮点数、32 位或 64 位有符号整数。JavaScript 根据规范将数字视为双精度 64 位格式 IEEE 754 值。因此,我们需要注意以下操作:

+ 0.2 = 0.30000000000000004

我们将无法存储像 9.99 美元这样的值,它代表小数点后的美分。请别误会;我们可以存储它们,但如果使用内置的 MongoDB 聚合框架或进行服务器端算术(同样适用于 JavaScript 客户端)时,我们将无法得到正确的结果。

不要担心;我们有几种解决方案可以使用。在 MongoDB 中存储货币值有两种常见的方法:

  • 精确精度是一种将货币值乘以 10 的幂的方法。

  • 相反,任意精度使用两个字段来表示货币值。一个字段以非数字格式(如字符串)存储精确值,另一个字段存储值的浮点近似值。

对于我们的实现,我们将使用精确精度模型。随着代码的进展,我们将讨论所有细节。

类别模型

正如我们之前讨论的,我们希望能够向特定类别添加支出。用户还应该能够邀请其他用户向类别添加支出。我们不会详细说明此功能的测试用例,但你应该考虑编写测试以确保一切按预期工作。

让我们创建一个名为app/models/category.js的文件,并添加以下代码行:

'use strict';

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

const CategorySchema = new Schema({
  name: {
    type: String,
    required: true
  },
  description: {
    type: String
  },
  owner: {
    type: ObjectId,
    ref: 'User',
    required: true
  },
  collaborators: {
    type: [
      {
        type: ObjectId,
        ref: 'User'
      }
    ]
  },
  createdAt: {
    type: Date,
    default: Date.now
  }
});

// compile Category model
module.exports = mongoose.model('Category', CategorySchema);

这里有两个重要事项需要注意:我们定义了类别的所有者,它始终是创建类别的认证用户,以及协作者字段,它包含可以插入类别支出的用户。

此外,别忘了通过添加以下突出显示的代码来更改模型配置文件config/models.js

['user', 'token', 'category', 'expense'].forEach(function(model) {
    require(modelsPath + model);
});

类别路由

为了在类别集合上公开简单的 CRUD 操作,我们必须为这些操作定义路由。为此,我们将创建一个名为app/routes/categories.js的路由器文件,并添加以下代码行:

'use strict';

const express = require('express');
const router = express.Router();
const categoryCtrl = require('../controllers/category');
const auth = require('../middlewares/authentication');

router.param('categoryId', expenseCtrl.findById);

router.get('/categories', auth.bearer(), categoryCtrl.getAll);
router.get('/categories/:categoryId', auth.bearer(), categoryCtrl.getOne);
router.post('/categories', auth.bearer(), categoryCtrl.create);
router.put('/categories/:categoryId', auth.bearer(), categoryCtrl.update);
router.delete('/categories/:categoryId', auth.bearer(), categoryCtrl.delete);

module.exports = router;

请记住,我们目前实际上没有实现类别控制器。让我们创建一个名为app/controllers/category.js的类别控制器。

通过 ID 获取类别

将以下代码行添加到app/controllers/category.js中:

'use strict';

const _ = require('lodash');
const mongoose = require('mongoose');
const Category = mongoose.model('Category');
const ObjectId = mongoose.Types.ObjectId;

module.exports.findById = findCategoryById;
module.exports.create = createCategory;
module.exports.getOne = getOneCategory;
module.exports.getAll = getAllCategories;
module.exports.update = updateCategory;
module.exports.delete = deleteCategory;

function findCategoryById(req, res, next, id) {
  if (!ObjectId.isValid(id)) {
    return res.status(404).json({ message: 'Not found.'});
  }

  Category.findById(id, (err, category) => {
    if (err) {
      return next(err);
    }

    if (!category) {
      return res.status(404).json({ message: 'Not found.'});
    }

    req.category = category;
    next();
  });
} 

categoryId路由param存在时,前面的代码将非常有用。它将自动获取一个类别,正如我们在路由文件中定义的那样。

创建类别

要创建一个类别,将以下代码行追加到控制器文件中:

function createCategory(req, res, next) {
  const data = req.body;
  data.owner = req.user.id;

  Category.create(data, (err, category) => {
    if (err) {
      return next(err);
    }

    res.status(201).json(category);
  });
} 

在创建类别之前,我们添加所有者的 ID,即当前用户的 ID。

获取一个和所有类别

我们还希望获取单个类别和所有类别。要获取一个类别,我们将使用通过 ID 获取类别的结果。要检索多个类别,我们将使用 Mongoose 的find()查询方法。我们可以轻松添加分页或设置限制,但我们将假设用户不会有这么多类别。这可能是我们应用程序的一个小改进。

将以下代码行添加到控制器中:

function getOneCategory(req, res, next) {
  res.json(req.category);
}

function getAllCategories(req, res, next) {
  Category.find((err, categories) => {
    if (err) {
      return next(err);
    }

    res.json(categories);
  });
} 

更新和删除分类

当我们通过 ID 获取一个分类时,我们将 Mongoose 返回的实例设置到请求对象中。由于这个原因,我们可以使用该实例来更改其属性并将其保存回 Mongo。添加以下代码:

function updateCategory(req, res, next) {
  const category = req.category;
  const data = _.pick(req.body, ['description', 'name']);
  _.assign(category, data);

  category.save((err, updatedCategory) => {
    if (err) {
      return next(err);
    }

    res.json(updatedCategory);
  });
}

删除分类时也可以使用相同的方法;也请添加以下代码行:

function deleteCategory(req, res, next) {
  req.category.remove((err) => {
    if (err) {
      return next(err);
    }

    res.status(204).json();
  });
} 

使用前面的代码行,我们已经完成了对分类的 CRUD 操作。

定义费用模型

之前,我们讨论了这样一个事实,即我们不能简单地将货币数据作为浮点数存储在数据库中或用于服务器端算术。我们场景的解决方案是使用精确精度来存储货币数据。换句话说,货币值将通过将初始值乘以 10 的幂来存储。

我们将假设所需的最高精度为十分之一分。根据这个假设,我们将初始值乘以 1000。例如,如果我们有一个初始值为 9.99 美元,数据库中存储的值将是 9990。

对于当前应用程序的实现,我们将使用美元(USD)作为货币值。缩放因子将为 1000,以保留到十分之一分的精度。使用精确精度模型,缩放因子需要在应用程序中保持一致,并且任何给定时间都应该从货币中确定。

让我们创建我们的费用模型,app/models/expense.js,并添加以下代码行:

'use strict';

const CURRENCY = 'USD';
const SCALE_FACTOR = 1000;

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

const ExpenseSchema = new Schema({
  name: {
    type: String
  },
  amount: {
    type: Number,
    default: 0
  },
  currency: {
    type: String,
    default: CURRENCY
  },
  scaleFactor: {
    type: Number,
    default: SCALE_FACTOR
  },
  user: {
    type: ObjectId,
    ref: 'User',
    required: true
  },
  category: {
    type: ObjectId,
    ref: 'Category',
    required: true
  },
  createdAt: {
    type: Date,
    default: Date.now
  }
}, {
  toObject: {
    virtuals: true
  },
  toJSON: {
    virtuals: true
  }
});

module.exports = mongoose.model('Expense', ExpenseSchema); 

以下表格将简要描述模式中的字段:

字段 描述
name 费用的名称
amount 货币的缩放金额
currency 用于表示货币的货币
scaleFactor 用于获取金额的缩放因子
user 费用所属的用户
category 费用所属的分类组
createdAt 费用对象创建的日期

Mongoose 有一个有趣的功能,称为 虚拟属性。这些属性不会持久化到数据库中,但在许多场景中非常有用。我们将使用一个名为 value 的虚拟属性,它将表示 amount 属性的货币价值。

在模型编译之前添加以下代码行:

ExpenseSchema.virtual('value')
.set(function(value) {
  if (value) {
    this.set('amount', value * this.scaleFactor);
  }
})
.get(function() {
  return this.amount / this.scaleFactor;
});

就像所有属性一样,虚拟属性可以有 getterssetters。我们将利用 setter 并添加自己的逻辑,这将根据给定的因子缩放值并获取所需的金额。此外,当获取虚拟的 value 属性时,我们将返回正确的货币表示形式,通过将存储的金额除以相应的缩放因子。

默认情况下,当进行查询时,Mongoose 不会返回 virtual attributes,但我们已覆盖了模式默认选项,以便在使用 .toJSON().toObject() 方法时返回所有 virtual attributes

描述费用模块功能

接下来,我们将为费用模块编写一些测试用例,以定义模块所需的行为。

提示

为了加快速度,我们只定义几个测试用例。其余的 CRUD 测试用例与早期实现中不同模块的相同。为了参考,您可以在以下链接查看测试套件的完整代码库:www.packtpub.com/.

让我们创建一个名为 tests/integration/expense.test.js 的文件。我们将定义最重要的测试用例:

  1. 在创建费用时,必须存在一个值和一个类别。该值应该是一个可以接受小数的数字:

        it('should save an expense', function(done) {
          request({
            method: 'POST',
            url: baseUrl + '/expenses',
            auth: {
              bearer: _token.value
            },
            form: {
              value: 14.99,
              category: _category.toString()
            },
            json:true
          }, function(err, res, body) {
            if (err) throw err;
    
            res.statusCode.should.equal(201);
            body.amount.should.equal(14990);
            body.scaleFactor.should.equal(1000);
            body.value.should.equal(14.99);
            body.category.should.equal(_category.toString());
            done();
          });
        });
    
  2. 我们应该能够从数据库中获取所有用户的费用:

        it('should get balance for all expenses', function(done) {
          request({
            method: 'GET',
            url: baseUrl + '/expenses/balance',
            auth: {
              bearer: _token.value
            },
            json:true
          }, function(err, res, body) {
            if (err) throw err;
    
            res.statusCode.should.equal(200);
            should.exist(body);
            body.balance.should.equal(33.33);
            body.count.should.equal(3);
            done();
          });
        });
    
  3. 如果需要,我们应该只获取给定类别的费用。当我们要显示特定类别的费用时,这会很有用:

        it('should get expenses balance only for a category', function(done) {
          request({
            method: 'GET',
            url: baseUrl + '/expenses/balance?category=' + _categoryOne.toString(),
            auth: {
              bearer: _token.value
            },
            json:true
          }, function(err, res, body) {
            if (err) throw err;
    
            res.statusCode.should.equal(200);
            should.exist(body);
            body.balance.should.equal(21.21);
            body.count.should.equal(2);
            done();
          });
        });
    

上述代码测试了费用的创建以及虚拟值属性是否正确工作。它还检查是否发送了无效的令牌,并且应用程序将相应地处理它。现在,有趣的部分开始了,即 balance 功能,它应该返回不同场景下费用的聚合值。

费用的 CRUD 操作

接下来,我们将逐一实现费用的 CRUD 操作。在继续之前,我们将创建一个新的路由文件名为 app/routes/expenses.js 并添加以下代码行:

'use strict';

const express = require('express');
const router = express.Router();
const expenseCtrl = require('../controllers/expense');
const auth = require('../middlewares/authentication');

router.param('expenseId', expenseCtrl.findById);

router.get('/expenses', auth.bearer(), expenseCtrl.getAll);
router.get('/expenses/:expenseId', auth.bearer(), expenseCtrl.getOne);
router.post('/expenses', auth.bearer(), expenseCtrl.create);
router.put('/expenses/:expenseId', auth.bearer(), expenseCtrl.update);
router.delete('/expenses/:expenseId', auth.bearer(), expenseCtrl.delete);

module.exports = router;

我们为每个路由添加了 bearer 认证。您本可以创建一个单独的路由来捕获所有需要认证的资源,但这样,您将能够对每个路由进行更精细的控制。

创建费用

让我们创建路由文件所需的控制器——app/controllers/expense.js——并添加创建费用的逻辑:

'use strict';

const _ = require('lodash');
const mongoose = require('mongoose');
const Expense = mongoose.model('Expense');
const ObjectId = mongoose.Types.ObjectId;

module.exports.create = createExpense;
module.exports.findById = findExpenseById
module.exports.getOne = getOneExpense;
module.exports.getAll = getAllExpenses;
module.exports.update = updateExpense;
module.exports.delete = deleteExpense;
module.exports.getBalance = getExpensesBalance;

function createExpense(req, res, next) {
  const data = _.pick(req.body, ['name', 'value', 'category', 'createdAt']);
  data.user = req.user.id;

  if (data.createdAt === null) {
    delete data.createdAt;
  }

  Expense.create(data, (err, expense) => {
    if (err) {
      return next(err);
    }

    res.status(201).json(expense);
  });
} 

我们想要创建的费用应该是令牌所有者的。因此,我们明确地将用户属性设置为认证用户的 ID。

通过 ID 获取费用

获取单个费用和更新费用的逻辑使用费用实例来显示或更新它。因此,我们只添加一个通过 ID 获取费用的逻辑。将以下代码行追加到控制器文件中:

function findExpenseById(req, res, next, id) {
  if (!ObjectId.isValid(id)) {
    return res.status(404).json({ message: 'Not found.'});
  }

  Expense.findById(id, (err, expense) => {
    if (err) {
      return next(err);
    }

    if (!expense) {
      return res.status(404).json({ message: 'Not found.'});
    }

    req.expense = expense;
    next();
  });
} 

因为在这里我们不会进行最终操作,所以我们只设置费用在请求对象中存在,并调用路由管道中的下一个处理程序。

获取单个费用

我们将扩展“通过 ID 获取费用”并仅响应资源的 JSON 表示。获取费用的逻辑应该是追加到控制器文件中的几行代码:

function getOneExpense(req, res, next) {
  if (!req.expense) {
    return res.status(404).json({ message: 'Not found.'});
  }

  res.json(req.expense);
}

获取所有费用

当获取所有支出时,我们需要采取不同的方法——一种能够使我们能够通过特定查询过滤它们的方法。支出还应返回特定类别的支出。我们不需要为所有这些场景实现不同的搜索逻辑。相反,我们可以创建一个将围绕我们的需求:

function getAllExpenses(req, res, next) {
  const limit = +req.query.limit || 30;
  const skip = +req.query.skip || 0;
  const query = {};

  if (req.category) {
    query.category = req.category.id;
  } else {
    query.user = req.user.id;
  }

  if (req.query.startDate) {
    query.createdAt = query.createdAt || {};
    query.createdAt.$gte = new Date(req.query.startDate);
  }

  if (req.query.endDate) {
    query.createdAt = query.createdAt || {};
    query.createdAt.$lte = new Date(req.query.endDate);
  }

  if (req.query.category) {
    query.category = req.query.category;
  }

  Expense
  .find(query)
  .limit(limit)
  .skip(skip)
  .sort({ createdAt: 'desc' })
  .populate('category')
  .exec((err, expenses) => {
    if (err) {
      return next(err);
    }

    res.json(expenses);
  });
}  

在使用 Mongoose 查询数据库以检索必要数据之前,我们构建一个查询变量,该变量将包含所有我们的标准。这里值得注意的一个好现象是,我们再次使用了 Mongoose 提供的查询构建器对象。支出在 MongoDB 中将以更大的数量存储。因此,我们添加了limitskip来检索有限的数据集。

可以使用日期范围查询支出。由于这个原因,createdAt属性将逐步扩展以匹配特定时间段内的支出集合。支出还应按时间顺序返回;新添加的支出应首先返回。

为了获取每个支出的所有必要信息,我们将使用数据库中的适当类别对象来填充支出的类别属性。

更新支出

将以下更新逻辑的代码添加到控制器文件中:

function updateExpense(req, res, next) {
  const data = _.pick(req.body, ['name', 'value', 'category', 'createdAt']);
  const expense = req.expense;

  if (data.createdAt === null) {
    delete data.createdAt;
  }

  _.assign(expense, data);

  expense.save((err, updatedExpense) => {
    if (err) {
      return next(err);
    }

    res.json(updatedExpense);
  });
} 

更新逻辑使用回调触发器为支出 ID 参数设置的请求对象上的支出实例。

删除支出

为了删除支出,我们只需使用以下代码从数据库中删除支出实例:

function deleteExpense(req, res, next) {
  req.expense.remove((err) => {
    if (err) {
      return next(err);
    }

    res.status(204).json();
  });
}

获取支出余额

让我们回到支出模型,并使用余额计算来扩展它。为了在不同场景下获取余额,我们将使用 MongoDB 的聚合框架。聚合数据意味着从集合中的数据操作得到的计算结果。

Mongo 提供了一套复杂的操作来对数据集进行操作。因为我们使用 Mongoose,所以我们有权访问Model.aggregate(),这将帮助我们创建聚合管道。

请记住,聚合返回的数据是以纯 JavaScript 对象的形式,而不是 Mongoose 文档。这是由于在使用聚合时,可以返回任何形状的文档。

在编译支出模型之前添加以下代码:

ExpenseSchema.statics.getBalance = getExpensesBalance;

function getExpensesBalance(opts, callback) {
  const query = {};

  // set the current user
  query.user = opts.user;

  if (opts.category || opts.category === null) {
    query.category = new mongoose.Types.ObjectId(opts.category);
  }

  if (opts.startDate && opts.endDate) {
    query.createdAt = {
      $gte: new Date(opts.startDate),
      $lte: new Date(opts.endDate)
    };
  }

  this.model('Expense').aggregate([
    { $match: query },
    { $group: { _id: null, balance: { $sum: '$amount' }, count: { $sum: 1 } } }
  ], (err, result) => {

    // result is an array with a single item, we can just return that
    const final = result[0];
    final.balance = final.balance / SCALE_FACTOR;

    callback(err, final);
  });
}

上述静态.getBalance()方法将根据测试用例中描述的不同场景计算当前余额。.aggregate()方法会经过多个阶段。第一个阶段是匹配阶段,它将选择所有符合我们定义查询的文档。匹配的结果被发送到分组阶段,在这里,文档根据指定的标识符进行分组。

此外,管道阶段可以使用运算符执行不同的任务,例如,在我们的场景中计算余额。我们使用一个累加运算符$sum,它为每个组返回一个数值。

在分组阶段,_id字段是必需的,但你可以为它指定一个 null 值来计算管道输入文档的所有值。分组操作器的 RAM 限制为 100 兆字节,但你可以将它设置为使用磁盘来写入临时文件。要设置此选项,请使用 Mongoose 并查看.allowDiskUse()方法。

添加缺失的控制器函数,app/controller/expense

function getExpensesBalance(req, res, next) {
  Expense.getBalance({
    user: req.user._id,
    category: req.query.category,
    startDate: req.query.start,
    endDate: req.query.end
  }, (err, result) => {
    if (err) {
      return next(err);
    }

    res.json(result);
  });
}

实现 Angular 客户端应用程序

在我们的项目中,我们已经到达了开始集成 AngularJS 应用程序的阶段。本章将采用不同的方法来构建所需的应用程序。理想的应用程序应该以模块化的方式构建,每个模块处理特定的功能。

当你构建 Angular 应用程序时,你可能已经熟悉了基于组件的方法。这意味着我们将创建小的模块,这些模块封装了特定的功能。这使得我们可以增量地添加功能;想象一下向应用程序添加垂直块。

为了使这生效,我们需要创建一个主要块来将所有东西粘合在一起,将所有功能和模块拉在一起。保持你的主应用程序模块轻薄,并将其余逻辑移动到应用程序模块中。

我喜欢遵循的一条规则是尽可能保持我的文件夹结构扁平。我总是试图降低文件夹的层级,以便我可以快速定位代码和功能。如果你的模块变得太大,你可以将其拆分或添加子文件夹。

项目初始化

让我们开始创建一个public/package.json文件。我们将使用npm来安装项目前端部分的依赖项。package.json文件将包含以下内容:

{
  "private": true,
  "name": "mean-blueprints-expensetracker-client",
  "dependencies": {
    "systemjs": "⁰.19.25",
    "es6-shim": "⁰.35.0",
    "es6-promise": "³.0.2",
    "rxjs": "⁵.0.0-beta.2",
    "reflect-metadata": "⁰.1.2",
    "zone.js": "⁰.6.6",
    "angular2": "².0.0-beta.14"
  },
  "devDependencies": {
    "typings": "⁰.7.12",
    "typescript": "¹.8.9"
  }
} 

运行此命令以安装所有依赖项:

npm install

安装成功后,创建一个名为public/src的文件夹。这个文件夹将包含主要的 Angular 应用程序。在这个文件夹内,我们将创建我们的模块文件夹和应用文件。

创建你的主应用程序组件文件,命名为public/src/app.component.ts,并按照以下步骤创建文件的最终版本:

  1. 添加必要的依赖项:

    import { Component, OnInit } from 'angular2/core';
    import { RouteConfig, RouterOutlet, RouterLink } from 'angular2/router';
    import { Router } from 'angular2/router';
    import { AuthHttp, AuthService, SigninComponent, RegisterComponent } from './auth/index';
    import { ExpensesComponent } from './expense/index';
    import { CategoriesComponent } from './expense/index';
    
  2. 配置你的路由:

    @RouteConfig([
      { path: '/', redirectTo: ['/Expenses'], useAsDefault: true },
      { path: '/expenses', as: 'Expenses', component: ExpensesComponent },
      { path: '/categories', as: 'Categories', component: CategoriesComponent },
      { path: '/signin', as: 'Signin', component: SigninComponent },
      { path: '/register', as: 'Register', component: RegisterComponent }
    ])
    

    我们定义了一个默认路径,该路径将重定向到expenses视图,显示所有条目给用户。还有一个Signinregister路由可用。

  3. 添加组件注解:

    @Component({
        selector: 'expense-tracker',
        directives: [
          RouterOutlet,
          RouterLink
        ],
        template: `
          <div class="app-wrapper card whiteframe-z2">
            <div class="row">
              <div class="col">
                <a href="#">Expense tracker</a>
                <a href="#" [routerLink]="['Expenses']">Expenses</a>
              </div>
            </div>
            <div class="row">
              <router-outlet></router-outlet>
            </div>
          </div>
        `
    })
    
  4. 定义组件的类:

    export class AppComponent implements OnInit {
      public currentUser: any;
      private _authHttp: AuthHttp;
      private _authSerivce: AuthService;
      private _router: Router;
    
      constructor(authHttp: AuthHttp, authSerice: AuthService, router: Router) {
        this._router = router;
        this._authSerivce = authSerice;
        this._authHttp = authHttp;
      }
    
      ngOnInit() {
        this.currentUser = {};
        this._authHttp.unauthorized.subscribe((res) => {
          if (res) {
            this._router.navigate(['./Signin']);
          }
        });
        this._authSerivce.currentUser.subscribe((user) => {
          this.currentUser = user;
        });
      }
    }
    

如果发生未经授权的调用,我们将用户重定向到Signin路由,以便使用有效凭据进行身份验证。

注册用户

我们的应用程序应该支持用户注册。我们已经有这个功能的后端逻辑。现在,我们只需要将其与我们的 Angular 应用程序结合起来。为此,我们将创建一个名为auth的通用模块,该模块将用于注册和验证用户。

认证服务

我们将继续使用auth服务,该服务将包含与 Node.js 后端应用程序的所有通信逻辑。创建一个名为public/src/auth/services/auth.service.ts的文件,并按照以下步骤实现服务的整个逻辑:

  1. 导入依赖项:

    import { Injectable } from 'angular2/core';
    import { Http, Response, Headers } from 'angular2/http';
    import { Subject } from 'rxjs/Subject';
    import { BehaviorSubject } from 'rxjs/Subject/BehaviorSubject';
    import { contentHeaders } from '../../common/index';
    
  2. 定义服务类:

    @Injectable()
    export class AuthService {
      public currentUser: Subject<any>;
      private _http: Http;
    
      constructor(http: Http) {
        this._http = http;
        this._initSession();
      }
    }
    
  3. 添加signin()方法:

      public signin(user: any) {
        let body = this._serialize(user);
        let basic = btoa(`${user.email}:${user.password}`);
        let headers = new Headers(contentHeaders);
        headers.append('Authorization', `Basic ${basic}`)
    
        return this._http
        .post('/auth/basic', '', { headers: headers })
        .map((res: Response) => res.json());
      }
    Append the register() method:
      public register(user: any) {
        let body = this._serialize(user);
    
        return this._http
        .post('/api/users', body, { headers: contentHeaders })
        .map((res: Response) => res.json());
      }
    
  4. 设置当前用户:

      public setCurrentUser(user: any) {
        this.currentUser.next(user);
      }
    

    我们希望公开一个简单的函数来设置currentUser可观察对象的下一个值。

  5. 初始化会话:

      private _initSession() {
        let user = this._deserialize(localStorage.getItem('currentUser'));
        this.currentUser = new BehaviorSubject<Response>(user);
        // persist the user to the local storage
        this.currentUser.subscribe((user) => {
          localStorage.setItem('currentUser', this._serialize(user));
          localStorage.setItem('token', user.token.hash || '');
        });
      }
    

    当应用程序重新加载时,我们希望从本地存储中检索当前用户以恢复会话。你可以添加的一个改进是检查令牌是否已过期。

  6. 添加辅助方法:

      private _serialize(data) {
        return JSON.stringify(data);
      }
    
      private _deserialize(str) {
        try {
          return JSON.parse(str);
        } catch(err) {
          console.error(err);
          return null;
        }
      }
    

前面的函数是stringifyparse JSON 方法的简单抽象。

寄存器组件

创建适当的组件文件,public/src/auth/components/register.component.ts,其中包含以下代码行:

import { Component } from 'angular2/core';
import { Router, RouterLink } from 'angular2/router';
import { AuthService } from '../services/auth.service';

export class RegisterComponent {
  private _authService: AuthService;
  private _router: Router;

  constructor(authService: AuthService, router: Router) {
    this._router = router;
    this._authService = authService;
  }

  register(event, name, email, password) {
    event.preventDefault();

    let data = { name, email, password };

    this._authService
    .register(data)
    .subscribe((user) => {
      this._router.navigateByUrl('/');
    }, err => console.error(err));
  }
}

当调用register方法时,我们只是尝试使用AuthService注册我们的用户。前面的代码中没有添加错误处理。只会在浏览器的控制台中打印一个简单的日志。让我们添加模板:

@Component({
    selector: 'register',
    directives: [
      RouterLink
    ],
    template: `
      <div class="login jumbotron center-block">
        <h1>Register</h1>
        <form role="form" (submit)="register($event, name.value, email.value, password.value)">
          <div class="form-group">
            <label for="name">Full name</label>
            <input type="text" #name class="form-control" id="email" placeholder="please enter your name">
          </div>
          <div class="form-group">
            <label for="email">E-mail</label>
            <input type="text" #email class="form-control" id="email" placeholder="enter valid e-mail">
          </div>
          <div class="form-group">
            <label for="password">Password</label>
            <input type="password" #password class="form-control" id="password" placeholder="now your password">
          </div>
          <button type="submit" class="button">Submit</button>
        </form>
      </div>
    `
})

寄存器组件相当简单。我们正在定义一个简单的寄存器函数,该函数将使用认证服务的register方法。所有必要的字段也可以在template属性中找到。

登录用户组件

为了认证用户,我们在认证服务中添加了一些额外的功能,使我们能够登录用户。因为我们没有在后端持久化用户的会话状态——换句话说,我们的后端是无状态的——我们必须在前端存储用户的当前状态。

记住我们创建了一个端点,该端点将为我们提供一个有效的用户名和密码对的令牌。我们将使用该端点来检索一个令牌,该令牌将使我们能够访问 API 的其余端点。

我们的登录组件相当简单,并且确实是从上一章中复用的,但让我们刷新一下记忆并看看它。SigninComponent位于public/src/auth/components/signin.component.ts

import { Component } from 'angular2/core';
import { Router, RouterLink } from 'angular2/router';
import { AuthService } from '../services/auth.service';

@Component({
    selector: 'signin',
    directives: [
      RouterLink
    ],
    template: `
      <div class="login jumbotron center-block">
        <h1>Login</h1>
        <form role="form" (submit)="signin($event, email.value, password.value)">
          <div class="form-group">
            <label for="email">E-mail</label>
            <input type="text" #email class="form-control" id="email" placeholder="enter your e-mail">
          </div>
          <div class="form-group">
            <label for="password">Password</label>
            <input type="password" #password class="form-control" id="password" placeholder="now your password">
          </div>
          <button type="submit" class="button">Submit</button>
        </form>
      </div>
    `
})
export class SigninComponent {
  private _authService: AuthService;
  private _router: Router;

  constructor(authService: AuthService, router: Router) {
    this._authService = authService;
    this._router = router;
  }

  signin(event, email, password) {
    event.preventDefault();

    let data = { email, password };

    this._authService
    .signin(data)
    .subscribe((user) => {
      this._authService.setCurrentUser(user);
      this._router.navigateByUrl('/');
    }, err => console.error(err));
  }
}

就像在RegisterComponent中一样,我们为我们的字段使用局部变量。使用AuthService,我们尝试认证我们的用户。我们并不真正关注错误处理,但如果用户成功认证,我们希望导航到root路径并设置当前用户。

常用功能

在进一步开发之前,我们需要考虑一些之前使用过的功能和一些额外的功能。例如,我们使用了一个常见的头定义,位于public/src/common/headers.ts下:

import { Headers } from 'angular2/http';

const HEADERS = {
  'Content-Type': 'application/json',
  'Accept': 'application/json'
};

export const contentHeaders = new Headers(HEADERS);

这仅仅是一种定义常量并在整个应用程序中使用它们而不重复的方法。所以,基本上,我们导入了 Angular 2 中的Headers并创建了一个新实例。你可以很容易地使用append()方法向这个头实例添加额外的字段,例如:

contentHeaders.append('Authorization', 'Bearer <token_value>');

现在还有一些其他事情需要考虑:

  • 当通过 API 向服务器请求资源时,我们应该发送所需的 Bearer 令牌

  • 如果用户发出调用,并且服务器以状态码 401(未经授权)响应,我们应该将用户重定向到登录页面

让我们看看我们能对前面的列表做些什么。

自定义 HTTP 服务

在我们创建自定义 HTTP 服务以调用 Express 后端应用程序时,我们在上一章中做了类似的事情。但是我们需要一些额外的东西,比如将令牌附加到通过此服务发出的每个调用中,以识别用户。

记住我们将在浏览器的LocalStorage中存储用户的令牌。这应该相当简单,我认为我们甚至可以在服务中添加它。让我们开始,创建一个名为public/src/auth/services/auth-http.ts的新文件:

import { Injectable } from 'angular2/core';
import { Http, Response, Headers, BaseRequestOptions, Request, RequestOptions, RequestOptionsArgs, RequestMethod } from 'angular2/http';
import { Observable } from 'rxjs/Observable';
import { Subject } from 'rxjs/Subject';
import { BehaviorSubject } from 'rxjs/Subject/BehaviorSubject';

@Injectable()
export class AuthHttp {
  public unauthorized: Subject<Response>;
  private _http: Http;

  constructor(http: Http) {
    this._http = http;
    this.unauthorized = new BehaviorSubject<Response>(null);
  }

  public get(url: string, opts?: RequestOptionsArgs) {
    return this.request({ url: url, method: RequestMethod.Get}, opts);
  }

  public post(url: string, body?: string, opts?: RequestOptionsArgs) {
    return this.request({ url: url, method: RequestMethod.Post, body: body}, opts);
  }

  public put(url: string, body?: string, opts?: RequestOptionsArgs) {
    return this.request({ url: url, method: RequestMethod.Put, body: body}, opts);
  }

  public delete(url: string, body?: string, opts?: RequestOptionsArgs) {
    return this.request({ url: url, method: RequestMethod.Delete, body: body}, opts);
  }

  // rest of the HTTP methods ...
}

因此,这是我们自定义的HttpAuth服务,它公开了一些公共方法,与上一章相同。现在变化发生在私有的request()方法中:

  private request(requestArgs: RequestOptionsArgs, additionalArgs?: RequestOptionsArgs) {
    let opts = new RequestOptions(requestArgs);

    if (additionalArgs) {
      opts = opts.merge(additionalArgs);
    }

    let req:Request = new Request(opts);

    if (!req.headers) {
      req.headers = new Headers();
    }

    if (!req.headers.has('Authorization')) {
      req.headers.append('Authorization', `Bearer ${this.getToken()}`);
    }

    return this._http.request(req).catch((err: any) => {
      if (err.status === 401) {
        this.unauthorized.next(err);
      }

      return Observable.throw(err);
    });
  }

在我们发出调用之前,我们将必要的令牌附加到Authorization头。令牌存储在浏览器的存储中,因此我们使用getToken()方法来检索它。如果请求未经授权,我们将它推送到我们的未经授权数据流中,该数据流包含失败的认证请求。

getToken()方法有一个非常简单的实现:

  private getToken() {
    return localStorage.getItem('token');
  }

使用单个导出文件

我们可以在每个模块文件夹的根目录中添加一个index.ts文件,以便导出所有公共成员。在auth模块中,我们可以有一个名为public/src/auth/index.ts的文件,其内容如下:

export * from './components/register.component';
export * from './components/signin.component';
export * from './services/auth.service';
export * from './services/auth-http';

这种技术将用于每个模块,并且不会进一步介绍。

类别模块

类别模块将包含执行类别 CRUD 操作和通过 Angular 服务与后端通信所需的所有逻辑。

类别服务

类别服务将相对简单,它只将管理类别的 CRUD 操作。以下步骤将描述实现此过程的方法:

  1. 创建一个名为public/app/categories/category.service.js的文件。

  2. 添加必要的业务逻辑:

    import { Injectable } from 'angular2/core';
    import { Http, Response, Headers } from 'angular2/http';
    import { Observable } from 'rxjs/Observable';
    import { Subject } from 'rxjs/Subject';
    import { BehaviorSubject } from 'rxjs/Subject/BehaviorSubject';
    import { AuthHttp } from '../auth/index';
    import { contentHeaders } from '../common/index';
    import { Category } from './category.model';
    
    @Injectable()
    export class CategoryService {
      public category: Subject<Category>;
      public categories: Observable<Array<Category>>;
    
      private _authHttp: AuthHttp;
      private _categoriesObserver: any;
    
      constructor(authHttp: AuthHttp) {
        this._authHttp = authHttp;
        this.categories = new Observable(
          observer => {
            this._categoriesObserver = observer
          }
        ).share();
        this.category = new BehaviorSubject<Category>(null);
      }
    
      getAll() {
        return this._authHttp
        .get('/api/categories', { headers: contentHeaders })
        .map((res: Response) => res.json())
        .map((data) => {
          let categories = data.map((category) => {
            return new Category(
              category._id,
              category.name,
              category.description,
              category.owner,
              category.collaborators
            );
          });
    
          this._categoriesObserver.next(categories);
    
          return categories;
        });
      }
    
      findById(id) {
        return this._authHttp
        .get(`/api/categories/${id}`, { headers: contentHeaders })
        .map((res: Response) => res.json())
      }
    
      create(category) {
        let body = JSON.stringify(category);
    
        return this._authHttp
        .post('/api/categories', body, { headers: contentHeaders })
        .map((res: Response) => res.json())
      }
    
      update(category) {
        let body = JSON.stringify(category);
    
        return this._authHttp
        .put(`/api/categories/${category._id}`, body, { headers: contentHeaders })
        .map((res: Response) => res.json())
      }
    
      delete(category) {
        return this._authHttp
        .put(`/api/categories/${category._id}`, '', { headers: contentHeaders })
        .map((res: Response) => res.json())
      }
    } 
    

如您所见,该服务将公开所有 CRUD 操作所需的方法。每个方法都将返回一个可观察对象,它将发出单个响应。我们还使用自己的AuthHttp来检查请求是否未经授权,用户需要登录。

注意,除了返回的可观察对象外,getAll()方法还更新了categories数据流,以便将新值推送到每个订阅者。当多个订阅者使用相同的数据源以自己的方式显示数据时,这将非常有用。

类别组件

我们将创建一个组件,用于我们在本章开头配置的 /categories 路径。本章早些时候使用了 AppComponent 的最终版本。

CategoriesComponent 将使用另外两个组件来创建一个新的类别并列出系统中的所有可用条目。让我们创建一个新的文件,public/src/category/categories.component.ts

import { Component } from 'angular2/core';
import { CategoryListComponent } from './category-list.component';
import { CategoryCreateComponent } from './category-create.component';

@Component({
    selector: 'categories',
    directives: [
      CategoryCreateComponent,
      CategoryListComponent
    ],
    template: `
      <category-create></category-create>
      <category-list></category-list>
    `
})
export class CategoryComponent {
  constructor() {}
}

之前的组件没有太多内容;我们没有移动部件。我们只是导入两个必要的组件并将它们包含在模板中。让我们继续实现此上下文中的其他两个组件。

创建一个新的类别

用户必须能够与我们的应用程序交互并添加新类别,因此我们将为此创建一个单独的组件。让我们将其分解为以下步骤:

  1. 首先,创建一个名为 public/src/category/components/category-create.component.ts 的视图文件。

  2. 导入必要的依赖项:

    import { Component, OnInit } from 'angular2/core';
    import { CategoryService } from '../category.service';
    import { Category } from '../category.model';
    
  3. 定义组件注解,其中包含模板:

    @Component({
        selector: 'category-create',
        template: `
          <div>
            <form role="form" (submit)="onSubmit($event)">
              <div class="form-group">
                <label for="name">Name</label>
                <input type="text" [(ngModel)]="category.name" class="form-control" id="name">
              </div>
              <div class="form-group">
                <label for="description">Description</label>
                <textarea class="form-control" id="description"
                  name="description" [(ngModel)]="category.description">
                </textarea>
              </div>
              <button type="submit" class="button">Add</button>
            </form>
          </div>
        `
    })
    
  4. 添加组件的类:

    export class CategoryCreateComponent implements OnInit {
      public category: Category;
      public categories: Array<Category>;
      private _categoryService: CategoryService;
    
      constructor(categoryService: CategoryService) {
        this._categoryService = categoryService;
      }
    
      ngOnInit() {
        this.category = new Category();
      }
    
      onSubmit(event) {
        event.preventDefault();
    
        this._categoryService
        .create(this.category)
        .subscribe((category) => {
          this._categoryService.category.next(category);
          this.category = new Category();
        }, err => console.error(err));
      }
    }
    

每次我们添加一个新的类别,我们希望向所有订阅者广播新项目。例如,类别列表应该显示新条目。在我们成功创建类别后,表单应该重置到其初始值。

列出所有类别

现在我们能够创建类别,我们应该能够为用户列出它们。为了列出类别,我们将使用两个组件,一个组件用于迭代来自服务器的数据,另一个用于显示类别的信息。

后者组件也将封装更新功能。因此,用户可以随时更改类别的信息,并在后端持久化更改。

让我们创建一个新的组件文件用于类别列表,名为 public/src/category/components/category-list.component.ts,并遵循以下步骤:

  1. 导入必要的模块:

    import { Component, OnInit, OnDestroy } from 'angular2/core';
    import { CategoryService } from '../category.service';
    import { CategoryComponent } from './category.component';
    import { Category } from '../category.model';
    

    我们导入了 CategoryComponent,但目前它还不存在,但我们应该已经对我们的组件如何使用有一个想法。

  2. 定义模板和组件注解:

    @Component({
        selector: 'category-list',
        directives: [CategoryComponent],
        template: `
          <div class="jumbotron center-block">
            <h2>List of all your categories</h2>
          </div>
          <div>
            <category *ngFor="#category of categories" [category]="category"></category>
          </div>
        `
    })
    

    我们正在使用 ngFor 指令来渲染列表中每个项目的 category 模板。

  3. 声明组件的类:

    export class CategoryListComponent implements OnInit, OnDestroy {
      public categories: Array<Category>;
      private _categoryService: CategoryService;
      private _categorySubscription: any;
    
      constructor(categoryService: CategoryService) {
        this._categoryService = categoryService;
      }
    
      ngOnInit() {
        this._categorySubscription = this._categoryService.category
        .subscribe((category) => {
          if (category) {
            this.categories.push(category);
          }
        });
        this._categoryService.getAll()
        .subscribe((categories) => {
          this.categories = categories;
        });
      }
    
      ngOnDestroy() {
        this._categorySubscription.unsubscribe();
      }
    }
    

当组件初始化时,我们将使用我们的 CategoryService 从后端检索所有可用的类别。除了获取所有必要的数据外,我们还订阅了创建新类别时的情况。基本上,我们订阅了类别数据流。

每次添加新的类别时,它将被推送到 categories 列表并显示给用户。为了向用户渲染信息,我们将有一个用于单个类别的组件。

当组件被销毁时,我们希望取消订阅数据流;否则,通知将被推送到数据流中。

类别组件

要显示我们列表中单个类别的信息,我们将创建一个新的组件,称为 public/src/category/components/category.component.ts

import { Component } from 'angular2/core';
import { CategoryService } from '../category.service';
import { Category } from '../category.model';

@Component({
    inputs: ['category'],
    selector: 'category',
    template: `
    <div>
      <form role="form" (submit)="onSubmit($event)">
        <div class="form-group">
          <label for="name">Name</label>
          <input type="text" [(ngModel)]="category.name" class="form-control" id="name">
        </div>
        <div class="form-group">
          <label for="description">Description</label>
          <textarea class="form-control" id="description"
            name="description" [(ngModel)]="category.description">
          </textarea>
        </div>
        <button type="submit" class="button">save</button>
      </form>
    </div>
    `
})
export class CategoryComponent {
  public category: Category;
  private _categoryService: CategoryService;

  constructor(categoryService: CategoryService) {
    this._categoryService = categoryService;
  }

  onSubmit(event) {
    event.preventDefault();
    this._categoryService.update(this.category)
    .subscribe((category) => {
      this.category = category;
    }, err => console.error(err));
  }
}

这个类别获取输入数据以显示关于类别的信息。当点击保存按钮并提交表单时,它还会触发一个事件。我们使用我们的服务与服务器通信并在 MongoDB 中持久化更改。

支出模块

在这个模块中,我们将处理与支出相关的功能。这将是前端应用中用户使用的主要模块,因为在这里他们将添加新的支出并通过我们的后端 API 将它们存储在 MongoDB 中。

支出服务

支出服务将实现支出上的 CRUD 操作,并且它的重要特性之一是获取支出的余额。为了创建支出服务,我们将遵循以下步骤:

  1. 创建一个名为 public/src/expense/expense.service.js 的文件。

  2. 定义服务的主要逻辑:

    import { Injectable } from 'angular2/core';
    import { Http, Response, Headers } from 'angular2/http';
    import { Observable } from 'rxjs/Observable';
    import { Subject } from 'rxjs/Subject';
    import { BehaviorSubject } from 'rxjs/Subject/BehaviorSubject';
    import { AuthHttp } from '../auth/index';
    import { contentHeaders, serializeQuery } from '../common/index';
    import { Expense } from './expense.model';
    
    @Injectable()
    export class ExpenseService {
      public expense: Subject<Expense>;
      public expenses: Observable<Array<Expense>>;
      public filter: Subject<any>;
      private _authHttp: AuthHttp;
      private _expensesObserver: any;
    
      constructor(authHttp: AuthHttp) {
        this._authHttp = authHttp;
        this.expenses = new Observable(
          observer => {
            this._expensesObserver = observer
          }
        );
        this.filter = new BehaviorSubject<any>(null);
        this.expense = new BehaviorSubject<Expense>(null);
      }
      create(expense) {
      }
      findById(id) {
      }
      getAll() { 
      }
      update(expense) {
      }
      delete(expense) {
      }
    }
    

我们刚刚定义了一个公开方法的列表。我们还公开了一些公共属性,以便外部更新过滤器,例如支出,以及一个可观察的支出数据流。

现在让我们逐个跟踪方法并附加它们的实际实现:

  1. 创建支出:

      create(expense) {
        let body = JSON.stringify(expense);
    
        return this._authHttp
        .post('/api/expenses', body, { headers: contentHeaders })
        .map((res: Response) => res.json())
        .map((expense) => {
          return new Expense(
            expense._id,
            expense.name,
            expense.currency,
            expense.amoun,
            expense.scaleFactor,
            expense.value,
            expense.user,
            expense.category,
            expense.createdAt
          );
        });
      }
    Getting one expense by ID:
      findById(id) {
        return this._authHttp
        .get(`/api/expenses/${id}`, { headers: contentHeaders })
        .map((res: Response) => res.json())
        .map((expense) => {
          return new Expense(
            expense._id,
            expense.name,
            expense.currency,
            expense.amoun,
            expense.scaleFactor,
            expense.value,
            expense.user,
            expense.category,
            expense.createdAt
          );
        });
      }
    
  2. 获取与给定查询标准匹配的所有支出:

      getAll(criteria?: any) {
        let query = '';
    
        if (criteria) {
          query = `?${serializeQuery(criteria)}`
        }
    
        this._authHttp
        .get(`/api/expenses${query}`, { headers: contentHeaders })
        .map((res: Response) => res.json())
        .map((data) => {
          return data.map((expense) => {
            return new Expense(
              expense._id,
              expense.name,
              expense.currency,
              expense.amoun,
              expense.scaleFactor,
              expense.value,
              expense.user,
              expense.category,
              expense.createdAt
            );
          });
        }).subscribe((expenses: Array<Expense>) => {
          this._expensesObserver.next(expenses);
        }, err => console.error(err));
      }
    

    上述方法使用 serializeQuery() 方法,该方法将我们的标准转换为 查询字符串 参数。我们这样做是为了根据给定的标准过滤支出。而且,我们不是从 HTTP 调用返回一个可观察的,而是更新我们的 expenses 数据流,以通知所有订阅者新可用的数据。

  3. 获取与查询标准匹配的支出余额:

      getExpensesBalance(criteria?: any) {
        let query = '';
    
        if (criteria) {
          query = `?${serializeQuery(criteria)}`
        }
    
        return this._authHttp
        .get(`/api/expenses/balance${query}`, { headers: contentHeaders })
        .map((res: Response) => res.json())
      }
    

    我们使用相同的 serializeQuery() 函数将我们的标准转换为 查询字符串

  4. 通过 ID 使用新数据更新支出:

      update(expense) {
        let body = JSON.stringify(expense);
    
        return this._authHttp
        .put(`/api/expenses/${expense._id}`, body, { headers: contentHeaders })
        .map((res: Response) => res.json())
      }
    Removing an existing expense by ID:
      delete(expense) {
        return this._authHttp
        .put(`/api/expenses/${expense._id}`, '', { headers: contentHeaders })
        .map((res: Response) => res.json())
      }
    

过滤支出

作为开始,我们将实现支出过滤。我们只想有所有必要的块来正确列出支出。基本上,这个组件将是一个简单的表单,包含三个输入:开始日期、结束日期和类别。

使用这些简单的标准,我们将在后端过滤我们的支出。记住,我们需要这些在 query 参数中,以便从 expenses 集合中检索正确的数据。

这个组件将依赖于 CategoryService 并订阅类别数据流。它还将向下推送新的值到过滤器流中,以通知每个订阅者过滤支出。

让我们遵循以下步骤来实现我们的组件:

  1. 导入模块:

    import { Component, OnInit, OnDestroy } from 'angular2/core';
    import { CategoryService, Category } from '../../category/index';
    import { ExpenseService } from '../expense.service';
    import { Expense } from '../expense.model';
    
  2. 定义我们组件的模板:

    @Component({
        selector: 'expense-filter',
        template: `
          <div>
            <form role="form">
              <div class="form-group">
                <label for="startDate">Start</label>
                <input type="date" [(ngModel)]="filter.startDate" class="form-control" id="startDate">
              </div>
              <div class="form-group">
                <label for="endDate">End</label>
                <input type="date" [(ngModel)]="filter.endDate" class="form-control" id="endDate">
              </div>
              <div class="form-group">
                <label for="category">Category</label>
                <select name="category" [(ngModel)]="filter.category">
                  <option *ngFor="#category of categories" [value]="category._id">
                    {{ category.name }}
                  </option>
                </select>
              </div>
              <button type="submit" class="button" (click)="onFilter($event)">Filter</button>
              <button type="button" class="button" (click)="onReset($event)">Reset</button>
            </form>
          </div>
        `
    })
    
  3. 添加 ExpenseFilterComponent 类:

    export class ExpenseFilterComponent implements OnInit, OnDestroy {
      public filter: any;
      public categories: Array<Category>;
      private _expenseService: ExpenseService;
      private _categoryService: CategoryService;
    
      constructor(
        expenseService: ExpenseService,
        categoryService: CategoryService
      ) {
        this._expenseService = expenseService;
        this._categoryService = categoryService;
      }
    }
    
  4. 初始化时会发生什么:

      ngOnInit() {
        this.filter = {};
        this.categories = [];
        this._subscriptions = [];
        this._subscriptions.push(
          this._categoryService
          .categories
          .subscribe((categories) => {
            this.categories = categories;
          })
        );
      }
    
  5. 当组件被销毁时:

      ngOnDestroy() {
        this._subscriptions.forEach((subscription) => {
          subscription.unsubscribe();
        })
      }
    

    我们必须取消订阅数据流。我们使用一个订阅列表来在一个地方保存所有这些,然后稍后遍历订阅并销毁它们。

  6. 我们如何更新过滤器流:

      onFilter(event) {
        event.preventDefault();
        this._expenseService.filter.next(this.filter);
      }
    
  7. 重置过滤器:

      onReset(event) {
        event.preventDefault();
        this.filter = {};
        this._expenseService.filter.next(this.filter);
      }
    

当组件初始化时,我们订阅 categories 数据流。如果用户点击 filter 按钮,我们将更新 filter,以便每个订阅者都可以获取新的过滤标准。

为了重置一切,我们可以使用 reset 按钮,回到初始状态。然后我们可以通知所有订阅者,我们可以再次检索所有支出。

添加新的支出

由于添加支出将是一个相当常用的功能,我们将把必要的逻辑添加到与列出支出相同的视图和控制台中。

记住,为了添加新的支出,它必须包含在一个类别中。因此,我们需要将类别列表加载到组件中。这应该类似于我们在 ExpenseFilterComponent 中之前所做的那样。

让我们通过以下步骤来实现添加支出的功能:

  1. 创建一个新的文件,命名为 public/src/expense/components/expense-create.component.ts

  2. 导入必要的模块:

    import { Component, OnInit, OnDestroy } from 'angular2/core';
    import { Router, RouterLink } from 'angular2/router';
    import { CategoryService, Category } from '../../category/index';
    import { ExpenseService } from '../expense.service';
    import { Expense } from '../expense.model';
    
  3. 使用以下模板添加注释:

    @Component({
        selector: 'expense-create',
        directives: [
          RouterLink
        ],
        template: `
          <div>
            <form role="form" (submit)="onSubmit($event)">
              <div class="form-group">
                <label for="name">Name</label>
                <input type="text" [(ngModel)]="expense.name" class="form-control" id="name">
              </div>
              <div class="form-group">
                <label for="category">Category</label>
                <select name="category" [(ngModel)]="expense.category">
                  <option *ngFor="#category of categories" [value]="category._id">
                    {{ category.name }}
                  </option>
                </select>
              </div>
              <div class="form-group">
                <label for="value">Amount</label>
                <input type="text" [(ngModel)]="expense.value" class="form-control" id="value">
              </div>
              <button type="submit" class="button">Add</button>
            </form>
          </div>
        `
    })
    
  4. 添加以下类:

    export class ExpenseCreateComponent implements OnInit, OnDestroy {
      public expense: Expense;
      public categories: Array<Category>;
      private _expenseService: ExpenseService;
      private _categoryService: CategoryService;
      private _subscriptions: Array<any>;
    
      constructor(
        expenseService: ExpenseService,
        categoryService: CategoryService
      ) {
        this._expenseService = expenseService;
        this._categoryService = categoryService;
      }
    
  5. 在初始化时,我们订阅类别数据流并将订阅存储起来,以便我们可以在以后取消订阅:

      ngOnInit() {
        this.expense = new Expense();
        this.categories = [];
        this._subscriptions = [];
        this._subscriptions.push(
          this._categoryService
          .categories
          .subscribe((categories) => {
            this.categories = categories;
          })
        );
      }
    
  6. 当组件销毁时取消订阅:

      ngOnDestroy() {
        this._subscriptions.forEach((subscription) => {
          subscription.unsubscribe();
        })
      }
    Create a new expense event:
      onSubmit(event) {
        event.preventDefault();
    
        this._expenseService
        .create(this.expense)
        .subscribe((expense) => {
          this._expenseService.expense.next(expense);
        }, err => console.error(err));
      }
    

列出支出

为了显示支出列表,我们将查询服务器获取必要的信息,并创建一个包含检索信息的表格。为此,我们将执行以下步骤:

  1. 创建支出控制器文件,命名为 public/src/expense/components/expense-list.component.ts

  2. 导入服务和其它依赖项:

    import { Component, OnInit, OnDestroy } from 'angular2/core';
    import { ExpenseService } from '../expense.service';
    import { Expense } from '../expense.model'; 
    
  3. 在模板中定义 expense 表:

    @Component({
        selector: 'expense-list',
        directives: [],
        template: `
          <div class="jumbotron center-block">
            <h2>List of all your expenses</h2>
          </div>
          <div>
            <table>
              <thead>
                <tr>
                  <th>Name</th>
                  <th>Category</th>
                  <th>Amount</th>
                  <th>Date</th>
                </tr>
              </thead>
              <tbody>
                <tr *ngFor="#expense of expenses">
                  <td>{{ expense.name }}</td>
                  <td>{{ expense.category.name }}</td>
                  <td>{{ expense.value }}</td>
                  <td>{{ expense.createdAt | date }}</td>
                </tr>
              </tbody>
            </table>
          </div>
        `
    })
    
  4. 声明 ExpenseListComponent 类:

    export class ExpenseListComponent implements OnInit, OnDestroy {
      public expenses: Array<Expense>;
      private _expenseService: ExpenseService;
      private _subscriptions: Array<any>;
    
      constructor(expenseService: ExpenseService) {
        this._expenseService = expenseService;
      }
    }
    
  5. 初始化时订阅所有数据流:

      ngOnInit() {
        this.expenses = [];
        this._subscriptions = [];
    
        this._subscriptions.push(
          this._expenseService
          .expenses
          .subscribe((expenses) => {
            this.expenses = expenses;
          })
        );
        this._subscriptions.push(
          this._expenseService
          .expense
          .subscribe((expense) => {
            if (expense) {
              this.expenses.push(expense);
            }
          })
        );
        this._subscriptions.push(
          this._expenseService
          .filter
          .subscribe((filter) => {
            if (filter) {
              this._expenseService.getAll(filter);
            }
          })
        );
      }
    
  6. 当组件销毁时取消订阅:

      ngOnDestroy() {
        this._subscriptions.forEach(subscription => {
          subscription.unsubscribe();
        });
      }
    

我们主要使用数据流来向用户显示信息。当创建新的支出时,我们只是得到通知并更新支出列表。如果加载了新的支出集,列表将使用新值更新。我们还订阅了过滤器的变化,以便我们可以使用该过滤器从后端获取数据。

显示平衡

我们希望显示支出的累积值。当我们过滤支出时,相同的过滤器应该应用于余额的查询。例如,我们可能想显示特定类别的支出;在这种情况下,应该显示该类别的余额。

因为我们都在后端做所有繁重的工作,并且通过 API 获取的结果格式良好,所以我们只需要实现一些功能来正确显示余额:

  1. 为组件创建一个新的文件,命名为 public/src/expense/components/expense-balance.component.ts

  2. 实现基类:

    import { Component, OnInit, OnDestroy } from 'angular2/core';
    import { ExpenseService } from '../expense.service';
    
    @Component({
        selector: 'expense-balance',
        directives: [],
        template: `
          <h2>
            Total balance: {{ info.balance }}
            <span>from {{ info.count }}</span>
          </h2>
        `
    })
    export class ExpenseBalanceComponent implements OnInit, OnDestroy {
      public info: any;
      private _expenseService: ExpenseService;
      private _subscriptions: Array<any>;
    
      constructor(expenseService: ExpenseService) {
        this._expenseService = expenseService;
      }
    
      ngOnInit() {
    
      }
    
      ngOnDestroy() {
    
      }
    
    }
    Subscribe to the change of filter on init:
      ngOnInit() {
        this.info = {};
        this._subscriptions = [];
    
        this._subscriptions.push(
          this._expenseService
          .filter
          .subscribe((filter) => {
            if (filter) {
              this._getBalance(filter);
            }
          })
        );
      }
    
  3. 根据标准从后端检索余额:

      ngOnDestroy() {
        this._subscriptions.forEach((subscription) => {
          subscription.unsubscribe();
        })
      }
    
  4. 取消订阅:

      ngOnDestroy() {
        this._subscriptions.forEach((subscription) => {
          subscription.unsubscribe();
        })
      }
    

支出组件

现在我们已经拥有了所有必要的组件,我们可以实现我们的主要支出组件,它将使用之前实现的所有子组件。我们应该创建一个名为 public/src/expense/components/expenses.component.ts 的新文件:

import { Component, OnInit } from 'angular2/core';
import { Router, RouterLink } from 'angular2/router';
import { ExpenseService } from '../expense.service';
import { CategoryService } from '../../category/index';
import { ExpenseCreateComponent } from './expense-create.component';
import { ExpenseListComponent } from './expense-list.component';
import { ExpenseBalanceComponent } from './expense-balance.component';
import { ExpenseFilterComponent } from './expense-filter.component';
@Component({
    selector: 'expenses',
    directives: [
      ExpenseCreateComponent,
      ExpenseListComponent,
      ExpenseBalanceComponent,
      ExpenseFilterComponent
    ],
    template: `
      <expense-balance></expense-balance>
      <expense-filter></expense-filter>
      <expense-create></expense-create>
      <expense-list></expense-list>
    `
})
export class ExpensesComponent implements OnInit {
  private _expenseService: ExpenseService;
  private _categoryService: CategoryService;

  constructor(
    expenseService: ExpenseService,
    categoryService: CategoryService
  ) {
    this._expenseService = expenseService;
    this._categoryService = categoryService;
  }

  ngOnInit() {
    this._categoryService.getAll().subscribe();
    this._expenseService.filter.next({});
  }
}

该组件相当简单,但在我们仅获取所有类别并将过滤器设置为空对象时,ngOnInit() 方法中发生了一件有趣的事情。当这种情况发生时,所有其他组件都会对我们的操作做出反应并相应更新。

通过这种方式,我们已经实现了支出模块,它允许用户添加支出并查看所有支出的列表。我们省略了一些功能,例如错误处理、分页和其他一些小功能,但你可以根据需要改进这段代码。

摘要

这就带我们结束了一个相当长的章节。

我们学习了如何使用 JavaScript 和 Node.js 操作货币数据以及如何将其存储在 MongoDB 中。我们实现了一个多用户系统,用户可以轻松地在任何时间注册和登录。

我们通过 API 公开了大部分后端功能。我们使用了一种无状态的认证机制,只有通过展示有效的令牌才能授予访问权限。

在下一章中,我们将构建一个更具公共导向的网页,包含不同的账户类型。

第三章:职位板

在本章中,我们将构建一个职位板应用程序。用户将能够创建个人资料并填写不同类型的信息,例如工作经历、他们参与的项目、认证信息,甚至是与教育相关的信息。此外,公司也将能够发布职位空缺,用户可以申请。

设置基础应用程序

在许多情况下,大多数开发者已经为他们使用的 Node 应用程序设置了他们自己的样板代码。这样做的一个原因可能是存在多种正确的方法来做事情。通常,你的样板代码将涵盖应用程序的初始功能,例如用户架构、登录和注册。

由于我们已经从最初的两章中建立了一个坚实的基础,我们可以重用大量的代码库。我已经创建了一个简单的基应用程序,我们可以从这里开始。只需按照以下步骤克隆项目:

  1. 从 GitHub 克隆项目github.com/robert52/express-api-starter

  2. 将您的样板项目重命名为jobboard

  3. 如果您愿意,可以通过运行以下命令停止指向初始 Git 仓库:

    git remote remove origin
    
    
  4. 跳转到您的工作目录:

    cd jobboard
    
    
  5. 安装所有依赖项:

    npm install
    
    
  6. 创建一个开发配置文件:

    cp config/environments/example.js config/environments/development.js
    
    

您的配置文件jobboard/config/environments/development.js应类似于以下内容:

'use strict';

module.exports = {
  port: 3000,
  hostname: '127.0.0.1',
  baseUrl: 'http://localhost:3000',
  mongodb: {
    uri: 'mongodb://localhost/jobboard_dev_db'
  },
  app: {
    name: 'Job board'
  },
  serveStatic: true,
  session: {
    type: 'mongo',                          
    secret: 'someVeRyN1c3S#cr3tHer34U',
    resave: false,                          
    saveUninitialized: true                 
  }
};

修改用户后端

用户后端逻辑需要稍作修改以适应我们的需求。例如,我们需要为我们的用户提供角色。我们将在讨论用户模型时详细说明这一点。我们必须添加授权策略。我们还需要为我们的用户提供一个个人资料。

修改用户模型

为了支持多种账户类型并最终为用户分配角色,我们需要对用户模型进行一些修改。这将告诉我们用户是注册了简单账户,其中他们可以定义带有工作经历的个人资料,还是创建一个想要发布职位机会的公司。

角色将定义用户可以执行的操作。例如,对于一家公司,我们可以有一个拥有完全控制账户的公司所有者,或者我们可以有一个是该公司的成员并发布可用的职位空缺的用户。

让我们使用以下内容修改jobboard/app/models/user.js中的用户架构:

var UserSchema = new Schema({
  email:  {
    type: String,
    required: true,
    unique: true
  },
  name: {
    type: String
  },
  password: {
    type: String,
    required: true,
    select: false
  },
  passwordSalt: {
    type: String,
    required: true,
    select: false
  },
  active: {
    type: Boolean,
    default: true
  },
  roles: {
    type: [
      {
        type: String,
        enum: ['user', 'member', 'owner']
      }
    ],
    default: ['user']
  },
  createdAt: {
    type: Date,
    default: Date.now
  }
});

我们在我们的用户架构中添加了一个额外的字段,更确切地说,是roles,它包含用户可以执行的操作。您可以将任何类型的角色添加到由枚举验证定义的有效角色列表中。

授权策略

为了授权我们的用户执行请求的操作,我们必须检查他们是否有权这样做。例如,只有公司所有者才能更改公司信息或添加新成员。

在项目的初始阶段,我喜欢将我的策略保持得尽可能简单和分离。换句话说,我不喜欢创建一个管理一切的东西,而是使用简单的函数来检查不同的场景。

让我们看看一个授权策略。创建一个名为 jobboard/app/middlewares/authorization.js 的文件,并添加以下内容:

module.exports.onlyMembers = authorizeOnlyToCompanyMembers;

function authorizeOnlyToCompanyMembers(req, res, next) {
  // check if user is member of company
  const isMember = req.resources.company.members.find((member) => {
    return member.toString() === req.user._id.toString();
  });

  if (!isMember) {
    return res.status(403).json({ message: 'Unauthorized' });
  }

  next();
}

这个简单的函数将检查公司的所有者是否是认证用户。前面的策略可以用以下方式使用:

router.put(
  '/companies/:companyId',
  auth.ensured,
  companyCtrl.findById,
  authorize.onlyOwner,
  companyCtrl.update,
  response.toJSON('company')
);

上述代码确保用户已认证,从 MongoDB 中通过 ID 获取公司,并检查我们之前实现的策略是否授权用户更新公司。

公司后端模块

我们将为我们应用程序实现第一个后端模块。这个模块将处理与公司相关的一切。

公司模型

我们将向公司模型添加一个简单但有趣的功能,它将从公司名称创建一个所谓的缩略名。在我们的语境中,缩略名是从公司名称生成的,以便作为有效的 URL 接受。它将用于以有意义的方式引用公司。例如,如果我们系统中有一个名为 Your Awesome Company 的公司,生成的缩略名将是 your-awesome-company

为了生成缩略名,我们将实现一个简单的辅助函数,以便在必要时可以重用它。创建一个名为 app/helpers/common.js 的文件,并添加以下代码行:

'use strict';

module.exports.createSlug = createSlug;

function createSlug(value) {
   return value
   .toLowerCase()
   .replace(/[^\w\s]+/g,'')
   .trim()
   .replace(/[\s]+/g,'-');
}

现在我们有了 helper 函数,我们可以定义 company 模型和它所需的模式。创建一个名为 app/models/company.js 的文件,并向其中添加以下代码:

'use strict';

const mongoose = require('mongoose');
const commonHelper = require('../helpers/common');
const Schema = mongoose.Schema;
const ObjectId = Schema.ObjectId;

let CompanySchema = new Schema({
  name: {
    type: String,
    required: true
  },
  slug: {
    type: String
  },
  owner: {
    type: ObjectId,
    required: true,
    ref: 'User'
  },
  members: {
    type: Array,
    default: []
  },
  createdAt: {
    type: Date,
    default: Date.now
  }
});

CompanySchema.pre('save', (next) => {
  this.slug = commonHelper.createSlug(this.name);
  next();
});

// compile Company model
module.exports = mongoose.model('Company', CompanySchema);

我们定义了公司的 mongoose 模式,并添加了一个预保存钩子来生成缩略名。在这个预保存钩子中,我们使用了来自通用辅助函数的 createSlug() 方法。中间件是按顺序运行的,因此我们需要调用 next() 来表示执行完成。

公司控制器

通过公司控制器,我们将公开管理公司所需的所有业务逻辑。我们将逐一讨论这些功能。

创建公司

用户成功注册公司类型账户后,他们可以创建一个新公司并成为所有者。我们将实现一个简单的创建功能并将其挂载到 Express 路由上。让我们创建一个名为 jobboard/app/controllers/company.js 的控制器文件,内容如下:

'use strict';

const _ = require('lodash');
const mongoose = require('mongoose');
const Company = mongoose.model('Company');

module.exports.create = createCompany;

function createCompany(req, res, next) {
  let data = _.pick(req.body, ['name', 'country', 'address']);
  data.owner = req.user._id;
  data.members = [req.user._id];

  Company.create(data, (err, company) => {
    if (err) {
      return next(err);
    }

    res.status(201).json(company);
  });
}

当我们定义模式时,我们在公司模型中添加了验证。我们添加的一件事是为创建方法选择必要的数据。公司的所有者默认是创建它的用户。我们还把用户添加到成员列表中。在成功创建了一个新公司后,我们返回一个包含有关新创建公司的信息的 JSON。

通过 ID 获取公司

现在我们能够创建公司了,是时候通过 ID 检索一个公司了。我们将追加以下代码到 app/controller/company.js 控制器文件:

module.exports.findById = findCompanyById;

function findCompanyById(req, res, next) {
  if (!ObjectId.isValid(id)) {
    res.status(404).send({ message: 'Not found.'});
  }

  Company.findById(req.params.companyId, (err, company) => {
    if (err) {
      return next(err);
    }

    req.resources.company = company;
    next();
  });
}

在前面的代码行中,我们使用了 mongoose 从公司模型提供的 findById 方法。在我们搜索 MongoDB 中的公司之前,我们想要确保 ID 是一个有效的 ObjectId

我们在这里添加的另一个有趣的功能是在请求中添加了一个全局的 resource 对象。这次我们不是返回 JSON,而是将其作为属性添加到我们将携带在 Express 路由的回调管道中的对象。这将在我们想要在其他情况下重用相同功能时非常有用。

获取所有公司

我们还希望获取存储在 MongoDB 中的所有公司。对于这个用例,一个简单的查询应该就足够了。我们可以添加一个简单的按国家过滤,默认返回最多 50 家公司。以下代码将实现此功能:

module.exports.getAll = getAllCompanies;

function getAllCompanies(req, res, next) {
  const limit = +req.query.limit || 50;
  const skip = +req.query.skip || 0;
  let query = _.pick(req.query, ['country']);

  Company
  .find(query)
  .limit(limit)
  .skip(skip)
  .exec((err, companies) => {
    if (err) {
      return next(err);
    }

    req.resources.companies = companies;
    next();
  });
}

更新公司

在更新公司时,我们只想从公司模型中更新一些字段。我们不想在更新公司时更改所有者或添加新成员。更改所有者功能将不会实现;只有添加新成员功能将实现,但它将由不同的模块处理。

将以下代码行追加到 jobboard/app/controllers/company.js:

module.exports.update = updateCompany;

function updateCompany(req, res, next) {
  let data = _.pick(req.body, ['name', 'country', 'address']);
  _.assign(req.resources.company, req.body);

  req.resources.company.save((err, updatedCompany) => {
    if (err) {
      return next(err);
    }

    req.resources.company = updatedCompany;
    next();
  });
}

添加公司成员

公司成员将只能有限地访问公司。他们可以发布空缺职位并筛选申请空缺职位的用户简历。我们将向位于 jobboard/app/controllers/company.js 的同一公司控制器添加此功能:

module.exports.addMember = addCompanyMember;

function addCompanyMember(req, res, next) {
  let includes = _.includes(req.resources.company.members, req.body.member);

  if (includes) {
    return res.status(409).json({
      message: 'User is already a member of your company',
      type: 'already_member'
    });
  }

  req.resources.company.members.push(req.body.member);
  req.resources.company.save((err, updatedCompany) => {
    if (err) {
      return next(err);
    }

    req.resources.company = updatedCompany;
    next();
  });
}

删除公司成员

我们还需要处理如何从公司中删除成员。我们将在添加成员逻辑之后追加此功能:

module.exports.removeMember = removeCompanyMember;

function removeCompanyMember(req, res, next) {
  let includes = _.includes(req.resources.company.members, req.body.member);

  if (!includes) {
    return res.status(409).json({
      message: 'User is not a member of your company',
      type: 'not_member'
    });
  }

  _.pull(req.resources.company.members, req.body.member);
  req.resources.company.save((err, updatedCompany) => {
    if (err) {
      return next(err);
    }

    req.resources.company = updatedCompany;
    next();
  });
}

公司路由

接下来,我们将定义所有必要的路由,以便从公司控制器访问之前实现的功能。让我们创建我们的路由器文件,命名为 jobboard/app/routes/companies.js,并添加以下内容:

'use strict';

const express = require('express');
const router = express.Router();
const companyCtrl = require('../controllers/company');
const auth = require('../middlewares/authentication');
const authorize = require('../middlewares/authorization');
const response = require('../helpers/response');

按照以下步骤定义端点:

  1. 创建公司:

    router.post(
      '/companies',
      auth.ensured,
      companyCtrl.checkUserCompany,
      companyCtrl.create
    );
    

    我们确保用户在系统中没有其他公司。

  2. 获取所有公司:

    router.get(
      '/companies',
      companyCtrl.getAll,
      response.toJSON('companies')
    );
    
  3. 通过 ID 获取公司:

    router.get(
      '/companies/:companyId',
      companyCtrl.findById,
      response.toJSON('company')
    );
    
  4. 更新公司:

    router.put(
      '/companies/:companyId',
      auth.ensured,
      companyCtrl.findById,
      authorize.onlyOwner,
      companyCtrl.update,
      response.toJSON('company')
    );
    

    公司的更新只能由所有者进行。

  5. 添加公司成员:

    router.post(
      '/companies/:companyId/members',
      auth.ensured,
      companyCtrl.findById,
      authorize.onlyOwner,
      companyCtrl.addMember,
      response.toJSON('company')
    );
    

    只有公司所有者可以添加成员。

  6. 删除公司成员:

    router.delete(
      '/companies/:companyId/members',
      auth.ensured,
      companyCtrl.findById,
      authorize.onlyOwner,
      companyCtrl.removeMember,
      response.toJSON('company')
    );
    

    我们也将限制这个动作只允许公司所有者执行。

  7. 导出路由器:

    module.exports = router;
    

职位后端模块

此模块将实现所有与职位相关的后端逻辑。我们将定义必要的模型和控制器。模块中最重要的部分将进行解释。

职位模型

职位模型将定义Jobs集合中的一个实体,并在创建新职位时处理必要的验证。至于公司模型,我们将使用自定义变量文件来处理职位行业和类型。这两个文件将分别位于jobboard/config/variables/industries.jsjobboard/config/variables/jobtypes.js。两者都导出一个对象列表。

为了实现职位模型,我们将遵循以下步骤:

  1. 创建模型文件,命名为jobboard/app/models/job.js

  2. 添加必要的依赖项:

    const mongoose = require('mongoose');
    const commonHelper = require('../helpers/common');
    const Industries = require('../../config/variables/industries');
    const Countries = require('../../config/variables/countries');
    const Jobtypes = require('../../config/variables/jobtypes');
    const Schema = mongoose.Schema;
    const ObjectId = Schema.ObjectId;
    
  3. 仅从变量文件中检索验证值列表:

    const indEnum = Industries.map(item => item.slug);
    const cntEnum = Countries.map(item => item.code);
    const jobEnum = Jobtypes.map(item => item.slug);
    
  4. 定义 Mongoose 模式:

    let JobSchema = new Schema({
      title: {
        type: String,
        required: true
      },
      slug: {
        type: String,
        required: true
      },
      summary: {
        type: String,
        maxlength: 250
      },
      description: {
        type: String
      },
      type: {
        type: String,
        required: true,
        enum: jobEnum
      },
      company: {
        type: ObjectId,
        required: true,
        ref: 'Company'
      },
      industry: {
        type: String,
        required: true,
        enum: indEnum
      },
      country: {
        type: String,
        required: true,
        enum: cntEnum
      },
      createdAt: {
        type: Date,
        default: Date.now
      }
    });
    
  5. 添加一个预保存钩子:

    JobSchema.pre('save', (next) => {
      this.slug = commonHelper.createSlug(this.name);
      next();
    });
    
  6. 最后,编译模型:

    module.exports = mongoose.model('Job', JobSchema);	
    

职位控制器

我们的控制将集成所有必要的业务逻辑来处理所有职位 CRUD 操作。之后,我们可以将控制器公开的方法挂载到特定的路由上,以便外部客户端可以与我们的后端通信。

为公司添加新职位

在创建新职位时,它应该为特定公司创建,因为职位代表公司的一个空缺职位。因此,在创建职位时,我们需要公司上下文。

创建一个名为jobboard/app/controllers/job.js的控制器文件,并添加以下创建逻辑:

const MAX_LIMIT = 50;
const JOB_FIELDS = ['title', 'summary', 'description', 'type', 'industry', 'country'];

const _ = require('lodash');
const mongoose = require('mongoose');
const Job = mongoose.model('Job');
const ObjectId = mongoose.Types.ObjectId;

module.exports.create = createJob;

function createJob(req, res, next) {
  let data = _.pick(req.body, JOB_FIELDS);
  data.company = req.company._id;

  Job.create(data, (err, job) => {
    if (err) {
      return next(err);
    }

    res.status(201).json(job);
  });
}

如我们之前所说,我们需要添加职位的公司上下文。为此,我们将向 Express 路由器请求管道添加一个通过 ID 获取公司的功能。别担心;当我们定义路由时会看到这一点。

通过 ID 查找职位

我们还应该从 Mongo 中通过 ID 检索一个职位。这里将使用与公司控制器中相同的逻辑。将以下代码添加到职位控制器中:

module.exports.findById = findJobById;

function findJobById(req, res, next) {
  if (!ObjectId.isValid(id)) {
    res.status(404).send({ message: 'Not found.'});
  }

  Job.findById(req.params.jobId, (err, job) => {
    if (err) {
      return next(err);
    }

    res.resources.job = job;
    next();
  });
}

获取所有职位

当检索所有可用职位时,应该有应用一些过滤器的可能性,例如职位类型、分配的行业或职位可用的国家。除了这些过滤器外,我们还需要获取公司中所有可用的开放职位。所有这些逻辑都将使用以下代码实现:

module.exports.getAll = getAllJobs;

function getAllJobs(req, res, next) {
  const limit = +req.query.limit || MAX_LIMIT;
  const skip = +req.query.skip || 0;
  let query = _.pick(req.query, ['type', 'country', 'industry']);

  if (req.params.companyId) {
    query.company = req.params.companyId;
  }

  Job
  .find(query)
  .limit(limit)
  .skip(skip)
  .exec((err, jobs) => {
    if (err) {
      return next(err);
    }

    req.resources.jobs = jobs;
    next();
  });
}

更新特定职位

我们还希望更新公司发布的职位,但仅限于公司成员。这种限制将由中间件处理;目前,我们只将实现更新功能。将以下代码添加到app/controllers/job.js

module.exports.update = updateJob;
function updateJob(req, res, next) {
  var data = _.pick(req.body, JOB_FIELDS);
  _.assign(req.resources.job, data);

  req.resources.job.save((err, updatedJob) => {
    if (err) {
      return next(err);
    }

    res.json(job);
  });
}

职位路线

首先,我们将创建一个名为app/routes/jobs.js的路由文件,并添加以下代码:

'use strict';

const express = require('express');
const router = express.Router();
const companyCtrl = require('../controllers/company');
const jobCtrl = require('../controllers/job');
const auth = require('../middlewares/authentication');
const authorize = require('../middlewares/authorization');
const response = require('../helpers/response');

获取一个或所有职位

现在我们有了基础,我们可以开始定义我们的路由。第一对路由将可供公共访问,因此检索一个或所有职位不需要进行身份验证。请添加以下代码:

router.get(
  '/jobs',
  jobCtrl.getAll,
  response.toJSON('jobs')
);

router.get(
  '/jobs/:jobId',
  jobCtrl.findById,
  response.toJSON('job')
);

奖励——获取特定公司的职位!

router.get(
  '/companies/:companyId/jobs',
  jobCtrl.getAll,
  response.toJSON('jobs')
);

创建路由

现在,在创建和更新职位时,事情会变得有些棘手。要创建一个职位,请添加以下代码:

router.post(
  '/companies/:companyId/jobs',
  auth.ensured,
  companyCtrl.findById,
  authorize.onlyMembers,
  jobCtrl.create
);

当创建一个职位时,用户必须登录并且必须是发布该职位的公司成员。为此,我们从数据库中检索一个公司并使用授权中间件。我们比较并检查认证用户是否在成员列表中。如果一切顺利,用户就可以创建一个新的职位空缺。

可能还有其他完成这些任务的方法,但这个解决方案可以带来好处,因为我们只在需要时请求资源。例如,如果用户已认证,我们可以在每个请求的req.user对象上添加公司对象,但这将意味着每个请求都会有额外的 I/O 操作。

更新路由

对于更新功能,附加以下代码:

router.put(
  '/companies/:companyId/jobs/:jobId',
  auth.ensured,
  companyCtrl.findById,
  authorize.onlyMembers,
  jobCtrl.findById,
  jobCtrl.update
);

如您所见,这里与创建路由相同的限制原则也存在。我们唯一额外添加的是检索一个职位 ID,这是更新功能所需要的。

通过这种方式,我们已经完成了job模块的后端逻辑实现。

职位申请

每个用户都可以申请职位,公司也想知道谁申请了他们可用的职位空缺。为了处理这些场景,我们将把所有职位的申请存储在 MongoDB 中单独的集合中。我们将描述后端 Node.js 应用程序逻辑。

应用程序模型

应用程序模型将非常简单和直接。我们本可以使用嵌入式数据模型。换句话说,我们可以在职位实体中保存所有申请。从我的观点来看,分开的集合给你更多的灵活性。

让我们创建一个名为app/models/application.js的文件,并将以下代码添加到定义模式中:

'use strict';

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

let ApplicationSchema = new Schema({
  user: {
    type: ObjectId,
    required: true,
    ref: 'User'
  },
  status: {
    type: String,
    default: 'pending',
    enum: ['pending', 'accepted', 'processed']
  },
  job: {
    type: ObjectId,
    required: true,
    ref: 'Job'
  },
  createdAt: {
    type: Date,
    default: Date.now
  }
});

module.exports = mongoose.model('Application', ApplicationSchema);

控制器逻辑

后端控制器将处理所有必要的逻辑,以管理与职位申请相关的端点上的传入请求。我们将把控制器中导出的每个方法挂载到特定的路由上。

申请职位

当候选人申请职位时,我们在 MongoDB 中存储该申请的引用。我们之前定义了Application模式。为了持久化申请,我们将在app/controllers/application.js控制器文件中使用以下后端逻辑:

module.exports.create = createApplication;

function createApplication(req, res, next) {
  Application.create({
    user: req.user._id,
    job: req.params.jobId
  }, (err, application) => {
    if (err) {
      return next(err);
    }

    res.status(201).json(application);
  });
}

通过 ID 查找给定应用程序

在更新和从数据库中删除应用程序时,我们需要通过其 ID 找到应用程序。有一个通用的逻辑来检索数据是很好的;它可以在不同的场景中被重用。将以下代码附加到控制器文件中:

module.exports.findById = findApplicationById;

function findApplicationById(req, res, next) {
  if (!ObjectId.isValid(id)) {
    res.status(404).send({ message: 'Not found.'});
  }

  Application.findById(req.params.applicationId, (err, application) => {
    if (err) {
      return next(err);
    }

    res.resources.application = application;
    next();
  });
}

再次强调,我们正在使用请求对象上的resource属性来填充查询的结果。

获取所有职位申请

每家公司都想看到他们列出的职位的所有申请。为了提供这个功能,职位控制器必须返回一个应用程序列表,并能够通过状态进行过滤。以下代码将实现这个功能:

module.exports.getAll = getAllApplications;

function getAllApplications(req, res, next) {
  const limit = +req.query.limit || 50;
  const skip = +req.query.skip || 0;
  let query = {
    job: req.params.jobId
  };

  if (req.query.status) {
    query.status = req.query.status;
  }

  Application
  .find(query)
  .limit(limit)
  .skip(offset)
  .exec((err, applications) => {
    if (err) {
      return next(err);
    }

    req.resources.applications = applications;
    next();
  });
}

更新应用程序

为了更改申请的状态,我们必须使用特定的状态值更新它。控制器中的 update 方法将处理此用例。将更新逻辑附加到控制器文件:

module.exports.update = updateApplication;

function updateApplication(req, res, next) {
  req.resources.application.status = req.body.status;

  req.resources.application.save((err, updatedApplication) => {
    if (err) {
      return next(err);
    }

    res.json(updatedApplication);
  });
}

从职位中删除申请

应聘者应该有能力删除空缺职位的申请。我们将不允许任何人除应聘者外删除申请。这种限制将由中间件处理。删除的后端逻辑应类似于以下内容:

module.exports.remove = removeApplication;

function removeApplication(req, res, next) {
  req.resources.application.remove((err) => {
    if (err) {
      return next(err);
    }

    res.json(req.resources.application);
  });
}

现在,我们不会讨论如何添加路由。您可以在应用程序的最终源代码中找到所有可用的路由。

创建新公司

在成功注册后,用户可以创建新的公司。我们已经使用 Node.js 实现了后端逻辑,并且应该能够将公司存储在 MongoDB 的 companies 集合中。

公司服务

尽管我们正在讨论创建公司的功能,但我们将添加所有端点到服务中:

  1. 让我们创建服务文件,命名为 jobboard/public/src/company/company.service.ts

  2. 导入必要的依赖项:

    import { Injectable } from 'angular2/core';
    import { Http, Response, Headers } from 'angular2/http';
    import { AuthHttp } from '../auth/index';
    import { contentHeaders } from '../common/index';
    import { Company } from './company.model';
    
  3. 创建 service 类:

    @Injectable()
    export class CompanyService {
      private _http: Http;
      private _authHttp: AuthHttp;
    }
    
  4. 添加 constructor

      constructor(http: Http, authHttp: AuthHttp) {
        this._http = http;
        this._authHttp = authHttp;
      }
    
  5. 附加 create 方法:

      create(company) {
        let body = JSON.stringify(company);
    
        return this._authHttp
        .post('/api/companies', body, { headers: contentHeaders })
        .map((res: Response) => res.json())  
      }
    
  6. 定义 findByid() 函数:

      findById(id) {
        return this._http
        .get(`/api/companies/${id}`, { headers: contentHeaders })
        .map((res: Response) => res.json())
      }
    
  7. 从后端检索所有公司:

      getAll() {
        return this._http
        .get('/api/companies', { headers: contentHeaders })
        .map((res: Response) => res.json())
      }
    
  8. 更新公司:

     update(company) {
        let body = JSON.stringify(company);
    
        return this._authHttp
        .put(`/api/companies/${company._id}`, body, { headers: contentHeaders })
        .map((res: Response) => res.json())
      }
    

创建公司组件

现在我们已经有一个完全功能化的服务,它与后端进行通信,我们可以开始实现我们的组件。创建公司组件将是第一个。

让我们创建一个新文件,命名为 public/src/company/components/company-create.component.ts,并添加组件的类和依赖项:

import { Component, OnInit } from 'angular2/core';
import { Router, RouterLink } from 'angular2/router';
import { CompanyService } from '../company.service';
import { Company } from '../company.model';
export class CompanyCreateComponent implements OnInit {
  public company: Company;
  private _router: Router;
  private _companyService: CompanyService;

  constructor(companyService: CompanyService, router: Router) {
    this._router = router;
    this._companyService = companyService;
  }

  ngOnInit() {
    this.company = new Company();
  }
}

Component 注解应类似于以下内容:

@Component({
    selector: 'company-create',
    directives: [
      RouterLink
    ],
    template: `
      <div class="login jumbotron center-block">
        <h1>Register</h1>
      </div>
      <div>
        <form role="form" (submit)="onSubmit($event)">
          <div class="form-group">
            <label for="name">Company name</label>
            <input type="text" [(ngModel)]="company.name" class="form-control" id="name">
          </div>
          <div class="form-group">
            <label for="email">Country</label>
            <input type="text" [(ngModel)]="company.country" class="form-control" id="country">
          </div>
          <div class="form-group">
            <label for="email">Address</label>
            <input type="text" [(ngModel)]="company.address" class="form-control" id="address">
          </div>
          <div class="form-group">
            <label for="password">Summary</label>
            <textarea [(ngModel)]="company.summary" class="form-control" id="summary"></textarea>
          </div>
          <button type="submit" class="button">Submit</button>
        </form>
      </div>
    `
})

为了将公司数据属性绑定到每个表单输入控件,我们使用了 ngModel 双向数据绑定。在提交表单时,执行 onSubmit() 方法。让我们添加前面的方法:

  onSubmit(event) {
    event.preventDefault();

    this._companyService
    .create(this.company)
    .subscribe((company) => {
      if (company) {
        this.goToCompany(company._id, company.slug);
      }
    }, err => console.error(err));
  }

这将通过我们的服务尝试创建新公司。如果公司成功创建,我们将导航到公司详情页面。goToCompany() 方法描述如下:

  goToCompany(id, slug) {
    this._router.navigate(['CompanyDetail', { id: id, slug: slug}]);
  }

我们使用路由器导航到公司的详细信息。路由器将构建所需的路径以进行导航。错误处理未涵盖。您还可以添加验证作为改进。

显示公司

我们从早期实现“添加新公司”功能时,对公司模块有一个良好的开端。因此,我们可以跳入并创建和实现其余的文件以显示所有公司。

为了在我们的应用程序中显示公司列表,我们创建了一个新的组件文件,命名为 public/src/company/components/company-list.component.ts

import { Component, OnInit } from 'angular2/core';
import { Router, RouterLink } from 'angular2/router';
import { CompanyService } from '../company.service';
import { Company } from '../company.model';

@Component({})
export class CompanyListComponent implements OnInit {
  public companies: Array<Company>;
  private _router: Router;
  private _companyService: CompanyService;

  constructor(companyService: CompanyService, router: Router) {
    this._router = router;
    this._companyService = companyService;
  }

  ngOnInit() {
    this._companyService
    .getAll()
    .subscribe((companies) => {
      this.companies = companies;
    });
  }
}

如您所见,我们有一个相当基本的组件。在初始化时,使用 CompanyService 从后端检索公司。我们直接订阅返回的 Observable 以更新组件的 companies 属性。

现在只剩下添加 Component 注解:

@Component({
    selector: 'company-list',
    directives: [
      RouterLink
    ],
    template: `
      <div class="jumbotron center-block">
        <h2>Companies list</h2>
        <p class="lead">Here you can find all the registered companies.</p>
      </div>
      <div>
      <div *ngFor="#company of companies" class="col col-25">
        <img src="img/208x140?text=product+image&txtsize=18"/>
        <h3>
          <a href="#"
            [routerLink]="['CompanyDetail', { id: company._id, slug: company.slug }]">
            {{ company.name }}
          </a>
          </h3>
      </div>
      </div>
    `
})

使用 ngFor,我们遍历公司数据并相应地显示。你可以显示其他数据,但现阶段,公司名称应该足够了。另外,当点击名称时,我们使用 RouterLink 导航到目标公司。

求职模块

我们将继续构建 job 模块。这样做的原因是 company 模块使用 job 模块中的一个组件来显示公司的可用职位列表。

求职服务

求职服务将处理与后端的通信,主要是 CRUD 操作。我们将创建一个 Angular 工厂来完成这个任务。创建一个名为 public/app/job/job.service.js 的新文件,并按照以下步骤操作:

  1. 定义基础结构和公开方法:

    import { Injectable } from 'angular2/core';
    import { Http, Response, Headers } from 'angular2/http';
    import { AuthHttp } from '../auth/index';
    import { contentHeaders, serializeQuery } from '../common/index';
    import { Job } from './job.model';
    
    @Injectable()
    export class JobService {
      private _http: Http;
      private _authHttp: AuthHttp;
    
      constructor(http: Http, authHttp: AuthHttp) {
        this._http = http;
        this._authHttp = authHttp;
      }
    }
    
  2. 实现 创建职位 方法:

      create(job) {
        let body = JSON.stringify(job);
    
        return this._authHttp
        .post('/api/jobs', body, { headers: contentHeaders })
        .map((res: Response) => res.json())
      }
    

    我们使用 AuthHttp 服务,因为创建端点需要认证用户。

  3. 添加按 ID 查找职位的代码:

      findById(id) {
        return this._http
        .get(`/api/jobs/${id}`, { headers: contentHeaders })
        .map((res: Response) => res.json())
      }
    
  4. 从后端查询所有职位:

      getAll(criteria) {
        let query = '';
        let str = serializeQuery(criteria);
    
        if (str) {
          query = `?${str}`;
        }
    
        return this._http
        .get(`/api/jobs${query}`, { headers: contentHeaders })
        .map((res: Response) => res.json())
      }
    

getAll() 方法接受一个标准作为参数以过滤职位。在某些情况下,我们只想获取给定公司的职位列表。我们使用 serializeQuery 函数构建查询字符串,该函数位于 public/src/common/query.ts 下,内容如下:

export function serializeQuery(query): string {
  var chunks = [];
  for(var key in query)
    if (query.hasOwnProperty(key)) {
      let k = encodeURIComponent(key);
      let v = encodeURIComponent(query[key]);
      chunks.push(`${k}=${v}`);
    }
  return chunks.join('&');
}

求职基础组件

我们将为我们 的 job 模块构建一个基础组件。它将包含显示子组件所需的全部 RouteConfig。创建一个名为 public/src/job/components/job-base.component.ts 的新文件:

import { Component } from 'angular2/core';
import { RouterOutlet, RouteConfig } from 'angular2/router';
import { JobService } from '../job.service';
import { JobListComponent } from './job-list.component';
import { JobDetailComponent } from './job-detail.component';
import { JobCreateComponent } from './job-create.component';

@RouteConfig([
  { path: '/', as: 'JobList', component: JobListComponent, useAsDefault: true },
  { path: '/:id/:slug', as: 'JobDetail', component: JobDetailComponent },
  { path: '/create', as: 'JobCreate', component: JobCreateComponent }
])
@Component({
    selector: 'job-base',
    directives: [
      RouterOutlet
    ],
    template: `
      <router-outlet></router-outlet>
    `
})
export class JobBaseComponent {
  constructor() {}
} 

我们将每个子组件挂载到特定的路径上。我们将使用与 JobDetail 相同的 URL 结构来处理 CompanyDetail。我认为使用 URL 中的 slug 可以使其看起来既美观又简洁。

接下来,我们将逐一定义组件。

求职组件

jobs 组件将在整个应用程序中重用。它的目的是根据几个因素显示职位列表。

创建一个名为 public/src/job/components/jobs.component.ts 的文件,并包含以下内容:

import { Component, OnInit } from 'angular2/core';
import { Router, RouterLink } from 'angular2/router';
import { JobService } from '../job.service';
import { Job } from '../job.model'; 

export class JobsComponent implements OnInit {
  public company: any;
  public jobs: Array<Job>;
  private _jobsService: JobService;
  private _router: Router;

  constructor(jobsService: JobService, router: Router) {
    this._router = router;
    this._jobsService = jobsService;
  }
}

添加 ngOnInit 方法以从 Express 应用程序中检索必要的数据,如下所示:

  ngOnInit() {
    let query: any = {};

    if (this.company) {
      query.company = this.company;
    }

    this._jobsService
    .getAll(query)
    .subscribe((jobs) => {
      this.jobs = jobs;
    });
  }

我们的组件有一个 company 属性,当我们要查询与某个公司相关的所有职位时将使用它。同时,别忘了添加以下注解:

@Component({
    selector: 'jobs',
    inputs: ['company'],
    directives: [RouterLink],
    template: `
      <div *ngFor="#job of jobs" class="col">
        <h3>
          <a href="#"
            [routerLink]="['/Jobs', 'JobDetail', { id: job._id, slug: job.slug }]">
            {{ job.title }}
          </a>
        </h3>
        <p>
          <a href="#"
            [routerLink]="['/Companies', 'CompanyDetail', { id: job.company._id, slug: job.company.slug }]">
            {{ job.company.name }}
          </a>
          <span>·</span>
          <span>{{ job.industry }}</span>
          <span>·</span>
          <span>{{ job.type }}</span>
          <span>·</span>
          <span>{{ job.createdAt }}</span>
        </p>
        <p>{{ job.summary }}</p>
      </div>
    `
})

我们的组件还有一个名为 company 的输入数据绑定属性。这将引用一个公司的 ID。同时创建一个链接到公司页面的链接。

求职列表组件

在此组件中,我们可以使用之前构建的 jobs 组件来列出系统中的所有可用职位。由于所有主要逻辑都位于 jobs 组件中,我们只需包含它。

创建一个名为 public/src/job/componets/job-list.component.ts 的新文件,并添加以下代码:

import { Component } from 'angular2/core';
import { JobService } from '../job.service';
import { Job } from '../job.model';
import { JobsComponent } from './jobs.component';

@Component({
    selector: 'job-list',
    directives: [JobsComponent],
    template: `
      <div class="login jumbotron center-block">
        <h2>Job openings</h2>
        <p class="lead">Take a look, maybe you will find something for you.</p>
      </div>
      <div>
        <jobs></jobs>
      </div>
    `
})
export class JobListComponent {
  public jobs: Array<Job>;
  private _jobsService: JobService;

  constructor(jobsService: JobService) {
    this._jobsService = jobsService;
  }
}

求职详情

职位详情页面将显示用户所需职位的所有必要信息。我们将使用与公司详情中相同的用户友好路由。幸运的是,我们已经有了一个与后端 API 通信的服务。

创建一个名为public/src/job/components/job-detail.component.ts的文件,并添加以下代码:

import { Component, OnInit } from 'angular2/core';
import { RouteParams, RouterLink } from 'angular2/router';
import { JobService } from '../job.service';
import { Job } from '../job.model';

@Component({})
export class JobDetailComponent implements OnInit {
  public job: Job;
  private _routeParams: RouteParams;
  private _jobService: JobService;

  constructor(jobService: JobService, routerParams: RouteParams) {
    this._routeParams = routerParams;
    this._jobService = jobService;
  }

  ngOnInit() {
    const id: string = this._routeParams.get('id');
    this.job = new Job();
    this._jobService
    .findById(id)
    .subscribe((job) => {
      this.job = job;
    });
  }
}

组件内的逻辑与CompanyDetailComponent中的几乎相同。使用id路由参数,我们从后端获取所需的职位。

Component注解应包含必要的模板和指令:

@Component({
    selector: 'job-detail',
    directives: [
      RouterLink
    ],
    template: `
      <div class="job-header">
        <div class="col content">
          <p>Added on: {{ job.createdAt }}</p>
          <h2>{{ job.name }}</h2>
          <div class="job-description">
            <h4>Description</h4>
            <div>{{ job.description }}</div>
          </div>
        </div>
        <div class="sidebar">
          <h4>Country</h4>
          <p>{{ job.country }}</p>
          <h4>Industry</h4>
          <p>{{ job.industry }}</p>
          <h4>Job type</h4>
          <p>{{ job.type }}</p>
        </div>
      </div>
    `
})

添加新职位

现在我们能够列出所有可用的职位,我们可以实现添加新职位的功能。这将会与我们在公司模块中实现的功能相似。

可能感觉你一直在重复做同样的事情,但本章的目的是创建一个专注于 CRUD 操作的应用程序。许多企业级应用程序都有实现这些操作的庞大模块。所以不用担心!我们将有章节来实验不同的技术和架构。

让我们继续并创建一个名为public/src/job/components/job-create.component.ts的文件:

import { Component, OnInit } from 'angular2/core';
import { Router, RouterLink } from 'angular2/router';
import { JobService } from '../job.service';
import { Job } from '../job.model';

export class JobCreateComponent implements OnInit {
  public job: Job;
  private _router: Router;
  private _jobService: JobService;

  constructor(jobService: JobService, router: Router) {
    this._router = router;
    this._jobService = jobService;
  }

  ngOnInit() {
    this.job = new Job();
  }

  onSubmit(event) {
    event.preventDefault();

    this._jobService
    .create(this.job)
    .subscribe((job) => {
      if (job) {
        this.goToJob(job._id, job.slug);
      }
    });
  }

  goToJob(id, slug) {
    this._router.navigate(['JobDetail', { id: id, slug: slug}]);
  }
}

Component类前添加以下注解:

@Component({
    selector: 'job-create',
    directives: [
      RouterLink
    ],
    template: `
      <div class="jumbotron center-block">
        <h1>Post a new job</h1>
        <p>We are happy to see that you are growing.</p>
      </div>
      <div>
        <form role="form" (submit)="onSubmit($event)">
          <div class="form-group">
            <label for="title">Job title</label>
            <input type="text" [(ngModel)]="job.title" class="form-control" id="title">
          </div>
          <div class="form-group">
            <label for="industry">Industry</label>
            <input type="text" [(ngModel)]="job.industry" class="form-control" id="industry">
          </div>
          <div class="form-group">
            <label for="country">Country</label>
            <input type="text" [(ngModel)]="job.country" class="form-control" id="country">
          </div>
          <div class="form-group">
            <label for="type">Job type</label>
            <input type="text" [(ngModel)]="job.type" class="form-control" id="type">
          </div>
          <div class="form-group">
            <label for="summary">Summary</label>
            <textarea [(ngModel)]="job.summary" class="form-control" id="summary"></textarea>
          </div>
          <div class="form-group">
            <label for="description">Description</label>
            <textarea [(ngModel)]="job.description" class="form-control" id="description"></textarea>
          </div>
          <button type="submit" class="button">Create a job</button>
        </form>
      </div>
    `
})

公司详情

可能你已经注意到,在我们之前列出所有公司时,我们创建了一些漂亮的 URL。我们将使用该路径来显示公司的所有详情以及可用的职位。

URL 还包含公司别名,这是公司名称的 URL 友好表示。它对用户没有好处,这只是我们添加的 URL 糖,以便更好地显示公司名称。查询后端数据时仅使用公司 ID。

既然我们已经有了所有必要的组件和服务,我们可以通过以下步骤实现我们的详情组件:

  1. 创建一个名为public/src/company/components/company-detail.component.ts的新文件。

  2. 添加必要的依赖项:

    import { Component, OnInit } from 'angular2/core';
    import { RouteParams, RouterLink } from 'angular2/router';
    import { CompanyService } from '../company.service';
    import { Company } from '../company.model';
    import { JobsComponent } from '../../job/index';
    
  3. 添加Component注解:

    @Component({
        selector: 'company-detail',
        directives: [
          JobsComponent,
          RouterLink
        ],
        template: `
          <div class="company-header">
            <h2>{{ company.name }}</h2>
            <p>
              <span>{{ company.country }}</span>
              <span>·</span>
              <span>{{ company.address }}</span>
            </p>
          </div>
          <div class="company-description">
            <h4>Description</h4>
          </div>
          <div class="company-job-list">
            <jobs [company]=company._id></jobs>
          </div>
        `
    })
    

    在模板中,我们使用我们之前实现的jobs组件,通过发送公司的id来列出公司的所有可用职位。

  4. 声明component类:

    export class CompanyDetailComponent implements OnInit {
      public company: Company;
      private _routeParams: RouteParams;
      private _companyService: CompanyService;
    
      constructor(companyService: CompanyService, routerParams: RouteParams) {
        this._routeParams = routerParams;
        this._companyService = companyService;
      }
    
      ngOnInit() {
        const id: string = this._routeParams.get('id');
        this.company = new Company();
        this._companyService
        .findById(id)
        .subscribe((company) => {
          this.company = company;
        });
      }
    }
    

用户个人资料

在我们的系统中,我们没有账户类型。我们只为用户定义角色,例如公司所有者、公司成员或候选人。因此,任何注册用户都可以用不同的信息填写他们的个人资料。

记住我们在用户模式中定义了一个profile属性。它将保存有关用户工作经验、教育或用户想要添加的任何其他相关数据的所有信息。

用户的个人资料将通过块来构建。每个块将分组一个特定领域,例如经验,允许用户为每个块添加新的条目。

个人资料后端

管理配置数据的后端逻辑尚未实现。我想传达一种我们在扩展现有后端的同时添加新功能的感觉。因此,我们将首先创建一个新的控制器文件,app/controllers/profile.js。然后添加以下代码:

'use strict';

const _ = require('lodash');
const mongoose = require('mongoose');
const User = mongoose.model('User');
const ProfileBlock = mongoose.model('ProfileBlock');
const ObjectId = mongoose.Types.ObjectId;

module.exports.getProfile = getUserProfile;
module.exports.createProfileBlock = createUserProfileBlock;
module.exports.updateProfile = updateUserProfile;

我们将导出三个函数来管理配置数据。让我们按照以下步骤定义它们:

  1. 获取当前认证用户和整个配置数据:

    function getUserProfile(req, res, next) {
      User
      .findById(req.user._id)
      .select('+profile')
      .exec((err, user) => {
        if (err) {
          return next(err);
        }
    
        req.resources.user = user;
        next();
      });
    }
    
  2. 为用户创建新的配置文件块:

    function createUserProfileBlock(req, res, next) {
      if (!req.body.title) {
        return res.status(400).json({ message: 'Block title is required' });
      }
    
      var block = new ProfileBlock(req.body);
      req.resources.user.profile.push(block);
    
      req.resources.user.save((err, updatedProfile) => {
        if (err) {
          return next(err);
        }
    
        req.resources.block = block;
        next();
      });
    }
    

    我们为 ProfileBlock 模式使用自定义模式来创建新的配置文件块并将其推送到用户的配置数据中。我们将回到我们的模式并定义它。

  3. 更新现有配置文件块:

    function updateUserProfile(req, res, next) {
      // same as calling user.profile.id(blockId)
      // var block = req.resources.user.profile.find(function(b) {
      //   return b._id.toString() === req.params.blockId;
      // });
    
      let block = req.resources.user.profile.id(req.params.blockId);
    
      if (!block) {
        return res.status(404).json({ message: '404 not found.'});
      }
    
      if (!block.title) {
        return res.status(400).json({ message: 'Block title is required' });
      }
    
      let data = _.pick(req.body, ['title', 'data']);
      _.assign(block, data);
    
      req.resources.user.save((err, updatedProfile) => {
        if (err) {
          return next(err);
        }
    
        req.resources.block = block;
        next();
      });
    }
    

当更新配置文件块时,我们需要搜索该特定块并使用新数据更新它。之后,更改将被保存并持久化到 MongoDB。

让我们看看我们的 ProfileBlock 模式,它位于 app/models/profile-block.js 下:

'use strict';

const mongoose = require('mongoose');
const commonHelper = require('../helpers/common');
const Schema = mongoose.Schema;

let ProfileBlock = new Schema({
  title: {
    type: String,
    required: true
  },
  slug: String,
  data: []
});

ProfileBlock.pre('save', function(next) {
  this.slug = commonHelper.createSlug(this.title);
  next();
});

module.exports = mongoose.model('ProfileBlock', ProfileBlock);

上述文档模式将被嵌入到用户的文档 profile 属性中。data 属性将包含所有配置文件块,以及它们自己的数据。

为了公开我们之前实现的功能,让我们创建一个配置路由文件,称为 app/routes/profile.js

'use strict';

const express = require('express');
const router = express.Router();
const profileCtrl = require('../controllers/profile');
const auth = require('../middlewares/authentication');
const response = require('../helpers/response');

router.get(
  '/profile',
  auth.ensured,
  profileCtrl.getProfile,
  response.toJSON('user')
);

router.post(
  '/profile/blocks',
  auth.ensured,
  profileCtrl.getProfile,
  profileCtrl.createProfileBlock,
  response.toJSON('block')
);

router.put(
  '/profile/blocks/:blockId',
  auth.ensured,
  profileCtrl.getProfile,
  profileCtrl.updateProfile,
  response.toJSON('block')
);

module.exports = router;

同步配置数据

为了存储和检索与用户相关的配置数据,我们将创建一个 Angular 服务,该服务将处理与后端的通信。

前端 profile 模块将位于 user 模块中,因为它们相关,并且我们可以根据它们的领域上下文进行分组。创建一个名为 public/src/user/profile/profile.service.ts 的文件,并添加以下基线代码:

import { Injectable } from 'angular2/core';
import { Http, Response, Headers } from 'angular2/http';
import { Subject } from 'rxjs/Subject';
import { BehaviorSubject } from 'rxjs/Subject/BehaviorSubject';
import { Observable } from 'rxjs/Observable';
import { contentHeaders } from '../../common/index';
import { AuthHttp } from '../../auth/index';
import { Block } from './block.model';

@Injectable()
export class ProfileService {
  public user: Subject<any> = new BehaviorSubject<any>({});
  public profile: Subject<Array<any>> = new BehaviorSubject<Array<any>>([]);
  private _http: Http;
  private _authHttp: AuthHttp;
  private _dataStore: { profile: Array<Block> };
}

这次,我们将使用 ObservablesSubject 来处理数据流。在这种情况下,它们更合适,因为有很多动态部分。配置数据可以从许多不同的来源更新,并且更改需要传递给所有订阅者。

为了有一个本地数据副本,我们将在服务中使用数据存储。现在让我们逐一实现每个方法:

  1. 添加类构造函数:

      constructor(http: Http, authHttp: AuthHttp) {
        this._http = http;
        this._authHttp = authHttp;
        this._dataStore = { profile: [] };
        this.profile.subscribe((profile) => {
          this._dataStore.profile = profile;
        });
      }
    
  2. 获取用户的配置信息:

      public getProfile() {
        this._authHttp
        .get('/api/profile', { headers: contentHeaders })
        .map((res: Response) => res.json())
        .subscribe((user: any) => {
          this.user.next(user);
          this.profile.next(user.profile);
        });
      }
    
  3. 创建新的配置文件块:

      public createProfileBlock(block) {
        let body = JSON.stringify(block);
    
        this._authHttp
        .post('/api/profile/blocks', body, { headers: contentHeaders })
        .map((res: Response) => res.json())
        .subscribe((block: any) => {
          this._dataStore.profile.push(block);
          this.profile.next(this._dataStore.profile);
        }, err => console.error(err));
      }
    
  4. 更新现有配置文件块:

      public updateProfileBlock(block) {
        if (!block._id) {
          this.createProfileBlock(block);
        } else {
          let body = JSON.stringify(block);
    
          this._authHttp
          .put(`/api/profile/blocks/${block._id}`, body, { headers: contentHeaders })
          .map((res: Response) => res.json())
          .subscribe((block: any) => {
            this.updateLocalBlock(block);
          }, err => console.error(err));
        }
      }
    

    当更新配置文件块时,我们检查该块是否存在 ID。如果没有,这意味着我们想要创建一个新的块,因此我们将使用 createProfileBlock()

  5. 从本地存储更新块:

      private updateLocalBlock(data) {
        this._dataStore.profile.forEach((block) => {
          if (block._id === data._id) {
            block = data;
          }
        });
    
        this.profile.next(this._dataStore.profile);
      }
    

编辑配置数据

要编辑用户的配置文件,我们将创建一个单独的组件。用户配置文件是使用块构建的。因此,我们应该为配置文件块创建另一个组件。

按照以下步骤实现 ProfileEditComponent

  1. 添加必要的依赖项:

    import { Component, OnInit } from 'angular2/core';
    import { ProfileBlockComponent } from './profile-block.component';
    import { ProfileService } from '../profile.service';
    import { Block } from '../block.model';
    
  2. 放置 Component 注解:

    @Component({
        selector: 'profile-edit',
        directives: [ProfileBlockComponent],
        template: `
        <section>
    
          <div class="jumbotron">
            <h2>Hi! {{user.name}}</h2>
            <p class="lead">Your public e-mail is <span>{{user.email}}</span> <br> and this is your profile</p>
          </div>
    
          <div class="row">
            <div class="col-md-12">
              <div class="profile-block" *ngFor="#block of profile">
                <profile-block [block]="block"></profile-block>
              </div>
            </div>
    
            <form class="form-horizontal col-md-12">
              <div class="form-group">
                <div class="col-md-12">
                  <input [(ngModel)]="newBlock.title" type="text" class="form-control" placeholder="Block title">
                </div>
              </div>
              <div class="form-group">
                <div class="col-md-12">
                  <button (click)="onClick($event)" class="button">New block</button>
                </div>
              </div>
            </form>
          </div>
    
        </section>
        `
    })
    
  3. 添加属性和构造函数:

    export class ProfileEditComponent implements OnInit {
      public user: any;
      public profile: any;
      public newBlock: Block;
      private _profileService: ProfileService;
    
      constructor(profileService: ProfileService) {
        this._profileService = profileService;
      }
    }
    
  4. 添加 ngOnInit() 方法:

      ngOnInit() {
        this.user = {};
        this.newBlock = new Block();
        this._profileService.user.subscribe((user) => {
          this.user = user;
        });
        this._profileService.profile.subscribe((profile) => {
          this.profile = profile;
        });
        this._profileService.getProfile();
      }
    
  5. 定义用户如何添加新块:

      onClick(event) {
        event.preventDefault();
        let profile = this.profile.slice(0);  // clone the profile
        let block = Object.assign({}, this.newBlock); // clone the new block
    
        profile.push(block);
        this._profileService.profile.next(profile);
        this.newBlock = new Block();
      }
    

我们将订阅配置文件数据流并显示所有块。为了显示配置文件块,我们使用了一个单独的组件。该组件将块作为数据输入获取。

当用户添加一个新块时,我们将新创建的块推送到配置文件中。这相当简单,因为我们使用了 RxJS 中的Subject。通过这种方式,我们可以将我们的配置文件数据与所有组件同步。

配置文件块组件

因为配置文件是由块组成的,我们可以创建一个可重用且封装了所有块功能的单独组件。让我们按照以下步骤创建我们的组件:

  1. 创建一个名为public/src/user/profile/components/profile-block.component.ts的新文件。

  2. 添加必要的依赖项:

    import { Component, OnInit } from 'angular2/core';
    import { ProfileService } from '../profile.service';
    import { Block } from '../block.model';
    import { Entry } from '../entry.model';
    
  3. 配置Component注解:

    @Component({
        selector: 'profile-block',
        inputs: ['block'],
        template: `
          <div class="panel panel-default">
            <div class="panel-heading">
              <h3 class="panel-title">{{block.title}}</h3>
            </div>
            <div class="panel-body">
              <div class="profile-block-entries">
                <div *ngFor="#entry of block.data">
                  <div class="form-group">
                    <label>Title</label>
                    <input class="form-control" type="text"
                      (keydown.enter)="onEnter($event)"
                      [(ngModel)]="entry.title">
                  </div>
                  <div class="form-group">
                    <label>Sub title</label>
                    <input class="form-control" type="text"
                      (keydown.enter)="onEnter($event)"
                      [(ngModel)]="entry.subTitle">
                  </div>
                  <div class="form-group">
                    <label>Description</label>
                    <textarea class="form-control"
                      (keydown.enter)="onEnter($event)"
                      [(ngModel)]="entry.description"></textarea>
                  </div>
                  <hr>
                </div>
              </div>
              <button class="btn btn-default btn-xs btn-block" (click)="addEntry($event)">
                <i class="glyphicon glyphicon-plus"></i> Add new entry
              </button>
            </div>
          </div>
        `
    })
    
  4. 定义ProfileBlockComponent类:

    export class ProfileBlockComponent implements OnInit {
      public block: any;
      private _profileService: ProfileService;
    
      constructor(profileService: ProfileService) {
        this._profileService = profileService;
      }
    
      ngOnInit() {
        console.log(this.block);
      }
    
      addEntry(event) {
        event.preventDefault();
        this.block.data.push(new Entry());
      }
    
      onEnter(event) {
        event.preventDefault();
        this._profileService.updateProfileBlock(this.block);
      }
    }
    

使用addEntry()方法,我们可以向我们的块添加更多条目。这是一个简单的操作,它将新条目推送到块的数据库中。为了保存更改,我们绑定到 keydown 事件,该事件将Enter键与onEnter()方法关联。此方法将使用之前实现的服务更新配置文件块。

如果一个块是新添加的并且没有idProfileService将处理这种情况,因此我们不需要在我们的组件中添加不同的方法调用。

额外模型

我们使用了一些额外的模型——这些模型在后端找不到——以帮助我们处理 Angular 部分。当创建初始值或为属性设置默认值时,它们非常有用。

Entry模型描述了配置文件块的单个条目。该模型可以在public/src/user/profile/entry.model.ts下找到:

export class Entry {
  title: string;
  subTitle: string;
  description: string;

  constructor(
    title?: string,
    subTitle?: string,
    description?: string
  ) {
    this.title = title || '';
    this.subTitle = subTitle || '';
    this.description = description || '';
  }
}

我们还在我们的模块中使用了第二个辅助模型——public/src/user/profile/block.model.ts

import { Entry } from './entry.model';

export class Block {
  _id: string;
  title: string;
  slug: string;
  data: Array<any>;

  constructor(
    _id?: string,
    title?: string,
    slug?: string,
    data?: Array<any>
  ) {
    this._id = _id;
    this.title = title;
    this.slug = slug;
    this.data = data || [new Entry()];
  }
}

之前使用的模型使用了Entry模型来初始化data属性,以防没有数据。您也可以为您的模型添加验证。这取决于应用程序的复杂性。

剩余的功能可以在以下链接的最终项目仓库中找到:github.com/robert52/mean-blueprints-jobboard

摘要

最后,我们到达了本章的结尾。

在本章中,我们从模板开始构建应用程序,扩展了一些功能,并添加了我们自己的新功能。我们创建了一个具有多种用户类型的系统,并添加了授权策略。此外,在最后几步中,我们扩展了我们的后端 API 以添加新功能,并为我们 Angular 2 应用程序添加了一个额外的模块。

在下一章中,我们将使用实时通信,看看用户如何在应用程序中相互交互。

第四章。聊天应用程序

在本章中,我们将构建一个聊天应用程序。我们将构建的应用程序将完美地作为公司内部沟通工具。团队可以创建频道来讨论与项目相关的某些事项,甚至可以发送自动删除的敏感数据消息,例如服务器的登录凭证等。

设置基本应用程序

我们将首先使用与上一章中相同的样板代码设置基本应用程序。按照以下简单步骤来实现这一点:

  1. 从 GitHub 克隆项目:github.com/robert52/express-api-starter

  2. 将你的样板项目重命名为mean-blueprints-chatapp

  3. 如果你想,你可以通过运行以下命令来停止指向初始 Git 仓库:

    git remote remove origin
    
    
  4. 跳转到你的工作目录:

    cd mean-blueprints-chatapp
    
    
  5. 安装所有依赖项:

    npm install
    
    
  6. 创建开发配置文件:

    cp config/environments/example.js config/environments/development.js
    
    

你的配置文件config/environments/development.js应该类似于以下内容:

module.exports = {
  port: 3000,
  hostname: '127.0.0.1',
  baseUrl: 'http://localhost:3000',
  mongodb: {
    uri: 'mongodb://localhost/chatapp_dev_db'
  },
  app: {
    name: 'MEAN Blueprints - chat application'
  },
  serveStatic: true,
  session: {
    type: 'mongo',                     
    secret: 'someVeRyN1c3S#cr3tHer34U',
    resave: false,                          
    saveUninitialized: true            
  },
  proxy: {
    trust: true
  },
  logRequests: false  
};

修改用户模型

我们不需要太多关于用户的信息,因此我们可以将User模式缩减到仅严格必要的信息。此外,我们可以添加一个profile字段,它可以存储关于用户的任何额外信息,例如社交媒体配置文件信息或其他账户数据。

让我们修改User模式从app/models/user.js如下:

const UserSchema = new Schema({
  email:  {
    type: String,
    required: true,
    unique: true
  },
  name: {
    type: String
  },
  password: {
    type: String,
    required: true,
    select: false
  },
  passwordSalt: {
    type: String,
    required: true,
    select: false
  },
  profile: {
    type: Mixed
  },
  createdAt: {
    type: Date,
    default: Date.now
  }
});

消息历史数据模型

消息历史将是一个用户通过聊天应用程序提交的消息集合。在 MongoDB 中存储此类数据时,我们可以选择多种方法。好事是,没有正确的实现,尽管我们有许多常见的实现方法和考虑因素。

我们的起点将是用户发送的消息是会话线程的一部分。当两个或更多用户互相聊天时,最初为他们创建一个会话线程。消息对那个会话是私有的。这意味着消息与另一个实体具有父子关系,在我们的例子中是线程实体。

考虑到我们应用程序的需求,我们可以探索以下实现来存储我们的消息:

  • 将每条消息存储在单独的文档中:这是最容易实现的,也是最灵活的,但它伴随着一些应用程序级别的复杂性。

  • 将所有消息嵌入到线程文档中:由于 MongoDB 对文档大小的限制,这不是一个可接受的解决方案。

  • 实现混合解决方案:消息存储在与线程文档分开的地方,但以类似桶的方式保存,每个桶存储有限数量的文档。因此,我们不会在一个桶中存储线程的所有消息,而是将它们分散开来。

对于我们的应用程序,我们可以采用每条消息一个文档的实现。这将为我们提供最大的灵活性和易于实现性。此外,我们可以轻松地以时间顺序和线程顺序检索消息。

线程模式

每条消息都将成为对话线程的一部分。有关谁参与对话等信息将存储在线程集合的文档中。

我们将从只包含必要字段的简单模式开始,其中我们将存储有关线程的简单信息。创建一个名为 /app/models/thread.js 的新文件,并使用以下模式设计:

'use strict';

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

const ThreadSchema = new Schema({
  participants: {
    type: [
      {
        type: ObjectId,
        ref: 'User'
      }
    ]
  },
  createdAt: {
    type: Date,
    default: Date.now
  }
});

module.exports = mongoose.model('Thread', ThreadSchema);

目前对我们来说最重要的部分是 participants 字段,它描述了谁参与了当前的对话。按照设计,我们的应用程序将支持多个用户参与同一个对话线程。想象一下,它就像一个频道,你的团队可以讨论一个特定的项目。

消息模式

如我们之前所说,我们将使用每条消息一个文档的方法。目前,我们将为我们的消息使用相当简单的模式。这可以根据应用程序的复杂性而改变。

我们将在 app/models/message.js 中定义我们的模式:

'use strict';

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

const MessageSchema = new Schema({
  sender: {
    type: ObjectId,
    required: true,
    ref: 'User'
  },
  thread: {
    type: ObjectId,
    required: true,
    ref: 'Thread'
  },
  body: {
    type: String,
    required: true
  },
  createdAt: {
    type: Date,
    default: Date.now
  }
});

module.exports = mongoose.model('Message', MessageSchema);

模式相当简单。我们有一个发送者,它引用了一个用户和一个线程。在线程实体中,我们将存储有关对话的附加数据。

线程后端

在 Node.js 后端应用程序中,我们将通过 Express 应用程序的路由定义提供与管理工作线程相关的端点。还应该有一种方法可以从特定的线程中获取消息历史。

线程控制器

我们将通过以下步骤在新的控制器文件 app/controllers/thread.js 中添加管理线程所需的所有业务逻辑:

  1. 添加所需的模块:

    'use strict';
    
    const mongoose = require('mongoose');
    const Thread = mongoose.model('Thread');
    
  2. 导出模块的方法:

    module.exports.allByUser = allThreadsByUser;
    module.exports.find = findThread;
    module.exports.open = openThread;
    module.exports.findById = findThreadById;
    
  3. 查找特定用户的全部线程:

    function allThreadsByUser(req, res, next) {
      Thread
      .find({
        participants: req.user._id
      })
      .populate('participants')
      .exec((err, threads) => {
        if (err) {
          return next(err);
        }
    
        req.resources.threads = threads;
        next();
      });
    }
    
  4. 通过不同的标准查找线程,例如,通过当前登录的用户和参与对话的另一个用户的 ID:

    function findThread(req, res, next) {
      let query = {};
      if (req.body.userId) {
        query.$and = [
          { participants: req.body.userId },
          { participants: req.user._id.toString() }
        ];
      }
    
      if (req.body.participants) {
        query.$and = req.body.participants.map(participant => {
          return { participants: participant };
        });
      }
    
      Thread
      .findOne(query)
      .populate('participants')
      .exec((err, thread) => {
        if (err) {
          return next(err);
        }
    
        req.resources.thread = thread;
        next();
      });
    }
    
  5. 打开一个新的对话:

    function openThread(req, res, next) {
      var data = {};
    
      //  If we have already found the thread 
      //  we don't need to create a new one
      if (req.resources.thread) {
        return next();
      }
    
      data.participants = req.body.participants || [req.user._id, req.body.user];
    
      Thread
      .create(data, (err, thread) => {
        if (err) {
          return next(err);
        }
    
        thread.populate('participants', (err, popThread) => {
          if (err) {
            return next(err);
          }
    
          req.resources.thread = popThread;
          next();
        });
      });
    }
    
  6. 最后,通过其 ID 查找线程:

    function findThreadById(req, res, next) {
      Thread
      .findById(req.params.threadId, (err, thread) => {
        if (err) {
          return next(err);
        }
    
        req.resources.thread = thread;
        next();
      });
    }
    

定义路由

所有必要的业务逻辑都在控制器文件中实现。我们只需要将控制器中的方法挂载到路由上,以便它们可以从外部调用。创建一个名为 app/routes/thread.js 的新文件。添加以下代码:

const express = require('express');
const router = express.Router();
const threadCtrl = require('../controllers/thread');
const messageCtrl = require('../controllers/message');
const auth = require('../middlewares/authentication');
const authorize = require('../middlewares/authorization');
const response = require('../helpers/response');

module.exports = router;

在我们添加了必要的模块依赖项之后,我们可以逐个实现每个路由:

  1. 获取所有用户的线程:

    router.get(
      '/threads',
      auth.ensured,
      threadCtrl.allByUser,
      response.toJSON('threads')
    );
    
  2. 打开一个新的线程。如果参与者已经有一个线程,它将被返回:

    router.post(
      '/thread/open',
      auth.ensured,
      threadCtrl.find,
      threadCtrl.open,
      response.toJSON('thread')
    );
    
  3. 通过 ID 获取线程:

    router.get(
      '/threads/:threadId',
      auth.ensured,
      threadCtrl.findById,
      authorize.onlyParticipants('thread'),
      response.toJSON('thread')
    )
    
  4. 获取线程的所有消息:

    router.get(
      '/threads/:threadId/messages',
      auth.ensured,
      threadCtrl.findById,
      authorize.onlyParticipants('thread'),
      messageCtrl.findByThread,
      response.toJSON('messages')
    );
    

我们跳过了几个步骤,并已经使用了消息控制器的一个方法;不要担心,我们将在下一步实现它。

消息控制器

我们的 API 应该返回特定对话的消息历史。我们将保持简单,直接从 MongoDB 的 Message 集合中检索所有数据。创建一个新的控制器文件,app/controllers/message.js,并添加以下逻辑以查找线程的所有消息文档:

'use strict';

const mongoose = require('mongoose');
const Thread = mongoose.model('Thread');
const Message = mongoose.model('Message');
const ObjectId = mongoose.Types.ObjectId;

module.exports.findByThread = findMessagesByThread;

function findMessagesByThread(req, res, next) {
  let query = {
    thread: req.resources.thread._id
  };

  if (req.query.beforeId) {
    query._id = { $lt: new ObjectId(req.query.sinceId) };
  }

  Message
  .find(query)
  .populate('sender')
  .exec(function(err, messages) {
    if (err) {
      return next(err);
    }

    req.resources.messages = messages;
    next();
  });
}

由于我们有很多内容要覆盖,我们既不会在后端也不会在前端处理消息历史的分页。但在前面的代码中,我已经添加了一些帮助。如果发送了 beforeId 查询字符串,则可以通过最后已知的 ID 容易地进行分页。还请记住,如果 _id 字段存储了 ObjectId 值,则按其排序几乎等同于按创建时间排序。

让我们更深入地探讨一下这个 _id 字段。大多数 MongoDB 客户端会自动生成 ObjectId 值并将其赋给 _id 字段。如果文档中没有发送 _id 字段,mongod(MongoDB 的主要守护进程)将添加该字段。

我们可能会遇到的一个问题是,如果消息文档是由单个秒内的多个进程或系统生成的。在这种情况下,插入顺序将不会严格保留。

后端聊天服务

到目前为止,我们只是触及了我们后端应用程序的表面。我们将在服务器上添加一个服务层。这个抽象层将实现所有业务逻辑,例如即时消息。服务层将处理与其他应用程序模块和层的交互。

至于应用程序的 WebSockets 部分,我们将使用 socketIO,这是一个实时通信引擎。他们有一个非常棒的聊天应用程序示例。如果你还没有听说过它,可以查看以下链接:

socket.io/get-started/chat/

聊天服务实现

现在我们已经熟悉了 socketIO,我们可以继续并实现我们的聊天服务。我们将首先创建一个名为 app/services/chat/index.js 的新文件。这将是我们的聊天服务的主要文件。添加以下代码:

'use strict';

const socketIO = require('socket.io');
const InstantMessagingModule = require('./instant-messaging.module');

module.exports = build;

class ChatService {
}

function build(app, server) {
  return new ChatService(app, server);
}

不要担心 InstantMessagingModule。我们只是将其添加为参考,以免忘记。我们稍后会回来揭示这个谜团。我们的类应该有一个构造函数。现在让我们添加它:

  constructor(app, server) {
    this.connectedClients = {};
    this.io = socketIO(server);
    this.sessionMiddleware = app.get('sessionMiddleware');
    this.initMiddlewares();
    this.bindHandlers();
  }

在构造函数中,我们初始化 socketIO,获取会话中间件,并最终将所有处理程序绑定到我们的 socketIO 实例。有关会话中间件的更多信息,可以在我们的 Express 配置文件 config/express.js 中找到。寻找类似的内容:

  var sessionOpts = {
    secret: config.session.secret,
    key: 'skey.sid',
    resave: config.session.resave,
    saveUninitialized: config.session.saveUninitialized
  };

  if (config.session.type === 'mongodb') {
    sessionOpts.store = new MongoStore({
      url: config.mongodb.uri
    });
  }

  var sessionMiddleware = session(sessionOpts);
  app.set('sessionMiddleware', sessionMiddleware);

好处在于我们可以将这个会话逻辑与 socketIO 共享,并通过 .use() 方法挂载。这将在 .initMiddlewares() 方法中完成:

  initMiddlewares() {
    this.io.use((socket, next) => {
      this.sessionMiddleware(socket.request, socket.request.res, next);
    });

    this.io.use((socket, next) => {
      let user = socket.request.session.passport.user;

      //  authorize user
      if (!user) {
        let err = new Error('Unauthorized');
        err.type = 'unauthorized';
        return next(err);
      }

      // attach user to the socket, like req.user
      socket.user = {
        _id: socket.request.session.passport.user
      };
      next();
    });
  }

首先,我们将会话中间件挂载到我们的实例上,这将会在类似挂载到我们的 Express 应用程序上的操作。其次,我们检查用户是否存在于套接字会话中,换句话说,即用户是否已经认证。

能够添加中间件是一个非常酷的特性,并使我们能够为每个已连接的套接字执行有趣的事情。我们还应该添加构造函数中的最后一个方法:

  bindHandlers() {
    this.io.on('connection', socket => {
      // add client to the socket list to get the session later
      this.connectedClients[socket.request.session.passport.user] = socket;
      InstantMessagingModule.init(socket, this.connectedClients, this.io);
    });
  }

对于每个成功连接的客户端,我们将初始化即时通讯模块并将已连接的客户端存储在映射中,以供以后参考。

即时通讯模块

为了稍微模块化一些,我们将把表示已连接客户端的功能性拆分到单独的模块中。目前只有一个模块,但将来你可以轻松地添加新的模块。InstantMessagingModule将位于主聊天文件的同一文件夹中,更确切地说,是app/services/chat/instant-messaging.module.js。你可以安全地向其中添加以下代码:

'use strict';

const mongoose = require('mongoose');
const Message = mongoose.model('Message');
const Thread = mongoose.model('Thread');

module.exports.init = initInstantMessagingModule;

class InstantMessagingModule {
}

function initInstantMessagingModule(socket, clients) {
  return new InstantMessagingModule(socket, clients);
}

该服务将使用MessageThread模型来验证和持久化数据。我们导出一个初始化函数而不是整个类。你可以轻松地向导出的函数添加额外的初始化逻辑。

类构造函数将会相当简单,看起来会类似于以下这样:

  constructor(socket, clients) {
    this.socket = socket;
    this.clients = clients;
    this.threads = {};
    this.bindHandlers();
  }

我们只是将必要的依赖项分配给每个属性,并将所有处理程序绑定到已连接的套接字。让我们继续使用.bindHandlers()方法:

  bindHandlers() {
    this.socket.on('send:im', data => {
      data.sender = this.socket.user._id;

      if (!data.thread) {
        let err = new Error('You must be participating in a conversation.')
        err.type = 'no_active_thread';
        return this.handleError(err);
      }

      this.storeIM(data, (err, message, thread) => {
        if (err) {
          return this.handleError(err);
        }

        this.socket.emit('send:im:success', message);

        this.deliverIM(message, thread);
      });
    });
  }

当通过 WebSockets 发送新消息时,它将通过.storeIM()方法存储,并通过.deliverIM()方法分发给每个参与者。

我们稍微抽象了发送即时消息的逻辑,所以让我们定义我们的第一个方法,它用于存储消息:

  storeIM(data, callback) {
    this.findThreadById(data.thread, (err, thread) => {
      if (err) {
        return callback(err);
      }

      let user = thread.participants.find((participant) => {
        return participant.toString() === data.sender.toString();
      });

      if (!user) {
        let err = new Error('Not a participant.')
        err.type = 'unauthorized_thread';
        return callback(err);
      }

      this.createMessage(data, (err, message) => {
        if (err) {
          return callback(err);
        }

        callback(err, message, thread);
      });
    });
  }

因此,基本上,.storeIM()方法找到对话线程并创建一条新消息。我们还添加了存储消息时的简单授权。发送者必须是给定对话的参与者。你可以将这部分逻辑移动到更合适的模块中。我将把它留给你作为练习。

让我们添加之前使用过的下两个方法:

  findThreadById(id, callback) {
    if (this.threads[id]) {
      return callback(null, this.threads[id]);
    }

    Thread.findById(id, (err, thread) => {
      if (err) {
        return callback(err);
      }

      this.threads[id] = thread;
      callback(null, thread);
    });
  }

  createMessage(data, callback) {
    Message.create(data, (err, newMessage) => {
      if (err) {
        return callback(err);
      }

      newMessage.populate('sender', callback);
    });
  }

最后,我们可以将我们的消息传递给其他参与者。实现可以在以下类方法中找到:

  deliverIM(message, thread) {
    for (let i = 0; i < thread.participants.length; i++) {
      if (thread.participants[i].toString() === message.sender.toString()) {
        continue;
      }

      if (this.clients[thread.participants[i]]) {
        this.clients[thread.participants[i]].emit('receive:im', message);
      }
    }
  }

我们已经完成了后端应用程序的开发。它应该实现了所有必要的功能,以便开始开发客户端 Angular 应用程序。

引导 Angular 应用程序

是时候开始使用 Angular 2 构建我们的客户端应用程序了。我们将集成SocketIO与客户端以与我们的后端应用程序通信。我们将展示应用程序最重要的部分,但你可以在任何时候查看最终版本。

引导文件

boot.ts file will be the final version, with all the necessary data added to it:
import { bootstrap } from 'angular2/platform/browser';
import { provide } from 'angular2/core';
import { HTTP_PROVIDERS } from 'angular2/http';
import { ROUTER_PROVIDERS, LocationStrategy, HashLocationStrategy } from 'angular2/router';
import { AppComponent } from './app.component';
import { ChatService }  from './services/chat.service';
import { ThreadService }  from './services/thread.service';
import { MessageService }  from './services/message.service';
import { UserService } from './services/user.service';

import 'rxjs/add/operator/map';
import 'rxjs/add/operator/share';
import 'rxjs/add/operator/combineLatest';
import 'rxjs/add/operator/distinctUntilChanged';
import 'rxjs/add/operator/debounceTime';

bootstrap(AppComponent, [
  HTTP_PROVIDERS, ROUTER_PROVIDERS,
  ChatService,
  ThreadService,
  MessageService,
  UserService,
  provide(LocationStrategy, {useClass: HashLocationStrategy})
]); 

我们将为这个特定的应用程序实现四个服务,并且我们将从一个应用程序组件开始。为了简化,我们将使用基于哈希的位置策略。

应用程序组件

我们应用程序的主要组件是app组件。目前我们将保持其简单性,只向其中添加一个路由出口,并配置我们应用程序的路由。

创建一个名为public/src/app.component.ts的新文件,包含以下代码:

import { Component } from 'angular2/core';
import { RouteConfig, RouterOutlet } from 'angular2/router';
import { Router } from 'angular2/router';
import { ChatComponent } from './chat/chat.component';

@RouteConfig([
  { path: '/messages/...', as: 'Chat', component: ChatComponent, useAsDefault: true }
])
@Component({
    selector: 'chat-app',
    directives: [
      RouterOutlet
    ],
    template: `
      <div class="chat-wrapper row card whiteframe-z2">
        <div class="chat-header col">
          <h3>Chat application</h3>
        </div>
        <router-outlet></router-outlet>
      </div>
    `
})
export class AppComponent {
  constructor() {
  }
}

我们创建了主应用程序组件并配置了一个将具有子路由的路由。默认情况下,ChatComponent将被挂载。所以,这非常基础。在我们继续应用程序的组件之前,让我们休息一下并定义自定义数据类型。

自定义数据类型

为了将类似的功能分组并具有自定义类型检查,我们将为应用程序中使用的每个实体定义类。这将使我们能够在创建实体时访问自定义初始化和默认值。

用户类型

我们在前端 Angular 应用程序中使用的第一个自定义数据类型将是用户。您可以使用接口来定义自定义类型或一个常规类。如果您需要默认值或自定义验证,请使用常规类定义。

创建一个名为public/src/datatypes/user.ts的新文件,并添加以下类:

export class User {
  _id: string;
  email: string;
  name: string;
  avatar: string;
  createdAt: string;

  constructor(_id?: string, email?: string, name?: string, createdAt?: string) {
    this._id = _id;
    this.email = email;
    this.name = name;
    this.avatar = 'http://www.gravatar.com/avatar/{{hash}}?s=50&r=g&d=retro'.replace('{{hash}}', _id);
    this.createdAt = createdAt;
  }
}

在实例化新用户时,user实例将预先填充avatar属性,包含一个特定的头像图片链接。我使用了gravatar并添加了用户的 ID 作为哈希来生成图像。通常,您必须使用用户的电子邮件作为md5哈希。显然,头像图像可以由任何服务提供。您甚至可以尝试向此应用程序添加文件上传和资料管理。

线程类型

接下来,我们将定义一个线程类,包含一些自定义初始化逻辑。创建一个名为public/src/datatypes/thread.ts的新文件:

import { User } from './user';

export class Thread {
  _id: string;
  name: string;
  participants: Array<User>;
  createdAt: string;

  constructor(_id?: string, name?: string, participants?: Array<User>, createdAt?: string) {
    this._id = _id;
    this.name = name || '';
    this.participants = participants || [];
    this.createdAt = createdAt;
  }

  generateName(omittedUser) {
    let names = [];
    this.participants.map(participant => {
      if (omittedUser._id !== participant._id) {
        names.push(participant.name);
      }
    });

    return (names[1]) ? names.join(', ') : names[0];
  }
}

如您所见,用户数据类型已被导入并用于表示给定线程的参与者必须是一个用户数组。还定义了一个类方法,用于根据对话线程中的参与用户生成特定线程的名称。

消息类型

最后,我们将定义在我们的应用程序中消息的结构。为此,我们将创建一个名为public/src/datatypes/message.ts的新文件,包含以下逻辑:

export class Message {
  _id: string;
  sender: any;
  thread: string;
  body: string;
  createdAt: string;
  time: string;
  fulltime: string;

  constructor(_id?: string, sender?: any, thread?: string, body?: string, createdAt?: string) {
    this._id = _id;
    this.sender = sender;
    this.body = body;
    this.createdAt = createdAt;
    this.time = this._generateTime(new Date(createdAt));
    this.fulltime = this._generateDateTime(new Date(createdAt));
  }

  private _generateTime(date) {
    return  date.getHours() + ":"
          + date.getMinutes() + ":"
          + date.getSeconds();
  }

  private _generateDateTime(date) {
    return date.getDate() + "/"
          + (date.getMonth()+1)  + "/"
          + date.getFullYear() + " @ "
          + this._generateTime(date);
  }
}

你可能已经在想,“为什么不包括User数据类型并将发送者标记为用户?”坦白说,这并不是必须的。您可以包含您喜欢的任何类型,代码仍然有效。这取决于您想向代码中添加多少粒度。

回到我们的代码,我们向Message类中添加了两个额外的方法,以生成两个时间戳,一个显示消息创建的时间,另一个显示包含日期和时间的完整时间戳。

应用程序服务

在初始章节中,我们根据它们的领域上下文对文件进行了分组。这次我们做了一些不同的处理,以强调你也可以从更扁平的方法开始。如果需要,你可以根据它们的领域上下文而不是类型来对文件进行分组。

尽管如此,我们还是将我们的组件根据它们的上下文分组,以便更快地定位它们。想象一下,你可以将整个应用程序加载到不同的应用程序中,并且拥有更扁平的文件夹结构将减少不必要的导航麻烦。

用户服务

我们将从一个简单的服务开始,该服务将处理所有用户应用程序逻辑。创建一个名为 public/src/services/user.service.ts 的新文件,并包含以下代码:

import { Injectable } from 'angular2/core';
import { Http, Response, Headers } from 'angular2/http';
import { Observable } from 'rxjs/Observable';
import { contentHeaders } from '../common/headers';
import { User } from '../datatypes/user';

type ObservableUsers = Observable<Array<User>>;

@Injectable()
export class UserService {
  public users: ObservableUsers;
  public user: User;
  private _http: Http;
  private _userObservers: any;
  private _dataStore: { users: Array<User> };

  constructor(http: Http) {
    this._http = http;
    this.users = new Observable(observer => this._userObservers = observer).share();
    this._dataStore = { users: [] };
    this.getAll();
  }
}

我们公开了一个 users 属性,它是一个可观察对象,并将其转换为 hot 可观察对象。我们为服务定义了一个内部数据存储,它是一个简单的对象。差点忘了提!记得导入你的依赖项。作为构造函数的结尾,我们从后端检索所有用户。我们在服务中这样做,这样我们就不需要从组件中显式调用它。

实际上,.getAll() 方法尚未实现,所以让我们给这个类添加以下方法:

  getAll() {
    return this._http
    .get('/api/users', { headers: contentHeaders })
    .map((res: Response) => res.json())
    .subscribe(users => this.storeUsers(users));
  }

我们还将数据持久性移动到另一个方法,以防我们想在别处使用它。将以下方法添加到 UserService 类中:

  storeUsers(users: Array<User>) {
    this._dataStore.users = users;
    this._userObservers.next(this._dataStore.users);
  }

目前,我们的应用程序已经拥有了来自 UserService 的所有必要功能,我们可以继续实现其他应用程序组件。

线程服务

线程服务将处理并共享与线程相关的数据。我们将存储从后端检索到的线程。此外,当前活动线程也将存储在这个服务中。

让我们先创建服务文件,命名为 public/src/services/thread.service.ts。之后,遵循以下步骤来实现服务的核心逻辑:

  1. 加载必要的依赖项:

    import { Injectable } from 'angular2/core';
    import { Http, Response, Headers } from 'angular2/http';
    import { Subject } from 'rxjs/Subject';
    import { BehaviorSubject } from 'rxjs/Subject/BehaviorSubject';
    import { Observable } from 'rxjs/Observable';
    import { contentHeaders } from '../common/headers';
    import { Thread } from '../datatypes/thread';
    import { User } from '../datatypes/user';
    
  2. 定义几个自定义数据类型:

    type ObservableThreads = Observable<Array<Thread>>;
    type SubjectThread = Subject<Thread>;
    
  3. 定义基类:

    @Injectable()
    export class ThreadService {
      public threads: ObservableThreads;
      public currentThread: SubjectThread = new BehaviorSubject<Thread>(new Thread());
      private _http: Http;
      private _threadObservers: any;
      private _dataStore: { threads: Array<Thread> };
      private _currentUser: any;
    }
    
  4. 添加构造函数:

      constructor(http: Http) {
        this._http = http;
        this._dataStore = { threads: [] };
        this.threads = new Observable(
          observer => this._threadObservers = observer
        ).share();
      }
    
  5. 添加必要的获取所有线程的方法:

      getAll() {
        return this._http
        .get('/api/threads', { headers: contentHeaders })
        .map((res: Response) => res.json())
        .map(data => {
          return data.map(thread => {
            return new Thread(thread._id, thread._id, thread.participants, thread.createdAt)
          });
        })
        .subscribe(threads => {
          this._dataStore.threads = threads;
          this._threadObservers.next(this._dataStore.threads);
        });
    

    此方法将从后端服务检索所有线程。它将它们存储在服务的数据存储中,并将最新值推送到线程观察者,以便所有订阅者都可以获取最新值。

  6. 定义如何打开一个新线程:

      open(data: any) {
        return this._http
        .post('/api/thread/open', JSON.stringify(data), { headers: contentHeaders })
        .map((res: Response) => res.json())
        .map(data => {
          return new Thread(data._id, data.name, data.participants, data.createdAt);
        });
      }
    

    open() 方法将返回一个可观察对象,而不是在服务内部处理数据。

  7. 我们还需要能够设置当前线程:

      setCurrentThread(newThread: Thread) {
        this.currentThread.next(newThread);
      }
    

    currentThread 是一个 BehaviorSubject,它将只保留最后一个值并与任何新的订阅者共享。这在存储当前线程时很有用。记住,你需要用初始值初始化这个主题。

  8. 暴露一个方法来从外部数据源存储线程:

      storeThread(thread: Thread) {
        var found = this._dataStore.threads.find(t => {
          return t._id === thread._id;
        });
    
        if (!found) {
          this._dataStore.threads.push(thread);
          this._threadObservers.next(this._dataStore.threads);
        }
      }
    

我们不想存储相同的线程两次。我们可以改进的一件事是更新已更改的线程,但在这个应用程序中我们不需要这个功能。这是你在改进此应用程序时应记住的事情。

使用最后实现的逻辑,我们应该有对话线程所需的最小功能。

消息服务

消息服务将会有一些不同,因为我们打算使用 socket.io 通过WebSockets从之前在本章中设置的 socket 服务器发送和接收数据。别担心!这种差异不会反映在应用程序的其他部分。服务应该始终抽象底层逻辑。

我们将首先创建一个名为public/src/services/message.service.ts的服务文件,并导入必要的依赖项:

import { Injectable } from 'angular2/core';
import { Http, Response, Headers } from 'angular2/http';
import { Observable } from 'rxjs/Observable';
import { ThreadService } from './thread.service';
import { contentHeaders } from '../common/headers';
import { Message } from '../datatypes/message';
import { User } from '../datatypes/user';
import * as io from 'socket.io-client';

你可以看到我们已将socket.io-client库中的所有内容导入为io。接下来,我们将添加类定义:

type ObservableMessages = Observable<Array<Message>>;

@Injectable()
export class MessageService {
  public messages: ObservableMessages;

  private _http: Http;
  private _threadService: ThreadService;
  private _io: any;
  private _messagesObservers: any;
  private _dataStore: { messages: Array<Message> };

  constructor(http: Http, threadService: ThreadService) {
    this._io = io.connect();
    this._http = http;
    this._threadService = threadService;
    this.messages = new Observable(observer => this._messagesObservers = observer).share();
    this._dataStore = { messages: [] };
    this._socketOn();
  }
}

在构造函数中,我们将初始化并连接到 socket 服务器。因为我们同时在服务器端和客户端使用默认配置,所以我们可以直接调用.connect()方法。_socketOn()私有方法将定义所有 socket 事件绑定;让我们添加这个方法:

  private _socketOn() {
    this._io.on('receive:im', message => this._storeMessage(message));
    this._io.on('send:im:success', message => this._storeMessage(message));
  }

我们刚刚定义了两个要监听的事件并调用_storeMessage()方法。对于每个事件,将通过 socket 接收到一条新消息。在此之后,我们应该在我们的MessageSerivce类中添加该方法:

  private _storeMessage(message: Message) {
    let sender = new User(
      message.sender._id,
      message.sender.email,
      message.sender.name,
      message.sender.createdAt
    );
    let m = new Message(
      message._id,
      new User(sender._id, sender.email, sender.name, sender.createdAt),
      message.thread,
      message.body,
      message.createdAt
    );
    this._dataStore.messages.push(m);
    this._messagesObservers.next(this._dataStore.messages);
  }

当存储一条新消息时,我们将创建一个新的User实例,以便拥有关于消息发送者的所有必要数据。此方法将仅在服务内部使用,但我们需要公开一个发送消息的方法,它将被其他组件访问:

  sendMessage(message: Message) {
    this._io.emit('send:im', message);
  }

发送消息并不难。我们只需要发出send:im事件并附加消息本身。除了发送和接收消息外,我们还需要获取特定线程的消息历史并存储在服务的数据存储中。让我们现在就做这件事:

  getByThread(threadId) {
    this._http
    .get('/api/threads/'+threadId+'/messages', { headers: contentHeaders })
    .map((res: Response) => res.json())
    .map(res => {
      return res.map(data => {
        let sender = new User(
          data.sender._id,
          data.sender.email,
          data.sender.name,
          data.sender.createdAt
        );
        return new Message(
          data._id,
          sender,
          data.thread,
          data.body,
          data.createdAt
        );
      });
    })
    .subscribe(messages => {
      this._dataStore.messages = messages;
      this._messagesObservers.next(this._dataStore.messages);
    });
  }

前面的方法应该为我们从 Express 应用程序中检索必要的数据。我们像之前存储传入消息时一样,为每条消息做同样的事情。更确切地说,我们正在使用发送者的信息实例化一个新的用户。对于消息服务来说,这就足够了。

聊天组件

现在我们有了所有必要的数据类型和服务,我们可以回到应用程序的组件。回顾一下app组件,一个好的开始是先从chat组件开始。创建一个名为public/src/chat/chat.component.ts的新文件,并添加以下导入:

import { Component } from 'angular2/core';
import { RouteConfig, RouterOutlet } from 'angular2/router';
import { ChatService } from '../services/chat.service';
import { ThreadListComponent } from '../thread/thread-list.component';
import { MessageListComponent } from '../message/message-list.component';
import { MessageFormComponent } from '../message/message-form.component';
import { UserListComponent } from '../user/user-list.component';
import { ChatHelpComponent } from './chat-help.component';

在导入所有所需的模块后,我们实际上可以实施我们的组件:

@RouteConfig([
  { path: '/',            as: 'ThreadMessagesDefault', component: ChatHelpComponent, useAsDefault: true },
  { path: '/:identifier', as: 'ThreadMessages', component: MessageListComponent }
])
@Component({
  selector: 'chat',
  directives: [
    ThreadListComponent,
    MessageFormComponent,
    UserListComponent,
    RouterOutlet
  ],
  template: `
    <div class="threads-container col sidebar">
      <user-list></user-list>
      <thread-list></thread-list>
    </div>

    <div class="messages-container col content">
      <router-outlet></router-outlet>

      <div class="message-form-container">
        <message-form></message-form>
      </div>
    </div>
  `
})
export class ChatComponent {
  private _chatService: ChatService;

  constructor(chatService: ChatService) {
    this._chatService = chatService;
  }
}

在组件的模板中包含了我们将要实现的组件,例如,线程列表组件,它将显示与其他用户的所有当前对话。聊天组件将是我们的较小组件的容器,但我们还添加了一个 RouterOutlet 来动态加载与当前路由匹配的组件。

默认路由将在路由参数中没有添加线程 ID 时加载一个辅助组件。我们可以将此与组件的主页关联起来。你可以使默认视图尽可能复杂;例如,你可以添加一个链接到最近打开的线程。我们现在将保持简单。创建一个名为 public/src/chat-help.component.ts 的新文件,并添加以下代码:

import { Component } from 'angular2/core';

@Component({
  selector: 'chat-help',
  template: `
    <h2>Start a new conversation with someone</h2>
  `
})
export class ChatHelpComponent {
  constructor() {
  }
}

这里没有太多花哨的东西!只是一个简单的组件,它有一个内联模板,为用户显示一条友好的消息。现在我们已经覆盖了这一点,我们可以继续并实现其余的组件。

用户列表组件

用户列表组件将为我们提供搜索用户和与他们开始新对话的能力。我们需要显示用户列表并根据搜索标准进行过滤。此外,点击列表中的用户应打开一个新的对话线程。所有这些都应该相对简单易行。让我们首先创建组件文件。创建一个名为 public/src/user/user.component.ts 的新文件,并具有以下基本结构:

import { Component } from 'angular2/core';
import { Router } from 'angular2/router'
import { Subject } from 'rxjs/Subject';
import { ReplaySubject } from 'rxjs/Subject/ReplaySubject';
import { UserService } from '../services/user.service';
import { ThreadService } from '../services/thread.service';
import { User } from '../datatypes/user';

@Component({
    selector: 'user-list',
    template: ``
})
export class UserListComponent {
  public users: Array<User>;
  public filteredUsers: Array<User>;
  public selected: boolean = false;
  public search: Subject<String> = new ReplaySubject(1);
  public searchValue: string = '';
  private _threadService: ThreadService;
  private _userService: UserService;
  private _router: Router;

  constructor(userService: UserService, threadService: ThreadService, router: Router) {
    this._userService = userService;
    this._threadService = threadService;
    this._router = router;
  }
}

我们导入了必要的依赖项并定义了组件的基础。组件将包含两个主要部分,用户列表和输入字段,用于从列表中搜索指定的用户。让我们定义组件的模板:

      <div class="user-search-container">
        <input
          type="text"
          class="form-control block"
          placeholder="start a conversation"
          [(ngModel)]="searchValue"
          (focus)="onFocus($event)"
          (input)="onInput($event)"
          (keydown.esc)="onEsc($event)"
        />
      </div>
      <div class="user-list-container">
        <div class="users-container" [ngClass]="{active: selected }">
          <div class="user-list-toobar">
            <a href="#" (click)="onClose($event)" class="close-button">
              <span>×</span>
              <span class="close-text">esc</span>
            </a>
          </div>
          <div *ngFor="#user of filteredUsers">
            <a href="javascript:void(0);" (click)="openThread($event, user)">{{user.name}}</a>
          </div>
        </div>
      </div>

为了显示用户列表,我们将订阅用户服务中的 users 可观察对象。将以下代码添加到构造函数中:

    this._userService.users.subscribe(users => {
      this.filteredUsers = this.users = users;
    });

要显示过滤后的用户列表,我们将使用以下逻辑。将以下代码添加到构造函数中:

    this.search
      .debounceTime(200)
      .distinctUntilChanged()
      .subscribe((value: string) => {
        this.filteredUsers = this.users.filter(user => {
          return user.name.toLowerCase().startsWith(value);
        });
      });

为了从输入中获取数据,我们可以使用类似以下的方法,该方法仅在用户开始输入时执行:

  onInput(event) {
    this.search.next(event.target.value);
  }

为了打开一个新的线程,我们为列表中显示的每个用户绑定了一个点击事件。让我们添加执行此操作所需的方法:

  openThread(event, user: User) {
    this._threadService.open({ userId: user._id }).subscribe(thread => {
      this._threadService.storeThread(thread);
      this._router.navigate(['./ThreadMessages', { identifier: thread._id}]);
      this.cleanUp();
    });
  }

上述代码将仅调用线程服务的 .open() 方法,并在成功后导航到返回的线程。我们还从这个组件中调用了 cleanUp() 方法,这将重置我们的组件到初始状态。

最后,让我们添加组件中缺失的所有逻辑。只需将方法附加到组件中:

  onFocus() {
    this.selected = true;
  }

  onClose(event) {
    this.cleanUp();
    event.preventDefault();
  }

  onEsc(event) {
    this.cleanUp();
    let target: HTMLElement = <HTMLElement>event.target;
    target.blur();
    event.preventDefault();
  }
  cleanUp() {
    this.searchValue = '';
    this.selected = false;
    this.search.next('');
  }

快速回顾一下,我们创建了一个用户列表组件,当聚焦到搜索输入时,它会显示列表中的所有用户;默认情况下,列表是隐藏的。我们添加了一些特殊事件;例如,按 Esc 键时,列表应该被隐藏。

显示线程

用户必须知道他/她参与的是哪个对话,因此我们需要向用户显示此信息。为此,我们将实现一个线程列表组件。

线程组件

为了显示线程列表,我们将为每个线程使用一个组件来封装显示给用户的所有信息和功能。要创建所需的组件,请遵循以下步骤:

  1. 创建组件文件,命名为 public/src/thread/thread.component.ts

  2. 导入必要的依赖项:

    import { Component, OnInit } from 'angular2/core';
    import { RouterLink } from 'angular2/router';
    import { ThreadService } from '../services/thread.service';
    import { Thread } from '../datatypes/thread';
    
  3. 添加组件注解:

    @Component({
        inputs: ['thread'],
        selector: 'thread',
        directives: [ RouterLink ],
        template: `
          <div class="thread-item">
            <a href="#" [routerLink]="['./ThreadMessages', { identifier: thread._id }]" data-id="{{thread._id}}">
              {{thread.name}}
              <span *ngIf="selected"> &bull; </span>
            </a>
          </div>
        `
    })
    
  4. 定义组件的类:

    export class ThreadComponent implements OnInit {
      public thread: Thread;
      public selected: boolean = false;
      private _threadService: ThreadService;
    
      constructor(threadService: ThreadService) {
        this._threadService = threadService;
      }
    
      ngOnInit() {
        this._threadService.currentThread.subscribe( (thread: Thread) => {
          this.selected = thread && this.thread && (thread._id === this.thread._id);
        });
      }
    }
    

我们创建了一个单独的线程组件,用于在列表中显示信息。我们使用 routerLink 导航到所需的对话线程。在初始化时,我们检查当前是哪个线程,以便我们可以标记选定的线程。

线程列表组件

现在我们有了线程组件,我们可以将它们显示在列表中。为此,将使用一个新的组件。让我们做类似的事情:

  1. 创建组件文件,命名为 public/src/thread/thread-list.component.ts

  2. 导入必要的依赖项,包括 thread 组件:

    import { Component, ChangeDetectionStrategy } from 'angular2/core';
    import { Observable } from 'rxjs/Observable';
    import { ThreadService } from '../services/thread.service';
    import { Thread } from '../datatypes/thread';
    import { ThreadComponent } from './thread.component';
    
  3. 构建组件注解:

    @Component({
        selector: 'thread-list',
        directives: [ThreadComponent],
        // changeDetection: ChangeDetectionStrategy.OnPushObserve,
        // changeDetection: ChangeDetectionStrategy.OnPush,
        template: `
          <h4>Recent <span class="muted">({{threads.length}})</span></h4>
          <thread
            *ngFor="#thread of threads"
            [thread]="thread">
          </thread>
        `
    })
    
  4. 定义 ThreadListComponent 类:

    export class ThreadListComponent {
      public threads: Array<Thread> = [];
      private _threadService: ThreadService;
    
      constructor(threadService: ThreadService) {
        this._threadService = threadService;
        this._threadService.threads.subscribe(threads => {
          this.threads = threads;
        });
        this._threadService.getAll();
      }
    }
    

这应该向用户显示一个打开的对话线程列表。我们使用 thread 服务获取组件所需的数据。如果服务的 threads 集合发生变化,更新策略将为我们处理必要的更新。

消息传递

现在我们可以开始和恢复对话,我们需要能够向该对话的参与者发送消息。我们将专注于实现此功能。

发送消息的流程相当简单。首先,我们将所需的消息发送到后端应用程序,为历史记录存储消息,并通知收件人有新消息。发送者和收件人都会在他们的设备上显示消息。

发送消息

在之前的 chat 组件中,我们使用了 message form 组件。此组件将允许我们输入消息并将它们发送到 Node.js 后端服务。让我们保持简单,只添加必要的功能。创建一个新的组件文件,命名为 public/src/message/message-form.component.ts

我们将在组件中导入两个服务。为我们的依赖项添加以下代码:

import { Component, OnInit } from 'angular2/core';
import { ThreadService } from '../services/thread.service';
import { MessageService } from '../services/message.service';
import { Message } from '../datatypes/message';
import { User } from '../datatypes/user';
import { Thread } from '../datatypes/thread';

现在,我们将添加组件注解并定义组件的类:

@Component({
    selector: 'message-form',
    // changeDetection: ChangeDetectionStrategy.OnPush,
    template: `
      <input
        class="message-form form-control"
        autocorrect="off" autocomplete="off" spellcheck="true"
        (keydown.enter)="onEnter($event)"
        [(ngModel)]="draftMessage.body"
      >
    `
})
export class MessageFormComponent implements OnInit {

  constructor() {
  }

  ngOnInit() {
  }
}

为了定义其余必要的逻辑,我们将遵循以下步骤:

  1. 向该类添加以下属性:

      public draftMessage: Message;
      private _messageService: MessageService;
      private _threadService: ThreadService;
      private _thread: Thread;
    
  2. 将构造函数更改为以下类似的形式:

      constructor(messageService: MessageService, threadService: ThreadService) {
        this._messageService = messageService;
        this._threadService = threadService;
        this._threadService.currentThread.subscribe(thread => this._thread = thread);
      }
    
  3. 修改组件的初始化以重置 草稿消息 值:

      ngOnInit() {
        this.draftMessage = new Message();
      }
    
  4. 添加发送消息的逻辑:

      sendMessage() {
        let message: Message = this.draftMessage;
        message.thread = this._thread._id;
        this._messageService.sendMessage(message);
        this.draftMessage = new Message();
      }
    

    这将简单地调用 thread 服务的 .sendMessage() 方法。

  5. 定义当用户按下 Enter 键时会发生什么:

      onEnter(event: any) {
        this.sendMessage();
        event.preventDefault();
      }
    

从技术上讲,我们现在可以向后端发送消息并将它们持久化到 MongoDB 以构建消息历史。

消息组件

要显示所有消息,我们将从小处着手,创建消息组件。此组件将是消息列表中的一个条目。我们应该已经有了显示单个消息所需的数据和逻辑。

要实现message组件,创建一个名为public/src/message/message.component.ts的新文件,并附加以下代码:

import { Component, AfterViewInit } from 'angular2/core';

@Component({
    inputs: ['message'],
    selector: 'message',
    template: `
      <div class="message-item">
        <div class="message-identifier">
          <img src="img/{{message.sender.avatar}}" width="36" height="36"/>
        </div>
        <div class="message-content">
          <div class="message-sender">
            <span class="user-name">{{message.sender.name}}</span>
            <span class="message-timestamp" title={{message.fulltime}}>{{message.time}}</span>
          </div>
          <div class="message-body">
            {{message.body}}
          </div>
        </div>
      </div>
    `
})
export class MessageComponent implements AfterViewInit {
  constructor() {
  }

  ngAfterViewInit() {
    var ml = document.querySelector('message-list .message-list');
    ml.scrollTop = ml.scrollHeight;
  }
}

该组件相当简单。它仅显示单个消息的数据,并在视图初始化后,将消息列表滚动到最底部。通过最后一个功能,如果实际可以放入视图中的消息多于实际消息,它将简单地滚动到最后一条消息。

消息列表组件

在我们实现单个消息组件之前,这是我们的消息列表组件所必需的,该组件将向用户显示所有消息。我们将使用与线程列表类似的模式来实现此组件。

按照以下步骤实现消息列表组件:

  1. 导入必要的依赖:

    import { Component } from 'angular2/core';
    import { RouteParams } from 'angular2/router';
    import { MessageService } from '../services/message.service';
    import { ThreadService } from '../services/thread.service';
    import { Thread } from '../datatypes/thread';
    import { Message } from '../datatypes/message';
    import { MessageComponent } from './message.component';
    
  2. 添加 Angular 组件注解:

    @Component({
        selector: 'message-list',
        directives: [MessageComponent],
        template: `
          <div class="message-list">
            <div *ngIf="messages.length === 0" class="empty-message-list">
              <h3>No messages so far :)</h3>
            </div>
            <message
              *ngFor="#message of messages"
              [message]="message"
              ></message>
          </div>
        `
    })
    
  3. 定义组件的类:

    export class MessageListComponent {
      public messages: Array<Message> = [];
      private _messageService: MessageService;
      private _threadService: ThreadService;
      private _routeParams:RouteParams;
    }
    Add the constructor:
      constructor(
        messageService: MessageService,
        threadService: ThreadService,
        routeParams: RouteParams
      ) {
        this._routeParams = routeParams;
        this._messageService = messageService;
        this._threadService = threadService;
        this._messageService.messages.subscribe(messages => this.messages = messages);
        let threadId: string = this._routeParams.get('identifier');
        this._threadService.setCurrentThread(new Thread(threadId));
        this._messageService.getByThread(threadId);
      }
    

由于我们每次导航到匹配的路由时都会重新加载组件,我们可以获取当前的identifier参数并通过当前线程 ID 加载消息。我们还设置了当前线程 ID,以便其他订阅者可以相应地采取行动。例如,在thread组件中,我们检查当前线程是否与组件的线程匹配。

摘要

我们已经到达了本章的结尾。本章是关于构建实时聊天应用程序。我们使用了WebSockets进行实时通信,将消息历史存储在 MongoDB 中,并创建了线程式对话。我们还为改进或添加新功能留出了空间。

在下一章,我们将尝试构建一个电子商务应用程序。

第五章:电子商务应用程序

本章将专注于构建一个类似电子商务的应用程序。我们将通过构建一个包含所有业务逻辑的核心并使用较小的应用程序来消费它来尝试不同的应用程序架构。还有一个有趣的事情要注意,那就是我们的电子商务应用程序的前端商店将使用服务器端渲染。

这种新的架构将使我们能够构建微应用;例如,一个应用可以是管理产品目录的管理应用程序。好处是每个微应用都可以使用不同的方法构建。

作为演示,我们不会在 Angular 中构建我们的前端商店。我知道这听起来很疯狂,但出于教育目的,这将非常棒。此外,我们还想强调构建混合应用程序是多么容易。

应用程序的管理部分将使用 Angular 2 来构建。因此,我们将构建一个无头核心后端服务。这个核心应用程序将被我们的微应用消费。

设置基本应用程序

在前面的章节中,我们使用自己的样板文件来启动应用程序的开发。这一章将有一个全新的文件夹结构,但请放心;我们仍然会使用大量现有的样板代码。

新的文件夹结构将给我们更多的灵活性,因为我们已经超出了最初的架构。一个好处,我们不会在本章中介绍,就是您可以将每个模块移动到单独的包中,并将它们作为依赖项安装。

在深入探讨之前,让我们先看看我们架构的高层次视图:

apps/
-- admin/
-- api/
-- auth/
-- frontstore/
-- shared/
core/
---- helpers/
---- middlewares
---- models
---- services
config/
---- environments/
---- strategies
tests/

文件夹结构的解释如下:

  • apps: 这个文件夹将包含多个微应用,例如frontstore,它将为访问我们电子商务商店的用户提供客户端应用程序。

  • core: 这将是我们的应用程序的核心,包含所有必要的业务逻辑:

    • middlewares: 在这个文件夹中,我们将存储所有将操作请求和响应对象的函数片段。一个很好的例子就是身份验证中间件。

    • models: 这个文件夹将存储所有后端模型。

    • services: 这将分组所有适用于不同客户端的通用应用程序逻辑集合,并将协调业务逻辑的消耗。

  • config: 所有应用程序配置文件都放在这里。

    • environments: 这个文件夹包含根据当前环境加载的文件
  • tests: 这个文件夹包含测试应用程序后端逻辑所需的所有测试。

数据建模

现在我们已经对架构有了高层次的认识,让我们定义我们的模型并看看它们是如何交互的。这将为您提供一个高层次的数据存储方式,您将如何将数据存储在数据库中。此外,它还将反映不同实体之间的连接,您可以在 MongoDB 的情况下决定哪些文档将被嵌入,哪些将被引用。

自定义货币数据类型

在之前的支出跟踪应用程序中,我们得出结论,在 JavaScript 和 MongoDB 中处理货币数据是有方法的。只需要额外的应用程序逻辑来处理精确精度解决方案。

由于我们使用 Mongoose 作为我们的 MongoDB ODM,我们可以为货币数据定义一个自定义模型。我知道这听起来很奇怪,但通过定义虚拟属性和在我们的应用程序中重用货币数据类型,这将给我们带来优势。

让我们创建一个名为 core/models/money.js 的文件,并添加以下 Mongoose 架构:

'use strict';

const DEF_CURRENCY = 'USD';
const DEF_SCALE_FACTOR = 100;

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

const MoneySchema = new Schema({
  amount:   { type: Number, default: 0 },
  currency: { type: String, default: DEF_CURRENCY },
  factor:   { type: Number, default: DEF_SCALE_FACTOR }
}, {
  _id:      false,
  toObject: { virtuals: true },
  toJSON:   { virtuals: true }
});

MoneySchema
.virtual('display')
.set(function(value) {
  if (value) {
    this.set('amount', value * this.factor);
  }
})
.get(function() {
  return this.amount / this.factor;
});

module.exports = mongoose.model('Money', MoneySchema);

为了提高可读性,我做了以下操作:

  1. 定义一个默认货币和默认缩放因子。为了实现更好的定制,你可以将这些添加到配置文件中。

  2. 添加了一个名为 display 的虚拟属性,它将是货币模型的显示值,例如,18.99。

现在,我们已经解决了这个问题,让我们看看前面的代码。我们创建了一个自定义的 Money 模型,它将作为 Money 数据类型为我们服务。正如你所看到的,我们禁用了 _id 属性的自动生成。这样,如果我们将模型用作嵌入式文档,Mongoose 不会生成 _id 属性。

让我们来看一个例子:

var price = new Money();
price.display = 18.99;
console.log(price.toObject());
// { amount: 1899, currency: 'USD', factor: 100, display: 18.99 }

当将价格转换为对象时,输出将包含所有必要的信息,我们不需要使用浮点数进行任何计算。记住,我们因为需要在整个应用程序中与货币保持一致性,所以将缩放因子和货币存储在价格模型中。

产品模型

当创建电子商务应用程序时,你必须考虑在目录中存储许多不同的产品类型。由于我们可以以任何结构表示数据,MongoDB 数据模型在这种情况下将非常有用。

在关系型数据库管理系统(RDBMS)中结构化数据会稍微困难一些;例如,一种方法是将每种产品类型表示为单独的表。每个表都会有不同的结构。另一种替代且流行的方法是 EAV,代表 实体属性值。在这种情况下,你维护一个至少包含三列的表:entity_idattribute_idvalue。EAV 解决方案非常灵活,但也有一些缺点。复杂的查询需要大量的 JOIN 操作,这可能会降低性能。

幸运的是,正如之前指出的那样,MongoDB 有一个动态架构解决方案,我们可以将所有产品数据存储在一个集合中。我们可以存储产品的通用信息和针对不同产品类型的产品特定信息。让我们开始定义我们的产品架构。创建一个名为 core/models/product.js 的文件,并添加以下代码:

'use strict';

const mongoose = require('mongoose');
const Money = require('./money').schema;
const commonHelper = require('../helpers/common');
const Schema = mongoose.Schema;
const ObjectId = Schema.ObjectId;
const Mixed = Schema.Types.Mixed;

const ProductSchema = new Schema({
  sku:          { type: String, required: true },
  category:     { type: String },
  title:        { type: String, required: true },
  summary:      { type: String },
  description:  { type: String },
  slug:         { type: String },
  images:       { type: [
    {
      caption:  { type: String },
      filename: { type: String }
    }
  ] },
  price:        { type: Money },
  details:      { type: Mixed },
  active:       { type: Boolean, default: false }
});

module.exports = mongoose.model('Product', ProductSchema);

如您所见,我们有一些字段,所有类型的商品都将拥有这些字段,并且我们有一个混合属性称为details,它将包含关于特定商品的所有必要细节。此外,我们为price属性使用了自定义数据类型。默认情况下,产品在产品目录中将被标记为非活动状态,这样它只有在所有必要信息添加后才会显示。

在本书的早期——更确切地说是在第三章中——我们使用了slug定义来为我们的职位空缺创建 URL 友好的标题。这次,我们将用它来为我们的产品标题。为了简化问题,我们将在创建新条目时自动生成它们。

在您的产品模型文件中,在module.exports行之前,添加以下代码:

ProductSchema.pre('save', function(next) {
  this.slug = commonHelper.createSlug(this.title);
  next();
});

为了唤醒您的记忆,我们在第三章中使用了相同的技巧,即职位板,从标题中创建一个缩略词。因此,这基本上是在数据库保存之前,从产品标题生成一个 URL 友好的字符串。

这基本上总结了我们的产品模式,并应该为我们存储产品在 MongoDB 中提供一个坚实的基础。

订单模型

由于我们正在尝试构建一个电子商务应用程序,我们某种程度上需要能够存储用户从我们的商店购买的商品。我们将所有这些信息存储在 MongoDB 的orders集合中。一个order条目应包含有关购买的产品、运输细节以及谁进行了购买的信息。

当您分析这个问题时,您首先想到的是,我们在下订单之前也需要有一个购物车。但如果我们把一切都简化为简单的用例,我们可以考虑购物车是一种特殊的订单。我的意思是,购物车包含将要购买的产品项,而订单将为这次购买创建。

简而言之,只有视角的改变会影响我们看待订单的方式。我们可以为订单添加一个type属性来决定其状态。因此,我们有几个关键点来定义我们的订单模式。现在我们可以创建一个名为core/models/order.js的新文件,并添加以下模式:

'use strict';

const mongoose = require('mongoose');
const Money = require('./money').schema;
const Schema = mongoose.Schema;
const ObjectId = Schema.ObjectId;
const Mixed = Schema.Types.Mixed;

const OrderSchema = new Schema({
  identifier:   { type: String },
  user:         { type: ObjectId, ref: 'User' },
  type:         { type: String, default: 'cart' },
  status:       { type: String, default 'active' },
  total:        { type: Money },
  details:      { type: Mixed },
  shipping:     { type: Mixed },
  items:        { type: [
    {
      sku:      { type: String },
      qty:      { type: Number, default: 1},
      title:    { type: String },
      price:    { type: Money },
      product:  { type: ObjectId, ref: 'Product' }
    }
  ]},
  expiresAt:    { type: Date, default: null },
  updatedAt:    { type: Date, default: Date.now },
  createdAt:    { type: Date, default: Date.now }
},  {
  toObject:     { virtuals: true },
  toJSON:       { virtuals: true }
});

module.exports = mongoose.model('Order', OrderSchema);

如您所见,订单将在items属性中存储所有选定的产品,以及一些简单信息,如产品的skuquantityprice。我们在项目列表中存储一些非平凡数据,如产品的title,这样我们就不必在非平凡操作中检索它。

当我们处理一个cart条目时,我们希望它最终过期,如果它没有被作为订单最终确定。这是因为我们希望释放购物车中的项目以供使用。

很可能,我们将存储关于订单和运输细节的额外信息,这些信息可能因订单而异。这就是为什么我们将它们标记为混合数据类型。

库存模型

到目前为止,我们已经定义了产品模式(schema)和订单模式(schema)。两者都没有提及库存状态。在 order 模式中,我们为每个产品项存储了订单中放置的数量,但这不会反映初始库存或当前库存。

在处理库存数据时,有几种方法,每种方法都有其自身的优点和缺点。例如,我们可以为每个实体产品存储单个记录;因此,如果我们有一个产品的 100 个库存单位,我们将在库存中存储 100 条记录。

在大型产品目录中,这不会是一个好的解决方案,因为 inventory 集合会迅速增长。当你有实体产品和低库存单位数量时,为每个单位存储单独条目可能是有益的。例如,一个木工店制作家具并希望跟踪每个物理单位的更多详细信息。

另一个选择是为每个产品存储单个条目,其中包含产品的库存数量。现在我们已经很好地了解了需要做什么,让我们创建一个名为 core/models/inventory.js 的库存模型,代码如下:

'use strict';

const mongoose = require('mongoose');
const Schema = mongoose.Schema;
const ObjectId = Schema.ObjectId;
const Mixed = Schema.Types.Mixed;

const InventorySchema = new Schema({
  sku:            { type: String },
  status:         { type: String, default: 'available' },
  qty:            { type: Number, default: 0 },
  carted:         { type: [
    { type: {
        sku:      { type: String },
        qty:      { type: Number, default: 1 },
        order:    { type: ObjectId, ref: 'Order' },
        product:  { type: ObjectId, ref: 'Product' }
      }
    }
  ]},
  createdAt:      { type: Date, default: Date.now }
});

module.exports = mongoose.model('Inventory', InventorySchema);

我们将事情推进了一步,并添加了一个 carted 属性。这将保存购物车中所有活跃的项目,帮助我们跟踪库存中每个预留项目的进度。

这样,你可以有一个干净的库存水平历史记录。你可以省略 carted 属性,只依赖 orders 集合中的信息。

核心服务层

因为我们的应用程序将有不同的客户端消费业务逻辑,我们将添加一个服务层;它将协调不同用例的操作。因此,我们将把大部分业务逻辑从控制器移动到服务中。可能现在还太早看到这种做法的好处,但随着我们继续本章的学习,它将变得更加有意义。

一个好处是,你可以简单地暴露你的服务层作为 RESTful API,或者添加另一个将在服务器端渲染模板并显示所有必要信息的客户端。无论应用程序的客户端实现如何,你都可以测试应用程序的业务逻辑。

产品目录

产品目录将包含你希望显示或仅存在于系统中的所有产品。目录中的每个项目都将存储在 MongoDB 的 products 集合中。我们将创建一个 ProductCatalog 服务,它将包含我们电子商务应用程序中管理产品的所有业务逻辑。

让我们按照以下步骤创建产品目录服务:

  1. 创建名为 core/services/product-catalog.js 的服务文件。

  2. 添加以下代码:

    'use strict';
    
    const MAX_PRODUCT_SHOWN = 50;
    const _ = require('lodash');
    const Product = require('../models/product');
    
    class ProductCatalog {
      constructor() {
      }
    }
    
    module.exports = ProductCatalog;
    
  3. 声明类构造函数:

      constructor(opts, ProductModel) {
        opts = opts || {};
        this.maxProductsShown = opts.maxProductsShown || MAX_PRODUCT_SHOWN;
        this.Product = ProductModel || Product;
      }
    
  4. 将产品添加到目录中:

      add(data, callback) {
        this.Product.create(data, callback);
      }
    
  5. 我们将逐个添加类方法。

  6. 编辑现有产品:

      edit(sku, data, callback) {
        //  remove sku; this should not change,
        //  add a new product if it needs to change
        delete data.sku;
    
        this.Product.findBySKU(sku, (err, product) => {
          if (err) {
            return callback(err);
          }
    
          _.assign(product, data);
          //  tell mongoose to increment the doc version `__v`
          product.increment();
          product.save(callback);
        });
      }
    
  7. 列出所有产品:

      list(query, limit, skip, callback) {
        if (typeof query === 'funciton') {
          callback = limit;
          limit = this.maxProductsShown;
          skip = 0;
        }
    
        // make sure we only allow retriving `50` products from the catalog
        if (+limit > this.maxProductsShown) {
          limit = this.maxProductsShown;
        }
    
        this.Product.find(query).limit(limit).skip(skip).exec(callback);
      }
    
  8. 使用 sku 标识符获取更多详细信息:

      details(sku, callback) {
        this.Product.findBySKU(sku, callback);
      }
    
  9. 通过 slug 获取产品:

      detailsBySlug(slug, callback) {
        this.Product.findBySlug(slug, callback);
      }
    Remove a product:
      remove(sku, callback) {
        this.Product.findBySKU(sku, (err, product) => {
          if (err) {
            return callback(err);
          }
    
          product.remove(callback);
        });
      }
    

我们成功地为我们产品目录服务奠定了基础。正如你所看到的,它只掩盖了最终模块中的一些功能,这些模块不应该知道底层或数据是如何存储的。它可以是数据库,比如我们案例中的 MongoDB,或者简单地是一个文件系统。

我们获得的第一大好处是可测试性,因为我们可以在实现高级层之前测试应用程序的业务逻辑并运行集成测试。例如,我们可以有如下代码片段,它来自tests/integration/product-catalog.test.js

const ProductCatalog = require('../../core/services/product-catalog');

// … rest of the required modules

describe('Product catalog', () => {
  let mongoose;
  let Product;
  let productCatalog;
  let productData = { ... };  //  will hold the product related data

  before(done => {
    mongoose = require('../../config/mongoose').init();
    productCatalog = new ProductCatalog();
    // … more code
    done();
  });

  it('should add a new product to the catalog', done => {
    productCatalog.add(productData, (err, product) => {
      if (err) { throw err; }

      should.exist(prod);
      prod.title.should.equal('M.E.A.N. Blueprints');
      done();
    });
  });  
});

前面的测试用例将简单地检查服务执行的所有操作是否正确。我们在前面的章节中进行了大量的测试驱动开发,在后面的章节中,我们更多地关注功能,但这并不意味着我们忽略了编写测试。测试可以在完整的源代码中找到,供你在开发应用程序时检查和遵循。

库存管理器

野外有许多电子商务解决方案都包含库存管理器,这将帮助你跟踪产品的库存水平,补充你的产品库存,或者按需调整。

我们不想在产品文档中嵌入库存信息,所以我们将为每个产品单独存储它。你可以用很多方法跟踪你的库存;我们选择了一个适合大多数用例的解决方案,并且易于实现。

在我们开始编码之前,我想通过测试用例给你一些提示,关于我们将要实现的内容:

  1. 我们应该能够跟踪产品的库存:

      it('should create an inventory item for a product', done => {
        inventoryManager.create({
          sku: 'MEANB',
          qty: 1
        }, (err, inventoryItem) => {
          if (err) throw err;
    
          should.exist(inventoryItem);
          inventoryItem.sku.should.equal('MEANB');
          inventoryItem.qty.should.equal(1);
          done();
        });
      });
    
  2. 根据需求,应从库存中预留给定产品的期望数量:

      it('should reserve an item if there is enough on stock', done => {
        inventoryManager.reserve('MEANB', new mongoose.Types.ObjectId(), 2, (err, result) => {
          if (err) throw err;
    
          should.exist(result);
          result.sku.should.equal('MEANB');
          done();
        });
      });
    
  3. 如果库存不足,服务不应满足请求:

      it('should not reserve an item if there is not enough on stock', done => {
        inventoryManager.reserve('MEANB', new mongoose.Types.ObjectId(), 2, (err, result) => {
          should.not.exist(result);
          should.exist(err);
          err.message.should.equal('Stock lever is lower then the desired quantity.');
          err.status.should.equal(409);
          err.type.should.equal('not_enough_stock_units')
          done();
        });
      });
    
  4. 增加可用数量:

      it('should increase the quantity for an inventory unit', done => {
        inventoryManager.increase('MEANB', 5, (err, inventory) => {
          if (err) throw err;
    
          inventory.qty.should.equal(6);
          done();
        });
      });
    
  5. 或者,你可以减少可用数量以进行调整:

      it('should decrease the quantity for an inventory unit', done => {
        inventoryManager.decrease('MEANB', 2, (err, inventory) => {
          if (err) throw err;
    
          inventory.qty.should.equal(4);
          done();
        });
      });
    

现在我们已经大致了解了需要做什么,让我们遵循以下步骤来创建我们的库存管理器服务:

  1. 创建一个新的文件,core/services/inventory-manager.js

  2. 定义一个起点:

    'use strict';
    
    const Inventory = require('../models/inventory');
    
    class InventoryManager {
      constructor() {}
    }
    
    module.exports = InventoryManager;
    
  3. 完成类构造函数:

      constructor(opts, InventoryModel) {
        this.opts = opts || {};
        this.Inventory = InventoryModel || Inventory;
      }
    

    记住,只要它具有至少必要的属性和方法,我们就可以在我们的服务中注入自定义的InventoryModel

  4. 创建一个新的库存项目方法:

      create(data, callback) {
        data.carted = [];
        this.Inventory.create(data, callback);
      }
    
  5. 修改数量私有方法:

      _modifyQuantity(sku, qty, reduce, callback) {
        qty = (reduce) ? qty * -1 : qty;
    
        this.Inventory.update({
          sku: sku
        }, {
          $inc: { qty: qty }
        }, (err, result) => {
          if (err) {
            return callback(err);
          }
    
          if (result.nModified === 0) {
            let err = new Error('Nothing modified.');
            err.type = 'nothing_modified';
            err.status = 400;
            return callback(err);
          }
    
          this.Inventory.findOne({ sku: sku }, callback);
        });
      }
    

    我们创建了一个私有方法,以下划线为前缀以增强语义。当操作库存水平时,这将成为主要入口点。如果没有变化,我们返回一个错误。在操作成功后,我们返回库存条目的当前状态。

  6. 增加和减少数量:

      increase(sku, quantity, callback) {
        this._modifyQuantity(sku, quantity, false, callback);
      }
    
      decrease(sku, quantity, callback) {
        this._modifyQuantity(sku, quantity, true, callback);
      }
    Reserve the quantity in the inventory:
      reserve(sku, orderId, quantity, callback) {
        let query = {
          sku: sku,
          qty: { $gte: quantity }
        };
    
        let update = {
          $inc: { qty: -quantity },
          $push: {
            carted: {
              qty: quantity,
              order: orderId
            }
          }
        };
    
        this.Inventory.update(query, update, (err, result) => {
          if (err) {
            return callback(err);
          }
    
          if (result.nModified === 0) {
            let err = new Error('Stock lever is lower then the desired quantity.');
            err.type = 'not_enough_stock_units';
            err.status = 409;
            return callback(err);
          }
    
          callback(null, {
            sku: sku,
            order: orderId,
            qty: quantity
          });
        });
      }
    

上述代码将预留库存中产品的可用数量。在某些情况下,系统无法满足请求的数量,所以我们检查在减少数量之前是否有所需的可用性。如果我们无法满足请求,我们返回一个特定的错误。

你可能还会注意到,我们逐渐添加了自己的自定义 Error 对象,它还包含了对状态码本身的建议。目前,由于底层 ODM 可能返回不同的 Error 对象,服务返回的错误没有标准格式。

在这本书中,我们可能无法满足所有用例,所以有时你必须将各个部分组合起来。

购物车

到目前为止,我们应该已经拥有了购物车服务所需的所有必要服务。现在,如果你允许我说的话,这个服务将会非常有趣。通常,电子商务解决方案都有一个购物车,客户可以轻松地添加或移除商品,更改数量,甚至放弃购物。

有一个重要的事情需要注意,那就是我们必须确保客户不能添加不可用的商品。换句话说,如果产品库存与请求的数量不匹配,添加操作不应成功。

基本上,我们的购物车服务将处理之前描述的所有业务逻辑。此外,当客户向购物车添加商品时,库存应该得到适当的更新。记住,我们的订单集合也会保存购物车。

关于需要做什么事情已经很明确了。如果不清楚,可以去快速查看一下测试用例。让我们创建我们的购物车服务,core/services/shopping-cart.js,并添加以下类:

'use strict';

const EXPIRATION_TIME = 15*60; // 15 minutes
const commonHelper = require('../helpers/common');
const Order = require('../models/order');
const InventoryManager = require('./inventory-manager');
const ProductCatalog = require('./product-catalog');

class ShoppingCart {
}

module.exports = ShoppingCart;

这里没有太多花哨的地方。我们可以通过添加构造函数来继续:

  constructor(opts, OrderModel, ProductService, InventoryService) {
    InventoryService = InventoryService || InventoryManager;
    ProductService = ProductService || ProductCatalog;
    this.opts = opts || {};
    this.opts.expirationTime = this.opts.expirationTime || EXPIRATION_TIME;
    this.Order = OrderModel || Order;
    this.inventoryManager = new InventoryService();
    this.productCatalog = new ProductService();
  }

在我忘记之前,我们将使用之前实现的其他两个服务来管理库存并从我们的目录中检索产品。此外,在将新商品添加到购物车之前,我们需要创建它。所以让我们添加 createCart() 方法:

  createCart(userId, data, callback) {
    data.user = userId;
    data.expiresAt = commonHelper.generateExpirationTime(this.opts.expirationTime);
    this.Order.create(data, callback);
  }

当将新产品添加到购物车时,我们必须注意一些事情,并且必须验证库存是否符合请求的要求。让我们绘制购物车服务的 addProduct() 方法:

  addProduct(cartId, sku, qty, callback) {
    this.productCatalog.findBySKU(sku, (err, product) => {
      if (err) {
        return callback(err);
      }

      let prod = {
        sku: product.sku,
        qty: qty
        title: product.title,
        price: product.price,
        product: product._id
      };

      //  push carted items into the order
      this._pushItems(cartId, prod, (err, result) => {
        if (err) {
          return callback(err);
        }

        //  reserve inventory
        this.inventoryManager.reserve(product.sku, cartId, qty, (err, result) => {
          //  roll back our cart updates
          if (err && err.type === 'not_enough_stock_units') {
            return this._pullItems(cartId, sku, () => callback(err));
          }

          // retrive current cart state
          this.findById(cartId, callback);
        });
      });
    });
  }

当将产品添加到购物车时,我们希望存储一些额外的信息,因此我们首先需要使用 SKU 从目录中检索产品。需要将所需数量的产品添加到购物车的商品中。在成功填充购物车后,我们需要减少库存中可用的单位数量。

如果库存中的商品不足,我们必须回滚购物车更新并在应用程序中引发错误。最后,我们得到一个持久化的购物车副本。

除了从其他两个服务中使用的其他方法外,我们还需要为 ShoppingCart 类实现一些方法,例如 _pushItems() 方法:

  _pushItems(cartId, prod, callback) {
    let exdate = commonHelper.generateExpirationTime(this.opts.expirationTime);
    let now = new Date();
    //  make sure the cart is still active and add items
    this.Order.update({
      { _id: cartId, status: 'active' },
      {
        $set: { expiresAt: exdate, updatedAt: now },
        $push: { items: prod }
      }
    }, (err, result) => {
      if (err) {
        return callback(err);
      }

      if (result.nModified === 0) {
        let err = new Error('Cart expired.');
        err.type = 'cart_expired';
        err.status = 400;
        return callback(err);
      }

      //  TODO: proper response
      callback(null, result);
    });
  }

购物车必须处于活动状态才能向其中添加商品。此外,我们还需要更新过期日期。记住,我们正在对文档执行原子操作,因此只返回操作的原始响应。

如果我们要回滚购物车,我们需要移除已添加的商品;_pullItems() 方法正是如此操作:

  _pullItems(cartId, sku, callback) {
    this.Order.update({
      { _id: cartId },
      { $pull: { items: { sku: sku } } }
    }, (err, result) => {
      if (err) {
        return callback(err);
      }

      if (result.nModified === 0) {
        let err = new Error('Nothing modified.');
        err.type = 'nothing_modified';
        err.status = 400;
        return callback(err);
      }

      //  TODO: proper response
      callback(null, result);
    });
  }

到目前为止,我们应该能够使用实现的功能轻松管理我们的购物车。ShoppingCart服务使用了InventoryManagerProductCatalog服务,暴露了处理购物车操作所需的精确业务逻辑。

认证微应用

Auth微应用将在不同场景下处理认证。它将成为我们认证用户的入口点,使用有状态和无状态的方法。

我们的核心模块已经暴露了中间件来检查用户是否已认证,以及与授权相关的中间件。此功能可以在任何模块或微应用中使用。

定义类

这将是我们的第一个微应用,所以让我们一步一步来:

  1. 创建一个名为apps/auth/index.js的新微应用。

  2. 添加以下基本内容:

    'use strict'
    
    const express = require('express');
    const router = express.Router();
    const Controller = require('./controller');
    
    class Auth {
    }
    
  3. 定义构造函数:

      constructor(config, core, app) {
        this.core = core;
        this.controller = new Controller(core);
        this.app = app;
        this.router = router;
        this.rootUrl = '/auth';
        this.regiterRoutes();
        this.app.use(this.rootUrl, this.router);
      }
    

    我们为我们的微应用定义了一个基本 URL,并在主 Express 应用上挂载了路由器。我们还创建了一个用于Auth微应用的 Controller 的新实例。

  4. 注册所有必要的路由:

      regiterRoutes() {
        this.router.post('/register', this.controller.register);
    
        /**
         *  Stateful authentication
         */
        this.router.post('/signin', this.controller.signin);
        this.router.get('/signout', this.controller.signout);
    
        /**
         *  Stateless authentication
         */
        this.router.post('/basic', this.controller.basic);
      }
    

    为了节省开发时间,我们从前几章借用了代码,所以前面的代码可能已经熟悉了。

  5. 在主server.js文件中初始化您的微应用:

    const Auth = require('./apps/auth');
    let auth = new Auth(config, core, app);
    

在主server.js文件中,我们将初始化每个应用。您可以查看server.js文件的最终版本,以确切了解放置内容的位置。

控制器

之前,我提到我们正在重用前几章中的代码。我们也为 Controller 做了这件事。我们将 Controller 转换成了一个名为AuthController的类,并暴露了以下方法:

  1. 使用有状态认证策略登录用户:

      signin(req, res, next) {
        passport.authenticate('local', (err, user, info) => {
          if (err || !user) {
            return res.status(400).json(info);
          }
    
          req.logIn(user, function(err) {
            if (err) {
              return next(err);
            }
    
            res.status(200).json(user);
          });
        })(req, res, next);
      }
    
  2. 使用无状态策略进行认证:

      basic(req, res, next) {
        passport.authenticate('basic', (err, user, info) => {
          if (err) {
            return next(err);
          }
    
          if (!user) {
            return res.status(400).json({ message: 'Invalid email or password.' });
          }
    
          Token.generate({ user: user.id }, (err, token) => {
            if (err) {
              return next(err);
            }
    
            if (!token) {
              return res.status(400).json({ message: 'Invalid email or password.' });
            }
    
            const result = user.toJSON();
            result.token = _.pick(token, ['hash', 'expiresAt']);
    
            res.json(result);
          });
    
        })(req, res, next);
      }
    

    在某些情况下,我们不需要持久化用户的会话。相反,我们创建一个将在每个请求中使用的令牌,以查看谁试图访问我们的端点。

  3. 在我们的系统中注册一个用户:

      register(req, res, next) {
        const userData = _.pick(req.body, 'name', 'email', 'password');
    
        User.register(userData, (err, user) => {
          if (err && (11000 === err.code || 11001 === err.code)) {
            return res.status(400).json({ message: 'E-mail is already in use.' });
          }
    
          if (err) {
            return next(err);
          }
    
          // just in case :)
          delete user.password;
          delete user.passwordSalt;
    
          res.json(user);
        });
      }
    

暴露 API

我们的核心业务逻辑需要以某种方式访问,我认为 RESTful API 会为我们提供很好的服务。为了更好地理解并遍历整个应用,我们只展示 API 的几个部分。

我们更关注整个应用从架构的角度,而不是拥有详细和完全集成的功能。

Api 类

对于这个微应用,我们将按类型上下文分组我们的文件。首先,我们将创建我们的微应用类,apps/api/index.js,并添加以下内容:

'use strict';

const ProductsRoutes = require('./routes/products');
const ProductController = require('./controllers/product');

class Api {
  constructor(config, core, app) {
    let productController = new ProductController(core);
    let productRoutes = new ProductsRoutes(core, productController);

    this.config = config;
    this.core = core;
    this.app = app;
    this.root = app.get('root');
    this.rootUrl = '/api';

    this.app.get('/api/status', (req, res, next) => {
      res.json({ message: 'API is running.' });
    });

    this.app.use(this.rootUrl, productRoutes.router);
  }
}

module.exports = Api;

此应用的这部分将ProductRoutes暴露的路由挂载到主 Express 应用上。前面的ProductRoutes类需要一个ProductController作为必需参数。

现在我们不会特别讨论每个 Controller 和路由,我们只关注产品部分。我们将使用ProductCatalog核心服务并调用所需业务逻辑。

产品控制器

这个控制器将处理管理产品的请求。我们将按照以下步骤来实现它:

  1. 创建一个名为 apps/api/controller/product.js 的新文件。

  2. 定义控制器:

    'use strict';
    
    const _ = require('lodash');
    
    let productCatalog;
    
    class ProductsController {
      constructor(core) {
        this.core = core;
        productCatalog = new core.services.ProductCatalog();
      }
    Add the create product method:
      create(req, res, next) {
        productCatalog.add(req.body, (err, product) => {
          if (err && err.name === 'ValidationError') {
            return res.status(400).json(err);
          }
    
          if (err) {
            return next(err);
          }
    
          res.status(201).json(product);
        });
      }
    
  3. 添加 getAll 产品方法:

      getAll(req, res, next) {
        const limit = +req.query.limit || 10;
        const skip = +req.query.skip || 0;
        const query = {} // you cloud filter products
    
        productCatalog.list(query, limit, skip, (err, products) => {
          if (err) {
            return next(err);
          }
    
          res.json(products);
        });
      }
    Implement a method that retrieves a single product:
      getOne(req, res, next) {
        productCatalog.details(req.params.sku, (err, product) => {
          if (err) {
            return next(err);
          }
    
          res.json(product);
        });
      }
    

产品路由

定义路由与我们在 Auth 微应用中之前所做的方式类似,但我们把路由移动到了一个单独的文件中,称为 apps/api/routes/products.js。文件的内容相当简单:

'use strict';

const express = require('express');
const router = express.Router();

class ProductsRoutes {
  constructor(core, controller) {
    this.core = core;
    this.controller = controller;
    this.router = router;
    this.authBearer = this.core.authentication.bearer;
    this.regiterRoutes();
  }

  regiterRoutes() {
    this.router.post(
      '/products',
      this.authBearer(),
      this.controller.create
    );

    this.router.get(
      '/products',
      this.authBearer(),
      this.controller.getAll
    );

    this.router.get(
      '/products/:sku',
      this.authBearer(),
      this.controller.getOne
    );
  }
}

module.exports = ProductsRoutes;

如您所见,我们从核心模块使用了 bearer 认证中间件来检查用户是否拥有有效的令牌。这个函数具有以下结构:

function bearerAuthentication(req, res, next) {
  return passport.authenticate('bearer', { session: false });
}

我认为我们已经了解了我们的 Api 微应用是如何工作的以及需要做什么。你可以通过项目的代码仓库来查看其余的代码。

共享资源

许多微应用将使用相同的静态资源,以避免在应用程序之间重复这些资源。我们可以创建一个微应用来提供所有共享资源。

而不是有一个主要的 public 文件夹,每个想要提供静态文件的微应用都可以有一个单独的 public 文件夹。这意味着我们可以将所有共享的静态资源移动到内部的 public 文件夹中。

我们将拥有以下文件夹结构:

apps/
-- shared/
---- public
------ assets/
---- index.js

index.js 文件将包含以下内容:

'use strict';

const path = require('path');
const serveStatic = require('serve-static');

class Shared {
  constructor(config, core, app) {
    this.app = app;
    this.root = app.get('root');
    this.rootUrl = '/';
    this.serverStaticFiles();
  }

  serverStaticFiles() {
    let folderPath = path.resolve(this.root, __dirname, './public');
    this.app.use(this.rootUrl, serveStatic(folderPath));
  }
}

module.exports = Shared;

我们定义一个类,并从 public 文件夹提供所有静态资源。我们使用了 path 模块中的 resolve 方法来解析到 public 文件夹的路径。

如您所见,修改我们之前章节中的架构相当简单。此外,前面的技术也将用于我们的 admin 微应用。

管理部分

通常,电子商务解决方案都包含一个管理部分,您可以在其中管理您的产品和库存。我们的应用的管理部分将使用 Angular 2 构建。没有什么特别的;我们不是已经用 Angular 构建了一些应用吗?

我们不会详细介绍所有细节,只介绍应用中最重要的一部分。不用担心!项目的完整源代码都是可用的。

管理微应用

我们从一开始就做了一些架构上的改变。我们的每个微应用都将服务于特定的目的。admin 微应用将托管使用 Angular 2 构建的管理应用。

在前面的章节中,我们使用了 server-static 来公开 public 文件夹的内容。这个应用将拥有自己的 public 文件夹,并且只包含与我们的 Angular 管理应用相关的文件。

这个微应用将会相当简单。创建一个名为 apps/admin/index.js 的文件,内容如下:

'use strict';

const path = require('path');
const serveStatic = require('serve-static');

class Admin {
  constructor(config, core, app) {
    this.app = app;
    this.root = app.get('root');
    this.rootUrl = '/admin';
    this.serverStaticFiles();
  }

  serverStaticFiles() {
    let folderPath = path.resolve(this.root, __dirname, './public');
    this.app.use(this.rootUrl, serveStatic(folderPath));
  }
}

module.exports = Admin;

Admin 类将定义我们的微应用,并使用 serverStaticFiles() 方法公开公共文件夹的内容以供外部使用。文件服务在 /admin URL 路径上挂载。

不要忘记查看主要的 server.js 文件以正确初始化您的 admin 微应用。

修改认证模块

admin应用使用令牌来授权访问 API 的端点。因此,我们需要对我们的AuthHttp服务进行一些更改,从apps/admin/public/src/auth/auth-http.ts开始。

这些更改会影响request方法,其外观将如下所示:

  private request(requestArgs: RequestOptionsArgs, additionalArgs?: RequestOptionsArgs) {
    let opts = new RequestOptions(requestArgs);

    if (additionalArgs) {
      opts = opts.merge(additionalArgs);
    }

    let req:Request = new Request(opts);

    if (!req.headers) {
      req.headers = new Headers();
    }

    if (!req.headers.has('Authorization')) {
      req.headers.append('Authorization', `Bearer ${this.getToken()}`);
    }

    return this._http.request(req).catch((err: any) => {
      if (err.status === 401) {
        this.unauthorized.next(err);
      }

      return Observable.throw(err);
    });
  }

对于每个请求,我们添加必要的令牌的Authorization头。此外,我们还需要使用以下方法从localStorage检索令牌:

  private getToken() {
    return localStorage.getItem('token');
  }

令牌将在成功登录后持久化到localStorage。在AuthService中,我们将存储当前用户及其令牌并将其持久化到localStorage

  public setCurrentUser(user: any) {
    this.currentUser.next(user);
  }

  private _initSession() {
    let user = this._deserialize(localStorage.getItem('currentUser'));
    this.currentUser = new BehaviorSubject<Response>(user);
    // persist the user to the local storage
    this.currentUser.subscribe((user) => {
      localStorage.setItem('currentUser', this._serialize(user));
      localStorage.setItem('token', user.token.hash || '');
    });
  }

当用户成功登录时,我们将当前用户存储在主题中,并通知所有订阅该更改的订阅者。

记住,我们可以通过使用位于边界上下文根目录的单个index.ts文件来简单地公开上下文的所有成员。对于auth模块,我们可以有如下结构:

auth/
-- components/
-- services/
-- index.ts

例如,我们的AuthHttp服务可以通过以下方式在index.ts中导出:

export * from './services/auth-http';

我们可以使用此行将其导入到另一个组件中:

import { AuthHttp } from './auth/index';

而不是以下方法:

import { AuthHttp } from './auth/services/auth-http';

产品管理

在后端部分,我们创建了一个服务并公开了一个 API 来管理产品。现在在客户端,我们需要创建一个模块来消费该 API 并允许我们执行不同的操作。

产品服务

我们将仅讨论产品服务中的几个方法,因为我们基本上将在管理部分执行简单的 CRUD 操作。让我们创建一个名为apps/admin/public/src/services/product.service.ts的文件,并包含以下基本内容:

import { Injectable } from 'angular2/core';
import { Http, Response, Headers } from 'angular2/http';
import { Observable } from 'rxjs/Observable';
import { ProductService } from './product.service';
import { contentHeaders } from '../common/headers';
import { Product } from './product.model';

type ObservableProducts = Observable<Array<Product>>;

@Injectable()
export class ProductService {
  public products: ObservableProducts;

  private _authHttp: AuthHttp;
  private _productsObservers: any;
  private _dataStore: { products: Array<Product> };

  constructor(authHttp: Http) {
    this._authHttp = authHttp;
    this.products = new Observable(observer => this._productsObservers = observer).share();
    this._dataStore = { products: [] };
  }
}

接下来,我们将添加getAll产品方法。当我们想要显示产品列表时,我们将使用此方法。将以下代码添加到ProductService中:

  getAll() {
    this._authHttp
    .get('/api/products', { headers: contentHeaders })
    .map((res: Response) => res.json())
    .subscribe(products => {
      this._dataStore.products = products;
      this._productsObservers.next(this._dataStore.products);
    });
  }

其余的方法都在项目的完整源代码中。

列出产品

在主要产品管理部分,我们将列出目录中所有可用的产品。为此,我们在apps/admin/public/product/components/product-list.component.ts下创建了一个另一个组件:

import { Component, OnInit } from 'angular2/core';
import { ProductService } from '../product.service';
import { Router, RouterLink } from 'angular2/router';
import { Product } from '../product.model';

@Component({
    selector: 'product-list',
    directives: [RouterLink],
    template: `
      <div class="product-list row">
        <h2 class="col">Products list</h2>
        <div *ngIf="products.length === 0" class="empty-product-list col">
          <h3>Add your first product to you catalog</h3>
        </div>
        <div class="col col-25">
          <a href="#" [routerLink]="['ProductCreate']" class="add-product-sign">+</a>
        </div>
        <div *ngFor="#product of products" class="col col-25">
          <img src="img/208x140?text=product+image&txtsize=18" />
          <h3>
            <a href="#"
              [routerLink]="['ProductEdit', { sku: product.sku }]">
              {{ product.title }}
            </a>
            </h3>
        </div>
      </div>
    `
})
export class ProductListComponent implements OnInit {
  public products: Array<Product> = [];
  private _productService: ProductService;

  constructor(productService: ProductService) {
    this._productService = productService;
  }

  ngOnInit() {
    this._productService.products.subscribe((products) => {
      this.products = products
    });
    this._productService.getAll();
  }
}

上述代码将仅列出从服务检索的所有产品,并具有编辑特定产品的路由链接。您可以轻松列出产品的额外详细信息;您只需修改模板即可。

主要产品组件

为了管理我们的路由,我们必须创建一个主入口点并创建一个组件来处理。为了有一个完整的画面,我将向您展示ProductComponent的最终版本,该版本位于apps/admin/public/src/product/product.component.ts下:

import { Component } from 'angular2/core';
import { RouteConfig, RouterOutlet } from 'angular2/router';
import { ProductListComponent } from './product-list.component';
import { ProductEditComponent } from './product-edit.component';
import { ProductCreateComponent } from './product-create.component';

@RouteConfig([
  { path: '/', as: 'ProductList', component: ProductListComponent, useAsDefault: true },
  { path: '/:sku', as: 'ProductEdit', component: ProductEditComponent },
  { path: '/create', as: 'ProductCreate', component: ProductCreateComponent }
])
@Component({
    selector: 'product-component',
    directives: [
      ProductListComponent,
      RouterOutlet
    ],
    template: `
      <div class="col">
        <router-outlet></router-outlet>
      </div>
    `
})
export class ProductComponent {
  constructor() {}
}

我们使用此组件来配置产品列表、创建新产品以及通过特定 SKU 编辑现有产品的路由。这样,我们可以轻松地将它挂载在更高层次组件上。

添加和编辑产品

基本上,我们所做的是使用了相同的模板来编辑和添加产品。在这个应用程序中,当查看产品详情时,您实际上正在编辑产品。这样,我们就不必从详细视图单独实现或隐藏编辑功能。

由于应用程序处于早期阶段,创建新产品和更新现有产品之间没有区别,我们可以减少工作量,同时实现这两者。

编辑产品源代码可以在 apps/admin/public/src/product/components/product-edit.component.ts 中找到。

订单处理

系统应处理订单,这意味着有人需要处理订单的状态。通常,订单可以有几种状态。我将在下表中尝试解释其中的一些:

名称 状态 描述
待处理 pending 订单已接收(通常是未付款)。
失败 failed 发生了错误;即支付失败或被拒绝。
处理中 processing 订单正在等待履行。
完成 completed 订单已履行并完成。通常,不需要进一步的操作。
挂起 on_hold 股票数量已减少,但等待进一步的确认,即支付。
取消 cancelled 订单被客户或管理员取消。

我们不会处理我们刚才描述的所有场景。应用程序的完整版本只支持其中的一些:待处理、处理中、已取消和已完成。由于我们不会实现支付方法,因此没有必要处理所有场景。

在这么多代码之后,我认为我们可以休息一下,只讨论这部分。您可以从 GitHub 仓库中查看工作版本。

检索订单

为了管理所有 incoming 订单,我们需要将它们列出来给管理员。我们不会深入到代码的细节,因为它与我们迄今为止所做的是非常相似的。

public/src/order/order.service.ts 中找到的服务将处理订单实体的所有操作。在这个应用程序中可以添加的一个不错的功能是从后端获取订单流。这与我们在第四章,聊天应用中工作时所做的类似,当时我们使用了 WebSockets。

换句话说,我们可以在新订单被添加到系统中时立即通知所有客户。当您有大量订单涌入并希望尽快收到通知以便尽快处理时,这将提供很大的帮助。

查看和更新订单

通常,在处理订单之前,您可能想查看更多关于它的信息,例如送货地址,或客户提供给您的任何其他信息。但与此同时,处理订单所需采取的操作应保持在最低限度。

考虑到所有这些,我们选择了这样一个解决方案:可以在同一上下文中查看和编辑订单。因此,OrderDetailsComponent 正好做到了这一点;它可以在以下位置找到:public/src/order/components/order-details.ts

完整的源代码可以在仓库中找到,但我会尽量解释我们在那里做了什么。

构建 Storefront

正如我们在本章开头所讨论的,我们将尝试一些不同的事情。我们不会为 Storefront 构建单页应用程序,而是将实现服务器端渲染的页面。

技术上,我们将构建一个经典的网页。页面将是动态的,使用视图引擎来渲染我们的模板。

我们希望真正利用我们无头核心应用程序的优势,并看看我们如何将其与不同的客户端应用程序集成,因此我们将使用第三方包进行一些服务器端渲染页面的实验。

我们可以很容易地使用 Angular 来构建它,但我想要添加一些变化,看看更复杂的解决方案是如何实施的。

Storefront 微应用

正如我们在应用程序的管理部分之前所看到的,我们将它从主应用程序解耦为一个微应用。因此,从技术上讲,我们可以在任何时间从这个应用程序中提取出 Storefront 所需的代码,将其添加到一个全新的 Express 应用程序中,并在网络上进行所有调用。

起初,这可能会显得有些奇怪,但一旦你的应用程序开始增长并且你需要扩展应用程序,这将为你提供优势,以便区分哪些部分需要扩展或移动到单独的应用程序以实现更好的可扩展性。

事先考虑总是一件好事,但我也不是特别喜欢过早的优化。你无法从一开始就确定你的应用程序在将来会如何增长,但提前规划是明智的。

Storefront 应用程序将展示我们如何在同一应用程序中集成不同的技术。重点是纯粹的教育性,这被添加到书中,以展示构建 Express 应用程序的不同方法。

让我们谈谈构建我们的 Storefront 所使用的科技。我们将使用 nunjucks,这是一个为 JavaScript 设计的优秀的模板引擎。它可以在服务器端和客户端同时使用。

在我们到达模板部分之前,我们需要做一些准备工作:

  1. apps/storefront 下创建一个新的 apps 文件夹。

  2. 添加一个新的文件,apps/storefront/index.js

  3. 定义微应用的类:

    'use strict';
    
    const express = require('express');
    const nunjucks = require('nunjucks');
    const router = express.Router();
    const ProductController = require('./controllers/products');
    
    class Storefront {
      constructor(config, core, app) {
        this.config = config;
        this.core = core;
        this.app = app;
        this.router = router;
        this.rootUrl = '/';
        this.productCtrl = new ProductController(core);
        this.configureViews();
        this.regiterRoutes();
        this.app.use(this.rootUrl, this.router);
      }
    }
    
  4. 配置视图引擎:

      configureViews() {
        let opts = {};
    
        if (!this.config.nunjucks.cache) {
          opts.noCache = true;
        }
    
        if (this.config.nunjucks.watch) {
          opts.watch = true;
        }
    
        let loader = new nunjucks.FileSystemLoader('apps/frontstore/views', opts);
    
        this.nunjucksEnv = new nunjucks.Environment(loader);
        this.nunjucksEnv.express(this.app);
      }
    
  5. 注册路由:

      registerRoutes() {
        this.router.get('/', this.productCtrl.home);
      }
    

对于这个微应用,我们开始使用视图引擎在服务器端渲染我们的模板。configureViews() 方法将初始化 nunjucks 环境,并从文件系统中加载模板文件。我们还检查是否应该从 nunjucks 激活缓存和监视功能。你可以在项目的文档中了解更多信息。

最后,我们将应用程序的路由注册为我们在之前一起构建的每个 Express 应用程序所做的那样。为了便于阅读,我只添加了主页位置,并且只实例化了ProductController

如果你想知道ProductController是什么,我们只是使用类方法为我们的控制器文件,这样我们就可以实例化它,并传递应用程序的核心。让我们看看apps/storefront/controllers/product.js中的一个代码段:

'use strict';

let productCatalog;

class ProductsController {
  constructor(core) {
    this.core = core;
    productCatalog = new core.services.ProductCatalog();
  }

  home(req, res, next) {
    productCatalog.list({}, 10, 0, (err, products) => {
      if (err) {
        next(err);
      }

      res.render('home', { products: products });
    });
  }
}

module.exports = ProductsController;

所以基本上,我们导出了一个控制器类,在home()方法中,我们使用我们的ProductCatalog服务从持久存储(在我们的情况下,是 MongoDB)检索产品。在成功获取所有产品后,我们使用响应对象的render()方法从我们的模板中渲染一个 HTML 响应。

店面页面

我们不会深入细节;你可以查看整个项目,看看事物是如何粘合在一起的。

主要布局

为了有一个单一的布局定义,几乎每个模板都会扩展一个主模板文件。这个主模板文件将包含一个完整 HTML 文档的所有必要标记。主布局文件可以在apps/storefront/views/layout.html下找到:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>ecommerce</title>
    {% include "includes/stylesheets.html" %}
  </head>
  <body>
    <div class="container">
      <div class="app-wrapper card whiteframe-z2">

        {% block header %}
        <header>
          <div class="row">
            <div class="col">
              <h1><a href="#">Awesome store</a></h1>
              <span class="pull-right">{{ currentUser.email }}</span>
            </div>
          </div>
        </header>
        {% endblock %}

        <div class="row">
          {% block content %}{% endblock %}
        </div>

      </div>
    <div>
    {% block footer %}
    <footer></footer>
    {% endblock %}
  </body>
</html>

主要的layout.html文件定义了注入内容的块。因为我们有一个Shared微应用,所以所有必要的资产都对我们可用,因此我们可以使用一个单独的文件,apps/storefront/views/includes/stylesheets.html来导入这些资产:

<link href='https://fonts.googleapis.com/css?family=Work+Sans' rel='stylesheet' type='text/css'>
<link rel="stylesheet" type="text/css" href="/assets/css/style.css">

列出产品

为了实现完全集成,让我们看看我们如何列出我们的产品。创建一个新的模板文件apps/storefront/views/home.html并添加以下内容:

{% extends "layout.html" %}

{% block content %}
  <div class="product-list row">
    <div class="col">
    {% for product in products %}
      {% include "partials/product.html" %}
    {% endfor %}
    </div>
  </div>
{% endblock %}

我们只是使用前面的代码扩展了content块,遍历产品列表,并使用部分视图创建一个新的产品。

让我们看看那个部分视图,apps/storefront/views/partials/product.html

<div class="col col-25 product-item">
  <a href="{{ baseUrl }}/products/{{ product.slug }}">
    <img src="img/208x140?text=product+image&txtsize=18" />
  </a>
  <h2>
    <a href="{{ baseUrl }}/products/{{ product.slug }}">{{ product.title }}</a>
  </h2>
  <p>{{ product.summary }}</p>
  <p>price: {{ product.price.display }} {{ product.price.currency}}</p>
  <p><button class="button">add to cart</button></p>
</div> 

静态 HTML 标记被转换成了动态视图。我们使用与用 Angular 2 构建的Admin微应用相同的结构。

如果你对剩余的代码感兴趣,请前往项目的仓库github.com/robert52/mean-blueprints-ecommerce以获取更多详细信息。这部分应用程序只是为了展示你可以集成到你的 MEAN 堆栈中的不同方法。你总是可以扩展你的堆栈,使用不同的技术,看看什么更适合你。

有时候,你需要将事物结合起来,但有一个坚实的基础可以使你的生活长期更容易。我们本可以使用 Angular 构建一切,但看到我们如何扩展我们的视野总是很棒。

摘要

本章是关于构建电子商务应用程序。从本章的开始,我们就开始尝试新的应用程序架构,这种架构可以很容易地扩展到未来,并且也用于我们的店面实现中的服务器端渲染。

尽管这与前几章有很大不同,但它很好地服务于教育目的,并为新的可能性打开了大门。请保持您的架构模块化,并且首先只对小部分进行实验,以查看事情是否如您所愿地发展。

在下一章中,我们将尝试通过添加拍卖应用来扩展我们现有的电子商务应用。

第六章:拍卖应用

本章将专注于构建一个类似拍卖的应用程序,它将依赖于之前构建的电子商务应用的 API。它将是一个小型概念验证应用。我们应用的后端解决方案将消费我们电子商务应用的后端 API。我希望最后一章能成为我们的游乐场,这样我们就可以通过这本书中使用的有趣技术,并且在一个较小但有趣的应用中有所乐趣。

设置基本应用

我们将从经典的 Express 应用程序样板开始。按照以下步骤设置基本项目:

  1. 从 GitHub 克隆项目:github.com/robert52/express-api-starter

  2. 将您的样板项目重命名为auction-app

  3. 如果您愿意,可以通过运行以下命令停止指向初始 Git 远程仓库:

    git remote remove origin
    
    
  4. 跳转到您的工作目录:

    cd auction-app
    
    
  5. 安装所有依赖项:

    npm install
    
    
  6. 创建开发配置文件:

    cp config/environments/example.js config/environments/development.js
    
    

您的配置文件,auction-app/config/environments/development.js,应类似于以下内容:

'use strict';

module.exports = {
  port: 3000,
  hostname: '127.0.0.1',
  baseUrl: 'http://localhost:3000',
  mongodb: {
    uri: 'mongodb://localhost/auction_dev_db'
  },
  app: {
    name: 'MEAN Blueprints - auction application'
  },
  serveStatic: true,
  session: {
    type: 'mongo',                          
    secret: 'someVeRyN1c3S#cr3tHer34U',
    resave: false,                     
    saveUninitialized: true
  },
  proxy: {
    trust: true
  },
  logRequests: false  
};

我们要构建的内容

我们将构建一个英文拍卖网站。之前的电子商务应用将为我们提供产品,管理员可以使用这些产品创建拍卖。拍卖有不同的特性;我们不会逐一讨论它们,而是将描述一个英文拍卖。

最常见的拍卖是英文拍卖;它是一个单维度的拍卖,唯一考虑的是为商品提供的出价。通常它是以卖家为导向的,意味着它是单方面的。

通常,拍卖会设定一个起始价格;这被称为保留价,在此价格以下,卖家不会出售商品。每个买家都会出价,每个人都知道每个出价,因此是公开叫价。获胜者支付获胜价格。

没有低于当前最高出价。通常,当没有人对支付最新价格感兴趣时,拍卖结束。此外,可以为拍卖设置一个结束时间。

结束时间可以是绝对时间,在我们的案例中是一个标准日期时间,或者是一个相对于最后出价的时间,例如 120 秒。在章节的后面,我们将讨论相对时间的好处。

数据建模

在我们的应用中,拍卖是一个特殊事件,用户——更准确地说,是出价者——可以对可供销售的商品进行出价。一个项目是电子商务平台上的一个产品,但它只保留显示给用户所需的信息。让我们更详细地讨论每个模型。

拍卖

拍卖将包含关于事件的所有必要信息。如前所述,我们将实现一个英文拍卖,我们将从我们的主要电子商务应用中出售商品。

英式拍卖是公开叫价,这意味着每个人都知道每个出价。获胜者将支付获胜价格。每个出价都会提高商品的价格,下一个出价者必须支付更多才能赢得拍卖。

所有拍卖都将有一个保留价格,这是一个低于我们不会出售我们的产品的起始价值。换句话说,这是卖方可以接受的最低价格。

为了简化问题,我们将为我们的拍卖设置一个结束时间。最后出价最接近结束时间的出价将是获胜出价。你也可以选择相对时间,这意味着你可以从最后出价(即 10 分钟)设置一个时间限制,如果在那个时间段内没有出价,就宣布获胜者。这可以非常有效地防止出价狙击。

例如,假设你在一个产品上出价 39 美元作为起始价格。通常情况下,你拥有最高的出价。现在想象一下,拍卖即将结束,但在结束前只有几秒钟,另一个出价者试图以 47 美元的价格出价。这将让你没有时间反应,所以最后出价者赢得了拍卖。这就是通常出价狙击的工作方式。

让我们看看 Mongoose 拍卖模式:

'use strict';

const mongoose = require('mongoose');
const Money = require('./money').schema;
const Schema = mongoose.Schema;
const ObjectId = Schema.ObjectId;
const Mixed = Schema.Types.Mixed;

var AuctionSchema = new Schema({
  item:           { type: Mixed },
  startingPrice:  { type: Money },
  currentPrice:   { type: Money },
  endPrice:       { type: Money },
  minAmount:      { type: Money },
  bids: [{
    bidder:       { type: ObjectId, ref: 'Bidder' },
    amount:       { type: Number, default: 0 },
    createdAt:    { type: Date, default: Date.now }
  }],
  startsAt:       { type: Date },
  endsAt:         { type: Date },
  createdAt:      { type: Date, default: Date.now }
});

module.exports = mongoose.model('Auction', AuctionSchema);

除了之前讨论的信息外,我们还将在我们的拍卖文档中嵌入所有出价。如果拍卖中有许多出价,这可能不是一个好主意,但既然我们打算进行固定时间的拍卖,出价将非常有限。对于热门拍卖,你只需将出价移动到单独的集合中,并在拍卖文档中保留引用。

出价者

我们正在使用我们电子商务应用程序的后端 API,因此我们不需要在我们的数据库中存储用户。但我们可以存储有关我们的出价用户的额外数据。为此,我们可以创建一个新的模型,称为app/models/bidder.js,并添加以下内容:

'use strict';

const mongoose = require('mongoose');
const Money = require('./money').schema;
const Schema = mongoose.Schema;
const ObjectId = Schema.ObjectId;
const Mixed = Schema.Types.Mixed;

const BidderSchema = new Schema({
  profileId:      { type: String },
  additionalData: { type: Mixed },
  auctions: [{
    auction:      { type: ObjectId, ref: 'Auction' },
    status:       { type: String, default: 'active'},
    joinedAt:     { type: Date, default: Date.now }
  }],
  createdAt:      { type: Date, default: Date.now }
});

module.exports = mongoose.model('Bidder', BidderSchema);

profileId存储用户的_id,以便从电子商务平台引用用户文档。你还可以在这个模型中存储额外的数据,并将出价者参与的拍卖存储在模型中。

拍卖后端

在上一章中,我们在我们的架构中添加了一个服务层。我们将遵循相同的模式。此外,我们还将添加一个额外的组件,称为Mediator,它将作为一个单一入口点来帮助我们与不同的模块进行通信。

我们将在构建我们的模块时遵循中介设计模式,这是一种行为设计模式。这将是一个单一的中央控制点,通过它进行通信。

中介

我们的Mediator将是一个对象,它将通过通道协调与不同模块的交互。一个模块可以订阅一个给定的事件,并在该事件发生时收到通知。所有这些与事件相关的话题几乎让我们想到使用 Node.js 的事件核心模块,该模块用于发出命名事件,从而调用要执行的功能。

这是一个很好的起点。我们需要解决的一个问题是我们的 Mediator 需要是一个单点入口,并且在我们应用程序的执行时间只能存在一个实例。我们可以简单地使用单例设计模式。考虑到所有这些,让我们实现我们的中介:

'use strict';

const EventEmitter = require('events');
let instance;

class Mediator extends EventEmitter {
  constructor() {
    super();
  }
}

module.exports = function singleton() {
  if (!instance) {
    instance = new Mediator();
  }

  return instance;
} 

这应该为我们模块的构建提供一个坚实的基础;目前这应该足够了。因为我们使用了 ES6 特性,我们可以直接扩展 EventEmitter 类。我们不是导出整个 Mediator 类,而是导出一个函数,该函数检查是否已经存在一个实例,如果没有,我们就创建 Mediator 类的新实例。

让我们看看我们将如何使用这种技术的例子:

'use strict';

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

mediator.on('some:awesome:event', (msg) => {
  console.log(`received the following message: ${msg}`);
});

mediator.emit('some:awesome:event', 'Nice!');

我们只需要引入 mediator 实例,并使用 .on() 方法订阅事件并执行一个函数。使用 .emit() 方法发布命名事件,并传递一个消息作为参数。

记住在使用 ES6 中的 箭头函数 时,监听函数中的 this 关键字不再指向 EventEmitter

拍卖管理器

我们不是在应用程序的控制器层实现所有业务逻辑,而是要构建另一个服务,称为 AuctionManager。这个服务将包含执行拍卖所需的所有必要方法。

使用这种技术,我们可以在以后轻松地决定如何导出我们应用程序的业务逻辑:使用传统的端点或通过 WebSockets。

让我们遵循几个步骤来实现我们的拍卖管理器:

  1. 创建一个名为 /app/services/auction-manager.js 的新文件。

  2. 添加必要的依赖项:

    const MAX_LIMIT = 30;
    
    const mongoose = require('mongoose');
    const mediator = require('./mediator')();
    const Auction = mongoose.model('Auction');
    const Bidder = mongoose.model('Bidder');
    
  3. 定义基类:

    class AuctionManager {
      constructor(AuctionModel, BidderModel) {
        this._Auction = AuctionModel || Auction;
        this._Bidder = BidderModel || Bidder;
      }
    }
    module.exports = AuctionManager;
    
  4. 获取所有拍卖的方法:

      getAllAuctions(query, limit, skip, callback) {
        if (limit > MAX_LIMIT) {
          limit = MAX_LIMIT;
        }
    
        this._Auction
        .find(query)
        .limit(limit)
        .skip(skip)
        .exec(callback);
      }
    
  5. 加入拍卖:

      joinAuction(bidderId, auctionId, callback) {
        this._Bidder.findById(bidderId, (err, bidder) => {
          if (err) {
            return callback(err);
          }
    
          bidder.auctions.push({ auction: auctionId });
          bidder.save((err, updatedBidder) => {
            if (err) {
              return callback(err);
            }
    
            mediator.emit('bidder:joined:auction', updatedBidder);
            callback(null, updatedBidder);
          });
        });
      }
    

    如您所见,我们开始使用我们的中介来触发事件。在这个阶段,当一位竞标者加入拍卖时,我们会触发一个事件。目前这对我们来说并没有增加太多价值,但当我们开始尝试我们的实时通信解决方案时,这将会变得很有用。

  6. 投标:

      placeBid(auctionId, bidderId, amount, callback) {
        if (amount <= 0) {
          let err = new Error('Bid amount cannot be negative.');
          err.type = 'negative_bit_amount';
          err.status = 409;
          return callback(err);
        }
    
        let bid = {
          bidder: bidderId,
          amount: amount
        };
    
        this._Auction.update(
          // query
          {
            _id: auctionId.toString()
          },
          // update
          {
            currentPrice: { $inc: amount },
            bids: { $push: bid }
          },
          // results
          (err, result) => {
            if (err) {
              return callback(err);
            }
    
            if (result.nModified === 0) {
              let err = new Error('Could not place bid.');
              err.type = 'new_bid_error';
              err.status = 500;
              return callback(err);
            }
    
            mediator.emit('auction:new:bid', bid);
            callback(null, bid);
          }
        );
      }
    

当进行投标时,我们只想将投标添加到我们的拍卖的投标列表中,为此,我们将使用原子操作来更新 currentPrice 并添加当前的投标。此外,在成功投标后,我们将为该事件触发一个事件。

拍卖师

我们将为即将推出的模块起一个花哨的名字,我们将称之为 Auctioneer。为什么这个名字?嗯,我们正在构建一个拍卖应用,所以我们可以添加一些复古的感觉,并添加一个拍卖师,他将宣布新的投标和谁加入了拍卖。

如您所猜测的,这将是我们实时通信模块。该模块将使用 SocketIO,我们将要做的事情与第四章中的类似,聊天应用,在那里我们使用了该模块进行实时通信。

我们将只通过我们的模块中最重要的一部分来查看不同的概念在实际中的应用。让我们创建一个名为 app/services/auctioneer.js 的文件,并添加以下内容:

'use strict';

const socketIO = require('socket.io');
const mediator = require('./mediator')();
const AuctionManager = require('./auction-manager');
const auctionManager =  new AuctionManager();

class Auctioneer {
  constructor(app, server) {
    this.connectedClients = {};
    this.io = socketIO(server);
    this.sessionMiddleware = app.get('sessionMiddleware');
    this.initMiddlewares();
    this.bindListeners();
    this.bindHandlers();
  }
}
module.exports = Auctioneer;

所以基本上,我们只是结构化我们的类并在构造函数中调用了一些方法。我们已经熟悉构造函数中的一些代码;例如,.initMiddlewares() 方法看起来与第四章,聊天应用相似,在那里我们使用中间件来授权和验证用户:

  initMiddlewares() {
    this._io.use((socket, next) => {
      this.sessionMiddleware(socket.request, socket.request.res, next);
    });

    this.io.use((socket, next) => {
      let user = socket.request.session.passport.user;

      // authorize user
      if (!user) {
        let err = new Error('Unauthorized');
        err.type = 'unauthorized';
        return next(err);
      }

      // attach user to the socket, like req.user
      socket.user = {
        _id: socket.request.session.passport.user
      };
      next();
    });
  }

我们在调用 .bindHandlers() 方法时初始化了我们的 SocketIO 处理程序,并通过调用 .bindListeners() 方法将监听器附加到我们的中介者上。

因此,我们的 .bindHandlers() 方法将具有以下结构:

  bindHandlers() {
    this.io.on('connection', (socket) => {
      // add client to the socket list to get the session later
      let userId = socket.request.session.passport.user;
      this.connectedClients[userId] = socket;

      // when user places a bid
      socket.on('place:bid', (data) => {
        auctionManager.placeBid(
          data.auctionId,
          socket.user._id,
          data.amount,
          (err, bid) => {
            if (err) {
              return socket.emit('place:bid:error', err);
            }

            socket.emit('place:bid:success', bid);
          }
        );

      });
    });
  }

记住,这只是一个部分代码,最终版本将包含更多的处理程序。所以,当新的客户端连接时,我们将一些处理程序附加到我们的 socket 上。例如,在先前的代码中,我们监听 place:bid 事件,当用户放置新的出价时,该事件将被调用,并且 AuctionManager 服务将持久化该出价。

显然,我们需要通知其他客户端关于发生的变化;我们不会在这里处理这一点。我们的 .placeBid() 方法在每次成功记录新的出价时通过 Mediator 发出事件。我们唯一需要做的是监听该事件,这在我们调用拍卖师构造方法中的 .bindListeners() 时已经完成了。

让我们看看 .bindListeners() 方法的一个部分代码示例:

  bindListeners() {
    mediator.on('bidder:joined:auction', (bidder) => {
      let bidderId = bidder._id.toString();
      let currentSocket = this.connectedClients[bidderId];
      currentSocket.emit.broadcast('bidder:joined:auction', bidder);
    });

    mediator.on('auction:new:bid', (bid) => {
      this.io.sockets.emit('auction:new:bid', bid);
    });
  }

在前面的代码中,我们监听当一位竞标者加入拍卖时的情况,并向每个客户端广播一条消息,期望只触发事件的 socket 客户端。当放置新的出价时,我们向每个 socket 客户端发出事件。所以基本上,我们有两个类似的广播功能,但有一个主要区别;一个向每个客户端发送消息,期望触发事件的那个客户端,而第二个则向所有已连接的客户端广播。

从控制器中使用服务

如我们之前讨论的,我们的服务可以从任何模块中消费,并以不同的方式向客户端暴露。之前,我们使用了 AuctionManager 并通过 WebSockets 公开其业务逻辑。现在,我们将使用简单的端点来完成同样的工作。

让我们创建一个名为 app/controllers/auction.js 的控制器文件,并包含以下内容:

'use strict';

const _ = require('lodash');
const mongoose = require('mongoose');
const Auction = mongoose.model('Auction');
const AuctionManager = require('../services/auction-manager');
const auctionManager = new AuctionManager();

module.exports.getAll = getAllAuctions;

function getAllAuctions(req, res, next) {
  let limit = +req.query.limit || 30;
  let skip = +req.query.skip || 0;
  let query = _.pick(req.query, ['status', 'startsAt', 'endsAt']);

  auctionManager.getAllAuctions(query, limit, skip, (err, auctions) => {
    if (err) {
      return next(err);
    }

    req.resources.auctions = auctions;
    next();
  });
}

我们在书中已经多次这样做,所以这里没有什么新的。控制器导出一个函数,该函数将附加从服务返回的所有拍卖,并且稍后响应将被转换为 JSON 响应。

从电子商务 API 获取数据

当创建拍卖时,我们需要关于我们添加到拍卖中的物品的额外信息。关于产品物品的所有信息都存储在上一章中构建的电子商务平台上。

我们在本章中没有涵盖拍卖的创建,但我们可以讨论与电子商务 API 的底层通信层。在数据建模阶段,我们没有讨论将用户存储在数据库中。

不包括用户管理的原因是我们将消耗第三方 API 来管理我们的用户。例如,验证和注册将通过电子商务平台处理。

电子商务客户端服务

为了与第三方 API 进行通信,我们将创建一个服务来代理请求。由于我们不会从 API 消费很多端点,我们可以创建一个单一的服务来处理所有事情。随着你的应用程序的增长,你可以轻松地根据域上下文对文件进行分组。

让我们创建一个名为app/services/ecommerce-client.js的新文件,并按照以下步骤操作:

  1. 声明在服务中使用的常量并包含依赖项:

    'use strict';
    
    const DEFAULT_URL = 'http://localhost:3000/api';
    const CONTENT_HEADERS = {
      'Content-Type': 'application/json',
      'Accept': 'application/json',
    };
    
    const request = require('request');
    
  2. 定义一个用于配置请求对象的自定义RequestOptions类:

    class RequestOptions {
      constructor(opts) {
        let headers = Object.assign({}, CONTENT_HEADERS, opts.headers);
    
        this.method = opts.method || 'GET';
        this.url = opts.url;
        this.json = !!opts.json;
        this.headers = headers;
        this.body = opts.body;
      }
    
      addHeader(key, value) {
        this.headers[key] = value;
      }
    }
    

    为了减少使用request进行调用所需的代码结构,我们定义了一个自定义类来实例化默认的请求选项。

  3. 添加EcommerceClient类:

    class EcommerceClient {
      constructor(opts) {
        this.request = request;
        this.url = opts.url || DEFAULT_URL;
      }
    }
    

    EcommerceClient类将成为我们访问第三方 API 的主要入口点。它更像是一个门面,以不知道我们应用程序中使用的底层数据源。

  4. 指定如何验证用户:

      authenticate(email, password, callback) {
        let req = new RequestOptions({
          method: 'POST',
          url: `${this.url}/auth/basic`
        });
        let basic = btoa(`${email}:${password}`);
    
        req.addHeader('Authorization', `Basic ${basic}`);
    
        this.request(req, function(err, res, body) => {
          callback(err, body);
        })
      }
    

    API 服务器将为我们处理验证;我们只是使用在调用 API 时返回的令牌。我们的自定义RequestOptions类允许我们添加额外的头部数据,例如Authorization字段。

  5. 添加getProducts()方法:

      getProducts(opts, callback) {
        let req = new RequestOptions({
          url: `${this.url}/api/products`
        });
        req.addHeader('Authorization', `Bearer ${opts.token}`);
    
        this.request(req, function(err, res, body) => {
          callback(err, body);
        })
      }
    

如您所见,使用相同的原理,我们可以从我们的电子商务应用程序中检索数据。唯一不同的是,我们需要在我们的调用中添加一个令牌。我们不会讨论如何消费我们的服务,因为我们已经在本书中多次这样做过。

在控制器中使用它应该相当简单,并配置一个路由器来向客户端应用程序公开必要的端点。

前端服务

由于我们只触及我们应用程序的最重要部分,我们将讨论在 Angular 应用程序中使用的服务的实现。我认为理解与后端应用程序的底层通信层是很重要的。

拍卖服务

AuctionService将处理与后端 API 的所有通信,以获取特定拍卖的信息,或者简单地获取所有可用的拍卖。为此,我们将创建一个新的文件,public/src/services/auction.service.ts

import { Injectable } from 'angular2/core';
import { Response, Headers } from 'angular2/http';
import { Observable } from 'rxjs/Observable';
import { Subject } from 'rxjs/Subject';
import { BehaviorSubject } from 'rxjs/Subject/BehaviorSubject';
import { AuthHttp } from '../auth/index';
import { contentHeaders } from '../common/headers';
import { Auction } from './auction.model';
import { SubjectAuction, ObservableAuction, ObservableAuctions } from './types';

const URL = 'api/auctions';

@Injectable()
export class AuctionService { 
}

我们导入了我们的依赖项,并添加了一个URL常量以提高代码的可读性,但你可以按自己的意愿处理基本 URL 配置。在我们添加必要的方法之前,还有一些事情需要处理,所以让我们定义构造函数和类属性:

  public currentAuction: SubjectAuction = new BehaviorSubject<Auction>(new Auction());
  public auctions: ObservableAuctions;
  public auction: ObservableAuction;

  private _http: Http;
  private _auctionObservers: any;
  private _auctionsObservers: any;
  private _dataStore: { auctions: Array<Auction>, auction: Auction };

  constructor(http: Http, bidService: BidService) {
    this._http = http;
    this.auction = new Observable(observer => this._auctionObservers = observer).share();
    this.auctions = new Observable(observer => this._auctionsObservers = observer).share();
    this._dataStore = { auctions: [], auction: new Auction() };
  }

我们导出了一个用于单个拍卖和拍卖列表的 Observable。我们还对当前拍卖感兴趣。除了所有熟悉的定义外,我们还添加了一个用于内部使用的第三个服务。

当获取单个拍卖或所有拍卖时,我们将更新观察者的下一个值,以便订阅者通过变化的发生得到通知:

    public getAll() {
    this._authHttp
    .get(URL, { headers: contentHeaders })
    .map((res: Response) => res.json())
    .map((data) => {
      return data.map((auction) => {
        return new Auction(
          auction._id,
          auction.item,
          auction.startingPrice,
          auction.currentPrice,
          auction.endPrice,
          auction.minAmount,
          auction.bids,
          auction.status,
          auction.startsAt,
          auction.endsAt,
          auction.createdAt
        );
      });
    })
    .subscribe(auctions => {
      this._dataStore.auctions = auctions;
      this._auctionsObservers.next(this._dataStore.auctions);
    }, err => console.error(err));
  } 

要获取单个拍卖,我们可以使用以下方法:

  public getOne(id) {
    this._authHttp
    .get(`${URL}/${id}`)
    .map((res: Response) => res.json())
    .map((data) => {
      return new Auction(
        data._id,
        data.item,
        data.startingPrice,
        data.currentPrice,
        data.endPrice,
        data.minAmount,
        data.bids,
        data.status,
        data.startsAt,
        data.endsAt,
        data.createdAt
      );
    })
    .subscribe(auction => {
      this._dataStore.auction = auction;
      this._auctionObservers.next(this._dataStore.auction);
    }, err => console.error(err));
  }

因此,这个服务将与我们 Node.js 应用程序通信,并将所有接收到的数据存储在内部存储中。除了从服务器获取数据外,我们最终还想存储当前的拍卖,因此这段代码应该处理它:

  public setCurrentAuction(auction: Auction) {
    this.currentAuction.next(auction);
  }

套接字服务

套接字服务将处理与 SocketIO 服务器的通信。好处是,我们有一个单一的入口点,并且可以将底层逻辑抽象到应用程序的其余部分。

创建一个名为 public/src/common/socket.service.ts 的新文件,并添加以下内容:

import { Injectable } from 'angular2/core';
import * as io from 'socket.io-client';
import { Observable } from 'rxjs/Rx';
import { ObservableBid } from '../bid/index';
import { ObservableBidder } from '../bidder/index' 

export class SocketService {
}

我们只导入 SocketIO 客户端和所有其他数据类型。另外,别忘了添加你类所需的其余必要代码:

  public bid: ObservableBid;
  public bidder: ObservableBidder;
  private _io: any;

  constructor() {
    this._io = io.connect();
    this._bindListeners();
  }

我们在这里做的一件有趣的事情是,通过以下技术公开 Observables——应用程序的其余部分可以订阅数据流:

  private _bindListeners() {
    this.bid = Observable.fromEvent(
      this._io, 'auction:new:bid'
    ).share();
    this.bidder = Observable.fromEvent(
      this._io, 'bidder:joined:auction'
    ).share();
  }

RxJs 的优点在于我们可以从事件中创建 Observables。当套接字发出事件时,我们只需从该事件创建一个 Observable。使用前面的代码,我们可以订阅来自后端的数据。

为了通过 SocketIO 向后端发送信息,我们可以公开一个 .emit() 方法,它将只是套接字客户端上的 .emit() 方法的包装器:

  public emit(...args) {
    this._io.emit.apply(this, args);
  }

投标服务

要了解整体情况,我们可以查看位于以下路径下的 BidServicepublic/src/bid/bid.service.ts。该类将具有类似的结构:

@Injectable()
export class BidService {
  public bid: any;
  public currentAuction: any;
  private _socketService: SocketService;
  private _auctionService: AuctionService;

  constructor(
    socketService: SocketService, 
    auctionService: AuctionService
  ) {    
    this._socketService = socketService;
    this._auctionService = auctionService;
    this.currentAuction = {};
    this._auctionService.currentAuction.subscribe((auction) => {
      this.currentAuction = auction;
    });
    this.bid = this._socketService.bid.filter((data) => {
      return data.auctionId === this.currentAuction._id;
    });
  }

  public placeBid(auctionId: string, bid: Bid) {
    this._socketService.emit('place:bid', {
      auctionId: auctionId,
      amount: bid.amount
    });
  }
}

BidService 将与 SocketService 交互以放置投标,这些投标将通过 Express 后端应用程序推送到所有已连接的客户端。我们还根据当前选定的拍卖过滤每个传入的投标。

当当前选定的拍卖发生变化时,我们想通过订阅 AuctionServicecurrentAuction 来更新我们的本地副本。

投标服务

BidderService 将是第一个使用 SocketService 并订阅 bidder 对象变化的。它将存储来自后端 Node.js 服务器的所有传入数据。

让我们创建一个名为 public/src/services/bidder.service.ts 的新文件,并添加以下基本内容:

import { Injectable } from 'angular2/core';
import { Observable } from 'rxjs/Observable';
import { Subject } from 'rxjs/Subject';
import { BehaviorSubject } from 'rxjs/Subject/BehaviorSubject';
import { contentHeaders } from '../common/headers';
import { SocketService } from './socket.service';
import { Bidder } from '../datatypes/bidder';
import { ObservableBidders } from '../datatypes/custom-types';

@Injectable()
export class BidderService {
}

现在我们有了起点,我们可以定义我们的构造函数并声明所有必要的属性:

  public bidders: ObservableBidders;

  private _socketService: SocketService;
  private _biddersObservers: any;
  private _dataStore: { bidders: Array<Bidder> };

  constructor() {
    this.bidders = new Observable(observer => this._biddersObservers = observer).share();
    this._dataStore = { bidders: [] };
  }

在这个概念验证中,我们不会从这个服务中进行任何 HTTP 调用,并且我们主要会在数据存储中存储信息。以下 public 方法将很有用:

  public storeBidders(bidders: Array<Bidder>) {
    this._socketService = socketService;
    this._dataStore = { bidders: [] };
    this.bidders = new Observable(observer => {
      this._biddersObservers = observer;
    }).share();
    this._socketService.bidder.subscribe(bidder => {
      this.storeBidder(bidder);
    });    
  }

  public storeBidder(bidder: Bidder) {
    this._dataStore.bidders.push(bidder);
    this._biddersObservers.next(this._dataStore.bidders);
  }

  public removeBidder(id: string) {
    let bidders = this._dataStore.bidders;

    bidders.map((bidder, index) => {
      if (bidder._id === id) {
        this._dataStore.bidders.splice(index, 1);
      }
    });

    this._biddersObservers.next(this._dataStore.bidders);
  }

在前面的章节中,我们已经以类似的形式使用了这种逻辑。为了简化,我们只需在我们的数据结构中存储竞标者或单个竞标者,并更新观察者的下一个值,这样每个订阅者都会收到通知以获取最新值。

之前,我们使用了一个自定义数据类型 Bidder——或者如果你更熟悉,可以称之为模型。让我们快速看看它,位于以下路径——public/src/datatypes/bidder.ts

export class Bidder {
  _id:            string;
  profileId:      string;
  additionalData: any;
  auctions:       Array<any>;
  createdAt:      string

  constructor(
    _id?:            string,
    profileId?:      string,
    additionalData?: any,
    auctions?:       Array<any>,
    createdAt?:      string
  ) {
    this._id = _id;
    this.profileId = profileId;
    this.additionalData = additionalData;
    this.auctions = auctions;
    this.createdAt = createdAt;
  }
}

拍卖模块

我们已经采取了初步步骤并实现了我们的服务。现在我们可以开始在组件中使用它们了。在我们的 Auction 应用程序中有很多动态元素。应用程序中最具挑战性的部分将是拍卖详情页面。

上述代码将列出特定拍卖的详细信息,并列出当前的出价。当放置新的出价时,它将被推送到 bids 列表。

在我们的服务中,我们之前使用了 Auction 模型。让我们首先看看它,它位于 public/src/auction/auction.model.ts

import { Money } from '../common/index';

export class Auction {
  _id:            string;
  identifier:     string;
  item:           any;
  startingPrice:  any;
  currentPrice:   any;
  endPrice:       any;
  minAmount:      any;
  bids:           Array<any>;
  status:         string;
  startsAt:       string;
  endsAt:         string;
  createdAt:      string

  constructor(
    _id?:            string,
    item?:           any,
    startingPrice?:  any,
    currentPrice?:   any,
    endPrice?:       any,
    minAmount?:      any,
    bids?:           Array<any>,
    status?:         string,
    startsAt?:       string,
    endsAt?:         string,
    createdAt?:      string,
    identifier?:     string
  ) {
    this._id = _id;
    this.item = item || { slug: '' };
    this.startingPrice = startingPrice || new Money();
    this.currentPrice = currentPrice || this.startingPrice;
    this.endPrice = endPrice || new Money();
    this.minAmount = minAmount || new Money();
    this.bids = bids;
    this.status = status;
    this.startsAt = startsAt;
    this.endsAt = endsAt;
    this.createdAt = createdAt;
    this.identifier = identifier || `${this.item.slug}-${this._id}`;
  }
}

它有很多属性。当我们实例化模型时,我们进行一些初始化。我们使用一个自定义的 Money 模型,它反映了后端的自定义货币类型。

如果你记得,在 Job Board 应用程序中,我们使用了漂亮的 URL 来访问一家公司。我想保持同样的外观,但添加一点变化来尝试不同的结构。我们有相同的概念,但拍卖有一个不同的标识符。

我们正在使用产品的 slug 与拍卖的 _id 结合来作为我们的 identifier 属性。现在让我们看看 Money 模型,位于 public/src/common/money.model.ts

export class Money {
  amount: number;
  currency: string;
  display: string;
  factor: number;

  constructor(
    amount?: number,
    currency?: string,
    display?: string,
    factor?: number
  ) {
    this.amount = amount;
    this.currency = currency;
    this.display = display;
    this.factor = factor;
  }
}

如你所记,我们正在使用这些技术来为我们的对象提供初始值,并确保我们有必要的属性。为了刷新我们的记忆,amount 是通过将 display 值与 factor 相乘得到的。所有这些都是在服务器端完成的。

基本组件

我们将添加一个基本组件来配置我们的路由。我们的基本组件通常非常基础,没有太多逻辑;它只有与路由相关的逻辑。创建一个名为 public/src/auction/components/auction-base.component.ts 的新文件,并添加以下代码:

import { Component } from 'angular2/core';
import { RouteConfig, RouterOutlet } from 'angular2/router';
import { AuctionListComponent } from './auction-list.component';
import { AuctionDetailComponent } from './auction-detail.component';

@RouteConfig([
  { path: '/', as: 'AuctionList', component: AuctionListComponent, useAsDefault: true },
  { path: '/:identifier', as: 'AuctionDetail', component: AuctionDetailComponent }
])
@Component({
    selector: 'auction-base',
    directives: [
      AuctionListComponent,
      AuctionDetailComponent,
      RouterOutlet
    ],
    template: `
      <div class="col">
        <router-outlet></router-outlet>
      </div>
    `
})
export class AuctionBaseComponent {
  constructor() {}
}

拍卖列表

为了显示当前可用的拍卖列表,我们将创建一个新的组件,名为 public/src/auction/components/auction-list.component.ts

import { Component, OnInit } from 'angular2/core';
import { AuctionService } from '../auction.service';
import { Router, RouterLink } from 'angular2/router';
import { Auction } from '../auction.model';

@Component({
    selector: 'auction-list',
    directives: [RouterLink],
    template: `
      <div class="auction-list row">
        <h2 class="col">Available auctions</h2>
        <div *ngFor="#auction of auctions" class="col col-25">
          <h3>
            <a href="#"
              [routerLink]="['AuctionDetail', { identifier: auction.identifier }]">
              {{ auction.item.title }}
            </a>
          </h3>
          <p>starting price: {{ auction.startingPrice.display }} {{ auction.startingPrice.currency }}</p>
        </div>
      </div>
    `
})
export class AuctionListComponent implements OnInit {
  public auctions: Array<Auction> = [];
  private _auctionService: AuctionService;

  constructor(auctionService: AuctionService) {
    this._auctionService = auctionService;
  }

  ngOnInit() {
    this._auctionService.auctions.subscribe((auctions: Array<Auction>) => {
      this.auctions = auctions;
    });
    this._auctionService.getAll();
  }
}

从这个组件,我们将链接到拍卖详情。正如你所见,我们使用了 identifier 作为路由参数。属性值是在 Auction 模型内部构建的。

详情页面

在这个应用程序中,详情页面将包含最多的动态部分。我们将显示拍卖的详细信息并列出所有新的出价。此外,用户也可以从该页面进行出价。为了实现这个组件,让我们遵循以下步骤:

  1. 创建一个名为 public/src/auction/components/auction-detail.component.ts 的新文件。

  2. 添加依赖项:

    import { Component, OnInit } from 'angular2/core';
    import { AuctionService } from '../auction.service';
    import { RouterLink, RouteParams } from 'angular2/router';
    import { Auction } from '../auction.model';
    import { BidListComponent } from '../../bid/index';
    import { BidFormComponent } from '../../bid/index';
    
  3. 配置 Component 注解:

    @Component({
        selector: 'auction-detail,
        directives: [
          BidListComponent,
          BidFormComponent,
          RouterLink
        ],
        template: `
          <div class="col">
            <a href="#" [routerLink]="['AuctionList']">back to auctions</a>
          </div>
          <div class="row">
            <div class="col sidebar">
              <div class="auction-details">
                <h2>{{ auction.item.title }}</h2>
                <p>{{ auction.startingPrice.display }} {{ auction.startingPrice.currency }}</p>
                <p>{{ auction.currentPrice.dislpay }} {{ auction.startingPrice.currency }}</p>
                <p>minimal bid amount: {{ auction.minAmount.display }}</p>
              </div>
            </div>
            <div class="col content">
              <bid-list></bid-list>
              <bid-form></bid-form>
            </div>
          </div>
        `
    })
    
  4. 添加类:

    export class AuctionDetailComponent implements OnInit, OnDestroy {
      public auction: Auction;
      private _routeParams:RouteParams;
      private _auctionService: AuctionService;
    
      constructor(
        auctionService: AuctionService, 
        routeParams: RouteParams
      ) {    
        this._auctionService = auctionService;
        this._routeParams = routeParams;
      }
    }
    
  5. 实现 ngOnInit

      ngOnInit() {
        this.auction = new Auction();
        const identifier: string = this._routeParams.get('identifier');
        const auctionId = this.getAuctionId(identifier);
        this._auctionService.auction.subscribe((auction: Auction) => {
          this.auction = auction;
        });
        this._auctionService.getOne(auctionId);
      }
    
  6. 添加 ngOnDestroy

      ngOnDestroy() {
        this._auctionService.setCurrentAuction(new Auction());
      }
    

    当组件被销毁时,我们希望将 currentAuction 设置为空。

  7. 定义私有的 getAuctionId 方法:

      private getAuctionId(identifier: string) {
        const chunks = identifier.split('-');
        return chunks[chunks.length -1];
      }
    

我们使用 RouterParams 来获取标识符。因为我们有一个很好的 URI,我们只需要从标识符中剥离必要的信息。为此,我们使用了一个私有方法,该方法将 URL 组件分割成块,并只获取最后一部分。

URL 的最后一部分是拍卖的 id。在获得必要的 id 后,我们可以从我们的 API 中检索信息。

此组件使用了两个其他组件,BidListComponentBidFormComponent。第一个用于显示出价列表,监听出价数据流,并更新出价列表。

第二个,BidFormComponent,用于出价。将所有功能封装到单独的组件中更容易。这样,每个组件都可以专注于其领域需求。

出价模块

我们将用 bid 模块来结束这一章,因为我们已经在之前的 auction 模块中使用了它的许多组件。这里只讨论 bid listing,因为它涉及到与底层套接字流的工作。

列出出价

从之前的 AuctionDetailComponent 我们可以看到,这个组件将以出价为输入。这些数据来自 auction 实体,它保存了之前放置的出价。

创建一个名为 public/src/bid/components/bid-list.component.ts 的新文件:

import { Component, OnInit, OnDestroy } from 'angular2/core';
import { BidService } from '../bid.service';
import { Bid } from '../bid.model';
import { BidComponent } from './bid.component';

@Component({
    selector: 'bid-list',
    inputs: ['bids'],
    directives: [BidComponent],
    template: `
      <div class="bid-list">
        <div *ngIf="bids.length === 0" class="empty-bid-list">
          <h3>No bids so far :)</h3>
        </div>
        <bid *ngFor="#bid of bids" [bid]="bid"></bid>
      </div>
    `
})
export class BidListComponent implements OnInit, OnDestroy {
  public bids: Array<Bid>;
  private _bidService: BidService;
  private _subscription: any;

  constructor(bidService: BidService) {
    this._bidService = bidService;
  }

  ngOnInit() {
    this._subscription = this._bidService.bid.subscribe((bid) => {
      this.bids.push(bid);
    });
  }

  ngOnDestroy() {
    if (this._subscription) {
        this._subscription.unsubscribe();
    }
  }
}

我们订阅了来自 BidServicebid 数据流,以推送所有新的 incoming bids 并使用 BidComponent 显示它们。订阅也被存储起来,这样我们可以在组件销毁时取消订阅流。

出价组件

我们的出价组件将会相当简单。它将有一个 bid 输入,在视图初始化成功后,我们将滚动到出价列表视图的底部。让我们在 public/src/bid/components/bid.component.ts 下创建以下组件:

import { Component, AfterViewInit } from 'angular2/core';
import { Bid } from '../bid.model';

@Component({
    inputs: ['bid'],
    selector: 'bid',
    template: `
      <div class="bid-item">
        <div class="">
          <span class="">{{bid_id}}</span>
          <span class="">{{bid.amount}}</span>
        </div>
      </div>
    `
})
export class BidComponent implements AfterViewInit {
  public bid: Bid;

  constructor() {}

  ngAfterViewInit() {
    var ml = document.querySelector('bid-list .bid-list');
    ml.scrollTop = ml.scrollHeight;
  }
}

还让我们看看我们的 bid 模型,public/bid/bid.model.ts

export class Bid {
  _id:            string;
  bidder:         any;
  amount:         any;
  createdAt:      string

  constructor(
    _id?:         string,
    bidder?:      any,
    amount?:      any,
    createdAt?:   string
  ) {
    this._id = _id;
    this.bidder = bidder;
    this.amount = amount;
    this.createdAt = createdAt;
  }
}

现在我们已经从后端到我们的前端组件完成了一次完整的往返。数据从 WebSocket 服务器流到我们的 Angular 2 应用程序。

此应用程序的目的是浏览书中使用的所有技术,我们有机会组装一个概念验证。本章的主要重点是查看底层模块,它们将如何组合,以及数据如何在各个模块之间建模和传输。

摘要

这是我们的最后一章,我们创建了一个小的概念验证应用程序。目的是浏览书中的一些最有趣的部分和方法,看看我们如何将激动人心的想法结合起来,创建出既小又强大的东西。

此外,我们使用了现有的电子商务 API 来检索有关产品项的信息并管理我们的用户。我们没有理由再次经历这个过程,因为我们可以在快速原型设计时依赖第三方 API。

在大多数章节中,我们只触及了最重要的部分。每个章节所需的所有代码都可以在 Packt Publishing 网站上找到(www.packtpub.com/)。

posted @ 2025-10-09 13:24  绝不原创的飞龙  阅读(3)  评论(0)    收藏  举报