返回介绍

6.4 node.js

发布于 2023-05-19 13:36:37 字数 12864 浏览 0 评论 0 收藏 0

1990 年,我大学毕业后进入软件开发公司工作,到现在已经有 20 年了,不由得感叹日月如梭。

在这 20 年间,我从事软件开发工作的感受,就是无论是软件开发还是其他工作,最重要的就是提高效率。工作就像一座大山一样压在你面前,不解决掉它你就没饭吃。然而,自己所拥有的能力和时间都是有限的,要想在规定期限内解决所有的问题非常困难。

话说回来,在这 20 年的工作生涯中,我几乎没有开发过供客户直接使用的软件,这作为程序员似乎挺奇葩的。不过,我依然是一名程序员。从软件开发中,程序员能够学到很多丰富人生的东西。我从软件开发中学会了如何提高效率,作为应用,总结出了下面几个方法:

· 减负

· 拖延

· 委派

看起来这好像都是些浑水摸鱼的歪门邪道,其实这些方法对于提高工作效率是非常有用的。

减负

在计算机的历史上,提高处理速度的最有效手段,就是换一台新的电脑。计算机的性能不断提升,而且价格还越来越便宜,仅靠更新硬件就能够获得成倍的性能提升,这并不稀奇。

不过很遗憾,摩尔定律并不适用于人类,人类的能力不可能每两年就翻一倍,从工作的角度来看,上面的办法是行不通的。然而,如果你原地踏步的话,早晚会被更年轻、工资更便宜的程序员取代,效率先不说,至少项目的成本降低了,不过对于你来说这可不是什么值得高兴的事。

说点正经的,在软件开发中,如果不更换硬件,还可以用以下方法来改善软件的运行速度:

· 采用更好的算法

· 减少无谓的开销

· 用空间来换时间

如果将这些方法拿到人类的工作中来,那么“采用更好的算法”就相当于思考更高效的工作方式;“减少无谓的开销”则相当于减少低级重复劳动,用计算机来实现自动化。

“用空间来换时间”可能不是很容易理解。计算机即便进行重复劳动也不会有任何怨言,但还是需要人类来进行管理。如果能够将计算过一次的结果保存在某个地方,就可以缩短计算时间。

这样一来,所需要的内存空间增加了,但计算时间则可以得到缩短。在人类的工作中,应该是相当于将复杂的步骤和判断实现并总结成文档,从而提高效率的方法吧。

然而,在有限的条件下,提高工作效率的最好方法就是减负。我们所遇到的大部分工作都可以分为三种,即非得完成不可的、能完成更好但并不是必需的,以及干脆不做为好的。有趣的是,这三种工作之间的区别并非像外人所想象的那样简单。有一些工作虽然看起来绝对是必需的,但仔细想想的话就会发现也未必。

人类工作的定义比起计算机来说要更加模棱两可,像这样伴随不确定性,由惯性思维所产生的不必要不紧急的工作,如果能够砍掉的话,就能够大幅度提高工作效率。

拖延

减少不必要不紧急的工作,就能够更快地完成必要的工作,提高效率,关于这一点恐怕大家没有什么异议。不过,到底哪项工作是必要的,而哪项工作又不是必要的,要区分它们可比想象中困难得多。要找出并剔除不必要的工作,还真没那么容易。

其实,要做出明确的区分,还是有诀窍的,那就是利用人类心理上的弱点。人们普遍都有只看眼前而忽略大局的毛病,因此,当项目期限逼近时,就会产生“只要能赶上工期宁愿砸锅卖铁”这样的念头。

即便如此,估计也解决不了问题,还不如将计就计,干脆拖到不能再拖为止,这样一来,工期肯定赶不上了,只好看看哪些工作是真正必需的,剩下的那些就砍掉吧。换作平时,要想砍掉一些工作,总会有一些抵触的理由,如“这个说不定以后要用到”、“之前也一直这么做的”之类的,但在工期大限的压力面前,这些理由就完全撑不住了。这就是拖延的魔力。

不过,这个方法的副作用还是很危险的。万一估算错了时间,连必要的工作也完成不了,那可就惨了。所以说这只是个半开玩笑(但另一半可是认真的)的拖延用法,但除此之外,还有其他一些利用拖延的方法。

例如,每个任务都各自需要一定的时间才能完成,有些任务只要 5 分钟就能完成,而有些则需要几个月。如果能够实现列出每项任务的优先级和所需的时间,就可以利用会议之间的空档等碎片时间,来完成一些较小的工作。

这种思路,和 CPU 中的乱序执行如出一辙。进一步说,对于一项任务,还可以按照“非常紧急必须马上完成的工作”、“只要不忘记,什么时候完成都可以的工作”等细分成多个子任务。

这样,按照紧急程度从高到低来完成任务的话,就可以进一步提高自己的工作效率。在这里,和“乱序执行”一样,需要注意任务之间的相互依赖关系。当相互依赖关系很强时,即使改变这些任务的顺序,也无法提高效率,这一点无论在现实的工作中还是 CPU 中都是相通的。

委派

大多数人都无法同时完成多个任务,因此可以看成是只有单一核心的硬件。即便用拖延的手段提高了工作效率,但由于同时只能处理一项任务,而每天 24 小时这个时间是固定不变的,因此一个人能完成的工作总量是存在极限的。

这种时候,“委派”就成了一个有效的手段。“委派”这个词给人的印象可能不太好,说白了,就是当以自己的能力无法处理某项任务时,转而借用他人的力量来完成的意思。如果说协作、协调、团队合作的话,大概比委派给人的印象要好一些吧。说起来,这和用多核代替单核来提升处理能力的方法如出一辙。

不过,和多核一样,这种委派的做法也会遇到一些困难。多核的困难大概有下面几种:

· 任务分割

· 通信开销

· 可靠性

这些问题,无论是编程还是现实工作中都是共通的。

如何进行妥善的任务分割是一个难题。如果将处理集中在某一个核心(或者人员)上,效率就会降低,然而要想事先对处理所需要的时间做出完全的预测也是很困难的。尤其是某项任务和其他任务有相互依赖关系的情况下,可能无论如何分割都无法提高工作效率。

我们可以把任务分为两种:存在后续处理,在任务完成时需要获得通知的“同步任务”;执行开始后不必关心其完成情况的“异步任务”。同步任务也就意味着存在依赖关系,委派的效果也就不明显。因此,如何将工作分割成异步任务就成了提高效率的关键。

在有多名成员参与的项目中,通信开销(沟通开销)也不可小觑。在人类世界中,由于“想要传达而没能传达”、“产生了误会”等原因导致的通信开销,比编程世界中要更为显著。我认为导致软件开发项目失败的最大原因,正是由于没有对这种沟通开销引起足够的重视。

最后一个问题就是“可靠性”。固然,一个人工作的话,可能会因为生病而导致工作无法进行,这也是一种风险,但随着参与项目的人数增加,成员之中有人发生问题的概率也随之上升。这就好比只有一台电脑时,往往不太担心它会出故障,但在管理数据中心里上千台的服务器时,就必须要对每天都有几台机器会出故障的情况做好准备。

当项目规模增大时,万一有人中途无法工作,就需要考虑如何修复这一问题。当然,分布式编程也是一样的道理。

非阻塞编程

在编程世界中,减负、拖延和委派是非常重要的,特别是拖延和委派恐怕还不为大家所熟悉,但今后应该会愈发成为一种重要的编程技巧。下面我们先来介绍一下在编程中最大限度利用单核的拖延方法,然后再来介绍一下运用多核的委派方法。

如果对程序运行时间进行详细分析就可以看出,大多数程序在运行时,其中一大半的时间 CPU 都在无所事事。实际上,程序的一大部分运行时间都消耗在了等待输入数据等环节上,也就是说“等待”消耗了大量的 CPU 时间。

这样的等待被称为阻塞(blocking),而非阻塞编程的目的正是试图将阻塞控制在最低限度。下面我们来介绍一下作为非阻塞编程框架而备受瞩目的“node.js”。在这里,我们使用 JavaScript 来进行讲解。

node.js 框架

node.js 是一种用于 JavaScript 的事件驱动框架。提到 JavaScript,大家都知道它是一种嵌入在浏览器中、工作在客户端环境下的编程语言,而 node.js 却是在服务器端工作的。

默认嵌入到各种浏览器中的客户端语言,恐怕只有 JavaScript 这一种了,但在服务器端编程中,语言的选择则更为自由,那么为什么还要使用 JavaScript 呢?那是因为在服务器端使用 JavaScript 有它特有的好处。

首先是性能。随着 Google Chrome 中 v8 引擎的出现,各大浏览器在 JavaScript 引擎性能提升方面的竞争愈演愈烈。可以说,JavaScript 是目前动态语言中处理性能最高的一种,而 node.js 也搭载了以高性能著称的 Google Chrome v8 引擎。

其次,JavaScript 的标准功能很少,这也是一个好处。和其他一些独立语言,如 Ruby 和 Python 等不同,JavaScript 原本就是作为浏览器嵌入式语言诞生的,甚至都没有提供标准的文件 I/O 等功能。

然而,在事件驱动框架上编程时,通常输入输出等可能产生的“等待”是非常麻烦的,后面我们会详细讲解这一点。node.js 所搭载的 JavaScript 引擎本身就没有提供可能会产生阻塞的功能,因此不小心造成阻塞的风险就会相应减小。当然,像死循环、异常占用 CPU 等导致的“等待”还是无法避免的。

事件驱动编程

下面我们来介绍一下,在 node.js 这样的事件驱动框架中,应该如何编程。在传统的过程型编程中,各项操作是按照预先设定好的顺序来执行的(图 1)。这与人类完成工作的一般方式相同,因此很容易理解。

图 1 过程型编程

相对地,在事件驱动框架所提供的事件驱动编程中,不存在事先设定好的工作顺序,而是对来自外部的“事件”作出响应,并调用与该事件相对应的“回调函数”。这里所说的事件,包括“来自外部的输入”、“到达事先设定的时间”、“发生异常情况”等情况。在事件循环框架中,主循环程序一般是用一个循环来等待事件的发生,当检测到事件发生时,找到并启动与该事件相对应的处理程序(回调函数)。当回调函数运行完毕后,再次返回到循环中,等待下一个事件(图 2)。

图 2 事件驱动编程

我们可以认为,过程型编程类似于每个单独的员工完成工作的方式,而事件驱动编程则类似于公司整体完成工作的方式。当发生客户下订单的事件时,销售部门(事件循环)会在接到订单后将工作转交给各业务部门(回调函数)来完成,这和事件驱动编程的模型有异曲同工之妙。

事件循环的利弊

要实现和事件循环相同的功能,除了用回调函数之外,还可以采用启动线程的方式。不过,回调只是一种普通的函数调用,相比之下,线程启动所需要的开销要大得多。而且,每个线程都需要占用一定的栈空间(Linux 中默认为每个线程 8MB 左右)。

当然,我们可以使用线程池技术,事先准备好一些线程分配给回调函数来使用,但即便如此,在资源消耗方面还是单线程方式更具有优势。此外,使用单线程方式,还不必像多线程那样进行排他处理,由此引发的问题也会相对较少。

另一方面,单线程方式也有它的缺点。虽然单线程在轻量化方面具备优势,但也同时意味着无法利用多核。此外,如果回调函数不小心产生了阻塞,就会导致事件处理的整体停滞,但在多线程 / 线程池方式中就不存在这样的问题。

node.js 编程

无论是 Debian GNU/Linux 还是我所使用的 sid 1(开发版)中,都提供了 node.js 软件包,安装方法如下:

# apt-get install nodejs
1 sid 是 Debian 不稳定版本(开发版本)的代号:http://www.debian.org/releases/sid/

如果你所使用的发行版中没有提供软件包,就需要用源代码来安装。和其他大多数开源软件一样,node.js 的编译安装也是按 configure、make、make install 的标准步骤来进行的。

安装完毕后就可以使用 node 命令了,这是 node.js 的主体。不带任何参数启动 node 命令,就会进入下面这样的交互模式。

% node
> console.log("hello world")
hello world
console.log 是用于在交互模式下进行输出的函数。在非交互模式下则应该使用标准输出,因此可以认为,在正式环境下是不会使用它的。不过,如果要确认 node.js 是否工作正常,这个函数是非常方便的。在这里我们基本上是在交互模式下进行讲解的,因此会经常使用到 console.log 函数。

好,下面我们来引发一个事件试试看。要设置一个定时发生的事件及其相应的回调函数,可以使用 setTimeout() 函数。

> setTimeout(function(){
... console.log("hello");
... }, 2000)
调用 setTimeout() 的作用是,在经过第二个参数指定的时间(单位为毫秒)之后引发一个事件,并在该事件发生时调用第一个参数所指定的回调函数。Timeout 事件是一个仅发生一次的事件。

function () {
   console.log("hello");
}
这个部分是一个匿名函数,和 Ruby 中的 lambda 功能差不多。这里的重点是,调用 setTimeout() 函数之后,该函数马上就执行完毕并退出了。

setTimeout() 的作用仅仅是预约一个事件的发生,并设置一个回调函数,并没有必要进行任何等待。这与 Ruby 中的 sleep 方法是不同的,node.js 会持续对事件进行监视,基本上不会发生阻塞。

node.js 的对话模式,表面上看似乎是在一直等待标准输入,但实际上只是对标准输入传来数据这一事件进行响应,并设置一个回调函数,将输入的字符串作为 JavaScript 程序进行编译并执行。因此,在交互模式下输入 JavaScript 代码,就会被立即编译和执行,执行完毕后,会再度返回 node.js 事件循环,事件处理和对回调函数的调用,都是在事件循环中完成的。

setTimeout() 会产生一个仅发生一次的事件。如果要产生以一定间隔重复发生的事件,可以使用“setInterval()”函数来设置相应的回调函数。

 > var iv = setInterval(functi
on(){
 ...   console.log("hello");
 ... }, 2000)
 hello
 hello
通过 setInterval() 函数,我们设置了一个每 2000 毫秒发生一次的事件,并在发生事件时调用指定的回调函数。不过,每隔两秒就显示一条 hello 实在是太烦人了,我们还是把这个定期事件给取消掉吧。

为此我们需要使用 clearInterval() 函数。将 setInterval() 的返回值作为参数调用 clearInterval() 就可以解除指定的定期事件。

> clearInterval(iv);

node.js 网络编程

网络服务器才是发挥 node.js 本领的最好舞 台。我们先来实现一个最简单的服务器,即将从套接字接收的数据原原本本返回的“回声服务器”。用 node.js 实现的回声服务器如图 3 所示。

var net = require("net");
net.createServer(function(sock){
  sock.on("data", function(data) {
    sock.write(data);
  });}).listen(8000);
图 3 用 node.js 实现的回声服务器

作为对照,我们不用事件驱动框架,而是用 Ruby 实现另一个版本的回声服务器,如图 4 所示。

require "socket"

svr = TCPServer.open(8000)
socks = [svr]

loop do
  result = select(socks);
  next if result == nil
  for s in result[0]
    if s == svr
      ns = s.accept
      socks.push(ns)
    else
      if s.eof?
        s.close socks.delete(s)
      elsif str = s.readpartial(1024)
        s.write(str)
      end
    end
  end
end
图 4 用 Ruby 实现的回声服务器

我们来连接一下试试看。在 node.js 中,要启动回声服务器,需要将图 3 中的程序保存到文件中,如 echo.js,然后执行:

% node echo.js
就可以启动程序了。Ruby 版则可以将图 4 的程序保存为 echo.rb,并执行:

% ruby echo.rb
客户端我们就偷个懒,直接用 netcat 了。无论是使用 node.js 版还是 Ruby 版,都可以通过下列命令来连接:

% netcat localhost 8000
连接后,只要用键盘输入字符,就会得到一行和输入的字符相同的输出结果。要结束 telnet 会话,可以使用“Ctrl+C”组合键。

将图 3 程序和图 4 程序对比一下,会发现很多不同点。首先,图 4 的 Ruby 程序实际上是自行实现了相当于事件循环的部分。套接字的监听、注册、删除等管理工作也是自行完成的,这导致代码的规模变得相对较大。

node.js 版则比 Ruby 版要简洁许多。虽说采用 node.js 需要熟悉回调风格,但作为服务器的实现来说,显然还是事件驱动框架更加高效。

下面我们来详细看看 node.js 版和 Ruby 版之间的区别。

首先,node.js 版中,开头使用 require 函数引用了 net 库,net 库提供了套接字通信相关的功能。接下来调用的 net.createServer 函数,用于创建一个 TCP/IP 服务器套接字,并在该套接字上接受来自客户端的连接请求。在 createServer 的参数中所指定的函数,会被作为回调函数进行调用,当回调函数被调用时,会将客户端连接的套接字(sock)作为参数传递给它。sock 的 on 方法用于设置 sock 相关事件的回调函数。

当来自客户端的数据到达时会发生 data 事件,收到的数据会被传递给回调函数。这里我们要实现的是一个回声服务器,因此只需要将收到的数据原本返回即可。listen 方法用于在服务器套接字上监听指定的端口号。

随后,程序到达末尾,进入事件循环。需要注意的是,node.js 程序中,程序主体仅负责对事件和回调函数进行设置和初始化,实际的处理是在事件循环内完成的。

相对地,Ruby 版则需要自行管理事件循环和套接字,因此程序结构相对复杂一些。大体上是这样的:

1. 通过 TCPSever.open 打开服务器套接字。

2. 通过 select 等待事件。

3. 如果是对服务器套接字产生的事件,则通过 accept 打开客户端连接套接字。

4. 除此之外的事件,如遇到 eoffi(连接结束)则关闭客户端套接字。

5. 读取数据,并将数据原原本本回写至客户端。

在 node.js 中,上述 2、3、4 的部分已经嵌入在框架中,不需要以显式代码来编写,而且,程序员也不必关心资源管理等细节。正是由于这些特性,使得在回声服务器的实现上,node.js 的代码能够做到非常简洁。

node.js 回调风格

像图 3 这样将多个回调函数叠加起来的编程风格,恐怕还是要习惯一下才能上手。

下面我们通过实现一个简单的 HTTP 服务器,来仔细探讨一下回调风格。图 5 是运用 node.js 库实现的一个超简单的 HTTP 服务器。无论收到任何请求,它都只返回 hello world 这个纯文本字符串。

var http = require('http');

http.createServer(function(req, res) {
    res.writeHead(200, {'Content-Type':'text/plain'});
    res.write("hello world¥n");
    res.end();
}).listen(8000);
图 5 用 node.js 编写的 HTTP 服务器(1)

我们来实际访问一下试试看。

% curl http://localhost:8000/
hello world
很简单吧。

我们来思考一下回调风格。图 6 所示的,是一个读取当前目录下的 index.html 文件并返回其内容的 HTTP 服务器。index.html 的读取是用 fs 库的 readFile 函数来完成的。

var http = require("http");
var fs = require("fs");

http.createServer(function(req, res) {
    fs.readFile("index.html", function(err, content) {
        if (err) {
          res.writeHead(404, {"Content-Type":"text/plain"});
          res.write("index.html: no such file¥n");
        }
        else {
          res.writeHead(200, {"Content-Type":"text/html; charset=utf-8"});
          res.write(content);
        }
        res.end();
    });
}).listen(8000);
图 6 用 node.js 编写的 HTTP 服务器(2)

这个函数会在文件读取完毕后调用回调函数,也就是说即便是简单的文件输入输出也采用了回调风格。传递给回调函数的参数包括是否发生错误的信息,以及读取到的字符串。

node.js 的 fs 库中,也提供了用于同步读取操作的 readFileSync 函数,但在 node.js 中,还是推荐采用无阻塞风险的回调风格。

像这样,随着接受请求、读取文件等操作的叠加,回调函数的嵌套也会越来越深,这是回调风格所面临的一个课题。当然,我们也有方法来应对,不过关于这个问题,我们还是将来有机会再讲吧。

node.js 的优越性

通过刚才的介绍,大家是不是能够感觉到,用 node.js 可以很容易地实现一个互联网服务器呢?即使必须要习惯 node.js 的回调风格,这样的特性也是非常诱人的。

不过,用 node.js 来实现服务器的优越性并非只有“容易”这一点而已。首先,在事件检测上,node.js 并没有采用随连接数的增长速度逐渐变慢的 select 系统调用这一传统方式,而是采用了与连接数无关,能够维持一定性能的 epoll(Linux)和 kqueue(FreeBSD)等方式。因此,在连接数上限值方面可以比较令人放心。但是,对于每个进程来说,文件描述符的数量在操作系统中是存在上限的,要缓解这一上限可能还需要一些额外的设置。

其次,node.js 的 http 库采用了 HTTP1.1 的 keep-alive 方式,来自同一客户端的连接是可以重复使用的。TCP 套接字的连接操作需要一定的开销,而通过对连接的重复使用,当反复访问同一台服务器时,就可以获得较高的性能。

再有,通过运用事件驱动模型,可以减少每个连接所消耗的资源。综合上述这些优势可以看出,同一客户端对同一服务器进行频繁连接,且连接数非常大的场景,例如网络聊天程序的实现,使用 node.js 是最合适的。

我们对图 3 的回声服务器进行些许改造,就实现了一个简单的聊天服务器(图 7)。这里所做的改造,只是将回声服务器返回输入的字符串的部分,改成了将字符串发送给当前连接的所有客户端。另外,我们还为连接中断时发生的 end 事件设置了一个回调函数,用于将客户端从连接列表中删除。

var net = require("net");
var clients = [];

net.createServer(function(sock){
  clients.push(sock);
  sock.on("data", function(data) {
    for (var i=0; i<clients.length; i++)
{
      clients[i].write(data);
    }
  });
  sock.on("end", function() {
    var i = clients.indexOf(sock);
    clients.splice(i, 1);
  });
}).listen(8000);
图 7 用 node.js 编写的网络聊天程序

通过这样简单的修改,当多个客户端连接到服务器时,从其中任意客户端上输入的信息就可以在所有客户端上显示出来。当然,作为简易聊天程序来说,它还无法显示出某条消息是由谁发送的,因此还不是很实用。但如果能够从连接的套接字获取相关信息的话,修改起来也应该不难。

此外,我们在这里直接使用了 TCP 连接,但只要运用 keep-alive 和 Ajax(Asynchronous JavaScript and XML)技术,要用 HTTP 实现实时聊天(也就是所谓的 COMET)也并非难事。能够轻松开发出可负担如此大量连接的互联网服务器,正是 node.js 这一事件驱动框架的优势所在。

EventMachine 与 Rev

当然,面向 Ruby 的事件驱动框架也是存在的,其中最具代表性的当属 EventMachine 和 Rev。EventMachine 是面向 Ruby 的事件驱动框架中的元老,实际上,在 node.js 的官方介绍中,也用了“像 EventMachine”2这样的说法。之所以在这里没有介绍它们,是因为相比这些框架所提供的“为每个事件启动相应的对象方法”的方式来说,node.js 这样注册回调函数的方式更加容易讲解。

2 “Node is similar in design to and influenced by systems like Ruby's Event Machine or Python's Twisted.”(http://nodejs.org/about/

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据
    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文