学习Node全栈开发,你都需要掌握哪些知识?

异步社区官方博客

本书绝大部分内容关注的都是组成Node的核心模块和功能。我之所以尽量避免使用第三方模块,是因为Node还是一个很不稳定的环境,所以它对第三方模块的支持会随着时间的推移而发生快速且剧烈的变化。

但是我觉得,如果不了解更广泛的Node应用上下文的话,我们就无法真正掌握Node。换句话说,你需要熟悉Node全栈开发。这意味着你需要熟悉数据系统、API、客户端开发等,这些技术跨度很大,而它们只有一个共同点:基于Node。

最常见的Node全栈开发形式就是MEAN——MongoDB、Express、AngularJS和Node。当然,全栈开发可以包含其他工具,例如在数据库开发中使用MySQL或者Redis,以及除AngularJS以外的其他客户端框架。而Express已经家喻户晓了。如果你要使用Node开发的话,必须熟悉Express。

Picture 1{10%} 

MEAN的深入解读

如果要对MEAN、全栈开发和Express进行更深入的学习,我推荐Ethan Brown的《Node与Express开发》、Shyam Seshadri和Brad Green的《用AngularJS开发下一代Web应用》和Scott Davis的视频《MEAN技术栈架构》。

10.1 Express应用框架

在第5章中,我讲了如何简单地使用Node来构建一个Web应用程序。使用Node来创建Web应用很困难,所以像Express这样的框架才会变得非常流行:它提供了我们需要的绝大部分功能,使我们的工作变得非常简单。

有Node的地方,几乎都有Express,所以一定要熟悉这个框架。我们在本章中会介绍最简单的Express程序,但完成这些之后还需要进一步的训练。

Picture 1{10%} 

Express现在已经成为Node.js的基础组件

Express一开始很不稳定,但现在已经是Node.js基础组件之一。未来的开发应该会变得更稳定,功能也会更可靠。

Express有很好的文档支持,包括如何启动一个程序。我们会跟着文档大纲一步一步来,然后扩展我们的基本程序。一开始,我们要为应用程序创建一个子目录,起什么名字无所谓。然后使用npm来创建一个package.json文件,并将app.js作为程序入口。最后,键入以下命令,安装Express并保存到package.json的依赖中:

npm install express --save

Express的文档包含了一个基本的Hello World程序,将下面的代码放入app.js文件中:

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

app.get('/', function (req, res) {
  res.send('Hello World!');
}); 

app.listen(3000, function () {
  console.log('Example app listening on port 3000!');
});

app.get()函数会处理所有的GET请求,传入我们在前面几章已经很熟悉的requestresponse对象。按照惯例,Express程序会使用缩写形式,也就是reqres。它们在默认的requestresponse对象的功能基础上还加入了Express的功能。比如说,你可以调用res.write()res.end()来为Web请求提供响应,如我们在前几章中做过的一样。但是有了Express,你就可以用res.send(),只需一行就能实现同样的功能。

我们还可以使用Express的生成器来生成程序框架,而不是手动创建。下面就会用到这个功能,它会提供一个功能更详尽、可读性更高的Express程序。

首先,全局安装Express程序生成器:

sudo npm install express-generator –g

下一步,运行这个程序,后面跟上你想要创建的程序的名称。此处我以bookapp为例:

express bookapp

Express程序生成器会创建所需的子目录。然后进入bookapp子目录安装依赖:

npm install

好了,到此为止你的第一个Express程序框架就生成好了。如果你用的是OS X或者Linux环境,那么可以使用下面的命令来运行程序:

DEBUG=bookapp:* npm start

如果是Windows则需要在命令行中运行下面的命令:

set DEBUG=bookapp:* & npm start

如果不需要调试的话,直接使用npm start也可以启动程序。

程序启动之后会在默认的3000端口上监听请求。在浏览器中访问程序,你会得到一个简单的Web页面,页面上有一条欢迎语“Welcome to Express”。

程序会自动生成几个子目录和文件:

├── app.js
├── bin
│   └── www
├── package.json
├── public
│   ├── images 
│   ├── javascripts
│   └── stylesheets
│       └── style.css
├── routes
│   ├── index.js
│   └── users.js
└── views
    ├── error.jade
    ├── index.jade
    └── layout.jade

其中的很多组件我们都会讲到,但是能够公开访问的文件都放在public子目录中。你会注意到,图片文件和CSS文件都在这个目录中。动态内容的模板文件都在views目录中。routes目录包含了程序的Web接口,它们可以监听Web请求和显示Web页面。

Picture 4{12%} 

Jade现在已经更名为Pug

由于商标冲突,Jade的创始者无法再使用“Jade”作为Express和其他应用程序所使用的模板引擎的名称了。但是,从Jade到Pug的转换还在进行中。在生产环境,Express生成器仍将生成Jade文件,但是尝试安装Jade依赖的话则会产生一个错误信息:

Jade has been renamed to pug, please install the latest version of pug instead of jade

Pug的网站保留了Jade的名字,但是文档和功能都是Pug的。

bin目录下的www文件是程序的启动脚本。它是一个被转化为命令行程序的Node文件。如果查看生成的package.json文件,你会发现它出现在程序的启动脚本中。

{
  "name": "bookapp",
  "version": "0.0.0",
  "private": true,
  "scripts": {
    "start": "node ./bin/www"
  },
  "dependencies": {
    "body-parser": "~1.13.2",
    "cookie-parser": "~1.3.5",
    "debug": "~2.2.0",
    "express": "~4.13.1",
    "jade": "~1.11.0",
    "morgan": "~1.6.1",
    "serve-favicon": "~2.3.0"
  }
}

你需要在bin目录下安装别的脚本,来对应用程序进行测试、重启或其他控制。

现在让我们来深入了解一下这个程序,就从程序的入口——app.js文件开始吧。

当你打开app.js文件时,你会看到里面的代码比我们之前看到的简单程序还要多。代码中引入了更多的模块,其中大多数是为面向Web的应用程序提供中间件。被引入的模块也会包含程序特定的引用,也就是routes目录下的文件:

var express = require('express');
var path = require('path');
var favicon = require('serve-favicon');
var logger = require('morgan');
var cookieParser = require('cookie-parser');
var bodyParser = require('body-parser');

var routes = require('./routes/index');
var users = require('./routes/users');

var app = express();

其中所涉及的模块以及它们的功能如下:

每个中间件模块都同时兼容普通的HTTP服务和Express服务。

Picture 1{10%} 

什么是中间件

中间件是我们的应用程序和系统、操作系统以及数据库之间的桥梁。使用Express时,中间件就是应用程序链中的一部分,而每一部分都在完成与HTTP请求相关的特定功能——处理请求,或者对请求进行一些修改以便后面的中间件使用。Express所使用的中间件集合非常容易理解。

app.js中的下一段代码,通过app.use()函数和给定的路径加载中间件(也就是让它们在程序中可用)。加载的顺序同样重要,所以如果你还需要加载别的中间件,一定要根据开发人员建议的顺序进行添加。

这段代码还包含了视图引擎初始化的代码,我稍后会讲到。

// view engine setup
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'jade');

// uncomment after placing your favicon in /public
//app.use(favicon(path.join(__dirname, 'public', 'favicon.ico')));
app.use(logger('dev'));
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public')));

app.use()的最后一次调用引用了为数不多的Express内建的中间件之一 —— express.static,它的作用是处理所有的静态文件。如果一个Web用户请求一个HTML、JPEG或者其他的静态文件,这个请求就会由expess.static来处理。这个中间件加载之后,所有处于某个子目录的相对路径下的静态文件都可供使用,在本例中,这个子目录就是public。

回到app.set()函数调用,这个函数是用来定义视图引擎的,你需要用一个模板引擎来帮你将数据展现给用户。最流行的模板引擎之一 —— Jade会被默认加载,当然Mustache或者EJS也很好用。引擎的设置中会定义模板文件(视图)所在的子目录的位置,以及应该使用哪个视图引擎(Jade)。

Picture 4{12%} 

再次提醒:Jade现在叫Pug了

正如前面提到的,Jade现在叫Pug。Express文档和Pug文档你都需要查看一下,以便了解如何使用重新命名的模板引擎。

在本书付印之时,我修改了生成的package.json文件,将Jade替换为Pug:

<p>"pug": "2.0.0-alpha8",</p>

然后在app.js文件中,将jade引用替换为pug

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

修改完成后整个应用程序运行起来没有任何问题。

在views子目录中,你会发现3个文件:error.jade、index.jade和layout.jade。这3个文件可以帮你初始化,当然还需要将数据集成到程序中。你需要做的远不止这些。下面是生成的index.jade文件的内容:

extends layout

block content
  h1= title
  p Welcome to #{title}

extends layout这一行会将layout.jade文件中的Jade语法集成进来。下面是HTML中的标题(h1)和段落(p)元素。h1标题被赋值为title,也就是被传入模板的title变量,而title在段落元素中也用到了。这些值在模板中显示的方式,决定了我们必须回到app.js文件并加入下面的代码:

app.use('/', routes);
app.use('/users', users);

这些都是程序特定的入口,也就是响应客户请求的功能入口。根目录的请求('/')会被routes子目录中的index.js文件处理。users请求会被users.js文件处理。

在index.js文件中,我们会接触到Express路由(router),它提供了响应处理功能。Express文档提到,路由的行为需要使用下面的模式来定义:

app.METHOD(PATH, HANDLER)

METHOD指的是HTTP方法,Express支持很多种方法,包括常见的getpostputdelete,还有一些不常见的方法,比如searchheadoptions等。path指的是Web路径,而handler指的是处理这个请求的函数。在index.js中,方法是get,path是程序的根路径,而handler是一个传递请求和响应的回调函数:

var express = require('express');
var router = express.Router();

/* GET home page. */
router.get('/', function(req, res, next) {
  res.render('index', { title: 'Express' });
});

module.exports = router;

res.render()函数中,数据(局部变量)和视图将会被组合起来。这里使用的视图是我们前面看过的index.jade文件,你会发现模板中使用的title属性的值,被作为数据传递给render函数。你可以在本地代码中把Express改为任何你喜欢的内容,然后刷新页面看看修改结果。

app.js文件中剩下的部分就都是错误处理了,这部分留给读者自己分析理解。这是一个非常简单和快速的Express示例,幸运的是麻雀虽小,五脏俱全,你可以从这个例子中了解一个Express程序的基本结构是什么样的。

Picture 1{10%} 

数据整合

如果你想要了解如何在Express程序中进行数据整合,那么我就抛砖引玉推荐一下我自己的书——《JavaScript经典实例》(译版,中国电力出版社,2012年出版)。第14章展示了如何扩展一个现有的Express程序来集成MongoDB数据库和控制器,从而实现一个完整的MVC架构。

10.2 MongoDB和Redis数据库系统

在第7章中,例7-8展示了一个将数据插入MySQL数据库的示例程序。虽然一开始比较粗糙,但是Node程序对关系型数据库的支持越来越好了。比如Node对MySQL的稳定支持和用来在微软Azure环境中访问SQL Server的Tedious模块。

Node同样也支持另外一些数据库系统。本节中我会简单地介绍两种数据库:在Node开发中非常流行的MongoDB以及我个人最喜欢的Redis。

10.2.1 MongoDB

Node程序中最常见的数据库就是MongoDB。MongoDB是一个基于文档的数据库。文档被编码为BSON格式——JSON的一种二进制编码,或许这也是它在JavaScript中流行的原因。MongoDB用BSON文档代替了数据表中的列,用集合代替了数据表。

MongoDB不是唯一一个文档型数据库。同样类型的数据库还有Apache的CouchDB和Amazon的SimpleDB、RavenDB,甚至还有传奇的Lotus Notes。Node对各个现代数据库的支持水平不一,但是MongoDB和CouchDB是支持得最好的。

MongoDB不是一个简单的数据库系统,而且在将它集成到你的程序中之前,你需要花一些时间来学习它的功能。然后,等你准备好了,你会发现Node中的MongoDB原生NodeJS驱动(MongoDB Native NodeJS Driver)对MongoDB的支持简直是天衣无缝,而且你可以通过使用Mongoose来支持面向对象。

我不准备详细介绍如何在Node中使用MongoDB,但是我会提供一个例子,以便你理解它的工作方式。虽然底层的数据结构与关系型数据库不同,但是概念并没有多大变化:你需要创建一个数据库,然后创建一个数据集,向其中添加数据。这样你可以更新、查询或者删除数据。在例10-1的MongoDB例子中,首先连接到一个示例数据库,访问一个叫Widgets的数据集,然后清空数据集,再插入两条数据,最后将这两条数据查询出来并打印。

例10-1 使用MongoDB数据库

var MongoClient = require('mongodb').MongoClient;

// Connect to the db
MongoClient.connect("mongodb://localhost:27017/exampleDb",
                                        function(err, db) {
   if(err) { return console.error(err); }

   // access or create widgets collection
   db.collection('widgets', function(err, collection) {
      if (err) return console.error(err);

      // remove all widgets documents
      collection.remove(null,{safe : true}, function(err, result) {
         if (err) return console.error(err);
         console.log('result of remove ' + result.result);

         // create two records
         var widget1 = {title : 'First Great widget',
                         desc : 'greatest widget of all',
                         price : 14.99};
         var widget2 = {title : 'Second Great widget',
                         desc : 'second greatest widget of all',
                         price : 29.99};

         collection.insertOne(widget1, {w:1}, function (err, result) {
            if (err) return console.error(err);
            console.log(result.insertedId);

            collection.insertOne(widget2, {w:1}, function(err, result) {
               if (err) return console.error(err);
               console.log(result.insertedId);

               collection.find({}).toArray(function(err,docs) {
                  console.log('found documents');
                  console.dir(docs);

                  //close database
                  db.close();
               });
            });
         }); 
      });
   }); 
});

是的,代码中又出现了Node的回调地狱。你可以使用promise来规避它。

MongoClient对象就是我们连接数据库时所使用的对象。注意给出的端口号(27017)。这是MongoDB的默认端口号。我们所使用的数据库是exampleDB,写在连接URL中。我们所使用的数据集是widgets,用它来纪念开发者所熟知的Widget类。

意料之中的是,MongoDB的函数都是异步的。数据被插入之前,我们的程序会先在不使用查询语句的情况下调用collection.remove(),来删除数据集中的所有记录。如果不这样做,数据库就会存在重复记录,因为MongoDB会对每条新数据都赋予一个系统生成的唯一标识符,而我们也没有指定title或者其他字段为唯一标识符。

然后,我们调用collection.insertOne()来创建新数据,将定义对象的JSON作为参数传入。选项{w:1}表示写入策略(write concern),是MongoDB中写操作的响应级别。

数据被插入以后,我们的程序再次使用collection.find(),同样不带查询参数,来查询所有数据。这个函数实际上会创建一个指针,然后toArray()函数会将指针指向的内容生成一个数组返回。我们后面可以用console.dir()函数将它的内容打印出来。程序执行的结果会类似于下面的内容:

result of remove 1
56c5f535c51f1b8d712b6552
56c5f535c51f1b8d712b6553
found documents
[ { _id: ObjectID { _bsontype: 'ObjectID', id: 'VÅõ5Å\u001f\u001bq+eR' },
   title: 'First Great widget',
   desc: 'greatest widget of all',
   price: 14.99 },
  { _id: ObjectID { _bsontype: 'ObjectID', id: 'VÅõ5Å\u001f\u001bq+eS' },
   title: 'Second Great widget',
   desc: 'second greatest widget of all',
   price: 29.99 } ]

每个对象的标识符其实也是一个对象,而且是BSON格式的,所以打印出来的都是乱码。如果想要去掉乱码,你可以分别打印对象中的每个字段,然后使用toHexString()对BSON格式的内容进行转码:

docs.forEach(function(doc) {
                    console.log('ID : ' + doc._id.toHexString());
                    console.log('desc : ' + doc.desc);
                    console.log('title : ' + doc.title);
                    console.log('price : ' + doc.price);
                 });

最后的结果就成了:

result of remove 1
56c5fa40d36a4e7b72bfbef2
56c5fa40d36a4e7b72bfbef3
found documents
ID : 56c5fa40d36a4e7b72bfbef2
desc : greatest widget of all
title : First Great widget
price : 14.99
ID : 56c5fa40d36a4e7b72bfbef3
desc : second greatest widget of all
title : Second Great widget
price : 29.99

你可以使用命令行工具来查看MongoDB数据库中的数据。按照下面的顺序调用命令就可以启动工具并查看数据。

(1)输入mongo启动命令行工具。

(2)输入use exampleDb切换到exampleDb数据库。

(3)输入show collections查看所有的数据集。

(4)输入db.widgets.find()来查看Widget中的所有数据。

如果你想要用一个基于对象的方式来集成MongoDB,那么Mongoose就是你要找的东西。如果要集成到Express,Mongoose也许是一个更好的选择。

不用MongoDB的时候,记得将它关闭。

Picture 1{10%} 

Node文档中的MongoDB相关内容

Node的MongoDB驱动有在线的文档可以查看,你可以通过GitHub代码库来访问这个文档,也可以在MongoDB的网站上看到这个文档。我更推荐新手使用MongoDB网站上的文档。

10.2.2 Redis中的key/value存储

数据库有两种,一种是关系型数据库,另一种是非关系型数据库,而非关系型数据库,就是我们所说的NoSQL。在所有的NoSQL数据库中,有一种基于键/值(key/value)的数据结构,通常存储在内存中,从而能够提供极快的访问速度。3种最流行的基于内存的key/value存储分别是Memcached、Cassandra和Redis。Node开发人员应该感到高兴,因为Node对这3种存储都提供了支持。

Memcached主要用于缓存数据查询从而能快速访问内存中的数据。将它用于分布式计算也是一个不错的选择,只是它对复杂数据的支持有限。对于需要执行大量查询的应用程序,Memcached非常有用,但对于有大量数据写入和读取的应用程序来说则略逊一筹。对于后一种应用程序,Redis则是一个超棒的选择。Redis可以持久化,此外,它比Memcached提供了更多的灵活性,特别是在支持不同类型的数据时。美中不足的是,与Memcached不同,Redis只能在一台机器上工作。

Redis和Cassandra则比较相似。和Memcached一样的是,Cassandra支持集群。不一样的是,它对数据结构的支持有限。Cassandra对于ad hoc查询非常有用,Redis则不然。不过Redis使用简单,不复杂,而且要比Cassandra快很多。出于各种各样的原因,Redis在Node开发人员中获得了更多的关注。

Picture 1{10%} 

EARN

EARN(Express、AngularJS、Redis和Node)这个缩写让人读起来很有感觉。在The EARN Stack中有一个关于EARN的例子。

我推荐使用Node中的Redis模块,用npm就可以安装:

npm install redis

如果你打算在Redis上进行一些大型操作,我还建议安装Node模块支持hiredis,因为它是非阻塞的,可以提高性能:

npm install hiredis redis

Redis模块只对Redis进行了一层简单的封装。因此,你需要自己花时间学习Redis命令以及Redis数据存储的工作原理。

在Node应用中使用Redis时,要先引入模块:

var redis = require('redis');

接着需要使用createClient方法创建一个Redis客户端:

var client = redis.createClient();

createClient方法有3个可选的参数:port、hostoptions(稍后讲解)。默认的host是127.0.0.1,port是6379。这个端口就是Redis服务器的默认端口,所以如果Redis服务器与Node应用运行在同一台机器上,那么使用默认设置就可以工作。

第3个参数是一个对象,它支持一些选项,Redis模块的文档中有详细介绍。在熟悉Node和Redis前,使用默认设置就可以了。

一旦客户端连接到Redis数据库,你就可以给服务器发送命令了,直到调用client.quit()方法关闭应用程序与Redis服务的连接。如果想要强制关闭,可以使用client.end()方法。不过,后一种方法并不会等所有的返回值都被解析才断开。如果应用程序无响应或者你想重新开始运行程序,就可以使用client.end()

通过客户端连接发送Redis命令是一个相当直观的过程。所有命令都作为客户端对象上的方法暴露出来,而所有命令的参数都可以作为方法的参数传递。由于这是Node,所以最后一个参数是一个回调函数,回调函数的参数是一个错误对象和Redis命令的返回结果。

在下面的代码中,我们用client.hset()方法设置了一个hash属性。在Redis中,hash是字符串格式的字段和值的映射(mapping),比如“lastname”对应姓氏,而“firstname”对应名字,以此类推:

client.hset("hashid", "propname", "propvalue", function(err, reply) {
   // do something with error or reply
});

hset命令是用来设置值的,没有返回数据,因为存在Redis里面了。如果调用一个能获取多个值的方法,如client.hvals,则回调函数中的第二个参数将是一个数组——可以是字符串数组或对象数组:

client.hvals(obj.member, function (err, replies) {
   if (err) {
      return console.error("error response - " + err);
   }

   console.log(replies.length + " replies:");
   replies.forEach(function (reply, i) {
     console.log("    " + i + ": " + reply);
   });
});

由于Node的回调函数很普及,且很多Redis命令都是返回成功确认的操作,因此Redis模块提供了redis.print方法,该方法可以作为回调函数的最后一个参数传入:

client.set("somekey", "somevalue", redis.print);

redis.print函数会将错误信息或者控制台中返回的内容打印出来,然后返回。

为了在Node中演示Redis,我创建了一个消息队列(message queue)。消息队列是一种应用程序,它将某种形式的通信作为输入,然后存储到队列中。消息一直存储在队列中,直到被接收方取走,此时消息会被移出队列,发送给接收方(每次一条或者批量进行)。通信是异步的,因为存储消息的应用不要求接收器保持连接,接收器也不要求消息存储应用保持连接。

Redis是这种应用的理想存储介质。当消息被存储它们的应用程序接收时,它们被添加到队尾。当消息被接收它们的应用程序取出时,它们将从队首取出。

Picture 1{10%} 

了解一些TCP、HTTP和子进程相关的知识

这个Redis的例子由一个TCP服务器(因此使用了Node的Net模块)、一个HTTP服务器和一个子进程组成。第5章介绍了HTTP,第7章介绍了Net,第8章介绍了子进程。

在演示消息队列时,我创建了一个Node应用程序来访问几个不同子域名下的Web日志文件。应用程序用了Node子进程和UNIX的tail-f命令来访问不同日志文件的最新记录。

在访问这些日志记录时,应用程序使用了两个正则表达式对象:第一个用来提取访问到的资源的内容,第二个用来检测资源是否为图片文件。如果被访问的资源是图片文件,应用程序就把该资源的URL通过TCP消息发送到消息队列的应用程序中。

消息队列程序所做的事情就是在3000端口监听消息,然后将接收到的所有内容都发送到Redis数据库进行存储。

示例程序的第三部分是一个在8124端口监听请求的Web服务器。对于每个请求,它都会访问Redis数据库并取出图像数据库中靠前的记录,通过响应对象返回这条记录。如果Redis数据库在请求图片资源时返回null,则会打印出一条消息,表明应用程序已到达消息队列的末尾。

程序的第一部分在处理Web日志记录,如例10-2所示。UNIX的tail命令可以显示文本文件(或管道中的数据)的最后几行。当加上-f参数时,将会显示文件中几行然后暂停,并监听新的日志记录。一旦有新的记录,它就会将其打印出来。tail –f也可以用于需要同时监听多个文件的情况,它可以通过给数据打标签(标出其来源)的方式来管理这些内容。这个命令并不关心最新的记录来自哪个文件——它只关心日志本身。

一旦程序拿到了日志(log),它就会对数据进行正则表达式匹配,从而发现可以访问的图片资源(文件扩展名为.jpg、.gif、.svg或者.png)。如果匹配成功,就把资源URL发送到消息队列程序(一个TCP服务器)。程序很简单,它不会去检查字符串到底是文件后缀名还是嵌入在文件名中,比如this.jpg.html。对于这样的文件名,你会得到一个假阳性(false positive)结果。不过只要它能演示Redis的用法就够了。

例10-2 处理Web日志并将图片资源请求发送到消息队列的Node程序

var spawn = require('child_process').spawn;
var net = require('net');

var client = new net.Socket();
client.setEncoding('utf8');

// connect to TCP server
client.connect ('3000','examples.burningbird.net', function() {
    console.log('connected to server');
});

// start child process
var logs = spawn('tail', ['-f',
        '/home/main/logs/access.log',
        '/home/tech/logs/access.log',
        '/home/shelleypowers/logs/access.log',
        '/home/green/logs/access.log',
        '/home/puppies/logs/access.log']);

// process child process data
logs.stdout.setEncoding('utf8');
logs.stdout.on('data', function(data) {

   // resource URL
   var re = /GET\s(\S+)\sHTTP/g;

   // graphics test
   var re2 = /\.gif|\.png|\.jpg|\.svg/;

   // extract URL
   var parts = re.exec(data);
   console.log(parts[1]);

   // look for image and if found, store
   var tst = re2.test(parts[1]);
   if (tst) {
      client.write(parts[1]);
   }
});
logs.stderr.on('data', function(data) {
   console.log('stderr: ' + data);
});

logs.on('exit', function(code) {
   console.log('child process exited with code ' + code);
   client.end();
});

这个程序会输出如下所示的典型的控制台日志记录,需要关注的部分(图片文件访问)已用粗体标出:

/robots.txt
/weblog
/writings/fiction?page=10
/images/kite.jpg
/node/145
/culture/book-reviews/silkworm
/feed/atom/
/images/visitmologo.jpg
/images/canvas.png
/sites/default/files/paws.png
/feeds/atom.xml

例10-3包含了消息队列的代码。这个简单的程序会启动一个TCP服务器然后监听发送来的消息。当它接收到消息时,会抽取其中的数据存储到Redis数据库中。这个程序用Redis的rpush命令将数据存入图片列表的末尾(在代码中加粗标出)。

例10-3 接收消息并将它存入Redis列表的消息队列

var net = require('net');
var redis = require('redis');
 
var server = net.createServer(function(conn) {
   console.log('connected');
 
   // create Redis client
   var client = redis.createClient();
 
   client.on('error', function(err) {
     console.log('Error ' + err);
   }); 
 
   // sixth database is image queue
   client.select(6);
   // listen for incoming data
   conn.on('data', function(data) {
      console.log(data + ' from ' + conn.remoteAddress + ' ' +
        conn.remotePort);
 
      // store data 
      client.rpush('images',data);
   }); 
 
}).listen(3000);
server.on('close', function(err) {
   client.quit();
 
}); 
 
console.log('listening on port 3000');

下面是消息队列程序的控制台日志:

listening on port 3000
connected
/images/venus.png from 173.255.206.103 39519
/images/kite.jpg from 173.255.206.103 39519
/images/visitmologo.jpg from 173.255.206.103 39519
/images/canvas.png from 173.255.206.103 39519
/sites/default/files/paws.png from 173.255.206.103 39519

消息队列程序的最后一个需要演示的功能是监听8124端口的HTTP服务器,如例10-4所示。每当HTTP服务器接收到一个请求,它都会访问Redis数据库,取出图片列表中的下一条记录,并打印到响应(response)中。如果队列中没有内容了(例如,Redis返回null),则返回一条消息说消息队列为空。

例10-4 从Redis列表中取出信息并将它返回给HTTP服务器

var redis = require("redis"),
    http = require('http');

var messageServer = http.createServer();

// listen for incoming request
messageServer.on('request', function (req, res) {

   // first filter out icon request
   if (req.url === '/favicon.ico') {
      res.writeHead(200, {'Content-Type': 'image/x-icon'} );
      res.end();
      return;
   } 

   // create Redis client
   var client = redis.createClient();

   client.on('error', function (err) {
     console.log('Error ' + err);
   }); 

   // set database to 6, the image queue
   client.select(6);
   client.lpop('images', function(err, reply) {
      if(err) { 
        return console.error('error response ' + err);
      }

      // if data
      if (reply) {
         res.write(reply + '\n');
      } else {
         res.write('End of queue\n');
      }
      res.end();
   });
   client.quit();

});

messageServer.listen(8124);

console.log('listening on 8124');

通过浏览器访问HTTP服务器时,每个请求都会返回一个图片资源URL,直到消息队列为空。

这个例子涉及的数据很简单,但可能非常多,这也是它适合使用Redis的原因。Redis是一个快速、简单的数据库,而且不用花费太多精力就能将它集成到Node程序中。

何时创建Redis客户端

当我使用Redis时,有时候会创建一个Redis客户端让它始终存在于程序中,而有时则在Redis命令结束后就释放之前创建的Redis客户端。那么什么时候应该创建一个持久的Redis连接?什么时候又该建立连接并在结束使用后立即释放呢?

好问题。

为了测试这两种不同的策略,我创建了一个TCP服务器,用来监听请求(request)并一个将简单的散列值存入Redis数据库。接着我创建了另一个应用程序作为TCP客户端,它只负责将对象搭载在TCP消息中发送给服务器。

我用ApacheBench程序并发运行一些客户端,并重复这个过程,每次运行后测试其运行时间。首先运行那些使用了持久Redis连接的程序,接着运行那些为每个请求建立数据库连接、但使用之后就立即释放连接的程序。

我期望的测试结果是拥有持久化客户端连接的程序运行较快,结果证明在某种程度上,我是对的。大约在测试到一半的时候,建立持久连接的应用程序在一段很短的时间内处理速度急剧降低,然后恢复了相对较快的速度。

当然,最可能发生的情况是,在队列中等待的Redis数据库请求最终会(至少是短暂的)阻塞Node程序,直到队列被清空。而每一次请求都需要打开和关闭连接时,并不会发生类似的情况,因为这个过程所需的额外开销会减慢应用程序的运行速度,刚好没有达到数据库并发访问的上限。

10.3 AngularJS和其他全栈框架

首先声明,框架是一个很容易被过度使用的术语。人们把这个词用在一些前端库(如jQuery)、图片库(如D3)、Express,以及很多更加新潮的全栈应用程序中。在本章中,我说的“框架”指的是像AngularJS、Ember和Backbone这样的全栈框架。

为了熟悉全栈框架,你需要先熟悉TodoMVC网站。这个网站定义了一个基础类型应用(一个待办事项列表)的需求,然后邀请所有框架的开发人员提交他们的实现代码。同时网站也提供了一个原生JS的实现代码(没有用任何框架),以及一个用jQuery编写的实现代码。这个网站给开发人员提供了一个途径,来比较和区别使用不同的框架来实现同一个功能有哪些不同。其中包含了所有流行的框架,并非只有AngularJS,还有Backbone.js、Dojo、Ember、React等。同时它也高亮显示了那些结合多种技术的应用,比如其中一个同时使用了AngularJS、Express和Google Cloud Platform的应用。

To-Do建议大家使用如下的目录和文件结构:

index.html
package.json
node_modules/
css
└── app.css
js/
├── app.js
├── controllers/
└── models/
readme.md

这个结构并不复杂,它与ExpressJS应用的结构类似。但是,不同框架在实现需求时可能会有很大差异,这也是为什么使用To-Do应用程序是了解每个框架如何工作的好方法。

为了演示,我们来看看用AngularJs和Backbone.js框架实现的代码。我不会列出太多代码,因为当你读到这里的时候,这些代码肯定已经更新了。我们从AngularJS开始,重点看一下优化过的程序——该网站提供了几个基于AngularJS的不同实现。图10-1是添加了一些待办事项后的程序截图。

..\18-0427 图\10-1.tif{60%}

图10-1 添加3个待办事项后的To-Do程序

让我们从程序的入口app.js开始。正如我们所期望的,这个文件很简单,因为所有功能都被分解到不同的模型-视图-控制器(model-view-controller)的子模块中了。

/* jshint undef: true, unused: true */
/*global angular */
(function () {
'use strict';

/**
  * The main TodoMVC app module that pulls all dependency modules
declared in same named files
  *
  * @type {angular.Module}
  */
  angular.module('todomvc', ['todoCtrl', 'todoFocus', 'todoStorage']);
  })();

应用程序的名字是todomvc,它包含3个服务:todoCtrl、todoFocus、todoStorage。用户界面包含在根目录的index.html里。这个文件比较大,我只摘抄了一小部分。页面的主要内容被一个叫section的元素包起来了,它的定义如下:

<section id="todoapp" ng-controller="TodoCtrl as TC">
...
</section>

AngularJS在HTML中添加了一些注解,它们被称为指令(directive)。指令很容易识别,因为每条标准指令都以“ng-”开头,比如ng-submitng-blurng-model。在上面的代码片段中,ng-controller这个指令定义了当前视图(view)的控制器(controller)—— TodoCtrl,可以在模板中使用TC来引用它。

<form ng-submit="TC.doneEditing(todo, $index)">
    <input class="edit"
       ng-trim="false"
       ng-model="todo.title"
       ng-blur="TC.doneEditing(todo, $index)"
       ng-keydown="($event.keyCode === TC.ESCAPE_KEY)
                  && TC.revertEditing($index)"
       todo-focus="todo === TC.editedTodo">
</form>

在代码中可以看到多个指令(directive),其中大部分都很直观。ng-model这个指令表示视图和模型一致(也就是数据一致),在本例中是todo.titleTC.doneEditingTC.revertEditing是控制器中的函数。我将这部分代码从控制器中提出来放在下文中。TC.doneEditing函数会重置TC.editedTodo对象,去掉编辑过的To-Do标题首尾的空格,而如果标题为空,则会删除To-Do。TC.revertEditing函数也会重置TC.editedTodo对象,并将未编辑的To-Do对象重新赋值给原始对象。

TC.doneEditing = function (todo, index) {
      TC.editedTodo = {};
      todo.title = todo.title.trim();

      if (!todo.title) {
        TC.removeTodo(index);
      }
}; 

TC.revertEditing = function (index) {
      TC.editedTodo = {};
      todos[index] = TC.originalTodo;
};

Backnone.js的程序从界面和操作上看,都和AngularJS的版本很像,但是代码却有很大差异。如果说AngularJS版本的app.js文件是不算大,那么Backbone.js版本的主文件则更小:

/*global $ <i class="math-start"></i>/
/<i class="math-start"></i>jshint unused:false <i class="math-start"></i>/
var app = app || {};
var ENTER_KEY = 13;
var ESC_KEY = 27;

$(function () {
'use strict';

// kick things off by creating the `App`
new app.AppView();
});

一般情况下,应用程序都是从app.AppView ()开始的。app.js很简单,但是app.AppView ()的实现却不简单。和AngularJS使用指令来注解HTML的方式不同,Backbone.js会大量使用Userscore模板。在index.html文件中,你会看到它们在页内脚本元素中的使用,例如下面代表每个独立的To-Do模板的代码。HTML中穿插了一些模板标签,例如title,以及复选框是否勾选等。

<script type="text/template" id="item-template">
      <div class="view">
        <input class="toggle" type="checkbox" <%= completed ? 'checked'
             : '' %>>
        <label><%- title %></label>
        <button class="destroy"></button>
      </div>
      <input class="edit" value="<%- title %>">
    </script>

对模板的渲染发生在todo-view.js中,而在背后操纵渲染过程的则是app-view.js文件。下面是该文件中的一些代码:

// Add a single todo item to the list by creating a view for it, and
    // appending its element to the `<ul>`.
    addOne: function (todo) {
      var view = new app.TodoView({ model: todo });
      this.$list.append(view.render().el);
    },

渲染发生在todo-view.js文件中,我摘出其中一部分代码展示在下面。由于之前的脚本已经被嵌入到index.html中,所以你可以看到代码中引用了list元素的标识符item-template。在index.html中,script元素中的HTML提供了被视图(view)渲染的元素模板。在模板中只有数据的占位符,真实数据则来自模型(model)。在To-Do程序中,数据就是一个to-do的标题,无论它是否完整。

// The DOM element for a todo item...
app.TodoView = Backbone.View.extend({
    //... is a list tag.
    tagName:  'li',

    // Cache the template function for a single item. 
    template: _.template($('#item-template').html()), 

    ... 

    // Re-render the titles of the todo item.
    render: function () {
      // Backbone LocalStorage is adding `id` attribute instantly after
      // creating a model.  This causes our TodoView to render twice. Once
      // after creating a model and once on `id` change.  We want to
      // filter out the second redundant render, which is caused by this
      // `id` change.  It's known Backbone LocalStorage bug, therefore
      // we've to create a workaround.
      // https://github.com/tastejs/todomvc/issues/469
      if (this.model.changed.id !== undefined) {
        return; 
      } 

      this.$el.html(this.template(this.model.toJSON())); 
      this.$el.toggleClass('completed', this.model.get('completed')); 
      this.toggleVisible();

      this.$input = this.$('.edit'); 
      return this;
    },

理解Backbone.js中的代码逻辑要比AngularJS难一些,但是就像前面说过的,阅读To-Do程序的代码可以让这一切更加清晰。我也建议大家看看这个版本的不同实现,并且亲自尝试。

视图渲染只是框架之间众多差异中的一个。在渲染时AngularJS会重新构建DOM,而Backbone.js则实时修改。AngularJS提供了双向绑定机制,也就是说UI和模型之间的数据是自动同步的。Backbone.js是MVP(model-view- presenter)架构,而AngularJS则是MVC(model-view-controller),也就意味着Backbone.js并没有数据绑定功能,你需要自己实现。另一方面,Backbone.js也更轻量级,执行速度比AngularJS快,但是AngularJS通常更适用于框架初学者,因为理解起来更简单。

无论是上述两种框架,还是其他全栈框架,都是用来动态地创建网页的。我指的并不是第10.1节中所说的动态页面生成。这些框架让我们能够方便地开发人们所熟知的单页面应用(single-page application)。它们不会在服务器生成HTML,然后将其发送到浏览器,而是对数据进行打包后发给浏览器,然后让JavaScript对页面进行构建。

这种类型的应用有个显著的优势,就是当你改变数据或者查看页面上的信息详情时,不需要每次都刷新页面。

Gmail就是一个典型的SPA应用。当你打开收件箱中的某一封邮件时,整个页面并不会刷新。相反,任何邮件所需的数据都会从服务(server)端发过来,经过整合后显示在页面上。这样就提升了速度,同时减少了用户等待页面加载的时间。但是你不会想看这个页面的源码。如果你用浏览器的源码查看功能去查看那些Google网站的源码,你会疯掉的。

一个好的框架应该具备怎样的功能呢?我认为,框架应该具备的特性之一是支持显示层和数据层之间的数据绑定。也就是说如果数据变了,用户界面也要相应地更新。框架也应该支持模板引擎,类似Express中用到的Jade模板引擎。同时能够通过某种方式减少冗余代码,所以框架还需要支持组件重用和/或模块化。

在Express应用中,我们注意到URL路由和函数之间的连接。该URL成为访问一组数据或单个数据的唯一方式。为了找到某个特定的学生,你可能需要一个这样的URL:/students/A1234,然后请求一个页面,请求中包含能够识别这个学生(A1234)的信息。一个好的框架应该能够支持这类路由。

同样,好的框架也需要支持MV*模式,也就是说,至少要做到分离业务逻辑和展示逻辑。框架可能支持多种模型,比如MVC、MVP、MVVM等,但至少要支持数据和用户界面分离。

当然,考虑到本书的主旨,一个好的框架还要能与Node集成。

本文摘自《 Node学习指南》第2版

[美] 谢利·鲍尔斯(Shelley Powers) 著,曹隆凯,娄佳 译

本书是学习Node编程的入门指南。全书共12章,由浅入深。本书首先介绍Node的基础知识、Node的核心功能、Node的模块系统和REPL等,然后讲解Node的Web应用、流和管道、Node对文件系统的支持、网络和套接字、子进程、ES6等相关知识,最后介绍了全栈Node编程、Node的开发环境和产品环境以及Node的新应用。
本书适合有一定基础的JavaScript程序员阅读,也适合对学习Node应用开发感兴趣的读者学习参考。