Node.js:使用JavaScript搭建高性能网络程序

Node.js简介

Node.js(简称Node)是一种服务器端Javascript环境,它基于谷歌的V8引擎(Javascript运行时的一种实现)。V8和Node主要由C、C++实现,因而实现了高性能和低内存消耗的特点。然而V8和Node的不同之处在于V8主要是为了支持浏览器中的Javascript,而Node则旨在提供长时间运行的服务器进程。

与其他现代Javascript环境不同,Node进程并不依赖多线程去支持业务逻辑的并发执行;它基于一种异步IO事件模型(asynchronous I/O eventing model)。可以把Node服务器进程看作是一个单线程的守护进程(daemon),其中嵌入了Javascript引擎来支持定制。这和大部分其他编程语言的事件系统很不同:他们都以库的形式出现;而Node在语言层面上就支持了事件模型。

Javascript非常适合这种方法,因为它支持事件回调(callback)。例如,当一个浏览器完成了文档加载,一个用户点击了一个按钮,或者一个Ajax请求完成了,事件就会触发回调函数。Javascript函数式的本质让创建匿名函数并将其注册为事件处理器(event handler)极为容易。

多线程 VS 事件

在处理多个IO资源时(如网络服务器处理多个客户连接时)应用开发者很早便采用多线程编程技术了。这种技术之所以流行是因为它让开发者能够将他们的应用程序分成能够并发且相互合作的活动。这不仅保证了程序理解、实现和维护的简易性,也使更快速,更高效的执行成为可能。

对于如Web服务器这类执行惊人数量IO的应用,多线程使得应用能够更好地利用处理器。在现代多核系统上运行多个并发的线程是很符合直觉的,每个核心同时执行不同的线程实现了真正的并行。而在单核系统中,处理器执行一个线程,然后切换到另一个线程执行,一直如此。例如,当当前的线程在处理IO操作时(如向TCP套接字中写入数据),处理器将会把它的运行上下文(execution context)切换到另一个线程。这个切换之所以会发生是因为完成这个IO操作将会花费很多处理器周期。处理器与其将周期浪费在等待套接字操作完成,不如将IO操作挂起而执行其他线程,让自己忙于执行有意义的工作。当IO操作结束时,处理器会认为原来的线程已经准备好被执行了,因为它不再因为等待IO而被阻塞了。

即使很多开发者已经成功地在产品级应用中使用多线程技术,大多数人还是认为多线程有一个很大的缺点,就是复杂。多线程程序充满难以被孤立并解决的问题,例如死锁(deadlock)和对于在线程之间共享资源的保护不当。开发者在使用多线程时也失去了一定程度的控制权,因为通常是由操作系统决定哪个线程需要执行并且执行多长时间。

事件驱动编程(event-driven programming)提供了一个更有效,易于扩展的替代方案,它为让开发者能够对于应用程序活动的切换拥有更多控制权。在事件驱动模型中,应用依赖事件通知机制(event notification facilities),例如select()poll()等Unix系统调用( system calls),Linux的epoll服务,BSD UNIX的变体(如macOS)的kqueuekevent调用。应用程序注册器(applications register)只对某些特定的事件感兴趣,如数据准备好被某个特定的套接字读取。当这个事件发生时,通知系统会通知这个应用以便让它处理这个事件。

异步IO对事件驱动编程很重要,因为它防止了应用程序在等待IO操作时被阻塞。举个例子,如果一个应用程序在对一个套接字进行写操作并填满了套接字底层的缓冲区,通常,这个套接字会阻塞应用程序的写操作直到它的缓冲区重新达到可用状态,阻止了这应用程序去做其他有意义的工作。但是,如果这个套接字是非阻塞的,它将返回并告知应用程序:接下来暂时不能继续写了,由此通知应用程序应当稍后再重试。假设这个应用程序在这个套接字的事件通知系统里注册过,它就能够去做其他事情,并且知道它将在这个套接字的写缓冲区可用时收到通知。

像多线程编程一样,带有异步IO的事件驱动编程也容易造成问题。其中一个问题是:并非所有的进程间通信方法都可以在我们之前提到的事件通知机制中实现。例如,在大多数操作系统中,两个应用程序通过共享内存通信,共享内存段不提供句柄(handles)或者文件描述符(file descriptors )来让应用程序注册事件。在这些情况下,开发者必须寻找其他解决方案,例如在管道中执行写操作或者在写入共享内存时同时使用其他有能力处理事件的机制(some other event-capable mechanism together with writing to shared memory)。

另一个重要的问题是使用某些编程语言去处理事件和异步IO是非常复杂的。这是因为不同的事件在不同的上下文中需要不同的行为。程序通常使用回调函数来处理事件。在一些缺少匿名函数和闭包的语言中(例如C),开发者必须为每一个事件和事件的上下文编写不同的函数。保证这些函数都能够访问它们在被调用处理事件时需要的数据和上下文信息是及其复杂令人困惑的。很多这样的程序最终将成为一团难以理解且难以维护的面条代码和全局变量。

和以往的JavaScript不同

无论你对JavaScript怀有什么样的看法,它已经毋庸置疑地成为了任何现代的基于HTML的应用的中心元素。服务器端的JavaScript是一个合理的展望,它使得使用单一编程语言为基于Web的分布式应用的所有切面(aspect)编程成为可能。这个理念并不新颖,例如:Rhino JavaScript运行环境已经面世很长一段时间了。然而,服务器端的JavaScript还未成为主流,仅仅最近才获得大量的人气。

我们相信有一些因素造成了这个现象。一些被总称为“HTML 5”的技术的出现减少了一些客户端平台的备选方案的出现,迫使开发者了解并充分利用JavaScript来建造丰富的用户界面。NoSQL类型的数据库(例如CouchDB和Riak)使用JavaScript来定义数据视图(data views)和过滤器规则(filter criteria)。其他动态语言,例如Ruby和Python,已经成为可以接受的服务器端发开的选择。最后,Mozilla和Google都发布了高性能JavaScript运行时实现,它们非常快速并且可扩展性强。

Node编程模型

Node的IO方式非常严格:异步交互不是例外,而是常规。任何IO操作都依靠高阶函数(以函数为参数的函数)来处理,它们规定了有什么事情需要被处理的时候该做什么。只有在很罕见的情况下,Node开发者会增加一些同步的便利函数(convenience function),例如删除或者重命名文件时。但是,通常当一个操作可能需要网络或者文件IO被调用时,控制权将会立刻返回给调用者。当程序感兴趣的事情发生时,例如:一个网络套接字上的数据可供读取时,或者一个输出流可以被写入时,或者一个错误产生时,恰当的回调函数将会被调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
var sys = require("sys"),
http = require("http"),
url = require("url"),
path = require("path"),
fs = require("fs");
http.createServer(function (request, response) {
var uri = url.parse(request.url).pathname;
var filename = path.join(process.cwd(), uri);
path.exists(filename, function (exists) {
if (exists) {
fs.readFile(filename, function (err, data) {
response.writeHead(200);
response.end(data);
});
} else {
response.writeHead(404);
response.end();
}
});
}).listen(8080);
sys.log("Server running at http:localhost:8080/");

上面的程序是一个简单的实现一个HTTP Web服务器的例子,它提供磁盘上的静态文件。即使对于非Web开发者,JavaScript的语法对于其他之前接触过C-like语言的人也是非常易懂的。其中一个更具体的话题是funciton(...)语法。它会创建一个未命名的函数:JavaScript是一个函数式语言,因此它支持高阶函数。这在Node程序中随处可见。

这个程序的主要流程被显式调用的函数决定。这些函数绝不在任何IO相关的内容上阻塞,而是注册适当的回调函数作为事件处理的方法。如果你在其他编程语言的事件处理库中见过相似的概念,你可能会好奇显式调用事件循环(event loop)的阻塞调用在哪里。事件循环的概念是Node行为的绝对核心,所以它被隐藏在Node的实现中:程序的主要目的只是简单的设置好适当的处理方法。http.createServer函数是一个底层的高效HTTP协议实现的封装,作为唯一的参数以一个函数的身份被传递。只要新的请求的数据可以被读取时这个函数就会被调用。在另一个环境中,一个简单的实现可能会因为采用同步读取文件并传输回去而大幅降低了执行效率。Node并不提供同步读取文件的机会,唯一的选项只有通过readFile注册一个函数,它将会在数据就绪时被调用。

并发编程

一个Node服务器进程通常是通过命令形如node <scriptname>调用的,它是单线程的,却可以同时服务很多客户端。这看上去有些矛盾,但是请回想一下有一个隐式的主循环围绕着代码,并且在循环中真正发生的只是一些注册调用。在循环体中没有真正的IO和业务逻辑在运行。与IO相关的事件(例如一个连接被建立或者字节在套接字、文件或外部系统上传输)将触发真正的处理器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
var sys = require("sys"),
http = require("http"),
url = require("url"),
path = require("path"),
fs = require("fs");
http.createServer(function (request, response) {
var uri = url.parse(request.url).pathname;
var filename = path.join(process.cwd(), uri);
path.exists(filename, function (exists) {
if (exists) {
f = fs.createReadStream(filename);
f.addListener('open', function () {
response.writeHead(200);
});
f.addListener('data', function (chunk) {
response.write(chunk);
setTimeout(function () {
f.resume()
}, 100);
});
f.addListener('error', function (err) {
response.writeHead(500, {"Content-Type": "text/plain"});
response.write(err + "\n");
response.end();
});
f.addListener('close', function () {
response.end();
});
} else {
response.writeHead(404);
response.end();
}
});
}).listen(8080);
sys.log("Server running at http://localhost:8080/");

上面的程序是一个稍微复杂的简单HTTP服务器变体,但是它的功能多多了。同样,它解析HTTP请求中的URI并将URI的路径部分映射到服务器的一个文件名上。但这一次,文件是以分块而不是一次性读完的方式读取的。在某些情况下,适用于这个情况的函数作为回调函数被调用。这些情况包含:文件系统层准备好了提供一些字节的数据给应用程序(当文件被完全读取或某些错误发生时)。如果数据时可用的,它将被写入HTTP输出流。Node精妙的HTTP库支持HTTP 1.1的分块传输编码。同样,文件的读取和HTTP流的写入都是异步的。

上面的例子说明了开发者可以很轻易地构建一个高性能、异步、事件驱动的网络服务器而只需要少量的资源。它的主要原因是JavaScript(由于其函数式的本质)支持事件回调。事实上,这个模式被任何客户端JavaScript开发者所熟知。另外,将异步IO设为默认方式强迫开发者从头开始遵循异步模型。这是使用Node和其他编程语言的异步IO的主要不同(在其他语言中,异步IO只是其中一个选择,并且常常被认为过于先进了)。

运行多个进程

在单CPU多核心的硬件环境下,并行执行并不是一种幻象而是现实。虽然操作系统可以利用和其他运行在系统上的进程并行的异步IO交互高效地调度一个Node进程,Node仍旧运行在单一的进程上,因此业务逻辑并不会并行执行。在Node的世界中,对于这个问题的一个通用的解决方案是运行多个进程实例。

为了支持这一点,多Node库(multi-node library)(见 http://github.com/kriszyp/multi-node)最大限度地利用了操作系统在进程之间分享套接字的能力(而实现仅仅用了少于200行Node JavaScript代码)。例如,你可以你可以通过调用多Node的listen()函数并行地运行如上述的两个程序的HTTP服务器。这将启动多个监听同一端口的进程,实际上是将操作系统作为高效的负载均衡器。

服务器端JavaScript生态系统

Node是一个广为人知的支持服务器端JavaScript开发的框架和环境。Node的社区已经创建了一整套库的生态系统(为Node开发或兼容Node)。其中,如node-mysql或者node-couchdb等工具因分别支持同关系型以及NoSQL数据存储的异步交互而起到重要作用。很多框架提供全功能的Web栈,例如Connect和Express,他们分别可与Ruby界的Rack和Rails做比较(虽然可能并没有它们受欢迎)。Node的包管理器npm能够安装库和依赖包。最后,许多服从CommonJS规范的模块系统的客户端JavaScript库也能在Node平台上工作。一个令人印象深刻的Node模块列表可以在这里找到:http://github.com/ry/node/wiki/modules

总结

考虑到在大多数Web开发项目中,对于Javascript的了解是设计高级UI交互的先决条件,使用单一语言为所有方面编程是很诱人的。Node.js的架构使它易于使用具有高表现力的、函数式的语言为服务器编程,而不必牺牲性能或者远离编程的主流。

译者:wujm2007,译自:Node.js: Using JavaScript to Build High-Performance Network Programs

坚持原创技术分享,您的支持将鼓励我继续创作!