JavaScript-现代-Web-开发框架教程-四-

154 阅读53分钟

JavaScript 现代 Web 开发框架教程(四)

原文:JavaScript Frameworks for Modern Web Dev

协议:CC BY-NC-SA 4.0

八、AngularJS

构建大型应用的秘密是永远不要构建大型应用。将你的应用分成小块。然后,将这些可测试的小部分组装到您的大应用中。—贾斯汀·迈耶,JavaScriptMVC 的创造者

AngularJS 成功吸引了开发人员社区的大量关注,这是有充分理由的:该框架解决许多通常与单页面应用开发相关的挑战的独特方法与流行的替代方法有很大不同。这些差异为 Angular 赢得了一大批忠实粉丝,以及越来越多直言不讳的评论家。

随着本章的深入,你将会了解到 Angular 区别于其他单页面应用框架的一些独特的特性。我们还将提供一些指导,说明什么类型的项目可能最能从 Angular 中受益,以及其他替代方案可能更适合什么类型的项目。在我们结束这一章之前,我们还将花一点时间讨论 Angular 的历史,它的当前状态,以及这个框架的未来。

构建 Web 应用的声明式方法

Angular 最显著的特点是它允许开发人员以一种所谓的“声明式”方式创建 web 应用,而不是大多数开发人员习惯的“命令式”方法。这两种方法之间的差别是微妙的,但必须理解它才能真正体会 Angular 给桌面带来的独特好处。让我们看一下演示每种方法的两个例子。

命令式方法

命令式的:具有表达命令而不是陈述或问题的形式的—Merriam-Webster.com

当大多数人想到“编程”时,命令式方法通常是他们所想到的。使用这种方法,开发者指导计算机如何做某事。结果,期望的行为(有希望)得以实现。举例来说,考虑清单 8-1 ,它显示了一个简单的 web 应用,该应用使用命令式方法来显示一个无序的动物列表。

Listing 8-1. Simple, Imperative Web Application

// example-imperative/public/index.html

<!DOCTYPE html>

<html lang="en">

<head>

<meta charset="utf-8">

<title>Imperative App</title>

</head>

<body>

<ul id="myList">

</ul>

<script src="/bower_components/jquery/dist/jquery.js"></script>

<script>

var App = function App() {

this.init = function() {

var animals = ['cats', 'dogs', 'aardvarks', 'hamsters', 'squirrels'];

var $list = $('#myList');

animals.forEach(function(animal) {

$list.append('<li>' + animal + '</li>');

});

};

};

var app = new App();

app.init();

</script>

</body>

</html>

在这个例子中,我们的应用所期望的行为——创建一个动物列表——是由于我们明确地指示计算机如何着手创建它而实现的:

We start our application by creating a new instance of the App class and calling its init() method.   We specify our list’s entries in the form of an array (animals).   We create a reference to the desired container of our list ($list).   Finally, we iterate through each of our array’s entries and append them, one by one, to the container.  

当使用命令式方法创建应用时,该应用的源代码是控制该应用做什么以及何时做的主要来源。简而言之,命令式应用告诉计算机如何运行。

声明式方法

陈述性的:具有陈述的形式而不是问题或命令的形式的—Merriam-Webster.com

编程的声明性方法采用大多数人熟悉的传统的命令式方法,并彻底颠覆了它。当开发人员使用这种方法时,他们将精力集中在描述想要的结果上,而将实现该结果的必要步骤留给计算机本身。

举例来说,清单 8-2 显示了一个简单的 web 应用,与清单 8-1 中显示的非常相似。这里,在 Angular 的帮助下,使用更具声明性的方法显示了一个无序的动物列表。

Listing 8-2. Declarative Web Application Developed with Angular

// example-declarative/public/index.html

<!DOCTYPE html>

<html lang="en" ng-app="app">

<head>

<meta charset="utf-8">

<title>Declarative App</title>

</head>

<body>

<div ng-controller="BodyController">

<ul>

<li ng-repeat="animal in animals">{{animal}}</li>

</ul>

</div>

<script src="/bower_components/angularjs/angular.js"></script>

<script>

var app = angular.module('app', []);

app.controller('BodyController', function($scope) {

$scope.animals = ['cats', 'dogs', 'aardvarks', 'hamsters', 'squirrels'];

});

</script>

</body>

</html>

清单 8-2 中显示的 HTML 包含了几项重要的内容,但是现在,请注意页面中使用的各种非标准属性(例如,ng-appng-controllerng-repeat)。这些属性演示了指令的使用,这是 Angular 最突出和最受欢迎的特性之一。

简而言之,Angular 指令允许开发人员用他们自己的定制扩展来增强 HTML 的语法。这些扩展可以以类、定制属性、注释甚至全新的 DOM 元素的形式出现,我们很快就会看到。当 Angular 遇到这些指令时,它会自动执行与它们相关联的任何功能。这可能包括函数的执行、模板的加载等等。Angular 还包括几个自己的内置指令(比如清单 8-2 中使用的指令),其中很多我们将在本章中讨论。

当使用声明性方法创建 web 应用时,确定该应用中的控制流的责任从源代码转移到了接口。我们没有明确说明应用加载后需要发生什么(如清单 8-1 所示),而是让应用的界面自己描述需要发生什么。角度指令有助于实现这一点。

对于新手来说,应用开发的命令式方法和声明式方法之间的差异可能看起来很微妙,但是随着我们的继续,我想您会发现有很多令人兴奋的地方。

模块:构建松耦合应用的基础

当我们不再把复杂的应用作为一个单一的实体来对待,而是作为一个小组件的集合来一起工作以达到预期的目标时,它们就不再复杂了。Angular 模块是所有 Angular 项目的基本构建模块,它为我们提供了一种以这种方式构建应用的便捷模式。

再看一下清单 8-2 并注意这个例子对 Angular 的module()方法的调用,它既是 setter 又是 getter。在这里,我们创建了一个模块,将我们的应用作为一个整体。为了使用 setter 语法定义新模块,我们提供了新模块的名称,以及引用该模块所依赖的其他模块的名称数组。在这个例子中,我们的模块没有依赖项,但是为了使用 setter 语法,我们仍然传递了一个空数组。另一方面,清单 8-3 展示了一个具有两个依赖关系的新app模块的创建。

Listing 8-3. Creating a New Angular Module with Dependencies

/**

* Creates a new module that depends on two other modules - module1andmodule2``

*/

var app = angular.module('app', ['module1', 'module2']);

一旦定义了一个模块,我们就可以通过使用module()方法的 getter 语法来获取对它的引用,如清单 8-4 所示。

Listing 8-4. Angular’s module() Method Serves As a Getter when No Dependencies Array Is Passed

/**

* Returns a reference to a pre-existing module named app``

*/

var app = angular.module('app');

在这一章中,我们将会看到 Angular 为构建应用提供的许多工具。当我们这样做时,请记住这些工具总是在模块的上下文中使用。每个 Angular 应用本身就是一个依赖于其他模块的模块。了解了这一点,我们可以将角度应用的一般结构想象成如图 8-1 所示。

A978-1-4842-0662-1_8_Fig1_HTML.gif

图 8-1。

Every Angular application is a module, and Angular modules may specify other modules as dependencies

指定引导模块

物理世界中的重大建筑项目通常始于基石的铺设,即所有其他块围绕其设置的第一块基石。类似地,每个 Angular 项目都有自己的基石——代表应用本身的模块。初始化该模块(及其依赖项)的过程被称为 Angular 的“引导”过程,可以通过两种可能的方式之一启动。

自动引导

回头参考清单 8-2 ,注意附在页面html标签上的ng-app指令。当这个页面完成加载后,Angular 将自动检查这个指令是否存在。如果找到了,它引用的模块将作为应用的基础模块——代表应用本身的模块。该模块将自动初始化,此时应用将准备就绪。

手动引导

对于大多数应用,Angular 的自动引导过程应该足够了。然而,在某些情况下,对该过程何时发生进行更大程度的控制可能是有用的。在这种情况下,可以手动启动 Angular 的引导过程,如清单 8-5 所示。

Listing 8-5. Deferring Angular’s Bootstrap Process Until the Completion of an Initial jQuery-based AJAX Request

$.ajax({

'url': '/api/data',

'type': 'GET'

}).done(function() {

angular.bootstrap(document, ['app']);

});

在这个例子中,我们推迟 Angular 的引导过程,直到一个初始 AJAX 请求完成。只有这样,我们才调用 Angular 的bootstrap()方法,传递一个 DOM 对象作为我们应用的容器(它的“根元素”),以及一个数组,指定一个名为app(代表我们应用的模块)的模块作为依赖项。

Note

大多数情况下,角度应用将作为页面中唯一的应用存在;但是,多个角度应用可以在同一页面中共存。当他们这样做时,只有一个人可以利用 Angular 的自动引导过程,而其他人必须在适当的时间手动引导自己。

指令:DOM 的抽象层

通过使用原型继承,JavaScript 为开发人员提供了一种创建带有自定义内置行为的命名函数(类的 JavaScript 等价物)的机制。然后,其他开发人员可以实例化和使用这样的类,而不需要理解它们的内部工作原理。清单 8-6 中的例子演示了这个过程。

Listing 8-6. Prototypal Inheritance in Action

// example-prototype/index.js

function Dog() {

}

Dog.prototype.bark = function() {

console.log('Dog is barking.');

};

Dog.prototype.wag = function() {

console.log('Tail is wagging.');

};

Dog.prototype.run = function() {

console.log('Dog is running.');

};

var dog = new Dog();

dog.bark();

dog.wag();

dog.run();

这个将复杂行为抽象到简单接口背后的过程是一个基本的面向对象编程概念。同样,Angular 指令可以被视为 DOM 的抽象层,它为开发人员提供了一种创建复杂 web 组件的机制,只需使用简单的 HTML 标记就可以使用这些组件。清单 8-7 提供了一个例子,应该有助于澄清这个概念。

Listing 8-7. Example Demonstrating the Creation of a Simple Angular Directive

// example-directive1/public/index.html

<!DOCTYPE html>

<html lang="en" ng-app="app">

<head>

<meta charset="utf-8">

<title>Example Directive</title>

<link rel="stylesheet" href="/css/style.css">

<link rel="stylesheet" href="/bower_components/bootstrap/dist/css/bootstrap.css">

</head>

<body class="container">

<news-list></news-list>

<script src="/bower_components/angularjs/angular.js"></script>

<script>

var app = angular.module('app', []);

app.directive('newsList', function() {

return {

'restrict': 'E',

'replace': true,

'templateUrl': '/templates/news-list.html'

};

});

</script>

</body>

</html>

在为我们的应用创建了一个模块之后,我们通过调用我们的模块的directive()方法,传递一个名称和一个工厂函数来定义一个新的指令,这个工厂函数负责将描述我们的新指令的对象返回给 Angular。我们的工厂函数返回的对象可能会指定几个不同的选项,但是在这个简单的示例中,只使用了三个选项:

  • restrict:指定该指令是否应该与 Angular 找到的匹配属性(A)、类(C)或 DOM 元素(E)成对出现(或者三者的任意组合)。在这个例子中,E的值指定 Angular 应该只将我们的指令与标记名匹配的 DOM 元素配对。通过传递AEC,我们可以很容易地指定所有三个。
  • replace:值为true表示我们的组件应该完全替换与之配对的 DOM 元素。值false将允许我们创建一个指令,以某种方式简单地增加一个现有的 DOM 元素,而不是用别的东西完全替换它。
  • Angular 在这个 URL 上找到的标记一旦被插入到 DOM 中,就会代表我们的指令。也可以通过使用template选项直接传递模板的内容。

Note

关于我们新指令的名称,注意当我们在 Angular 中创建它时使用了 camelCase 格式,当我们在 HTML 中引用它时使用了破折号分隔的格式。这种差异是由于 HTML 标记不区分大小写的特性造成的。当 Angular 解析我们的 HTML 时,它会自动为我们解决命名约定中的这些差异。

现在,当我们在浏览器中加载应用时,Angular 会自动将新定义的指令与它找到的任何匹配的 DOM 元素配对。因此,<news-list>标签的所有实例将被图 8-2 中所示的元素替换。

A978-1-4842-0662-1_8_Fig2_HTML.jpg

图 8-2。

Our newly defined directive

我们刚刚讨论的基本示例只不过是用不同的模板替换了一个定制的 DOM 元素(我们将在下一节中通过添加我们自己的定制逻辑来构建这个示例)。但是,您应该已经开始注意到角度指令给开发人员带来的强大功能和便利。通过使用清单 8-7 中所示的简单标签将复杂组件注入应用的能力为开发人员提供了一种方便的机制,用于抽象简单外观背后的复杂功能,从而更易于管理。

掌握控制权

在上一节中,我们逐步创建了一个简单的 Angular 指令,最终,它只是用我们选择的单独模板替换了一个自定义 DOM 元素。这是指令本身的一个有用的应用,但是为了理解指令的全部功能,我们需要通过应用我们自己的定制逻辑来进一步应用这个例子,这将允许我们的指令实例以有趣的方式运行。我们可以在示波器和控制器的帮助下做到这一点。

范围和原型继承

一开始,角度范围可能有点难以理解,因为它们直接关系到 JavaScript 的一个更令人困惑的方面:原型继承。Angular 的新手经常会发现作用域是一个比较混乱的概念,但是对它们的深入理解对于使用这个框架来说是必不可少的。在我们继续之前,让我们花几分钟时间来探索它们的目的和工作原理。

在大多数“经典的”面向对象语言中,继承是通过使用类来完成的。另一方面,JavaScript 实现了一种完全不同的继承结构,称为原型继承,其中所有的继承都是通过使用对象和函数来完成的。清单 8-8 展示了这个过程的一个实例。

Listing 8-8. Example of Prototypal Inheritance, in Which Car Extends Vehicle

// example-prototype2/index.js

/**

* @class Vehicle

*/

var Vehicle = function Vehicle() {

console.log(this.constructor.name, 'says: I am a vehicle.');

};

Vehicle.prototype.start = function() {

console.log('%s has started.', this.constructor.name);

};

Vehicle.prototype.stop = function() {

console.log('%s has stopped.', this.constructor.name);

};

/**

* @class Car

*/

var Car = function Car() {

console.log(this.constructor.name, 'says: I am a car.');

Vehicle.apply(this, arguments);

};

Car.prototype = Object.create(Vehicle.prototype);

Car.prototype.constructor = Car;

Car.prototype.honk = function() {

console.log('%s has honked.', this.constructor.name);

};

var vehicle = new Vehicle();

vehicle.start();

vehicle.stop();

var car = new Car();

car.start();

car.honk();

car.stop();

/* Result:

Vehicle says: I am a vehicle.

Vehicle has started.

Vehicle has stopped.

Car says: I am a car.

Car says: I am a vehicle.

Car has started.

Car has honked.

Car has stopped.

*/

在这个例子中,定义了一个Vehicle函数。我们通过扩充它的原型来给它分配start()stop()实例方法。之后,我们定义了一个Car函数,只是这一次,我们用一个继承自Vehicle的函数替换了它的原型。最后,我们给Car分配一个honk实例方法。当运行这个例子时,请注意这样一个事实,即Vehicle的新实例可以启动和停止,而Car的新实例可以启动、停止和鸣响。这是工作中的原型继承。

这是一个需要掌握的重要概念——在 Angular 的引导阶段,会发生一个类似的过程,创建一个父对象(称为$rootScope)并附加到应用的根元素。之后,Angular 将继续解析 DOM 以搜索指令(Angular 将这个过程称为“编译”)。当遇到这些指令时,Angular 将创建继承自其最近祖先的新对象,并将它们分配给每个指令所附加的 DOM 元素。实际上,Angular 为我们应用中的每个组件创建了一个特殊的沙箱——用 Angular 的术语来说,就是一个“范围”。结果可以被可视化为类似于图 8-3 所示的东西。

A978-1-4842-0662-1_8_Fig3_HTML.jpg

图 8-3。

A web application with various components created with the help of directives. On the right, portions of the DOM are highlighted where new scopes have been created

用控制器操纵范围

角度控制器只不过是一个函数,它的唯一目的是操纵一个范围对象,在这里我们可以开始为我们的应用组件添加一些智能。清单 8-9 展示了我们在清单 8-7 中看到的例子的扩展版本。唯一的区别是向负责描述我们的指令的对象添加了一个controller属性。该指令使用的模板内容如清单 8-10 所示。

Listing 8-9. Extended Version of Listing 8-7 Example That Adds Custom Behavior to Our New Directive

// example-directive2/public/index.html

<!DOCTYPE html>

<html lang="en" ng-app="app">

<head>

<meta charset="utf-8">

<title>Example Directive</title>

<link rel="stylesheet" href="/css/style.css">

<link rel="stylesheet" href="/bower_components/bootstrap/dist/css/bootstrap.css">

</head>

<body class="container">

<news-list></news-list>

<script src="/bower_components/angularjs/angular.js"></script>

<script>

var app = angular.module('app', []);

app.directive('newsList', function() {

return {

'restrict': 'E',

'replace': true,

'controller': function($scope, $http) {

$http.get('/api/news').then(function(result) {

$scope.items = result.data;

});

},

'templateUrl': '/templates/news-list.html'

};

});

</script>

</body>

</html>

Listing 8-10. Contents of Our Directive’s Template

// example-directive2/public/templates/news-list.html

<div class="row">

<div class="col-xs-8">

<div ng-repeat="item in items">

<div class="media">

<div class="media-left">

<a href="#">

<img class="media-object" ng-src="{{item.img}}">

</a>

</div>

<div class="media-body">

<h4 class="media-heading" ng-bind="item.title"></h4>

</div>

</div>

</div>

</div>

</div>

在清单 8-9 中,请特别注意我们为指令的controller属性分配函数的部分。请注意,我们的控制器函数接收两个参数:$scope$http。目前,不要关心这些参数是如何传递给我们的控制器的——我们将在接下来的服务部分讨论这个问题。现在,要意识到的重要事情是,在我们的控制器中,$scope变量指的是 Angular 在 DOM 中第一次遇到我们的指令时自动为我们创建的对象。在这一点上,我们的控制器有机会改变该对象,结果,由于 Angular 对双向数据绑定的支持,可以看到这些变化反映在 DOM 中。

双向数据绑定

数据绑定描述了 Angular 将模板与 JavaScript 对象(即范围)链接起来的能力,允许模板引用范围内的属性,然后将这些属性呈现给浏览器。图 8-4 说明了这一过程。

A978-1-4842-0662-1_8_Fig4_HTML.gif

图 8-4。

Process by which data binding allows Angular applications to render data that is referenced within a scope object

Angular 对数据绑定的支持并不仅限于这种单向过程,即在一个范围内引用的数据显示在一个视图中。该框架还提供实现相反效果的指令,允许指令的范围随着其视图内发生的变化而更新(例如,当表单字段的值改变时)。当 Angular 的数据绑定实现被描述为“双向”时,这就是所指的。

Note

双向数据绑定的主题将在本章后面的“创建复杂表单”一节中详细讨论。

在清单 8-9 中,我们的控制器使用 Angular 的$http服务从我们的 API 中获取一个数组,该数组包含来自国家公共电台和 The Onion 的标题。然后,它将该数组赋给我们指令的$scope对象的items属性。要查看这些信息如何在 DOM 中得到反映,请注意清单 8-10 中显示的ng-repeat指令。这个核心 Angular 指令允许我们从模板中迭代数组,为数组中包含的每一项创建新的<div class="media">...</div>元素。最后,Angular 内置的ng-srcng-bind指令允许我们动态地将图像 URL 和文本内容分配给模板中适当的元素。

在浏览器中加载该应用后的最终结果如图 8-5 所示。

A978-1-4842-0662-1_8_Fig5_HTML.jpg

图 8-5。

Our application after having been loaded in the browser

通过服务和依赖注入实现松散耦合

在上一节中,我们介绍了将 Angular 应用组织为一系列嵌套作用域的基本过程,这些作用域可以由控制器操作,并通过双向数据绑定由模板引用。仅使用这些概念,有可能构建相当简单的应用(如本章中包含的一些示例所示),但是如果没有计划,构建任何更复杂的应用的尝试将很快陷入成长的烦恼。在这一节中,我们将发现服务如何支持开发人员构建松散耦合的角度应用,以适应增长。

依赖注入

在我们深入研究服务之前,有必要花一点时间来讨论一下依赖注入,这是一个对于客户端框架来说相当新的概念,Angular 非常依赖它。

首先,看一下清单 8-11 ,它显示了一个非常基本的 Node.js 应用,只有一个依赖项,即fs模块。在这个例子中,我们的模块负责通过require()方法检索fs模块。

Listing 8-11. Node.js Application That Depends on the fs Module

var fs = require('fs');

fs.readFile('∼/data.txt', 'utf8', function(err, contents) {

if (err) throw new Error(err);

console.log(contents);

});

我们在这里看到的模式,其中一个模块“需要”一个依赖,直观上是有意义的。一个模块需要另一个组件,所以它出去得到它。然而,依赖注入的概念颠覆了这个概念。清单 8-12 显示了 Angular 中依赖注入的一个简单例子。

Listing 8-12. Dependency Injection in Action Within Angular

var app = angular.module('app', []);

app.controller('myController', function($http) {

$http.get('/api/news').then(function(result) {

console.log(result);

});

});

像 Angular 这样实现依赖注入的框架规定了一个通用的模式,通过这个模式,模块可以将自己注册到一个中心控制点。换句话说,当一个应用被初始化时,模块有机会说,“这是我的名字,你可以在这里找到我。”之后,在程序执行的整个过程中,加载的模块可以简单地通过将它们指定为构造函数(或类)的参数来引用它们的依赖关系。它们被指定的顺序没有区别。

回头参考清单 8-12 。在这个例子中,我们创建了一个新的app模块来表示我们的应用。接下来,我们在应用的模块中创建一个名为myController的控制器,传递一个构造函数,每当需要一个新实例时就会调用这个函数。注意传入控制器构造函数的$http参数;这是工作中依赖注入的一个例子。我们的控制器所指的$http依赖项是 Angular 的核心代码库中包含的一个模块。在我们的应用的引导阶段,Angular 以服务的形式注册了这个模块——与您将要学习如何为自己创建的服务类型相同。

Note

按照惯例,Angular 提供的核心服务、API、属性都以$为前缀。为了防止可能的冲突,最好在您自己的代码中避免遵循此约定。

瘦控制器和胖服务

再看一下清单 8-9 ,它展示了使用控制器为应用的指令增加智能的过程。在这个例子中,我们的控制器创建了一个 AJAX 请求,从我们的 API 返回一组新闻标题。虽然这样做可行,但是这个例子并没有解决在整个应用中共享这些信息的真实和可预见的需求。

虽然我们可以让其他感兴趣的组件自己复制这个 AJAX 请求,但这并不理想,原因有很多。如果我们能够将收集这些标题的逻辑抽象到一个可以在整个应用中重用的集中式 API 中,我们会处于一个更好的位置。这样做将为我们提供许多好处,包括能够在 API 的消费者不知道的情况下,在单个位置更改获取这些信息的 URL。

我们马上就会看到,角度服务为我们提供了实现这一目标所需的工具。服务为我们提供了一种创建定义良好的接口的机制,这些接口可以在整个应用中共享和重用。当 Angular 应用的大部分逻辑以这种方式构建时,我们可以开始看到控制器的真实面目:只不过是一层薄薄的胶水,负责以对特定视图最有意义的方式将范围与服务绑定在一起。

在 Angular 中,存在三大类服务类型(其中一个被命名为“服务”),即工厂、服务和提供者。让我们来看看每一个。

工厂

清单 8-13 中所示的例子是在清单 8-9 的基础上构建的,它将获取标题所需的逻辑移到了一个工厂中。

Listing 8-13. Angular headlines Factory That Provides an API for Fetching News Headlines

// example-directive3/public/index.html

<!DOCTYPE html>

<html lang="en" ng-app="app">

<head>

<meta charset="utf-8">

<title>Example Directive</title>

<link rel="stylesheet" href="/css/style.css">

<link rel="stylesheet" href="/bower_components/bootstrap/dist/css/bootstrap.css">

</head>

<body class="container">

<news-list></news-list>

<script src="/bower_components/angularjs/angular.js"></script>

<script>

var app = angular.module('app', []);

app.directive('newsList', function() {

return {

'restrict': 'E',

'replace': true,

'controller': function($scope, headlines) {

headlines.fetch().then(function(items) {

$scope.items = items;

});

},

'templateUrl': '/templates/news-list.html'

};

});

app.factory('headlines', function($http) {

return {

'fetch': function() {

return $http.get('/api/news').then(function(result) {

return result.data;

});

}

};

});

</script>

</body>

</html>

在清单 8-13 中,headlines工厂用一个fetch()方法返回一个对象,当这个方法被调用时,它将查询我们的 API 以获取标题,并以承诺的形式返回它们。

在大多数角度应用中,工厂是最常用的服务类型。工厂第一次作为依赖项被引用时,Angular 将调用工厂的函数并将结果返回给请求者。对该服务的后续引用将收到与第一次引用该服务时最初返回的结果相同的结果。换句话说,工厂可以被认为是单例的,因为它们从来不会被调用超过一次。

服务

清单 8-14 中所示的例子是在清单 8-9 的基础上构建的,它将获取标题所需的逻辑转移到了一个服务中。

Listing 8-14. Angular headlines Service That Provides an API for Fetching News Headlines

// example-directive4/public/index.html

<!DOCTYPE html>

<html lang="en" ng-app="app">

<head>

<meta charset="utf-8">

<title>Example Directive</title>

<link rel="stylesheet" href="/css/style.css">

<link rel="stylesheet" href="/bower_components/bootstrap/dist/css/bootstrap.css">

</head>

<body class="container">

<news-list></news-list>

<script src="/bower_components/angularjs/angular.js"></script>

<script>

var app = angular.module('app', []);

app.directive('newsList', function() {

return {

'restrict': 'E',

'replace': true,

'controller': function($scope, headlines) {

headlines.fetch().then(function(items) {

$scope.items = items;

});

},

'templateUrl': '/templates/news-list.html'

};

});

app.service('headlines', function($http) {

this.fetch = function() {

return $http.get('/api/news').then(function(result) {

return result.data;

});

};

});

</script>

</body>

</html>

在 Angular 中,服务的功能几乎和工厂一样,只有一个关键的区别。虽然简单地调用工厂函数,但是服务函数是通过关键字new作为构造函数调用的,允许它们以实例化的类的形式定义。您选择使用哪一种很大程度上取决于风格偏好,因为两者可以实现相同的最终结果。

在这个例子中,我们没有像在工厂中那样返回一个对象,而是将一个fetch()方法分配给this,这个对象最终由我们的服务的构造函数返回。

提供者

清单 8-15 中所示的例子是在清单 8-9 的基础上构建的,它将获取标题所需的逻辑移到了一个提供者中。

Listing 8-15. Angular headlines Provider That Provides an API for Fetching News Headlines

// example-directive5/public/index.html

<!DOCTYPE html>

<html lang="en" ng-app="app">

<head>

<meta charset="utf-8">

<title>Example Directive</title>

<link rel="stylesheet" href="/css/style.css">

<link rel="stylesheet" href="/bower_components/bootstrap/dist/css/bootstrap.css">

</head>

<body class="container">

<news-list></news-list>

<script src="/bower_components/angularjs/angular.js"></script>

<script>

var app = angular.module('app', []);

app.directive('newsList', function() {

return {

'restrict': 'E',

'replace': true,

'controller': function($scope, headlines) {

headlines.fetch().then(function(items) {

$scope.items = items;

});

},

'templateUrl': '/templates/news-list.html'

};

});

app.config(function(headlinesProvider) {

headlinesProvider.limit = 10;

});

app.provider('headlines', function() {

this.$get = function($http) {

var self = this;

return {

'fetch': function() {

return $http.get('/api/news', {

'params': {

'limit': self.limit || 20

}

}).then(function(result) {

return result.data;

});

}

};

};

});

</script>

</body>

</html>

与工厂和服务完全负责确定自己的设置不同,Angular 提供程序允许开发人员在其父模块的配置阶段配置它们。这样,提供者可以被认为是可配置的工厂。在这个例子中,我们定义了一个headlines提供者,它的功能与我们在清单 8-13 中创建的工厂相同,只是这一次,fetch()方法将一个可配置的limit参数传递给我们的 API,允许它指定它将接收的结果数量的限制。

在清单 8-15 中,我们在提供者中的this.$get处定义了一个工厂函数。当headlines提供者作为依赖项被引用时,Angular 将调用这个函数并将其结果返回给请求者,就像它在清单 8-13 中对我们的工厂所做的那样。相比之下,请注意我们的提供者的fetch()方法是如何引用在模块的config块中定义的limit属性的。

创建路线

用 Angular 等框架构建的所谓“单页应用”为用户提供了更类似于传统桌面应用的流畅体验。他们通过预加载所有(或大部分)所需的各种资源(例如,脚本、样式表等)来实现这一点。)在单个预先页面加载中。然后,对不同 URL 的后续请求被拦截,并通过后台 AJAX 请求进行处理,而不需要完全刷新页面。在本节中,您将学习如何在 Angular 的ngRoute模块的帮助下管理这样的请求。

清单 8-16 建立在之前清单 8-13 中显示的例子之上。然而,这一次,我们在应用中添加了两条路径,允许用户导航到标有“仪表板”和“新闻标题”的部分只有在用户导航到/#/headlines路线后,我们的newsList指令才会被注入页面。为实现这一目标,采取了以下步骤:

Define a configuration block that will be executed during our application’s bootstrap phase. Within this function, we reference the $routeProvider service provided by Angular’s angular-route package, which must be installed in addition to Angular’s core library.   Define an array, routes, within which objects are placed that define the various routes to be made available by our application. In this example, each object’s route property defines the location at which the route will be loaded, while the config property allows us to specify a controller function and template to be loaded at the appropriate time.   Iterate through each entry of the routes array and pass the appropriate properties to the when() method made available by the $routeProvider service. This approach provides us with a simple method by which multiple routes can be defined. Alternatively, we could have made two separate, explicit calls to the $routeProvider.when() method without using an array.   Utilize the $routeProvider.otherwise() method to define a default route to be loaded in the event that no route (or an invalid route) is referenced by the user.   Listing 8-16. Angular Application That Defines Two Routes, dashboard and headlines

// example-router1/public/index.html

<!DOCTYPE html>

<html lang="en" ng-app="app">

<head>

<meta charset="utf-8">

<title>Routing Example</title>

<link rel="stylesheet" href="/css/style.css">

<link rel="stylesheet" href="/bower_components/bootstrap/dist/css/bootstrap.css">

</head>

<body class="container">

<ng-view></ng-view>

<script src="/bower_components/angularjs/angular.js"></script>

<script src="/bower_components/angular-route/angular-route.js"></script>

<script src="/modules/news-list.js"></script>

<script>

var app = angular.module('app', ['ngRoute', 'newsList']);

app.config(function($routeProvider) {

var routes = [

{

'route': '/dashboard',

'config': {

'templateUrl': '/templates/dashboard.html'

}

},

{

'route': '/headlines',

'config': {

'controller': function($log) {

$log.debug('Welcome to the headlines route.');

},

'templateUrl': '/templates/headlines.html'

}

}

];

routes.forEach(function(route) {

$routeProvider.when(route.route, route.config);

});

$routeProvider.otherwise({

'redirectTo': '/dashboard' // Our default route

});

});

</script>

</body>

</html>

路线参数

实际上,在典型的角度应用中存在的大多数路线被设计成提供基于每条路线期望的一个或多个参数值而变化的动态内容。清单 8-17 中所示的例子演示了如何实现这一点。

Listing 8-17. Angular Application with Routes That Vary Their Content Based on the Value of an Expected Parameter

// example-router2/public/index.html

<!DOCTYPE html>

<html lang="en" ng-app="app">

<head>

<meta charset="utf-8">

<title>Routing Example</title>

<link rel="stylesheet" href="/css/style.css">

<link rel="stylesheet" href="/bower_components/bootstrap/dist/css/bootstrap.css">

</head>

<body class="container">

<ng-view></ng-view>

<script src="/bower_components/angularjs/angular.js"></script>

<script src="/bower_components/angular-route/angular-route.js"></script>

<script>

var app = angular.module('app', ['ngRoute']);

app.config(function($routeProvider) {

var routes = [{

'route': '/dashboard',

'config': {

'templateUrl': '/templates/dashboard.html',

'controller': function($scope, $http) {

return $http.get('/api/animals').then(function(result) {

$scope.animals = result.data;

});

},

}

},

{

'route': '/animals/:animalID',

'config': {

'templateUrl': '/templates/animal.html',

'controller': function($scope, $route, $http) {

$http.get('/api/animals/' + $route.current.params.animalID).then(function(result) {

$scope.animal = result.data;

});

}

}

}];

routes.forEach(function(route) {

$routeProvider.when(route.route, route.config);

});

$routeProvider.otherwise({

'redirectTo': '/dashboard' // Our default route

});

});

</script>

</body>

</html>

路线解析

如果操作正确,单页应用可以为用户提供比标准应用更好的体验。也就是说,这些改进不是没有代价的。在单页应用的整个生命周期中,协调各种 API 调用是非常具有挑战性的。在我们继续之前,让我们接触一下 Angular 的ngRoute模块提供的一个特别有用的特性,它可以帮助我们驯服这种复杂性:分辨率。

解决方案允许我们定义一个或多个步骤,这些步骤必须在转换到特定路线之前发生。如果为路径定义的任何解析碰巧返回承诺,则只有在每个解析完成后,到所需路径的转换才会完成。清单 8-18 中所示的示例显示了实际的路由解析。

Listing 8-18. Route Resolutions in Action

// example-router3/public/index.html

<!DOCTYPE html>

<html lang="en" ng-app="app">

<head>

<meta charset="utf-8">

<title>Routing Example</title>

<link rel="stylesheet" href="/css/style.css">

<link rel="stylesheet" href="/bower_components/bootstrap/dist/css/bootstrap.css">

</head>

<body class="container">

<ng-view></ng-view>

<script src="/bower_components/angularjs/angular.js"></script>

<script src="/bower_components/angular-route/angular-route.js"></script>

<script>

var app = angular.module('app', ['ngRoute']);

app.config(function($routeProvider) {

$routeProvider.when('/dashboard', {

'templateUrl': '/templates/dashboard.html',

'controller': function($scope, animals, colors) {

$scope.animals = animals;

$scope.colors = colors;

},

'resolve': {

'animals': function($http) {

return $http.get('/api/animals').then(function(result) {

return result.data;

});

},

'colors': function($http) {

return $http.get('/api/colors').then(function(result) {

return result.data;

});

}

}

});

$routeProvider.otherwise({

'redirectTo': '/dashboard' // Our default route

});

});

</script>

</body>

</html>

在本例中,定义了一条路线,在对 API 进行两次相应的调用以获取该信息后,该路线显示动物和颜色的列表。我们在 route 的resolve对象中创建请求,而不是直接从 route 的控制器中请求这些信息。因此,当我们的路由控制器函数被调用时,我们可以肯定地知道请求已经完成。

创建复杂表单

HTML 表单很难管理。一个主要关注点是验证,通过该过程,用户可以意识到问题(例如,未填写的必填字段)并被引导到问题的解决方案。此外,复杂的表单经常需要额外的逻辑,允许它们根据用户对前面问题的回答来改变内容。在接下来的几页中,我们将通过几个例子来展示 Angular 如何帮助简化这些挑战。

确认

设计良好的 HTML 表单会仔细考虑用户体验。他们没有假设用户完全理解他们被要求做什么。当存在问题时,他们还会特意通知用户,以及解决问题所需的步骤。幸运的是,Angular 的声明性语法允许开发人员轻松创建遵守这些规则的表单。

清单 8-19 显示了我们第一个例子的 HTML,而清单 8-20 显示了附带的控制器。

Listing 8-19. HTML Form That Implements Validation and Displays Dynamic Feedback to the User

// example-form1/public/index.html

<!DOCTYPE html>

<html lang="en" ng-app="app">

<head>

<meta charset="utf-8">

<title>Example Form</title>

<link rel="stylesheet" href="/css/style.css">

</head>

<body ng-controller="formController">

<form name="myForm" ng-class="formClass" ng-submit="submit()" novalidate>

<div class="row">

<div ng-class="{

'has-error': !myForm.first_name.$pristine && !myForm.first_name.$valid,

'has-success': !myForm.first_name.$pristine && myForm.first_name.$valid

}">

<label>First Name</label>

<input

type="text"

name="first_name"

ng-model="model.first_name"

class="form-control"

ng-minlength="3"

ng-maxlength="15"

ng-required="true">

<p ng-show="

!myForm.first_name.$pristine &&

myForm.first_name.$error.required">

First name is required.

</p>

<p ng-show="

!myForm.first_name.$pristine &&

myForm.first_name.$error.minlength">

First name must be at least 3 characters long.

</p>

<p ng-show="

!myForm.first_name.$pristine &&

myForm.first_name.$error.maxlength">

First name can have no more than 15 characters.

</p>

</div>

<div ng-class="{

'has-error': !myForm.last_name.$pristine && !myForm.last_name.$valid,

'has-success': !myForm.last_name.$pristine && myForm.last_name.$valid

}">

<label>Last Name</label>

<input

type="text"

name="last_name"

ng-model="model.last_name"

class="form-control"

ng-minlength="3"

ng-maxlength="15"

ng-required="true">

<p ng-show="

!myForm.last_name.$pristine &&

myForm.last_name.$error.required">

Last name is required.

</p>

<p ng-show="

!myForm.last_name.$pristine &&

myForm.last_name.$error.minlength">

Last name must be at least 3 characters long.

</p>

<p ng-show="

!myForm.last_name.$pristine &&

myForm.last_name.$error.maxlength">

Last name can have no more than 15 characters.

</p>

</div>

</div>

<div class="row">

<div>

<button type="submit" ng-disabled="myForm.$invalid">Submit</button>

<button type="button" ng-click="reset()">Reset</button>

</div>

</div>

</form>

<hr>

<div class="output" ng-bind="output"></div>

<script src="/bower_components/angularjs/angular.js"></script>

<script src="/app/index.js"></script>

</body>

</html>

Listing 8-20. Controller That Has Been Attached to the Document’s <body> Element

// example-form1/public/app/index.js

var app = angular.module('app', []);

app.controller('formController', function($scope, $http, $log) {

$scope.formClass = null;

$scope.model = {};

$http.get('/api/model').then(function(result) {

$scope.model = result.data;

});

$scope.submit = function() {

if (!$scope.myForm.$valid) return;

$http.post('/api/model', {

'model': $scope.model

}).then(function() {

alert('Form submitted.');

}).catch(function(err) {

alert(err);

});

};

$scope.reset = function() {

$scope.model = {};

$http.post('/api/model', {

'model': $scope.model

});

};

/**

* Angular’s built-in $watch() method (available within every controller)

* enables us to watch for and respond to changes that occur within variables

* defined at the $scope level. Here we save the contents of our a form as

* a JSON string to $scope.output, which is referenced by our template.

*/

$scope.$watch('model', function() {

$scope.output = angular.toJson($scope.model, 4);

}, true);

});

当 Angular 编译我们的应用时,它会将一个内置的form指令应用到模板中包含的<form>元素。该指令将创建一个特殊控制器FormController的新实例,该实例用于管理表单实例。最后,基于我们表单的name属性的值(在本例中是myForm),Angular 将把对新创建的FormController实例的引用分配给表单的父范围,允许我们的控制器在$scope.myForm与我们新创建的表单进行交互。

FormController的实例提供了许多有用的属性和方法,您可以在清单 8-19 和清单 8-20 中看到这些属性和方法。例如,请注意我们如何在ng-disabled指令的帮助下动态地启用或禁用表单的提交按钮。在这个例子中,我们设置了这个指令来引用表单的$invalid属性,该属性将总是返回TRUEFALSE来指示表单中包含的任何输入是否处于无效状态。

清单 8-19 还应用了额外的内置角度指令(ng-minlengthng-maxlengthng-required)来在我们的表单中实现一些简单的验证规则。在每个输入的正下方,我们的模板引用了myForm对象上的各种属性来确定当前存在什么错误(如果有的话)。基于这些信息,它可以向用户隐藏或显示适当的反馈。

请注意清单 8-19 中的ng-model指令在我们表单的每个输入字段上的使用。这个指令(专门设计用于表单控件)允许我们实现双向数据绑定,这个概念在本章前面已经简单提到过。随着在每个字段中输入的值发生变化,我们的范围也将更新,属性由ng-model引用。由于双向数据绑定,相反的效果也成立。如果我们的控制器要修改由ng-model引用的值,匹配的表单输入也会相应地更新。值得注意的是,ng-model指令是我们确定表单输入值的首选方法。在 Angular 中,输入的name属性仅用于验证目的。

图 8-6 、图 8-7 和图 8-8 显示了用户将在其浏览器中看到的最终结果。

A978-1-4842-0662-1_8_Fig8_HTML.jpg

图 8-8。

Our form in its final state, after the user has entered all of their information

A978-1-4842-0662-1_8_Fig7_HTML.jpg

图 8-7。

As the user enters their information, the form dynamically displays the appropriate feedback, based on the information that has been submitted. Here we notify the user that the “First Name” field should be at least three characters long

A978-1-4842-0662-1_8_Fig6_HTML.jpg

图 8-6。

Our form in its initial state. The example that is included with this chapter includes a preview of our scope’s model object that will automatically update as data is entered into the form

条件逻辑

表单通常需要额外的逻辑来确定在什么情况下应该显示某些问题或其他信息。一种常见的情况是,只有在用户选择了“电子邮件”作为首选联系方式后,表单才会要求用户输入电子邮件地址。我们的下一个例子,如清单 8-21 所示,将建立在前一个例子的基础上,展示如何通过使用ng-if指令来实现这样的逻辑。图 8-9 和图 8-10 显示了浏览器中渲染的最终结果。

A978-1-4842-0662-1_8_Fig10_HTML.jpg

图 8-10。

Our form displaying the appropriate input field once a value has been chosen for “Contact Method”

A978-1-4842-0662-1_8_Fig9_HTML.jpg

图 8-9。

The initial state of our form, before a value has been selected for “Contact Method” Listing 8-21. Excerpt from Our Example’s Template Showing the HTML Added to Our Previous Example

// example-form2/public/index.html

<div class="row">

<div ng-class="{

'has-error': !myForm.contact_method.$pristine && !myForm.contact_method.$valid,

'has-success': !myForm.contact_method.$pristine && myForm.contact_method.$valid

}">

<label>Contact Method</label>

<select

name="contact_method"

ng-model="model.contact_method"

ng-required="true">

<option value="">Select One</option>

<option value="email">Email</option>

<option value="phone">Phone</option>

</select>

<p ng-show="

!myForm.contact_method.$pristine &&

myForm.contact_method.$error.required">

Contact method is required.

</p>

</div>

<div ng-if="model.contact_method == 'email'" ng-class="{

'has-error': !myForm.email.$pristine && !myForm.email.$valid,

'has-success': !myForm.email.$pristine && myForm.email.$valid}">

<label>Email Address</label>

<input

type="email"

name="email"

ng-model="model.email"

ng-required="true">

<p ng-show="

!myForm.email.$pristine &&

myForm.email.$error.required">

Email address is required.

</p>

</div>

<div ng-if="model.contact_method == 'phone'" ng-class="{

'has-error': !myForm.phone.$pristine && !myForm.phone.$valid,

'has-success': !myForm.phone.$pristine && myForm.phone.$valid}">

<label>Phone Number</label>

<input

type="tel"

name="phone"

ng-model="model.phone"

ng-required="true">

<p ng-show="

!myForm.phone.$pristine &&

myForm.phone.$error.required">

Phone number is required.

</p>

</div>

</div>

可重复部分

对于最后一个例子,让我们看看 Angular 如何帮助我们根据用户的输入创建一个使用可重复部分的表单。在清单 8-22 中,我们创建了一个表单,要求用户为他们的每只宠物创建“类型”和“名字”条目。添加后,每个条目还会提供一个链接,允许用户删除它。

Listing 8-22. Template (and Accompanying Controller) Demonstrating the use of Repeatable Sections

// example-form3/public/index.html

<div class="row">

<div>

<h2>Pets</h2> <small><a ng-click="addPet()">Add Pet</a></small>

</div>

</div>

<div class="row" ng-repeat="pet in model.pets">

<div>

<label>Pet Type</label>

<select

ng-attr-name="pet_type{{$index}}"

ng-model="pet.type"

required>

<option value="">Select One</option>

<option value="cat">Cat</option>

<option value="dog">Dog</option>

<option value="Goldfish">Goldfish</option>

</select>

</div>

<div ng-class="{

'has-error': !myForm.last_name.$pristine && !myForm.last_name.$valid,

'has-success': !myForm.last_name.$pristine && myForm.last_name.$valid

}">

<label>

Pet’s Name <small class="pull-right">

<a ng-click="removePet(pet)">Remove Pet</a></small>

</label>

<input

type="text"

ng-attr-name="pet_name{{$index}}"

ng-model="pet.name"

ng-minlength="3"

ng-maxlength="15"

required>

<p ng-show="

!myForm.last_name.$pristine &&

myForm.last_name.$error.required">

Last name is required.

</p>

<p ng-show="

!myForm.last_name.$pristine &&

myForm.last_name.$error.minlength">

Last name must be at least 3 characters long.

</p>

<p ng-show="

!myForm.last_name.$pristine &&

myForm.last_name.$error.maxlength">

Last name can have no more than 15 characters.

</p>

</div>

</div>

// example-form5/public/app/index.js

$scope.addPet = function() {

$scope.model.pets.push({});

};

$scope.removePet = function(pet) {

$scope.model.pets.splice($scope.model.pets.indexOf(pet), 1);

};

在清单 8-22 中,我们使用 Angular 的ng-repeat指令来迭代作用域的model.pets数组中的条目。注意我们如何能够在由ng-repeat创建的范围内引用{{$index}}来确定我们在数组中的当前位置。使用这些信息,我们为每个条目分配一个唯一的名称,以便进行验证。

我们的模板在该部分的顶部为用户提供了一个全局的“添加宠物”链接,当单击该链接时,调用已经在我们的控制器中定义的addPet()方法。这样做会将一个空对象附加到我们的作用域的model.pets数组中。当我们的ng-repeat指令遍历每个条目时,我们也为用户提供了一个删除链接。单击这个链接将当前条目从我们的model.pets数组传递给我们作用域的removePet()方法,后者将它从数组中移除。

图 8-11 显示了浏览器中渲染的最终结果。

A978-1-4842-0662-1_8_Fig11_HTML.jpg

图 8-11。

Our final example, as presented to the user

摘要

在这一章的开始,我们花了一点时间来比较传统的,“命令式”开发方法和 Angular 喜欢的“声明式”方法。虽然每种方法都有其优点和缺点,但很难否认 Angular 的方法特别适合解决与表单开发相关的问题。这不是巧合。

随着时间的推移,Angular 已经慢慢发展成为一个能够支持大型应用的框架,但这并不是它的初衷。Angular 最初的重点实际上是形式发展,Angular 背后的共同创造者之一 miko he very 欣然承认。这是一个需要注意的重要事实,因为它说明了 Angular 特别适合的项目类型(以及那些可能存在更合适的替代方案的项目)。

自从最初发布以来,Angular 吸引了大量的评论,大部分是正面的。该框架对指令和依赖注入的实现对客户端开发的前景产生了巨大的影响,并提出了开发人员应该从类似框架中得到什么的巨大问题。

也就是说,一段时间以来,对框架提出合理批评的开发人员数量一直在稳步增长。这种批评主要围绕着与 Angular 使用所谓的“脏检查”作为双向数据绑定实现的一部分有关的性能问题。这种批评是公平的,因为 Angular 的双向数据绑定实现效率很低。然而,根据作者的经验,Angular 的性能对于它所设计的绝大多数用例来说是绰绰有余的。在这本书出版的时候,一个重要的重写版本(2.0 版)也正在进行中,这将解决许多问题,如果不是全部的话。

如果你目前想知道 Angular 是否适合你的项目,没有简单的“是”或“否”的答案;这完全取决于你的具体需求。不过,总的来说,我是个超级粉丝。基于 Web 的应用变得越来越复杂,功能也越来越丰富。只有当开发人员拥有工具,能够抽象出简单接口背后的复杂性时,他们才能创建和维护这样的应用。通过使用诸如指令之类的工具,Angular 以非常令人兴奋的方式将这个广为人知的概念扩展到了 DOM。

相关资源

  • 角度:??、??、??

九、Kraken

一个组织的学习能力,以及快速将学习转化为行动的能力,是最终的竞争优势。——杰克·韦尔奇

就开发平台而言,Node 仍然是这个领域的新生事物。但是,正如许多知名和受尊敬的组织所证明的那样,JavaScript 作为服务器端语言所带来的好处已经对他们开发和部署软件的方式产生了巨大的影响。在对 Node 的众多赞誉中,道琼斯的项目经理迈克尔·约尔马克宣称“简单的事实是 Node 重新发明了我们创建网站的方式。开发人员只需几天,而不是几周就能构建关键功能。”( https://www.joyent.com/blog/the-node-firm-and-joyent-offer-node-js-training

LinkedIn 移动工程总监 Kiran Prasad 表示:“在服务器端,我们的整个移动软件堆栈完全构建在 Node 中。一个原因是规模。第二个是 Node,它向我们展示了巨大的性能提升。”( https://nodejs.org/download/docs/v0.6.7/

Node 无疑在开发社区中产生了一些相当大的波澜,尤其是当您考虑到它相对年轻的时候。尽管如此,我们还是要明确一点:这个平台远非完美。JavaScript 非常具有表现力和灵活性,但是它的灵活性也很容易被滥用。虽然基于节点的项目享受着快速的开发周期和令人印象深刻的性能提升,但它们经常受到语言本身和整个开发社区整体缺乏约定的困扰。虽然这个问题在小型、集中的开发团队中可能不明显,但随着团队规模和分布的增长,它会很快出现——只要问问 PayPal ( www.paypal-engineering.com/2013/11/ )的工程总监 Jeff Harrell 就知道了:

We especially like the ubiquity of Express, but we find it doesn't expand well in many development teams. Express is nondescript, and it allows you to set up the server in any way you think fit. This is very flexible, but not consistent with large teams ... As time goes by, as more and more teams choose node.js and turn it into Kraken.js, we see the emergence of patterns; It is not a framework in itself, but a convention layer above express, allowing it to be extended to larger development organizations. We want our engineers to focus on building their applications, not just setting up their environment.

本章将向您介绍 Kraken,一个由 PayPal 开发人员为您带来的基于 Express 的应用的安全和可伸缩层。本章涵盖的主题包括

  • 环境感知配置
  • 基于配置的中间件注册
  • 结构化路线注册
  • 灰尘模板引擎
  • 国际化和本地化
  • 增强的安全技术

Note

Kraken 建立在 Express 的坚实基础之上,Express 是 Node 的极简 web 框架,它的 API 已经成为这一类框架事实上的标准。因此,本章假定读者已经对 Express 有了基本的工作熟悉。本章还讨论了本书 Grunt、Yeoman 和 Knex/Bookshelf 章节中的概念。如果您不熟悉这些主题,您可能希望在继续之前阅读这些章节。

环境感知配置

随着应用的开发、测试、试运行和部署,它们自然会经历一系列相应的环境,每个环境都需要自己独特的配置规则集。例如,考虑图 9-1 ,它展示了应用在持续集成和交付部署管道中移动的过程。

A978-1-4842-0662-1_9_Fig1_HTML.gif

图 9-1。

Application that requires unique settings based on its environment

随着图 9-1 中的应用在每个环境中前进,告诉它如何连接到它所依赖的各种外部服务的设置必须相应地改变。Kraken 的confit库通过为节点应用提供一个简单的、环境感知的配置层,为开发人员提供了实现这一目标的标准约定。

Confit 通过加载一个默认的 JSON 配置文件(通常命名为config.json)来运行。Confit 然后试图根据环境变量NODE_ENV的值加载一个额外的配置文件。如果找到特定于环境的配置文件,它指定的任何设置都会递归地与默认配置中定义的设置合并。

本章的confit-simple项目提供了一个简单的应用,它依赖于confit来确定其配置。清单 9-1 展示了confit初始化的过程,而清单 9-2 展示了项目的/config文件夹的内容,其中confit被指示搜索配置文件。

Listing 9-1. Initializing confit

// confit-simple/index.js

var confit = require('confit');

var prettyjson = require('prettyjson');

var path = require('path');

var basedir = path.join(__dirname, 'config');

confit(basedir).create(function(err, config) {

if (err) {

console.log(err);

process.exit();

}

console.log(prettyjson.render({

'email': config.get('email'),

'cache': config.get('cache'),

'database': config.get('database')

}));

});

Listing 9-2. Contents of the /config Folder

// Default configuration

// confit-simple/config/config.json

{

// SMTP server settings

"email": {

"hostname": "email.mydomain.com",

"username": "user",

"password": "pass",

"from": "My Application <noreply@myapp.com>"

},

"cache": {

"redis": {

"hostname": "cache.mydomain.com",

"password": "redis"

}

}

}

// Development configuration

// confit-simple/config/development.json

{

"database": {

"postgresql": {

"hostname": "localhost",

"username": "postgres",

"password": "postgres",

"database": "myapp"

}

},

"cache": {

"redis": {

"hostname": "localhost",

"password": "redis"

}

}

}

// Production configuration

// confit-simple/config/production.json

{

"database": {

"postgresql": {

"hostname": "db.myapp.com",

"username": "postgres",

"password": "super-secret-password",

"database": "myapp"

}

},

"cache": {

"redis": {

"hostname": "redis.myapp.com",

"password": "redis"

}

}

}

在继续之前,请注意我们项目的默认配置文件在email属性下提供了电子邮件服务器的连接设置,而项目的特定于环境的配置文件都没有提供这样的信息。相比之下,默认配置在嵌套的cache:redis属性下为 Redis 缓存服务器提供连接设置,而两种特定于环境的配置都为此属性提供覆盖信息。

还要注意,默认配置文件在email属性上方包含一个注释。注释不是 JSON 规范的一部分,如果我们试图使用 Node 的require()方法来解析这个文件的内容,通常会导致抛出错误。然而,Confit 会在试图解析文件之前去掉这样的注释,允许我们根据需要在配置中嵌入注释。

清单 9-3 显示了当项目在NODE_ENV环境变量设置为development的情况下运行时,记录到控制台的输出。

Listing 9-3. Running the confit-simple Project in development Mode

$ export NODE_ENV=development && node index

email:

hostname: email.mydomain.com

username: user

password: pass

from:     My Application <noreply@myapp.com>

cache:

redis:

hostname: localhost

password: redis

database:

postgresql:

hostname: localhost

username: postgres

password: postgres

database: myapp

Note

在清单 9-3 中,从终端运行$ export NODE_ENV=development来设置NODE_ENV环境变量的值。该命令仅适用于 Unix 和类 Unix 系统(包括 OS X)。Windows 用户将需要运行$ set NODE_ENV=development。同样重要的是要记住,如果没有设置NODE_ENV环境变量,confit将假设应用运行在development环境中。

如清单 9-3 所示,confit通过将config/development.json环境配置文件的内容与默认的config/config.json文件合并来编译我们项目的配置对象,优先于development.json中指定的任何设置。因此,我们的配置对象继承了仅存在于config.json中的email设置,以及在开发环境的配置文件中定义的cachedatabase设置。在清单 9-1 中,这些设置通过使用配置对象的get()方法来访问。

Note

除了访问顶级配置设置(例如,database,如清单 9-1 所示),我们的配置对象的get()方法还可以用来访问使用:作为分隔符的深层嵌套配置设置。例如,我们可以用config.get('database:postgresql')直接引用项目的postgresql设置。

在清单 9-4 中,我们再次运行confit-simple项目,只是这次我们用值production设置了NODE_ENV环境变量。正如所料,输出显示我们的配置对象从config.json继承了email属性,同时也从production.json继承了cachedatabase属性。

Listing 9-4. Running the confit-simple Project in production Mode

$ export NODE_ENV=production && node index

email:

hostname: email.mydomain.com

username: user

password: pass

from:     My Application <noreply@myapp.com>

cache:

redis:

hostname: redis.myapp.com

password: redis

database:

postgresql:

hostname: db.myapp.com

username: postgres

password: super-secret-password

database: myapp

游击手

正如前面的例子所示,Confit 是为处理 JSON 配置文件而设计的。作为一种配置格式,JSON 很容易使用,但是在灵活性方面偶尔会有一些不足。Confit 有益地弥补了这个缺点,它支持插件,称之为“游击手处理程序”。举例来说,考虑清单 9-5 ,其中使用了包含在confit’s核心库中的两个游击手处理程序importconfig

Listing 9-5. Demonstrating the Use of the import and config Shortstop Handlers

// confit-shortstop/config/config.json

{

// The import handler allows us to set a property’s value to the contents

// of the specified JSON configuration file.

"app": "import:./app",

// The config handler allows us to set a property’s value to that of the

// referenced property. Note the use of the . character as a delimiter,

// in this instance.

"something_else": "config:app.base_url"

}

// confit-shortstop/config/app.json

{

// The title of the application

"title": "My Demo Application",

// The base URL at which the web client can be reached

"base_url": "https://myapp.com

// The base URL at which the API can be reached

"base_api_url": "https://api.myapp.com

}

清单 9-6 显示了本章的confit-shortstop项目运行时打印到控制台的输出。在这个例子中,import shortstop 处理程序允许我们用一个单独的 JSON 文件的内容填充app属性,使我们能够将特别大的配置文件分解成更小、更容易管理的组件。config处理程序允许我们通过引用另一个部分中预先存在的值来设置配置值。

Listing 9-6. Output of This Chapter’s confit-shortstop Project

$ node index.js

app:

title:        My Demo Application

base_url:https://myapp.com

base_api_url:https://api.myapp.com

something_else:https://myapp.com

虽然confit本身只包括对我们刚刚提到的两个游击手处理程序(importconfig)的支持,但是在shortstop-handlers模块中可以找到几个非常有用的附加处理程序。我们来看四个例子。

清单 9-7 显示了本章confit-shortstop-extras项目的主脚本(index.js)。这个脚本很大程度上反映了我们已经在清单 9-1 中看到的那个,有一些小的不同。在这个例子中,额外的处理程序是从shortstop-handlers模块导入的。此外,不是通过传递项目的config文件夹(basedir)的路径来实例化confit,而是传递一个选项对象。在这个对象中,我们继续为basedir指定一个值,但是我们也传递一个protocols对象,为confit提供我们想要使用的附加游击手处理程序的引用。

Listing 9-7. index.js Script from the confit-shortstop-extras Project

// confit-shortstop-extras/index.js

var confit = require('confit');

var handlers = require('shortstop-handlers');

var path = require('path');

var basedir = path.join(__dirname, 'config');

var prettyjson = require('prettyjson');

confit({

'basedir': basedir,

'protocols': {

// The file handler allows us to set a property’s value to the contents

// of an external (non-JSON) file. By default, the contents of the file

// will be loaded as a Buffer.

'file': handlers.file(basedir /* Folder from which paths should be resolved */, {

'encoding': 'utf8' // Convert Buffers to UTF-8 strings

}),

// The require handler allows us to set a property’s value to that

// exported from a module.

'require': handlers.require(basedir),

// The glob handler allows us to set a property’s value to an array

// containing files whose names match a specified pattern

'glob': handlers.glob(basedir),

// The path handler allows us to resolve relative file paths

'path': handlers.path(basedir)

}

}).create(function(err, config) {

if (err) {

console.log(err);

process.exit();

}

console.log(prettyjson.render({

'app': config.get('app'),

'something_else': config.get('something_else'),

'ssl': config.get('ssl'),

'email': config.get('email'),

'images': config.get('images')

}));

});

在本例中,使用了四个额外的游击手处理器(从shortstop-handlers模块导入):

  • file:使用指定文件的内容设置属性
  • require:使用节点模块的导出值设置属性(对于只能在运行时确定的动态值特别有用)
  • glob:将属性设置为包含文件名与指定模式匹配的文件的数组
  • path:将属性设置为被引用文件的绝对路径

清单 9-8 显示了这个项目的默认配置文件。最后,清单 9-9 显示了这个项目运行时打印到控制台的输出。

Listing 9-8. Default Configuration File for the confit-shortstop-extras Project

// confit-shortstop-extras/config/config.json

{

"app": "import:./app",

"something_else": "config:app.base_url",

"ssl": {

"certificate": "file:./certificates/server.crt",

"certificate_path": "path:./certificates/server.crt"

},

"email": "require:./email",

"images": "glob:../publimg/**/*.jpg"

}

Listing 9-9. Output from the confit-shortstop-extras Project

$ export NODE_ENV=development && node index

app:

title:        My Demo Application

base_url:https://myapp.com

base_api_url:https://api.myapp.com

something_else:https://myapp.com

ssl:

certificate_path: /opt/confit-shortstop-extras/config/certificates/server.crt

certificate:

"""

-----BEGIN CERTIFICATE-----

MIIDnjCCAoYCCQDy8G1RKCEz4jANBgkqhkiG9w0BAQUFADCBkDELMAkGA1UEBhMC

VVMxEjAQBgNVBAgTCVRlbm5lc3NlZTESMBAGA1UEBxMJTmFzaHZpbGxlMSEwHwYD

VQQKExhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQxFDASBgNVBAMUCyoubXlhcHAu

Y29tMSAwHgYJKoZIhvcNAQkBFhFzdXBwb3J0QG15YXBwLmNvbTAeFw0xNTA0MTkw

MDA4MzRaFw0xNjA0MTgwMDA4MzRaMIGQMQswCQYDVQQGEwJVUzESMBAGA1UECBMJ

VGVubmVzc2VlMRIwEAYDVQQHEwlOYXNodmlsbGUxITAfBgNVBAoTGEludGVybmV0

IFdpZGdpdHMgUHR5IEx0ZDEUMBIGA1UEAxQLKi5teWFwcC5jb20xIDAeBgkqhkiG

9w0BCQEWEXN1cHBvcnRAbXlhcHAuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A

MIIBCgKCAQEAyBFxMVlMjP7VCU5w70okfJX/oEytrQIl1ZOAXnErryQQWwZpHOlu

ZhTuZ8sBJmMBH3jju+rx4C2dFlXxWDRp8nYt+qfd1aiBKjYxMda2QMwXviT0Td9b

kPFBCaPQpMrzexwTwK/edoaxzqs/IxMs+n1Pfvpuw0uPk6UbwFwWc8UQSWrmbGJw

UEfs1X9kOSvt85IdrdQ1hQP2fBhHvt/xVVPfi1ZW1yBrWscVHBOJO4RyZSGclayg

7LP+VHMvkvNm0au/cmCWThHtRt3aXhxAztgkI9IT2G4B9R+7ni8eXw5TLl65bhr1

Gt7fMK2HnXclPtd3+vy9EnM+XqYXahXFGwIDAQABMA0GCSqGSIb3DQEBBQUAA4IB

AQDH+QmuWk0Bx1kqUoL1Qxtqgf7s81eKoW5X3Tr4ePFXQbwmCZKHEudC98XckI2j

qGA/SViBr+nbofq6ptnBhAoYV0IQd4YT3qvO+m3otGQ7NQkO2HwD3OUG9khHe2mG

k8Z7pF0pwu3lbTGKadiJsJSsS1fJGs9hy2vSzRulgOZozT3HJ+2SJpiwy7QAR0aF

jqMC+HcP38zZkTWj1s045HRCU1HdPjr0U3oJtupiU+HAmNpf+vdQnxS6aM5nzc7G

tZq74ketSxEYXTU8gjfMlR4gBewfPmu2KGuHNV51GAjWgm9wLfPFvMMYjcIEPB3k

Mla9+pYx1YvXiyJmOnUwsaop

-----END CERTIFICATE-----

"""

email:

hostname: smtp.myapp.com

username: user

password: pass

from:     My Application <noreply@myapp.com>

images:

- /opt/confit-shortstop-extras/publimg/cat1.jpg

- /opt/confit-shortstop-extras/publimg/cat2.jpg

- /opt/confit-shortstop-extras/publimg/cat3.jpg

基于配置的中间件注册

Express 通过一系列可配置的“中间件”功能来处理传入的 HTTP 请求,如图 9-2 所示。

A978-1-4842-0662-1_9_Fig2_HTML.gif

图 9-2。

Series of Express middleware calls

在这个过程的每一步,主动中间件功能都能够

  • 修改传入的请求对象
  • 修改传出响应对象
  • 执行附加代码
  • 结束请求-响应循环
  • 调用系列中的下一个中间件函数

举例来说,考虑清单 9-10 ,它展示了一个简单的 Express 应用,该应用依赖于三个中间件模块:morgancookie-parserratelimit-middleware。当该应用处理传入的 HTTP 请求时,会发生以下步骤:

The morgan module logs the request to the console.   The cookie-parser module parses data from the request’s Cookie header and assigns it to the request object’s cookies property.   The ratelimit-middleware module rate-limits clients that attempt to access the application too frequently.   Finally, the appropriate route handler is called.   Listing 9-10. Express Application That Relies on Three Middleware Modules

// middleware1/index.js

var express = require('express');

// Logs incoming requests

var morgan = require('morgan');

// Populates req.cookieswith data parsed from theCookie header

var cookieParser = require('cookie-parser');

// Configurable API rate-limiter

var rateLimit = require('ratelimit-middleware');

var app = express();

app.use(morgan('combined'));

app.use(cookieParser());

app.use(rateLimit({

'burst': 10,

'rate': 0.5,

'ip': true

}));

app.get('/animals', function(req, res, next) {

res.send(['squirrels', 'aardvarks', 'zebras', 'emus']);

});

app.listen(7000);

这种方法为开发人员提供了相当大的灵活性,允许他们在请求-响应周期的任何时候执行自己的逻辑。它还允许 Express 通过将执行不重要任务的责任委托给第三方中间件模块来维护相对较小的内存占用。尽管这种方法很灵活,但随着应用和开发团队的规模和复杂性的增长,管理起来也会很麻烦。

Kraken 的meddleware模块通过为 Express 应用提供基于配置的中间件注册流程,简化了中间件管理。这样,它为开发人员提供了一种标准化的方法来指定 Express 应用应该依赖哪些中间件模块,应该以什么顺序加载它们,以及应该传递给每个模块的选项。清单 9-11 显示了前一个例子的更新版本,其中meddleware模块管理所有中间件功能的注册。

Listing 9-11. Configuration-based Middleware Registration with the meddleware Module

// middleware2/index.js

var express = require('express');

var confit = require('confit');

var meddleware = require('meddleware');

var app = express();

var path = require('path');

confit(path.join(__dirname, 'config')).create(function(err, config) {

app.use(meddleware(config.get('middleware')));

app.get('/animals', function(req, res, next) {

res.send(['squirrels', 'aardvarks', 'zebras', 'emus']);

});

app.listen(7000);

});

// middleware2/config/config.json

{

"middleware": {

"morgan": {

// Toggles the middleware module on / off

"enabled": true,

// Specifies the order in which middleware should be registered

"priority": 10,

"module": {

// The name of an installed module (or path to a module file)

"name": "morgan",

// Arguments to be passed to the module’s factory function

"arguments": ["combined"]

}

},

"cookieParser": {

"enabled": true,

"priority": 20,

"module": {

"name": "cookie-parser"

}

},

"rateLimit": {

"enabled": true,

"priority": 30,

"module": {

"name": "ratelimit-middleware",

"arguments": [{

"burst": 10,

"rate": 0.5,

"ip": true

}]

}

}

}

}

在 Krakenmeddleware模块的帮助下,该应用中第三方中间件管理的所有方面都从代码转移到了标准化的配置文件中。结果是应用不仅更有条理,而且更容易理解和修改。

事件通知

由于中间件功能是通过meddleware模块向 Express 注册的,相应的事件由应用发出,为开发人员提供了一种简单的方法来确定加载什么中间件功能以及以什么顺序加载(参见清单 9-12 )。

Listing 9-12. Events Are Emitted As Middleware s Registered via the meddleware Module

var express = require('express');

var confit = require('confit');

var meddleware = require('meddleware');

var app = express();

var path = require('path');

confit(path.join(__dirname, 'config')).create(function(err, config) {

// Listening to all middleware registrations

app.on('middleware:before', function(data) {

console.log('Registering middleware: %s', data.config.name);

});

// Listening for a specific middleware registration event

app.on('middleware:before:cookieParser', function(data) {

console.log('Registering middleware: %s', data.config.name);

});

app.on('middleware:after', function(data) {

console.log('Registered middleware: %s', data.config.name);

});

app.on('middleware:after:cookieParser', function(data) {

console.log('Registered middleware: %s', data.config.name);

});

app.use(meddleware(config.get('middleware')));

app.get('/animals', function(req, res, next) {

res.send(['squirrels', 'aardvarks', 'zebras', 'emus']);

});

app.listen(7000);

});

结构化路线注册

在上一节中,您了解了 Kraken 的meddleware模块如何通过将加载和配置这些功能所需的逻辑移动到标准化的 JSON 配置文件中来简化中间件功能注册。同样,Kraken 的enrouten模块也运用了同样的概念,将结构带到了经常找不到的地方——快捷路线。

具有少量路线的简单快速应用通常可以使用单个模块来完成,在该模块中定义了每个可用的路线。然而,随着应用的深度和复杂性逐渐增加,这样的组织结构(或缺乏组织结构)会很快变得难以管理。Enrouten 通过提供三种方法来解决这一问题,通过这三种方法可以以一致的结构化方式定义快捷路线。

索引配置

使用 enrouten 的index配置选项,可以指定单个模块的路径。然后,该模块将被加载,并被传递给一个已被挂载到根路径的 Express 路由器实例。该选项为开发人员提供了定义路线的最简单方法,因为它不强制任何特定类型的组织结构。虽然这个选项为新的应用提供了一个很好的起点,但是必须小心不要滥用它。这个选项经常与 enrouten 的directoryroutes配置选项结合使用,我们将很快介绍这两个选项。

清单 9-13 显示了一个简单的 Express 应用,它的路线是在confitmeddlewareenrouten的帮助下配置的,还有附带的confit配置文件。清单 9-14 显示了传递给 enrouten 的index选项的模块内容。本节中的后续示例将以此示例为基础。

Listing 9-13. Express Application Configured with confit, meddleware, and enrouten

// enrouten-index/index.js

var express = require('express');

var confit = require('confit');

var handlers = require('shortstop-handlers');

var meddleware = require('meddleware');

var path = require('path');

var configDir = path.join(__dirname, 'config');

var app = express();

confit({

'basedir': configDir,

'protocols': {

'path': handlers.path(configDir),

'require': handlers.require(configDir)

}

}).create(function(err, config) {

app.use(meddleware(config.get('middleware')));

app.listen(7000);

console.log('App is available at:``http://localhost:7000

});

// enrouten-index/config/config.json

{

"middleware": {

"morgan": {

"enabled": true,

"priority": 10,

"module": {

"name": "morgan",

"arguments": ["combined"]

}

},

"enrouten": {

"enabled": true,

"priority": 30,

"module": {

"name": "express-enrouten",

"arguments": [

{

"index": "path:../routes/index"

}

]

}

}

}

}

Listing 9-14. Contents of the Module Passed to Enrouten’s index Option

// enrouten-index/routes/index.js

module.exports = function(router) {

router.route('/')

.get(function(req, res, next) {

res.send('Hello, world.');

});

router.route('/api/v1/colors')

.get(function(req, res, next) {

res.send([

'blue', 'green', 'red', 'orange', 'white'

]);

});

};

目录配置

清单 9-15 展示了 enrouten 的directory配置选项的使用。设置后,enrouten将递归扫描指定文件夹的内容,搜索导出接受单个参数的函数的模块。对于它找到的每个模块,enrouten将传递一个 Express 路由器实例,该实例已被安装到由该模块在目录结构中的位置预先确定的路径上,这是一种“约定优于配置”的方法。

Listing 9-15. Setting Enrouten’s directory Configuration Option

// enrouten-directory/config/config.json

{

"middleware": {

"enrouten": {

"enabled": true,

"priority": 10,

"module": {

"name": "express-enrouten",

"arguments": [{ "directory": "path:../routes" }]

}

}

}

}

图 9-3 显示了该项目的/routes文件夹的结构,清单 9-16 显示了/routes/api/v1/accounts/index.js模块的内容。基于这个模块在/routes文件夹中的位置,它定义的每条路由的 URL 都将以/api/v1/accounts为前缀。

A978-1-4842-0662-1_9_Fig3_HTML.jpg

图 9-3。

Structure of This Project’s /routes Folder Listing 9-16. The /api/v1/accounts Controller

// enrouten-directory/routes/api/v1/accounts/index.js

var _ = require('lodash');

var path = require('path');

module.exports = function(router) {

var accounts = require(path.join(APPROOT, 'models', 'accounts'));

/**

* @route /api/v1/accounts

*/

router.route('/')

.get(function(req, res, next) {

res.send(accounts);

});

/**

* @route /api/v1/accounts/:account_id

*/

router.route('/:account_id')

.get(function(req, res, next) {

var account = _.findWhere(accounts, {

'id': parseInt(req.params.account_id, 10)

});

if (!account) return next(new Error('Account not found'));

res.send(account);

});

};

路线配置

Enrouten 的directory配置选项通过基于指定文件夹的布局自动确定应用 API 的结构,提供了一种支持“约定胜于配置”的方法。这种方法提供了一种以有组织和一致的方式构建快速路线的快速简单的方法。然而,复杂的应用可能最终会发现这种方法相当局限。

具有大量复杂、深度嵌套路由的 API 的应用可能会从 enrouten 的routes配置选项中获得更大的好处,该选项允许开发人员为应用的每个路由创建完全独立的模块。然后在配置文件中指定 API 端点、方法、处理程序和特定于路由的中间件——这是一种有组织的方法,允许最大程度的灵活性,但代价是稍微有些冗长。

清单 9-17 显示了本章enrouten-routes项目配置文件的摘录。这里,我们将一个对象数组传递给 enrouten 的routes配置选项,其中的条目描述了 Express 提供的各种路线。注意,除了指定路由、HTTP 方法和处理程序之外,每个条目还可以选择指定一组特定于路由的中间件功能。因此,这个应用能够应用一个中间件功能,负责在一个路由接一个路由的基础上授权进入的请求。如清单 9-17 所示,auth中间件功能没有应用于用户最初登录的路径,允许他们在发出后续请求之前登录。

Listing 9-17. Specifying Individual Routes via Enrouten’s routes Configuration Option

// enrouten-routes/config/config.json (excerpt)

"arguments": [{

"index": "path:../routes",

"routes": [

{

"path": "/api/v1/session",

"method": "POST",

"handler": "require:../routes/api/v1/session/create"

},

{

"path": "/api/v1/session",

"method": "DELETE",

"handler": "require:../routes/api/v1/session/delete",

"middleware": [

"require:../middleware/auth"

]

},

{

"path": "/api/v1/users",

"method": "GET",

"handler": "require:../routes/api/v1/users/list",

"middleware": [

"require:../middleware/auth"

]

},

// ...

]

}]

清单 9-18 显示了负责处理这个应用的/api/v1/users路由的传入 GET 请求的模块的内容。该模块导出一个函数,该函数接受标准的req, res, next快速路由处理程序签名。

Listing 9-18. The /routes/api/v1/users/list Route Handler

var models = require('../../../../lib/models');

module.exports = function(req, res, next) {

models.User.fetchAll()

.then(function(users) {

res.send(users);

})

.catch(next);

};

灰尘模板

许多流行的 JavaScript 模板引擎(例如,Mustache 和 Handlebars)标榜自己是“无逻辑的”——这一属性描述了它们帮助开发人员在应用的业务逻辑和表示层之间保持清晰分离的能力。如果维护得当,这种分离使得在呈现给用户的界面中发生重大变化成为可能,同时只需要最少的(如果有的话)幕后伴随变化(反之亦然)。

所谓的“无逻辑”模板引擎通过强制执行一组严格的规则来实现这一目标,这些规则防止开发人员创建通常被称为“意大利面条代码”的代码,这是一种以难以理解甚至更难解开的方式将代码与表示相结合的混乱局面。任何曾经处理过类似于清单 9-19 中所示的 PHP 脚本的人都会立即理解在这两个关注点之间保持一层隔离的重要性。

Listing 9-19. Spaghetti Code, an Unmaintainable Mess

<?php

print "<!DOCTYPE html><head><title>";

$result = mysql_query("SELECT * FROM settings") or die(mysql_error());

print $result[0]["title"] . "</title></head><body><table>";

print "<thead><tr><th>First Name</th><th>Last Name</th></tr></thead><tbody>";

$users = mysql_query("SELECT * FROM users") or die(mysql_error());

while ($row = mysql_fetch_assoc($users)) {

print "<tr><td>" . $row["first_name"] . "</td><td>" . $row["last_name"] . "</td></tr>";

}

print "</tbody></table></body></html>";

?>

无逻辑模板引擎试图通过禁止在应用的视图中使用逻辑来防止开发人员创建杂乱无章的代码。这种模板通常能够引用所提供的信息有效载荷中的值,遍历数组,并基于简单的布尔逻辑打开和关闭其内容的特定部分。

不幸的是,这种相当严厉的方法经常带来它希望防止的问题,尽管是以一种意想不到的方式。尽管无逻辑的模板引擎(如 Handlebars)阻止在模板本身中使用逻辑,但它们并没有首先否定逻辑存在的必要性。准备数据以供模板使用所需的逻辑必须存在于某个地方,通常,使用无逻辑模板引擎会导致与表示相关的逻辑溢出到业务层。

Dust 是 Kraken 青睐的 JavaScript 模板引擎,它试图通过采用一种更好地被认为是“少逻辑”而不是严格意义上的“少逻辑”的方法来解决这个问题通过允许开发人员在他们的模板中以“助手”的形式嵌入稍微高级一点的逻辑,Dust 允许表示层逻辑留在它应该在的地方,表示层,而不是业务层。

背景和参考

当使用 Dust 模板时,两个主要组件开始发挥作用:模板本身和一个(可选的)对象文字,该对象文字包含要从模板中引用的任何数据。在清单 9-20 中,这个过程由一个指定 Dust 作为其渲染引擎的 Express 应用演示。注意本例中adaro模块的使用。adaro模块作为 Dust 的一个方便的包装器,抽象出一些额外的设置,否则这些设置将是 Dust 与 Express 集成所必需的。默认情况下,它还包括一些方便的助手函数,我们将在本章后面介绍。

Listing 9-20. Express Application Using Dust As Its Rendering Engine

// dust-simple/index.js

var express = require('express');

var adaro = require('adaro');

var app = express();

/**

* By default, Dust will cache the contents of an application’s templates as they are

* loaded. In a production environment, this is usually the preferred behavior.

* This behavior will be disabled in this chapter’s examples, allowing you to modify

* templates and see the result without having to restart Express.

*/

app.engine('dust', adaro.dust({

'cache': false

}));

app.set('view engine', 'dust');

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

var data = {

'report_name': 'North American Countries',

'languages': ['English', 'Spanish'],

'misc': {

'total_population': 565000000

},

'countries': [

{

'name': 'United States',

'population': 319999999,

'english': true,

'capital': { 'name': 'Washington D.C.', 'population': 660000 }

},

{

'name': 'Mexico',

'population': 118000000,

'english': false,

'capital': { 'name': 'Mexico City', 'population': 9000000 }

},

{

'name': 'Canada',

'population': 35000000,

'english': true,

'capital': { 'name': 'Ottawa', 'population': 880000 }

}

]

};

app.get('/', function(req, res, next) {

res.render('main', data);

});

app.listen(8000);

在清单 9-20 中,一个包含北美国家数组的对象文字(被 Dust 称为“上下文”)被传递给一个 Dust 模板,其内容如清单 9-21 所示。在这个模板中,通过将所需的键放在一对花括号中来引用数据。嵌套属性也可以通过使用点符号({misc.total_population})来引用。

Listing 9-21. Accompanying main Dust Template

// dust-simple/views/main.dust

<!DOCTYPE html>

<html lang="en">

<head>

<meta charset="utf-8">

<meta http-equiv="X-UA-Compatible" content="IE=edge">

<meta name="viewport" content="width=device-width, initial-scale=1">

<title>App</title>

<link href="/css/style.css" rel="stylesheet">

</head>

<body>

{! Dust comments are created using this format. Data is referenced by wrapping the

desired key within a single pair of curly brackets, as shown below. !}

<h1>{report_name}</h1>

<table>

<thead>

<tr>

<th>Name</th>

<th>Population</th>

<th>Speaks English</th>

<th>Capital</th>

<th>Population of Capital</th>

</tr>

</thead>

<tbody>

{! Templates can loop through iterable objects !}

{#countries}

<tr>

<td>{name}</td>

<td>{population}</td>

<td>{?english}Yes{:else}No{/english}</td>

{#capital}

<td>{name}</td>

<td>{population}</td>

{/capital}

</tr>

{/countries}

</tbody>

</table>

<h2>Languages</h2>

<ul>

{#languages}

<li>{.}</li>

{/languages}

</ul>

<h2>Total Population: {misc.total_population}</h2>

</body>

</html>

部分

在 Dust 进行渲染的过程中,它通过将一个或多个“上下文”应用到相关的模板来获取引用的数据。最简单的模板只有一个上下文,引用传递的 JSON 对象的最外层。例如,考虑清单 9-21 中所示的模板,其中使用了两个引用{report_name}{misc.total_population}。Dust 通过在清单 9-20 所示的对象中搜索匹配的属性(从最外层开始)来处理这些引用。

Dust 部分提供了一种方便的方法,通过这种方法可以创建额外的上下文,允许模板访问嵌套的属性,而不需要从最外层开始的引用。例如,考虑清单 9-22 ,其中创建了一个新的上下文{#misc}...{/misc},允许使用更短的语法访问嵌套的属性。

Listing 9-22. Creating a New Dust Section

// Template

<h1>{report_name}</h1>

{#misc}

<p>Total Population: {total_population}</p>

{/misc}

// Rendered Output

<h1>Information About North America</h1>

<p>Total Population: 565000000</p>

循环

在前面的例子中,创建了一个新的 Dust 部分(和相应的上下文)。因此,新部分的内容可以直接访问被引用的对象文字的属性。同样,Dust 部分也可以用来遍历数组的条目。清单 9-23 通过创建一个引用countries数组的新部分来演示这个过程。与上一个例子中只应用了一次的部分不同,{#countries} ... {/countries}部分将被应用多次,对它引用的数组中的每个条目应用一次。

Listing 9-23. Iterating Through an Array with Sections

// Template

{#countries}

{! The current position within the iteration can be referenced at $idx !}

{! The size of the object through which we are looping can be referenced at $len !}

<tr>

<td>{name}</td>

<td>{population}</td>

<td>{capital.name}</td>

<td>{capital.population}</td>

</tr>

{/countries}

// Rendered Output

<tr>

<td>United States</td>

<td>319999999</td>

<td>Washington D.C.</td>

<td>660000</td>

</tr>

<tr>

<td>Mexico</td>

<td>118000000</td>

<td>Mexico City</td>

<td>9000000</td>

</tr>

<tr>

<td>Canada</td>

<td>35000000</td>

<td>Ottawa</td>

<td>880000</td>

</tr>

清单 9-24 展示了一个模板遍历一个数组的过程,该数组的条目是原始数据类型(即不是对象)。对于每次迭代,值本身可以通过{.}语法直接引用。

Listing 9-24. Iterating Through an Array Containing Primitive Data Types

// Template

<ul>

{#languages}<li>{.}</li>{/languages}

</ul>

// Rendered Output

<ul>

<li>English</li>

<li>Spanish</li>

</ul>

制约性

Dust 基于是否通过简单的真实性测试,为有条件地呈现内容提供内置支持。清单 9-25 中显示的模板通过根据每个国家的english属性是否引用“真”值来呈现文本“是”或“否”来演示这个概念。

Listing 9-25. Applying Conditionality Within a Dust Template

// Template

{#countries}

<tr>

<td>{name}</td>

<td>{?english}Yes{:else}No{/english}</td>

{!

The opposite logic can be applied as shown below:

<td>{^english}No{:else}Yes{/english}</td>

!}

</tr>

{/countries}

// Rendered Output

<tr>

<td>United States</td>

<td>Yes</td>

</tr>

<tr>

<td>Mexico</td>

<td>No</td>

</tr>

<tr>

<td>Canada</td>

<td>Yes</td>

</tr>

Note

在模板中应用条件性时,理解 Dust 将应用的规则很重要,因为它决定了属性的“真实性”。空字符串、布尔 false、空数组、null 和 undefined 都被认为是 false。数字 0、空对象以及基于字符串的“0”、“null”、“undefined”和“false”都被认为是真的。

部分的

Dust 最强大的特性之一 partials 允许开发者在其他模板中包含模板。因此,复杂的文档可以被分解成更小的部分(即“片段”),以便于管理和重用。清单 9-26 中显示了一个演示这个过程的简单例子。

Listing 9-26. Dust Template That References an External Template (i.e., “Partial”)

// Main Template

<h1>{report_name}</h1>

<p>Total Population: {misc.total_population}</p>

{>"countries"/}

{!

In this example, an external template - countries - is included by a parent

template which references it by name (using a string literal that is specified

within the template itself). Alternatively, the name of the external template

could have been derived from a value held within the template’s context, using

Dust’s support for "dynamic" partials. To do so, we would have wrapped the

``countries string in a pair of curly brackets, as shown here:

{>"{countries}"/}

!}

// "countries" template

{#countries}

<tr>

<td>{name}</td>

<td>{population}</td>

<td>{capital.name}</td>

<td>{capital.population}</td>

</tr>

{/countries}

// Rendered Output

<h1>Information About North America</h1>

<p>Total Population: 565000000</p>

<tr>

<td>United States</td>

<td>Yes</td>

</tr>

<tr>

<td>Mexico</td>

<td>No</td>

</tr>

<tr>

<td>Canada</td>

<td>Yes</td>

</tr>

阻碍

考虑一个常见的场景,其中创建了一个由多个页面组成的复杂 web 应用。这些页面中的每一个都显示一组独特的内容,同时与其他页面共享通用元素,如页眉和页脚。借助 Dust blocks,开发人员可以在一个位置定义这些共享元素。之后,希望从它们继承的模板可以这样做,同时还保留了在必要时覆盖其内容的能力。

让我们看一个例子,应该有助于澄清这一点。清单 9-27 显示了定义站点整体布局的 Dust 模板的内容。在这个实例中,指定了一个默认的页面标题{+title}App{/title},以及一个用于正文内容的空占位符。

Listing 9-27. Dust Block from Which Other Templates Can Inherit

// dust-blocks/views/shared/base.dust

<!DOCTYPE html>

<html lang="en">

<head>

<meta charset="utf-8">

<meta http-equiv="X-UA-Compatible" content="IE=edge">

<meta name="viewport" content="width=device-width, initial-scale=1">

<title>{+title}App{/title}</title>

<link href="/css/style.css" rel="stylesheet">

</head>

<body>

{+bodyContent/}

</body>

</html>

清单 9-28 显示了一个 Dust 模板的内容,它继承了清单 9-27 中的例子。它首先将父模板作为一部分嵌入到自身中({>"shared/base"/})。接下来,它将内容注入到已定义的{+bodyContent/}占位符{<bodyContent}...{/bodyContent}中。在这个例子中,我们的模板选择不覆盖父模板中指定的默认页面标题。

Listing 9-28. Dust Template Inheriting from a Block

// dust-blocks/views/main.dust

{>"shared/base"/}

{<bodyContent}

<p>Hello, world!</p>

{/bodyContent}

过滤

Dust 包括几个内置的过滤器,允许模板在渲染之前修改值。举例来说,考虑这样一个事实,Dust 将自动对模板中引用的任何值进行 HTML 转义。换句话说,如果一个上下文包含一个content键,其值与此处显示的值相匹配:

<script>doBadThings();</script>

Dust 会自动将该值呈现为

&lt;script&gt;doBadThings()&lt;/script&gt;

虽然我们在这里看到的行为通常是需要的,但遇到需要禁用这种行为的情况并不罕见。这可以通过使用过滤器来实现:

{content|s}

在本例中,|s过滤器禁用引用值的自动转义。表 9-1 包含 Dust 提供的内置过滤器列表。

表 9-1。

List of Built-in Filters Provided by Dust

| 过滤器 | 描述 | | --- | --- | | `s` | 禁用 html 转义 | | `h` | 强制 HTML 转义 | | `j` | 强制 JavaScript 转义 | | `u` | 用`encodeURI()`编码 | | `uc` | 用`encodeURIComponent()`编码 | | `js` | Stringifies 一个 JSON 文本 | | `jp` | 解析 JSON 字符串 |
创建自定义过滤器

除了提供几个核心过滤器之外,Dust 还让开发人员可以通过创建他们自己的定制过滤器来轻松扩展这种行为,如清单 9-29 所示。在本例中,创建了一个定制的formatTS过滤器。当被应用时,该过滤器将把引用的时间戳转换成人类可读的格式(例如,1776 年 7 月 4 日)。

Listing 9-29. Defining a Custom Dust Filter

// dust-filters/index.js

var express = require('express');

var adaro = require('adaro');

var app = express();

var moment = require('moment');

app.engine('dust', adaro.dust({

'cache': false,

'helpers': [

function(dust) {

dust.filters.formatTS = function(ts) {

return moment(ts, 'X').format('MMM. D, YYYY');

};

}

]

}));

app.set('view engine', 'dust');

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

app.get('/', function(req, res, next) {

res.render('main', {

'events': [

{ 'label': 'Moon Landing', 'ts': -14558400 },

{ 'label': 'Fall of Berlin Wall', 'ts': 626616000 },

{ 'label': 'First Episode of Who\'s the Boss', 'ts': 464529600 }

]

});

});

// dust-filters/views/main.dist (excerpt)

<tbody>

{#events}

<tr>

<td>{label}</td>

<td>{ts|formatTS}</td>

</tr>

{/events}

</tbody>

上下文助手

除了存储数据,Dust 上下文还能够存储函数(称为“上下文助手”),其输出可以在以后被传递到的模板引用。这样,Dust 上下文就不仅仅是简单的原始信息负载,而是一个视图模型,是应用的业务逻辑和视图之间的中介,能够以最合适的方式格式化信息。

清单 9-30 中的例子展示了这个特性,其中一个应用向用户展示了一个服务器表。每个条目显示一个名称,以及一条指示每个服务器是否在线的消息。标题显示系统的整体健康状况,由systemStatus()上下文助手生成。请注意,模板能够引用我们的上下文助手,就像引用任何其他类型的值一样(例如,对象文字、数组、数字、字符串)。

Listing 9-30. Dust Context Helper

// dust-context-helpers1/index.js (excerpt)

app.all('/', function(req, res, next) {

res.render('main', {

'servers': [

{ 'name': 'Web Server', 'online': true },

{ 'name': 'Database Server', 'online': true },

{ 'name': 'Email Server', 'online': false }

],

'systemStatus': function(chunk, context, bodies, params) {

var offlineServers = _.filter(this.servers, { 'online': false });

return offlineServers.length ? 'Bad' : 'Good';

}

});

});

// dust-context-helpers1/views/main.dust (excerpt)

<h1>System Status: {systemStatus}</h1>

<table>

<thead><tr><th>Server</th><th>Online</th></tr></thead>

<tbody>

{#servers}

<tr>

<td>{name}</td>

<td>{?online}Yes{:else}No{/online}</td>

</tr>

{/servers}

</tbody>

</table>

如本例所示,每个 Dust 上下文助手都接收四个参数:chunkcontextbodiesparams。让我们来看几个演示它们用法的例子。

矮胖的人或物

上下文助手的chunk参数为它提供了对正在呈现的模板的当前部分的访问——被 Dust 称为“块”举例来说,考虑清单 9-31 ,其中上下文助手与模板中定义的默认内容配对。在这个例子中,systemStatus()上下文助手可以通过调用chunk.write()方法,用自己的值覆盖块的默认内容“未知”。助手可以通过返回chunk作为它的值来表明它已经选择这样做。

Listing 9-31. Dust Context Helper Paired with Default Content

// dust-context-helpers2/index.js (excerpt)

app.all('/', function(req, res, next) {

res.render('main', {

'servers': [

{ 'name': 'Web Server', 'online': true },

{ 'name': 'Database Server', 'online': true },

{ 'name': 'Email Server', 'online': false }

],

'systemStatus': function(chunk, context, bodies, params) {

if (!this.servers.length) return;

if (_.filter(this.servers, { 'online': false }).length) {

return chunk.write('Bad');

} else {

return chunk.write('Good');

}

}

});

});

// dust-context-helpers2/views/main.dust (excerpt)

<h1>System Status: {#systemStatus}Unknown{/systemStatus}</h1>

语境

根据模板的决定,context参数为上下文助手提供了对上下文活动部分的方便访问。清单 9-32 中显示的模板通过为每个被传递的服务器引用一次isOnline()上下文助手来演示这一点。每次,isOnline()助手通过context.get()获取活动部分的online属性的值。

Listing 9-32. The context Argument Provides Context Helpers with Access to the Active Section

// dust-context-helpers3/index.js (excerpt)

app.all('/', function(req, res, next) {

res.render('main', {

'servers': [

{ 'name': 'Web Server', 'online': true },

{ 'name': 'Database Server', 'online': true },

{ 'name': 'Email Server', 'online': false }

],

'systemStatus': function(chunk, context, bodies, params) {

return _.filter(this.servers, { 'online': false }).length ? 'Bad': 'Good';

},

'isOnline': function(chunk, context, bodies, params) {

return context.get('online') ? 'Yes' : 'No';

}

});

});

// dust-context-helpers3/views/main.dust (excerpt)

<h1>System Status: {systemStatus}</h1>

<table>

<thead><tr><th>Server</th><th>Online</th></tr></thead>

<tbody>

{#servers}

<tr>

<td>{name}</td>

<td>{isOnline}</td>

</tr>

{/servers}

</tbody>

</table>

身体

想象一个场景,其中模板的大部分内容由一个或多个上下文助手决定。Dust 没有强迫开发人员以笨拙的方式连接字符串,而是允许这些内容保留在它应该在的地方——模板中——作为上下文助手可以选择呈现的选项。

清单 9-33 通过将四个不同的内容主体传递给description()上下文助手来演示这一点。助手的bodies参数为它提供了对该内容的引用,然后它可以通过向chunk.render()传递适当的值来选择呈现该内容。

Listing 9-33. Selectively Rendering Portions of a Template via the bodies Argument

// dust-context-helpers4/index.js (excerpt)

app.all('/', function(req, res, next) {

res.render('main', {

'servers': [

{ 'name': 'Web Server', 'online': true },

{ 'name': 'Database Server', 'online': true },

{ 'name': 'Email Server', 'online': false },

{ 'name': 'IRC Server', 'online': true }

],

'systemStatus': function(chunk, context, bodies, params) {

return _.filter(this.servers, { 'online': false }).length ? 'Bad': 'Good';

},

'isOnline': function(chunk, context, bodies, params) {

return context.get('online') ? 'Yes' : 'No';

},

'description': function(chunk, context, bodies, params) {

switch (context.get('name')) {

case 'Web Server':

return chunk.render(bodies.web, context);

break;

case 'Database Server':

return chunk.render(bodies.database, context);

break;

case 'Email Server':

return chunk.render(bodies.email, context);

break;

}

}

});

});

// dust-context-helpers4/index.js (excerpt)

<h1>System Status: {systemStatus}</h1>

<table>

<thead><tr><th>Server</th><th>Online</th><th>Description</th></tr></thead>

<tbody>

{#servers}

<tr>

<td>{name}</td>

<td>{isOnline}</td>

<td>

{#description}

{:web}

A web server serves content over HTTP.

{:database}

A database server fetches remotely stored information.

{:email}

An email server sends and receives messages.

{:else}

-

{/description}

</td>

</tr>

{/servers}

</tbody>

</table>

参数

除了引用被调用的上下文的属性(通过context.get()),上下文助手还可以访问模板传递给它的参数。清单 9-34 中所示的例子通过将每个服务器的uptime属性传递给formatUptime()上下文助手来演示这一点。在这个例子中,helper 将提供的值params.value转换成更容易阅读的形式,然后将它写到块中。

Listing 9-34. Context Helpers Can Receive Parameters via the params Argument

// dust-context-helpers5/index.js (excerpt)

app.all('/', function(req, res, next) {

res.render('main', {

'servers': [

{ 'name': 'Web Server', 'online': true, 'uptime': 722383 },

{ 'name': 'Database Server', 'online': true, 'uptime': 9571 },

{ 'name': 'Email Server', 'online': false, 'uptime': null }

],

'systemStatus': function(chunk, context, bodies, params) {

return _.filter(this.servers, { 'online': false }).length ? 'Bad': 'Good';

},

'formatUptime': function(chunk, context, bodies, params) {

if (!params.value) return chunk.write('-');

chunk.write(moment.duration(params.value, 'seconds').humanize());

}

});

});

// dust-context-helpers5/views/main.dust (excerpt)

{#servers}

<tr>

<td>{name}</td>

<td>{?online}Yes{:else}No{/online}</td>

<td>{#formatUptime value=uptime /}</td>

</tr>

{/servers}

在清单 9-35 中,我们看到了一个稍微复杂一点的上下文助手参数演示。在这个例子中,parseLocation()助手接收一个引用了上下文属性的字符串:value="{name} lives in {location}"。为了正确解释这些参考资料,必须首先借助 Dust 的helpers.tap()方法评估参数。

Listing 9-35. Parameters That Reference Context Properties Must Be Evaluated

// dust-context-helpers6/index.js

var express = require('express');

var adaro = require('adaro');

var app = express();

var morgan = require('morgan');

app.use(morgan('combined'));

var engine = adaro.dust();

var dust = engine.dust;

app.engine('dust', engine);

app.set('view engine', 'dust');

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

app.all('/', function(req, res, next) {

res.render('main', {

'people': [

{ 'name': 'Joe', 'location': 'Chicago' },

{ 'name': 'Mary', 'location': 'Denver' },

{ 'name': 'Steve', 'location': 'Oahu' },

{ 'name': 'Laura', 'location': 'Nashville' }

],

'parseLocation': function(chunk, context, bodies, params) {

var content = dust.helpers.tap(params.value, chunk, context);

return chunk.write(content.toUpperCase());

}

});

});

app.listen(8000);

// dust-context-helpers6/views/main.dust

{#people}

<li>{#parseLocation value="{name} lives in {location}" /}</li>

{/people}

异步上下文助手

辅助函数为 Dust 提供了强大的功能和灵活性。它们允许上下文对象充当视图模型——应用的业务逻辑和用户界面之间的智能桥梁,能够获取信息并针对特定用例进行适当格式化,然后将其传递给一个或多个视图进行呈现。尽管这很有用,但就如何将这些辅助函数应用于强大的效果而言,我们实际上才刚刚开始触及皮毛。

除了直接返回数据,Dust helper 函数也能够异步返回数据,清单 9-36 中的例子演示了这个过程。这里我们创建了两个上下文助手,cars()trucks()。前者返回一个数组,后者返回一个解析为数组的承诺。从模板的角度来看,这两个函数的使用是相同的。

Listing 9-36. Helper Functions Can Return Promises

// dust-promise1/index.js (excerpt)

app.get('/', function(req, res, next) {

res.render('main', {

'cars': function(chunk, context, bodies, params) {

return ['Nissan Maxima', 'Toyota Corolla', 'Volkswagen Jetta'];

},

'trucks': function(chunk, context, bodies, params) {

return new Promise(function(resolve, reject) {

resolve(['Chevrolet Colorado', 'GMC Canyon', 'Toyota Tacoma']);

});

}

});

});

// dust-promise1/views/main.dust (excerpt)

<h1>Cars</h1>

<ul>{#cars}<li>{.}</li>{/cars}</ul>

<h2>Trucks</h1>

<ul>{#trucks}<li>{.}</li>{/trucks}</ul>

在承诺被拒绝的情况下,Dust 还为有条件地显示内容提供了一种方便的方法。清单 9-37 展示了这一过程。

Listing 9-37. Handling Rejected Promises

// dust-promise2/index.js (excerpt)

app.get('/', function(req, res, next) {

res.render('main', {

'cars': function(chunk, context, bodies, params) {

return ['Nissan Maxima', 'Toyota Corolla', 'Volkswagen Jetta'];

},

'trucks': function(chunk, context, bodies, params) {

return new Promise(function(resolve, reject) {

reject('Unable to fetch trucks.');

});

}

});

});

// dust-promise2/views/main.dust (excerpt)

<h1>Cars</h1>

<ul>{#cars}<li>{.}</li>{/cars}</ul>

<h2>Trucks</h1>

<ul>{#trucks}

<li>{.}</li>

{:error}

An error occurred. We were unable to get a list of trucks.

{/trucks}</ul>

有能力以承诺的形式向模板提供信息是有用的,原因有很多,但当这种功能与 Dust 的流媒体接口配合使用时,事情就变得有趣多了。为了更好地理解这一点,请考虑清单 9-38 ,它很大程度上反映了我们之前的例子。然而,在这种情况下,我们利用 Dust 的流接口,在渲染时将模板的一部分下推到客户端,而不是等待整个过程完成。

Listing 9-38. Streaming a Template to the Client As Data Becomes Available

// dust-promise2/index.js

var Promise = require('bluebird');

var express = require('express');

var adaro = require('adaro');

var app = express();

var engine = adaro.dust();

var dust = engine.dust;

app.engine('dust', engine);

app.set('view engine', 'dust');

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

app.get('/', function(req, res, next) {

dust.stream('views/main', {

'cars': ['Nissan Maxima', 'Toyota Corolla', 'Volkswagen Jetta'],

'trucks': function(chunk, context, bodies, params) {

return new Promise(function(resolve, reject) {

setTimeout(function() {

resolve(['Chevrolet Colorado', 'GMC Canyon', 'Toyota Tacoma']);

}, 4000);

});

}

}).pipe(res);

});

app.listen(8000);

根据所讨论的模板的复杂性,这种方法对用户体验的影响通常是巨大的。这种方法允许我们在内容可用时将内容推送到客户端,而不是强迫用户等待整个页面加载完毕后再继续。因此,用户在访问应用时感觉到的延迟通常会大大减少。

除尘助手

在上一节中,我们探讨了如何通过使用上下文助手来扩展上下文对象,以包含与特定视图相关的逻辑。以类似的方式,Dust 允许在全局级别定义辅助函数,使它们可用于所有模板,而无需在其上下文中显式定义。Dust 附带了许多这样的助手。通过利用它们,开发人员可以更容易地解决在使用更严格、无逻辑的模板解决方案时经常遇到的许多挑战。

清单 9-39 显示了 JSON 数据的摘录,本节的其余示例将引用这些数据。

Listing 9-39. Excerpt of the JSON Data Passed to a Dust Template

// dust-logic1/people.json (excerpt)

[{

"name": "Joe", "location": "Chicago", "age": 27,

"education": "high_school", "employed": false, "job_title": null

}, {

"name": "Mary", "location": "Denver", "age": 35,

"education": "college", "employed": true, "job_title": "Chef"

}]

逻辑助手

清单 9-40 展示了 Dust 逻辑助手@eq的用法,通过它我们可以在两个指定值keyvalue之间进行严格的比较。在本例中,第一个值job_title引用当前上下文中的一个属性。第二个值"Chef",被定义为模板中的一个文字值。

Listing 9-40. Using a Dust Logic Helper to Conditionally Display Content

// dust-logic1/views/main.dust (excerpt)

{#people}

{@eq key=job_title value="Chef"}

<p>{name} is a chef. This person definitely knows how to cook.</p>

{:else}

<p>{name} is not a chef. This person may or may not know how to cook.</p>

{/eq}

{/people}

知道了这一点,想象一个场景,我们想要在两个数字之间执行严格的相等检查,其中一个作为上下文属性被引用,而另一个在模板中被指定为文字。为了做到这一点,我们必须将我们的文字值转换成适当的类型,如清单 9-41 所示。

Listing 9-41. Casting a Literal Value to the Desired Type

{#people}

{@eq key=age value="27" type="number"}

<p>{name} is 27 years old.</p>

{/eq}

{/people}

Dust 提供了许多逻辑助手,可以用来进行简单的比较。它们的名称和描述在表 9-2 中列出。

表 9-2。

Logic Helpers Provided by Dust

| 逻辑助手 | 描述 | | --- | --- | | `@eq` | 严格等于 | | `@ne` | 不严格等于 | | `@gt` | 大于 | | `@lt` | 不到 | | `@gte` | 大于或等于 | | `@lte` | 小于或等于 |
Switch 语句

经常使用的@select助手提供了一种方法,通过这种方法我们可以模仿switch (...)语句,使得模板可以基于指定的值指定内容的多种变化(参见清单 9-42 )。

Listing 9-42. Mimicking a switch Statement with the @select Helper

{@gte key=age value=retirement_age}

<p>{name} has reached retirement age.</p>

{:else}

<p>

{@select key=job_title}

{@eq value="Chef"}Probably went to culinary school, too.{/eq}

{@eq value="Professor"}Smarty pants.{/eq}

{@eq value="Accountant"}Good with numbers.{/eq}

{@eq value="Astronaut"}Not afraid of heights.{/eq}

{@eq value="Pilot"}Travels frequently.{/eq}

{@eq value="Stunt Double"}Fearless.{/eq}

{! @none serves as a default case !}

{@none}Not sure what I think.{/none}

{/select}

</p>

{/gte}

迭代助手

Dust 为解决迭代中经常遇到的问题提供了三个有用的助手。例如,清单 9-43 演示了@sep助手的使用,通过它我们可以定义除最后一次迭代之外的每次迭代的内容。

Listing 9-43. Ignoring Content During a Loop’s Last Iteration with @sep

// dust-logic1/views/main.dust (excerpt)

{#people}{name}{@sep}, {/sep}{/people}

// output

Joe, Mary, Wilson, Steve, Laura, Tim, Katie, Craig, Ryan

Dust 总共提供了三个解决迭代挑战的助手。这些在表 9-3 中列出。

表 9-3。

Iteration Helpers

| 迭代助手 | 描述 | | --- | --- | | `@sep` | 为每个迭代呈现内容,最后一次除外 | | `@first` | 仅呈现第一次迭代的内容 | | `@last` | 仅呈现最后一次迭代的内容 |
数学表达式

使用 Dust 的@math助手,模板可以根据数学表达式的结果调整它们的内容。这种调整可以通过两种方式之一进行。第一个在清单 9-44 中演示,其中数学表达式的结果在模板中被直接引用。第二个在清单 9-45 中演示,其中内容根据调用@math助手的结果有条件地呈现。

Listing 9-44. Directly Referencing the Result of a Mathematical Expression

// dust-logic1/views/main.dust (excerpt)

{#people}

{@lt key=age value=retirement_age}

<p>{name} will have reached retirement age in

{@math key=retirement_age method="subtract" operand=age /} year(s).</p>

{/lt}

{/people}

Listing 9-45. Conditionally Rendering Content Based on the Result of a Call to the @math Helper

// dust-logic1/views/main.dust (excerpt)

{#people}

{@lt key=age value=retirement_age}

{@math key=retirement_age method="subtract" operand=age}

{@lte value=10}{name} will reach retirement age fairly soon.{/lte}

{@lte value=20}{name} has quite a ways to go before they can retire.{/lte}

{@default}{name} shouldn’t even think about retiring.{/default}

{/math}

{/lt}

{/people}

Dust 的@math助手支持的各种“方法”包括:addsubtractmultiplydividemodabsfloorceil

上下文转储

在开发过程中很有用,Dust 的@contextDump助手允许您快速呈现当前上下文对象(JSON 格式),提供对 Dust 在调用它的部分中看到的值的洞察。此处显示了其用法示例:

{#people}<pre>{@contextDump /}</pre>{/people}

自定义助手

在本章的前面,您学习了如何创建上下文帮助器,使用它们可以扩展上下文对象以包含自定义功能。同样,自定义 Dust helpers 也可以在全局级别创建。清单 9-46 展示了如何应用这一点。

Listing 9-46. Creating and Using a Custom Dust Helper

// dust-logic1/index.js (excerpt)

dust.helpers.inRange = function(chunk, context, bodies, params) {

if (params.key >= params.lower && params.key <= params.upper) {

return chunk.render(bodies.block, context);

} else {

return chunk;

}

}

// dust-logic1/views/main.dust (excerpt)

{#people}

{@gte key=age value=20}

{@lte key=age value=29}<p>This person is in their 20's.</p>{/lte}

{/gte}

{@inRange key=age lower=20 upper=29}<p>This person is in their 20's.</p>{/inRange}

{/people}

在这个示例的模板中,创建了一个循环,在这个循环中,我们遍历上下文中定义的每个人。对于每个人,如果他们碰巧在 20 岁左右的年龄段,就会显示一条消息。首先,使用预先存在的逻辑助手@gte@lt的组合来显示该消息。接下来,使用已经在全局级别定义的定制@inRange助手再次显示消息。

现在您已经熟悉了 Kraken 所依赖的许多基本组件,让我们继续创建我们的第一个真正的 Kraken 应用。

我们去找 Kraken

在本书关于开发工具的第一部分中,我们介绍了四个有用的工具,它们有助于管理许多与 web 开发相关的任务,其中包括:Bower、Grunt 和 Yeoman。Kraken 依赖于这些工具中的每一个,还有一个约曼生成器,它将帮助我们构建项目的初始结构。如果您还没有这样做,您应该通过 npm 全局安装这些模块,如下所示:

$ npm install -g yo generator-kraken bower grunt-cli

用约曼创建一个新的 Kraken 项目是一个互动的过程。在这个例子中,我们向生成器传递新项目的名称(app),此时它开始提示我们一些问题。图 9-4 显示了创建本章的app项目所采取的步骤。

A978-1-4842-0662-1_9_Fig4_HTML.jpg

图 9-4。

Creating a Kraken application using the Yeoman generator

一旦您回答了这些问题,生成器将创建项目的初始文件结构,并开始安装必要的依赖项。之后,你应该找到一个新的包含项目内容的app文件夹,它应该如图 9-5 所示。

A978-1-4842-0662-1_9_Fig5_HTML.jpg

图 9-5。

Initial file structure for the app project

Kraken 的 Yeoman generator 已经自动创建了一个新的 Express 应用,这个程序是使用本章前面介绍的模块组织的。我们可以立即启动当前状态的项目,如清单 9-47 所示。之后,可以在本地地址访问该项目(见图 9-6 )。

A978-1-4842-0662-1_9_Fig6_HTML.jpg

图 9-6。

Viewing the Project in the Browser for the First Time Listing 9-47. Launching the Project for the First Time

$ npm start

> app@0.1.0 start /Users/tim/temp/app

> node server.js

Server listening on http://localhost:8000

Application ready to serve requests.

Environment: development

正如你所看到的,我们的项目已经被预先配置(在confitmeddleware的帮助下)使用了许多有用的中间件模块(例如cookieParsersession等)。).为了进一步了解所有这些是如何组合在一起的,清单 9-48 显示了项目index.js脚本的内容。

Listing 9-48. Contents of Our New Project’s index.js Script

// app/index.js

var express = require('express');

var kraken = require('kraken-js');

var options, app;

/*

* Create and configure application. Also exports application instance for use by tests.

* Seehttps://github.com/krakenjs/kraken-js#options

*/

options = {

onconfig: function (config, next) {

/*

* Add any additional config setup or overrides here. config is an initialized

* confit (https://github.com/krakenjs/confit/

*/

next(null, config);

}

};

app = module.exports = express();

app.use(kraken(options));

app.on('start', function () {

console.log('Application ready to serve requests.');

console.log('Environment: %s', app.kraken.get('env:env'));

});

我们在这里看到的kraken-js模块只不过是一个标准的 Express 中间件库。然而,Kraken 并没有简单地给 Express 增加一些额外的功能,而是负责配置一个完整的 Express 应用。它将在许多其他模块的帮助下完成这项工作,包括本章已经介绍过的模块:confitmeddlewareenroutenadaro

如清单 9-48 所示,Kraken 被传递了一个包含onconfig()回调函数的配置对象,该对象将在 Kraken 为我们完成初始化confit后被调用。在这里,我们可以提供我们不想直接在项目的 JSON 配置文件中定义的任何最后的覆盖。在此示例中,没有进行此类覆盖。

控制器、模型和测试

在本章的“结构化路线组织”一节中,我们发现了enrouten如何有助于使定义快速路线的杂乱方式变得有序。默认情况下,一个新的 Kraken 项目被设置为使用 enrouten 的directory配置选项,允许它递归地扫描指定文件夹的内容,搜索导出接受单个参数的函数的模块(即router)。对于它找到的每个模块(称为“控制器”),enrouten将传递一个 Express 路由器实例,该实例已经安装到由该模块在目录结构中的位置预先确定的路径上。通过查看 Kraken 为我们的项目创建的默认控制器,我们可以看到这个过程的运行,如清单 9-49 所示。

Listing 9-49. Our Project’s Default Controller

// app/controllers/index.js

var IndexModel = require('../models/index');

module.exports = function (router) {

var model = new IndexModel();

/**

* The default route served for us when we access the app at: http://localhost:8000

*/

router.get('/', function (req, res) {

res.render('index', model);

});

};

除了为我们的项目创建一个默认控制器,Kraken 还负责创建一个相应的模型,IndexModel,你可以在清单 9-49 中看到引用。我们将很快讨论 Kraken 与模型的关系,但首先,让我们走一遍创建我们自己的新控制器的过程。

第三章介绍了约曼,展示了生成器能够提供子命令,这些子命令能够为开发人员提供功能,这些功能的有用性远远超出了项目的初始创建。Kraken 的 Yeoman generator 利用了这一点,提供了一个controller子命令,用它可以快速创建新的控制器。举例来说,让我们创建一个新的控制器,负责管理一组 RSS 提要:

$ yo kraken:controller feeds

在为生成器的controller子命令指定了我们想要的路径feeds之后,会自动为我们创建五个新文件:

  • controllers/feeds.js:控制器
  • models/feeds.js:型号
  • test/feeds.js:测试套件
  • public/templates/feeds.dust:灰尘模板
  • locales/US/en/feeds.properties:国际化设置

现在,让我们把注意力放在这里列出的前三个文件上,从模型开始。在下一节中,我们将看看附带的 Dust 模板和内部化设置文件。

模型

清单 9-50 显示了我们项目的新feeds模型的初始状态。如果你期待一些复杂的东西,你可能会失望。正如您所看到的,这个文件只不过是一个通用的存根,我们希望用我们自己的持久层来替换它。

Listing 9-50. Initial Contents of the feeds Model

// models/feeds.js

module.exports = function FeedsModel() {

return {

name: 'feeds'

};

};

不同于其他许多“全栈”框架,它们试图为开发人员提供解决所有可能需求(包括数据持久性)的工具,Kraken 采取了一种极简主义的方法,不试图重新发明轮子。这种方法认识到,开发人员已经可以访问各种各样得到良好支持的库来管理数据持久性,本书涵盖了其中的两个库:Knex/Bookshelf 和 Mongoose。

举例来说,让我们更新这个模块,以便它导出一个书架模型,能够在 SQLite 数据库中存储的feeds表中获取和存储信息。清单 9-51 显示了feeds型号的更新内容。

Listing 9-51. Updated feeds Model That Uses Knex/Bookshelf

// models/feeds.js

var bookshelf = require('../lib/bookshelf');

var Promise = require('bluebird');

var feedRead = require('feed-read');

var Feed = bookshelf.Model.extend({

'tableName': 'feeds',

'getArticles': function() {

var self = this;

return Promise.fromNode(function(callback) {

feedRead(self.get('url'), callback);

});

}

});

module.exports = Feed;

Note

清单 9-51 中显示的更新模型假设您已经熟悉 Knex 和 Bookshelf 库,以及配置它们的必要步骤。如果不是这样,你可能想读读第十二章。无论如何,本章的app项目提供了这里显示的代码的完整功能演示。

控制器

清单 9-52 显示了我们项目的新feeds控制器的初始内容。与我们项目附带的原始控制器一样,这个控制器引用了 Kraken 为我们方便地创建的相应模型,我们已经看到了。

Listing 9-52. Initial Contents of the feeds Controller

// controllers/feeds.js

var FeedsModel = require('../models/feeds');

/**

* @url http://localhost:8000/feeds

*/

module.exports = function (router) {

var model = new FeedsModel();

router.get('/', function (req, res) {

});

};

在默认状态下,feeds控制器完成的任务很少。让我们更新这个控制器,以包含一些额外的路由,允许客户端与我们的应用的Feed模型进行交互。清单 9-53 中显示了feeds控制器的更新版本。

Listing 9-53. Updated feeds Controller

var Feed = require('../models/feeds');

module.exports = function(router) {

router.param('feed_id', function(req, res, next, id) {

Feed.where({

'id': id

}).fetch({

'require': true

}).then(function(feed) {

req.feed = feed;

next();

}).catch(next);

});

/**

* @url http://localhost:8000/feeds

*/

router.route('/')

.get(function(req, res, next) {

return Feed.where({})

.fetchAll()

.then(function(feeds) {

if (req.accepts('html')) {

return res.render('feeds', {

'feeds': feeds.toJSON()

});

} else if (req.accepts('json')) {

return res.send(feeds);

} else {

throw new Error('Unknown Accept value: ' + req.headers.accept);

}

})

.catch(next);

});

/**

* @url http://localhost:8000/feeds/:feed_id

*/

router.route('/:feed_id')

.get(function(req, res, next) {

res.send(req.feed);

});

/**

* @url http://localhost:8000/feeds/:feed_id/articles

*/

router.route('/:feed_id/articles')

.get(function(req, res, next) {

req.feed.getArticles()

.then(function(articles) {

res.send(articles);

})

.catch(next);

});

};

有了这些更新,客户现在能够

  • 列表订阅源
  • 获取关于特定源的信息
  • 从特定的订阅源获取文章

在下一节中,我们将看看 Kraken 为我们应用的这一部分创建的测试套件。使用这个测试套件,我们可以验证我们定义的路由是否如预期的那样工作。

测试套件

清单 9-54 显示了 Kraken 为我们的新控制器创建的测试套件的初始内容。这里我们看到一个测试,它是在 SuperTest 的帮助下定义的,SuperTest 是 SuperAgent 的一个扩展,SuperAgent 是一个用于发出 HTTP 请求的简单库。

Listing 9-54. Test Suite for the feeds Controller

// test/feeds.js

var kraken = require('kraken-js');

var express = require('express');

var request = require('supertest');

describe('/feeds', function() {

var app, mock;

beforeEach(function(done) {

app = express();

app.on('start', done);

app.use(kraken({

'basedir': process.cwd()

}));

mock = app.listen(1337);

});

afterEach(function (done) {

mock.close(done);

});

it('should say "hello"', function(done) {

request(mock)

.get('/feeds')

.expect(200)

.expect('Content-Type', /html/)

.expect(/"name": "index"/)

.end(function (err, res) {

done(err);

});

});

});

在这个例子中,向我们的应用的/feeds端点发出一个 GET 请求,并做出以下断言:

  • 服务器应该用 HTTP 状态代码 200 来响应。
  • 服务器应该用一个包含字符串htmlContent-Type头来响应。
  • 响应的主体应该包含字符串"name": "index"

鉴于我们最近对新控制器所做的更新,这些断言不再适用。让我们用一些相关的测试来代替它们。清单 9-55 显示了测试套件的更新内容。

Listing 9-55. Updated Contents of the feeds Test Suite

// test/feeds/index.js

var assert = require('assert');

var kraken = require('kraken-js');

var express = require('express');

var request = require('supertest');

describe('/feeds', function() {

var app, mock;

beforeEach(function(done) {

app = express();

app.on('start', done);

app.use(kraken({'basedir': process.cwd()}));

mock = app.listen(1337);

});

afterEach(function(done) {

mock.close(done);

});

it('should return a collection of feeds', function(done) {

request(mock)

.get('/feeds')

.expect('Content-Type', /json/)

.expect(200)

.end(function(err, res) {

if (err) return done(err);

assert(res.body instanceof Array, 'Expected an array');

done();

});

});

it('should return a single feed', function(done) {

request(mock)

.get('/feeds/1')

.expect('Content-Type', /json/)

.expect(200)

.end(function(err, res) {

if (err) return done(err);

assert.equal(typeof res.body.id, 'number',                     'Expected a numeric id property');

done();

});

});

it('should return articles for a specific feed', function(done) {

request(mock)

.get('/feeds/1/articles')

.expect('Content-Type', /json/)

.expect(200)

.end(function(err, res) {

if (err) return done(err);

assert(res.body instanceof Array, 'Expected an array');

done();

});

});

});

我们更新的测试套件现在包含三个测试,旨在验证我们的每个新控制器的路由是否正常工作。例如,考虑第一个测试,它将向我们的应用的/feeds端点发出 GET 请求,并做出以下断言:

  • 服务器应该用 HTTP 状态代码 200 来响应。
  • 服务器应该用一个包含字符串jsonContent-Type头来响应。
  • 服务器应该以数组的形式返回一个或多个结果。

Note

回想一下,我们的应用的Feed模型是在 Knex 和 Bookshelf 库的帮助下创建的。您在这个项目中看到的引用数据来自 Knex“种子”文件(seeds/developments/00-feeds.js),我们可以用样本数据填充我们的数据库。在任何时候,都可以通过从命令行运行$ grunt reset-db将这个项目的 SQLite 数据库重置为初始状态。如果这些概念对你来说不熟悉,你可能想阅读第十二章。

图 9-7 显示了当我们项目的test Grunt 任务被调用时打印到控制台的输出。

A978-1-4842-0662-1_9_Fig7_HTML.jpg

图 9-7。

Running the test suite

国际化和本地化

Kraken 为创建能够自适应以满足多种语言和地区的独特需求的应用提供了内置支持,这是大多数希望在多种多样的市场中广泛使用的产品的重要要求。在这一节中,我们将了解完成这一任务的两个步骤,国际化和本地化,以及如何在 Kraken 应用的上下文中应用它们,该应用的模板是在服务器上生成的。

国际化(通常简称为 i18n)是指开发能够支持多个地区和方言的应用的行为。实际上,这是通过避免在应用的模板中直接使用特定于地区的单词、短语和符号(例如,货币符号)来实现的。取而代之的是占位符,这些占位符是在请求模板时根据发出请求的用户的位置或设置填充的。举个例子,考虑清单 9-56 中显示的 Dust 模板,它负责呈现本章app项目的主页。

Listing 9-56. Dust Template for the Home Page of app Project

// app/public/templates/index.dust

{>"layouts/master" /}

{<body}

<div class="panel panel-default">

<div class="panel-heading">

<h3 class="panel-title">{@pre type="content" key="greeting" /}</h3>

</div>

<div class="panel-body">

<form method="post" action="/sessions">

<div class="form-group">

<label>{@pre type="content" key="email_address" /}</label>

<input type="email" name="email" class="form-control">

</div>

<div class="form-group">

<label>{@pre type="content" key="password" /}</label>

<input type="password" name="password" class="form-control">

</div>

<button type="submit" class="btn btn-primary">

{@pre type="content" key="submit" /}

</button>

</form>

</div>

</div>

{/body}

这里的基本语义应该是熟悉的,基于之前在本章关于灰尘的部分中讨论过的内容。正如你所看到的,这个模板不是直接嵌入内容,而是依赖于 Kraken 提供的一个特殊的 Dust 助手,@pre,通过它我们可以引用存储在单独的、特定于地区的内容文件中的内容。清单 9-57 中显示了这个特定模板的相应内容文件。

Listing 9-57. Corresponding Content Files for the Dust Template Shown in Listing 9-56

// app/locales/US/en/index.properties

# Comments are supported

greeting=Welcome to Feed Reader

submit=Submit

email_address=Email Address

password=Password

// app/locales/ES/es/index.properties

greeting=Bienvenida al Feed Reader

submit=Presentar

email_address=Correo Electrónico

password=Contraseña

Note

注意这个例子的模板public/templates/index.dust的位置,以及它对应的内容属性文件locales/US/en/index.propertieslocales/ES/es/index.properties的位置。Kraken 被配置为一对一地将 Dust 模板与内容属性文件配对,方法是根据它们的路径和文件名进行匹配。

国际化(i18n)主要关注创建能够支持本地化内容注入的应用,与之相反,本地化(l10n)指的是创建特定于地区和方言的内容文件(如本例中所示)的过程。清单 9-58 中显示的控制器展示了 Kraken 如何帮助开发者将这些概念结合在一起,为用户提供满足他们特定需求的内容。

Listing 9-58. Serving a Locale-Specific Version of the Home Page

// app/controllers/index.js

module.exports = function (router) {

/**

* The default route served for us when we access the app

* at http://localhost:8000

*/

router.get('/', function (req, res) {

res.locals.context = { 'locality': { 'language': 'es', 'country': 'ES' } };

res.render('index');

});

};

这个例子是我们最初在清单 9-49 中看到的控制器的更新版本,它负责呈现我们的应用的主页。在这里,我们通过将内容文件分配给传入 Express response 对象的locals.context属性来指定用于定位内容文件的国家和语言。如果没有指定这样的值,Kraken 的默认行为是使用美国英语。渲染模板的英文版和西班牙文版分别如图 9-8 和图 9-9 所示。

A978-1-4842-0662-1_9_Fig9_HTML.jpg

图 9-9。

Spanish version of the application’s home page

A978-1-4842-0662-1_9_Fig8_HTML.jpg

图 9-8。

English version of the application’s home page

检测位置

清单 9-58 中显示的示例演示了将特定区域设置手动分配给传入请求的过程。但是,它没有演示自动检测用户所需本地化设置的过程。

清单 9-59 展示了一种基于accept-language HTTP 请求头的值来确定位置的简单方法。在这个例子中,我们已经从我们的路由中删除了用于确定用户位置的逻辑,并将它放在了一个更合适的位置——一个将为每个传入请求调用的中间件功能。

Listing 9-59. Detecting Locality Based on the Value of the accept-language HTTP Request Header

// app/lib/middleware/locale.js

var acceptLanguage = require('accept-language');

/**

* Express middleware function that automatically determines locality based on the value

* of the accept-language header.

*/

module.exports = function() {

return function(req, res, next) {

var locale = acceptLanguage.parse(req.headers['accept-language']);

res.locals.context = {

'locality': { 'language': locale[0].language, 'country': locale[0].region }

};

next();

};

};

// app/config/config.json (excerpt)

"middleware":{

"locale": {

"module": {

"name": "path:./lib/middleware/locale"

},

"enabled": true

}

}

Note

虽然很有帮助,但是accept-language HTTP 请求头并不总是反映发出请求的用户所需的本地化设置。务必为用户提供一种自行手动指定此类设置的方法(例如,作为“设置”页面的一部分)。

安全

鉴于 Kraken 出身于全球在线支付处理商 PayPal,该框架高度重视安全性也就不足为奇了。Kraken 在张亿嘟嘟的帮助下做到了这一点,该库按照开放 Web 应用安全项目(OWASP)的建议,用许多增强的安全技术扩展了 Express。这些扩展以多个可独立配置的中间件模块的形式提供。在本节中,我们将简要介绍 Kraken 帮助保护 Express 免受常见攻击的两种方法。

Note

这份材料绝不应被视为详尽无遗。它仅用于作为在 Kraken/快速应用环境中实现安全性的起点。强烈建议在 Web 上实现安全性的读者,通过阅读完全致力于这一主题的许多优秀书籍中的几本,来深入研究这一主题。

防御跨站点请求伪造攻击

为了理解跨站点请求伪造(CSRF)攻击背后的基本前提,理解大多数 web 应用对其用户进行身份验证的方法是很重要的:基于 cookie 的身份验证。该过程如图 9-10 所示。

A978-1-4842-0662-1_9_Fig10_HTML.gif

图 9-10。

Cookie-based authentication

在一个典型的场景中,用户将他们的凭证提交给一个 web 应用,然后该应用将这些凭证与文件中的凭证进行比较。假设凭证是有效的,那么服务器将创建一个新的会话——本质上是一个代表用户成功登录尝试的记录。然后,属于该会话的唯一标识符以 cookie 的形式传输给用户,cookie 由用户的浏览器自动存储。浏览器向应用发出的后续请求将自动附加存储在该 cookie 中的信息,允许应用查找匹配的会话记录。因此,应用能够验证用户的身份,而不需要用户在每次请求时重新提交用户名和密码。

CSRF 攻击利用应用和用户浏览器之间存在的信任关系(即会话),诱使用户向应用提交非预期的请求。让我们看一个例子,它应该有助于解释这是如何工作的。图 9-11 展示了用户登录可信应用的过程——在这种情况下,本章源代码中包含的csrf-server项目。

A978-1-4842-0662-1_9_Fig11_HTML.jpg

图 9-11。

Signing into a trusted application

图 9-12 显示了用户成功登录应用后出现的欢迎屏幕。在这里,我们可以看到用户的一些基本信息,包括他们的姓名和他们的帐户是何时创建的。

A978-1-4842-0662-1_9_Fig12_HTML.jpg

图 9-12。

Successful sign-in attempt

在这一点上,想象一个场景,用户离开应用(没有退出)并访问另一个网站,在用户不知道的情况下,有恶意的意图(见图 9-13 )。这个恶意网站的副本可以在本章的csrf-attack项目中找到。在这个例子中,恶意网站用免费糖果和蝴蝶的诱人承诺引诱用户点击按钮。

A978-1-4842-0662-1_9_Fig13_HTML.jpg

图 9-13。

Malicious web site attempting to convince the user to click a button

清单 9-60 显示了这个恶意网站的 HTML 摘录,这将有助于解释当用户点击这个按钮时会发生什么。如您所见,单击该按钮将触发对原始应用的/transfer-funds路由的 POST 请求的创建。

Listing 9-60. Malicious Web Form

// csrf-attack/views/index.dust (excerpt)

<form method="post" action="``http://localhost:7000/transfer-funds

<button type="submit" class="btn btn-primary">

Click Here for Free Candy and Butterflies

</button>

</form>

点击按钮后,用户没有收到承诺的免费糖果和蝴蝶,而是收到一条消息,表明所有的资金都已从他们的帐户中转出,如图 9-14 所示。

A978-1-4842-0662-1_9_Fig14_HTML.jpg

图 9-14。

Successful CSRF attack

可以采取几个不同的步骤来抵御这种性质的攻击。Kraken 抵御它们的方法被称为“同步器令牌模式”在这种方法中,为每个传入的请求生成一个随机字符串,客户端随后可以将该字符串作为模板上下文的一部分或通过响应头进行访问。重要的是,这个字符串不是作为 cookie 存储的。客户端发出的下一个 POST、PUT、PATCH 或 DELETE 请求必须包含这个字符串,然后服务器会将其与之前生成的字符串进行比较。只有在匹配的情况下,请求才会被允许进行。

让我们看看这在实践中是如何工作的。图 9-15 显示了本章app项目的签到页面。回头参考清单 9-56 来查看这个页面的底层 HTML。

A978-1-4842-0662-1_9_Fig15_HTML.jpg

图 9-15。

Sign-in page for this chapter’s app project

在其当前状态下,任何使用此表单登录的尝试都将导致图 9-16 所示的错误。这里我们看到一条来自 Kraken 的错误消息,警告我们缺少“CSRF 令牌”

A978-1-4842-0662-1_9_Fig16_HTML.jpg

图 9-16。

Kraken’s “CSRF token missing” Error

这个错误可以通过在应用的登录表单中添加一个单独的隐藏输入来解决。清单 9-61 显示了我们的应用更新的 Dust 模板的摘录,以及渲染输出的摘录。

Listing 9-61. Inserting a Hidden _csrf Field into the Sign-In Form

// app/public/templates/index.dust (excerpt)

<form method="post" action="/sessions">

<input type="hidden" name="_csrf" value="{_csrf}">

<!-- ... ->

</form>

// Rendered output

<form method="post" action="/sessions">

<input type="hidden" name="_csrf" value="OERRGi9AGNPEYnNWj8skkfL9f0JIWJp3uKK8g=">

<!-- ... ->

</form>

这里我们创建了一个名为_csrf的隐藏输入,张亿嘟嘟已经将它的值自动传递给模板上下文中同名的属性。我们在本例中看到的值OERRGi9AGNPEYnNWj8skkfL9f0JIWJp3uKK8g=,是张亿嘟嘟为我们生成的随机散列(即“同步器令牌”)。当我们提交此表单时,张亿嘟嘟将验证此值是否与之前提供给我们的值相匹配。如果它们匹配,则允许请求继续进行。否则,将引发错误。这种方法允许应用通过要求额外的、不作为 cookie 的一部分存储的识别信息来防御 CSRF 攻击,使得攻击者更加难以欺骗用户执行非预期的操作。

配置内容安全策略标题

张亿嘟嘟为开发人员提供了一种配置应用内容安全策略(CSP)的便捷机制。这些规则向支持浏览器提供关于各种资源(例如,脚本、样式表、图像等)的位置的指令。)可以加载。定义时,这些规则以Content-Security-Policy响应头的形式传递给浏览器。

作为一个例子,参见清单 9-62 ,其中张亿嘟嘟的csp中间件模块被提供了一个配置对象,该对象指定只有图像可以从任何域加载。所有其他资源必须来自应用的域。

Listing 9-62. Configuring an Application’s Content Security Policy

app.use(lusca({

'csp': {

'default-src': '\'self\'',

'img-src': '*'

}

});

Note

有关可通过Content-Security-Policy标题配置的各种选项的完整列表,请访问位于 https://owasp.org 的开放 Web 应用安全项目(OWASP)。

摘要

节点社区深受所谓的“Unix 哲学”的影响,这种哲学提倡创建小型的、紧密集中的模块,旨在做好一件事。这种方法通过培育一个开放源代码模块的大型生态系统,使 Node 作为一个开发平台蓬勃发展。PayPal 将这一理念铭记于心,将 Kraken 构建成一个模块的集合,而不是一个单一的整体框架,为基于 Express 的应用扩展和提供结构。通过采用这种方法,PayPal 已经成功地为 Node 生态系统贡献了几个模块,开发者可以从中受益,无论他们是否选择整体使用 Kraken。

相关资源