九、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

{

// Theimporthandler allows us to set a property’s value to the contents

// of the specified JSON configuration file.

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

// Theconfighandler 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.comT2】

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

"base_api_url": "https://api.myapp.comT2】

}

清单 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.comT3】

base_api_url:https://api.myapp.comT3】

something_else:https://myapp.comT3】

虽然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': {

// Thefilehandler 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

}),

// Therequirehandler allows us to set a property’s value to that

// exported from a module.

'require': handlers.require(basedir),

// Theglobhandler 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.comT3】

base_api_url:https://api.myapp.comT3】

something_else:https://myapp.comT3】

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');

// Populatesreq.cookieswith data parsed from theCookieheader

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:7000T2】

});

// 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 adefaultcase !}

{@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#optionsT2】

*/

options = {

onconfig: function (config, next) {

/*

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

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

*/

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 与模型的关系,但首先,让我们走一遍创建我们自己的新控制器的过程。

第 3 章介绍了约曼,展示了生成器能够提供子命令,这些子命令能够为开发人员提供功能,这些功能的有用性远远超出了项目的初始创建。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 库,以及配置它们的必要步骤。如果不是这样,你可能想读读第 12 章。无论如何,本章的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('UnknownAcceptvalue: ' + 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 numericidproperty');

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 数据库重置为初始状态。如果这些概念对你来说不熟悉,你可能想阅读第 12 章。

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 theaccept-languageheader.

*/

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-fundsT2】

<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

`

` `` `