使用 Node.js 作为完整的云环境开发堆栈

使用通过回调的异步 I/O 开发并发模型,并构建聊天服务器

本文探讨 Node.js,这是一个用于 UNIX® 类平台上 V8 JavaScript™ 引擎的事件驱动的 I/O 框架,设计这一框架的目的是为了编写可伸缩的网络程序,如 Web 服务器。本文通过一个完整的例子说明如何在 Node.js 中构建聊天服务器,分析了这个框架以及围绕它的生态系统(包括云计算产品),并对这个框架进行了总结。

Noah Gift, 助理工程主管, AT&T Interactive

/developerworks/i/p-nogift.jpg

Noah Gift 是 O'Reilly 出版的 Python For UNIX and Linux System Administration 的作者之一,现在还在为 Manning 撰写 Google App Engine In Action 一书。他是一名作家、演说家、顾问和社区负责人,并为 Red Hat MagazineO'ReillyMacTech 撰稿。他的咨询公司的网站是 http://www.giftcs.com,在 http://noahgift.com 可以找到他的许多作品。还可以在 Twitter(http://twitter.com/noahgift)上关注他的近况。

他拥有加州洛杉矶的 CIS 的硕士学位,以及加州 Poly San Luis Obispo 的营养科学学士学位。他是通过 Apple 和 LPI 认证的系统管理员,曾经在许多公司工作过,如加利福尼亚理工学院、Disney Feature Animation、Sony Imageworks、Turner Studios 和 Weta Digital。在空闲的时候,他喜欢和妻子 Leah 以及他们的儿子 Liam 一起度过,谱写钢琴曲、参加马拉松比赛以及积极地参与体育活动。



Jeremy Jones, 高级系统工程师, Predictix

/developerworks/i/p-jejones.jpgJeremy Jones 是 Predictix 的一位高级系统工程师。他是很多在线文章的作者和 Python for UNIX and Linux System Administration 一书的合著者。他最擅长解决 Python 方面的问题,但在 bash、JavaScript、perl、C# 和 Java 方面也有很深的造诣。 他喜欢排除故障和解决各领域的高难度问题。他的业余时间都用于做木匠活以及和家人在一起。



2011 年 8 月 01 日

随着技术创新表面上继续以指数级速度发展,新思想层出不穷。服务器端的 JavaScript 就是这些新思想之一。 Node.js 是一种事件驱动的 I/O 框架,用于 UNIX 类平台上的 V8 JavaScript 引擎,适合于编写可伸缩的网络程序,如 Web 服务器。 Node.js 正是这种新思想的实现。

Node.js 并非与 JavaScript 抗衡,而是使用它作为完整的开发堆栈,从服务器端代码一直延伸到浏览器。Node.js 还充分利用了另一种 创新思想:通过回调利用异步 I/O 的并发性模型。

Node.js 云计算平台

在云计算环境中使用 Node.js 框架时,能显示出它的一个巨大优点。对于应用程序开发人员,这往往归结使用平台即服务 (PaaS) 或基础架构即服务 (IaaS) 模型。对于开发人员而言,最抽象和公认最方便的方法是使用 PaaS 提供程序。图 1 十分简单地说明了 PaaS 和 IaaS 模型的结构。

图 1. PaaS 与 IaaS 结构
PaaS 与 IaaS 结构

最近,一个激动人心的开源项目 Cloud Foundry 公布了代码以创建一个能够运行 Node.js 的私有 PaaS。 同样的主机引擎也可用在公共云和商业云中,而且它们接受软件补丁。

基础架构管理是一大痛点,如果能够将这项工作外包(永远!)给规模经营的提供商,且无论是源代码,还是物理硬件资源,对于开发人员确实是一个激动人心的时刻。


使用 Node.js shell

在我们着手编写一个完整的 Node.js 例子之前,让我们先开始介绍如何使用交互式 shell。如果尚未安装 Node.js,您可以参考资源部分,然后按照说明安装它,或者使用在线的交互式 Node.js 站点之一,它允许您直接在浏览器中输入代码。

要在 Node.js 中以交互方式编写 JavaScript 函数,在命令行提示中输入node,如下所示:

lion% node
> var foo = {bar: 'baz'};
> console.log(foo);
{ bar: 'baz' }
>

在这个例子中,创建了对象foo,然后调用console.log 将它输出到控制台。 这十分有效而且有趣,不过当您使用 tab 完成功能来探讨 foo 时,如下面的例子所示,真正的乐趣才刚刚开始。 如果输入 foo.bar.,然后按下 tab 键,您将看到对象上的可用方法。

> foo.bar.
[...output suppressed for space...]
foo.bar.toUpperCase           foo.bar.trim
foo.bar.trimLeft              foo.bar.trimRight

试用 toUpperCase 方法似乎很有趣,下面显示了它的用法:

> foo.bar.toUpperCase();
'BAZ'

您可以看到,该方法将字符串转换为大写字母。这类交互式开发非常适合于使用像 Node.js 这样的事件驱动型框架进行开发。

在完成简单介绍之后,我们开始真正地构建一些东西。


用 Node.js 构建聊天服务器

Node.js 让编写基于事件的网络服务器变得十分简单。例如,让我们创建一些聊天服务器。 第一个服务器十分简单,几乎没有什么功能,也没有任何异常处理。

一个聊天服务器允许多个客户端连接到它。每个客户端都可以编写消息,然后广播给所有其他用户。下面给出了最简单的聊天服务器的代码。

net = require('net');

var sockets = [];

var s = net.Server(function(socket) {

    sockets.push(socket);

    socket.on('data', function(d) {

        for (var i=0; i < sockets.length; i++ ) {
            sockets[i].write(d);
        }
    });
});

s.listen(8001);

在不到 20 行代码中(实际上,真正实现功能的代码只有 8 行),您已经构建了一个能够使用的聊天服务器。 下面是这个简单程序的流程:

  • 当一个套接字进行连接时,将该套接字对象附加到一个数组。
  • 当客户端写入它们的连接时,将该数据写到所有的套接字。

现在,让我们检查所有代码,并解释这个例子如何实现聊天服务器预定功能。第一行允许访问 net 模块的内容:

net = require('net');

让我们使用这个模块中的 Server

您将需要一个位置来保存所有客户端连接,以便在写入数据时可以写到它们中去。 下面是用于保存所有客户端套接字连接的变量:

var sockets = [];

下一行开始一个代码块,规定当每个客户端连接时要做的事情。

var s = net.Server(function(socket) {

传递到 Server 中的惟一参数是将针对每个客户端连接进行调用的一个函数。 在这个函数中,将客户端连接添加到所有客户端连接的列表中:

sockets.push(socket);

下一部分代码建立了一个事件处理器,规定了当一个客户端发送数据时要做的事情:

socket.on('data', function(d) {

    for (var i=0; i < sockets.length; i++ ) {
        sockets[i].write(d);
    }
});

socket.on() 方法调用为节点注册一个事件处理器,以便当某些事件发生时它知道如何处理。 当接收到来自客户端的数据时,Node.js 会调用这个特殊的事件处理器。其他的事件处理器包括 connectendtimeoutdrainerrorclose

socket.on() 方法调用的结构类似于前面提过的 Server() 调用。 您传入一个函数给这两者,当有事发生时调用此函数。这种回调方法在异步网络框架中很常见。 这是当开始使用像 Node.js 这样的异步框架时,拥有过程编程经验的人会遇到的主要问题。

在这种情况下,当任意客户端发送数据给服务器时,就会调用这个匿名函数并将数据传入函数中。它基于您已经积累的套接字对象列表进行迭代, 并给它们全部发送相同的数据。每个客户端连接都将接收到这些数据。

这个聊天服务器十分简单,它缺少一些非常基础的功能,比如识别是谁发送哪条消息,或者处理某个客户端断开的情况。 (如果一个客户端从这台聊天服务器断开,任何人发送消息,服务器都会崩溃。)

下面的源代码(在下载示例文件中叫做 chat2.js )是一个经过改进的套接字服务器,其功能有所增强,能够处理“糟糕的情况”(比如客户端断开)。

net = require('net');

var sockets = [];
var name_map = new Array();
var chuck_quotes = [
    "There used to be a street named after Chuck Norris, but it was changed because 
     nobody crosses Chuck Norris and lives.",
    "Chuck Norris died 20 years ago, Death just hasn't built up the courage to tell 
     him yet.",
    "Chuck Norris has already been to Mars; that's why there are no signs of life.",
    "Some magicians can walk on water, Chuck Norris can swim through land.",
    "Chuck Norris and Superman once fought each other on a bet. The loser had to start 
     wearing his underwear on the outside of his pants."
]

function get_username(socket) {
    var name = socket.remoteAddress;
    for (var k in name_map) {
        if (name_map[k] == socket) {
            name = k;
        }
    }
    return name;
}

function delete_user(socket) {
    var old_name = get_username(socket);
    if (old_name != null) {
        delete(name_map[old_name]);
    }
}

function send_to_all(message, from_socket, ignore_header) {
    username = get_username(from_socket);
    for (var i=0; i < sockets.length; i++ ) {
        if (from_socket != sockets[i]) {
            if (ignore_header) {
                send_to_socket(sockets[i], message);
            }
            else {
                send_to_socket(sockets[i], username + ': ' + message);
            }
        }
    }
}

function send_to_socket(socket, message) {
    socket.write(message + '\n');
}

function execute_command(socket, command, args) {
    if (command == 'identify') {
        delete_user(socket);
        name = args.split(' ', 1)[0];
        name_map[name] = socket;
    }
    if (command == 'me') {
        name = get_username(socket);
        send_to_all('**' + name + '** ' + args, socket, true);
    }
    if (command == 'chuck') {
        var i = Math.floor(Math.random() * chuck_quotes.length);
        send_to_all(chuck_quotes[i], socket, true);
    }
    if (command == 'who') {
        send_to_socket(socket, 'Identified users:');
        for (var name in name_map) {
            send_to_socket(socket, '- ' + name);
        }
    }
}

function send_private_message(socket, recipient_name, message) {
    to_socket = name_map[recipient_name];
    if (! to_socket) {
        send_to_socket(socket, recipient_name + ' is not a valid user');
        return;
    }
    send_to_socket(to_socket, '[ DM ' + get_username(socket) + ' ]: ' + message);
}

var s = net.Server(function(socket) {
    sockets.push(socket);
    socket.on('data', function(d) {
        data = d.toString('utf8').trim();
        // check if it is a command
        var cmd_re = /^\/([a-z]+)[ ]*(.*)/g;
        var dm_re = /^@([a-z]+)[ ]+(.*)/g;
        cmd_match = cmd_re.exec(data)
        dm_match = dm_re.exec(data)
        if (cmd_match) {
            var command = cmd_match[1];
            var args = cmd_match[2];
            execute_command(socket, command, args);
        }
        // check if it is a direct message
        else if (dm_match) {
            var recipient = dm_match[1];
            var message = dm_match[2];
            send_private_message(socket, recipient, message);
        }
        // if none of the above, send to all
        else {
            send_to_all(data, socket);
        };

    });
    socket.on('close', function() {
        sockets.splice(sockets.indexOf(socket), 1);
        delete_user(socket);
    });
});
s.listen(8001);

稍微高级一点的主题:聊天服务器的负载平衡

通常,负载按比例增长也是部署到云环境的理由之一。这种部署需要实现一些负载平衡机制。

大多数轻量级 Web 服务器,比如 nginx 和 lighttpd,都能够针对多台 HTTP 服务器进行负载平衡,但如果您想要在非 HTTP 服务器之间实现平衡,nginx 可能无法满足要求。而且尽管存在通用的 TCP 负载平衡器,您可能不会喜欢它们使用的负载平衡算法。或者它们没有提供您想要使用的一些功能。或者,您只是想享受构造自己的负载平衡器的乐趣。

下面是最简单的负载平衡器。它没有实现任何故障恢复,希望所有的目的地都是可用的,而且没有进行任何错误处理。它十分简约。 基本的理念是,它接收一个来自客户端的套接字连接,随机挑选一个目标服务器进行连接,然后将来自客户端的所有数据转发给该服务器,并将来自该服务器的所有数据都发回到客户端。

net = require('net');

var destinations = [
    ['localhost', 8001],
    ['localhost', 8002],
    ['localhost', 8003],
]

var s = net.Server(function(client_socket) {
    var i = Math.floor(Math.random() * destinations.length);
    console.log("connecting to " + destinations[i].toString() + "\n");
    var dest_socket = net.Socket();
    dest_socket.connect(destinations[i][1], destinations[i][0]);

    dest_socket.on('data', function(d) {
        client_socket.write(d);
    });
    client_socket.on('data', function(d) {
        dest_socket.write(d);
    });
});
s.listen(9001);

destinations 的定义是我们要进行平衡的后端服务器的配置。 这是一个简单的多维数组,主机名是第一个元素,端口号是第二个元素。

Server() 的定义类似于聊天服务器的例子。您创建一个套接字服务器,并让它监听一个端口。这次它将监听 9001 端口。

针对 Server() 定义的回调首先随机选择一个要连接到的目的地:

var i = Math.floor(Math.random() * destinations.length);

您可能已经使用过轮询算法或使用“最少连接数”算法完成一些额外的工作然后离去,但我们想尽可能地保持简单。

这个例子中有两个指定的套接字对象: client_socketdest_socket

  • client_socket 是负载平衡器与客户端之间的连接。
  • dest_socket 是负载平衡器与被平衡服务器之间的连接。

这两个套接字分别处理一个事件:接收到的数据。当它们其中一个收到数据时,就会将数据写到另一个套接字。

让我们完整地了解当一个客户端通过负载平衡器连接到通用网络服务器上,发送数据,然后接收数据时发生的事情。

  1. 当一个客户的连接到负载平衡器时,Node.js 在客户端与自己本身之间创建一个套接字,我们称之为 client_socket
  2. 当连接建立之后,负载平衡器挑选一个目的地并创建一个指向该目的地的套接字连接,我们称之为 dest_socket
  3. 当客户端发送数据时,负载平衡器将相同的数据推送到目的地服务器。
  4. 当目的地服务器做出响应并将一些数据写到 dest_socket 时,负载平衡器通过 client_socket 将这些数据推送回客户端。

可以对这个负载平衡器进行一些改进,包括错误处理,在同一个进程中嵌入另一个进程以动态增加和移除目的地,增加不同的平衡算法,以及增加一些容错处理。


超越原生解决方案:Express Web 框架

Node.js 配备有 HTTP 服务器功能,但较为低级。如果要在 Node.js 中构建一个 Web 应用程序,您可能会考虑 Express——一个为 Node.js 打造的 Web 应用程序开发框架。它弥补了 Node.js 的一些不足。

在下一个例子中,让我们重点关注使用 Express 胜过简单的 Node.js 的一些明显优势。 请求路由就是其中之一,还有一个是为 HTTP "verb" 类型注册一个事件,比如“get”或“post”。

下面给出了一个十分简单的 Web 应用程序,它只是演示了 Express 的一些基本功能。

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

app.get('/', function(req, res){
  res.send('This is the root.');
});

app.get('/root/:id', function(req, res){
  res.send('You sent ' + req.params.id + ' as an id');
});

app.listen(7000);

这两行以 app.get() 开始的代码是事件处理器,当 GET 请求进入时就会触发。 这两次方法调用的第一个参数是一个正则表达式,用于指定用户可能传入的 URL。第二个参数是真正处理请求的一个函数。

正则表达式参数是路由机制。如果请求类型(GET、POST等)与资源(/, /root/123)匹配,就会调用处理器函数。在第一次 app.get() 调用中,/ 被简单地指定为资源。而在第二次调用中,在指定/root 时后面还加了一个 ID。映射 regex 的 URL 中资源前面的冒号(:) 字符表明,这部分稍后可作为一个参数使用。

当请求类型与正规表达式匹配时,就会调用处理器函数。 此函数带有两个参数,一个请求(req) 和一个响应(res)。 前面提到的参数被附加给请求对象。而 Web 服务器传回给用户的消息被传入到响应对象。

这是一个非常简单的例子,但已经清楚地说明“真正的应用程序”如何利用这个框架来构建更加丰富和完整的功能。如果插入一个模板系统和一些数据引擎(传统的或 NoSQL 均可),您可以轻松构建出一组功能来满足真正应用程序的需求。

Express 的特点之一是高性能。这与其他快速 Web 应用程序框架的常见特性一起,让 Express 在注重高性能和海量可伸缩性的云部署领域中占据了重要的位置。


应了解的知识

有两个概念/趋势需要了解:

  • 键/值数据库的突然流行。
  • 其他异步的 Web 范型。

键/值数据库... 为什么突然流行?

因为 JavaScript 是 Web 的通用语言,对于 JavaScript Object Notation (JSON) 的讨论通常远远落后于 JavaScript 相关的研究。 JSON 是在 JavaScript 与一些其他语言之间交换数据的最常用途径。JSON 本质上是一种键/值存储,因此天生适用于对键/值数据库感兴趣的 JavaScript 和 Node.js 开发人员。毕竟,如果能够以 JSON 格式存储数据,JavaScript 开发人员的工作就将变得轻松很多。

有一个不太相关的趋势,在 NoSQL 数据库环境中也会涉及键/值数据库。CAP 定理(也叫做 Brewer 定理)指出,一个分布式系统有 3 个核心属性: 一致性、可用性和分区容忍性(formal proof of CAP)。 这条定理是 NoSQL 发展背后的推动力量,它为牺牲传统关系数据库的某些特性以换取(通常是高可用性)提供了理论基础。一些流行的键/值数据库 包括 Riak、Cassandra、CouchDB 和 MongoDB。

异步 Web 范型

事件驱动的异步 Web 框架已经存在了相当长一段时间。其中最流行和最新的异步 Web 框架是 Tornado,它使用 Python 语言编写,在 Facebook 内部使用。 下面这个例子说明了 hello_world 在 Tornado 中(在下载示例文件中叫做 hello_tornado.py )是什么样子。

import tornado.ioloop
import tornado.web

class MainHandler(tornado.web.RequestHandler):
    def get(self):
        self.write("Hello, world")

application = tornado.web.Application([
    (r"/", MainHandler),
])

if __name__ == "__main__":
    application.listen(8888)
    tornado.ioloop.IOLoop.instance().start()

Twisted.web 也是用 Python 语言写的,工作方式也十分类似。

最后谈到真正的 Web 服务器本身,与 Apache 不同,nginx 不使用线程,而是使用一种事件驱动的(异步)架构来处理请求。 异步 Web 框架使用 nginx 作为其 Web 服务器是十分常见的情况。


结束语

Node.js 在 Web 开发人员中非常引人关注。它允许开发团队同时在客户端和服务器端上编写 JavaScript。 它们还可以结合与 JavaScript 相关的强大技术:JQuery、V8、JSON 和事件驱动的编程。 另外还有基于 Node.js 开发的生态系统,比如 Express Web 框架。

Node.js 的优点引人关注,它也存在一些缺点。如果是 CPU 密集型编程,就无法体现 Node.js 提供的非阻塞 I/O 方面的优点。 有些架构可以解决这类问题,比如将一个池中的进程分流到每个 Node.js 实例上运行,但需要由开发人员去实现它。

参考资料

学习

获得产品和技术

  • 参见 IBM Smart Business Development 和 Test on the IBM Cloud 上可用的 产品镜像

讨论

条评论

developerWorks: 登录

标有星(*)号的字段是必填字段。


需要一个 IBM ID?
忘记 IBM ID?


忘记密码?
更改您的密码

单击提交则表示您同意developerWorks 的条款和条件。 查看条款和条件

 


在您首次登录 developerWorks 时,会为您创建一份个人概要。您的个人概要中的信息(您的姓名、国家/地区,以及公司名称)是公开显示的,而且会随着您发布的任何内容一起显示,除非您选择隐藏您的公司名称。您可以随时更新您的 IBM 帐户。

所有提交的信息确保安全。

选择您的昵称



当您初次登录到 developerWorks 时,将会为您创建一份概要信息,您需要指定一个昵称。您的昵称将和您在 developerWorks 发布的内容显示在一起。

昵称长度在 3 至 31 个字符之间。 您的昵称在 developerWorks 社区中必须是唯一的,并且出于隐私保护的原因,不能是您的电子邮件地址。

标有星(*)号的字段是必填字段。

(昵称长度在 3 至 31 个字符之间)

单击提交则表示您同意developerWorks 的条款和条件。 查看条款和条件.

 


所有提交的信息确保安全。


static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=10
Zone=Cloud computing
ArticleID=750021
ArticleTitle=使用 Node.js 作为完整的云环境开发堆栈
publish-date=08012011