Node学习指南 第2版

978-7-115-50541-5
作者: [美] 谢利·鲍尔斯(Shelley Powers)
译者: 曹隆凯娄佳
编辑: 武晓燕
分类: Node

图书目录:

详情

Node.js是一套用来编写高性能网络服务器的JavaScript工具包。它可以让JavaScript在服务器端运行,因此,它可用来快速构建网络服务及应用的平台。 本书是学习Node编程的入门指南。全书共12章,由浅入深。本书首先介绍Node的基础知识、Node的核心功能、Node的模块系统和REPL等,然后讲解Node的Web应用、流和管道、Node对文件系统的支持、网络和套接字、子进程、ES6等相关知识,最后介绍了全栈Node编程、Node的开发环境和产品环境以及Node的新应用。 本书适合有一定基础的JavaScript程序员阅读,也适合对学习Node应用开发感兴趣的读者学习参考。

图书摘要

版权信息

书名:Node学习指南(第2版)

ISBN:978-7-115-50541-5

本书由人民邮电出版社发行数字版。版权所有,侵权必究。

您购买的人民邮电出版社电子书仅供您个人使用,未经授权,不得以任何方式复制和传播本书内容。

我们愿意相信读者具有这样的良知和觉悟,与我们共同保护知识产权。

如果购买者有侵权行为,我们可能对该用户实施包括但不限于关闭该帐号等维权措施,并可能追究法律责任。

著    [美] 谢利•鲍尔斯(Shelley Powers)

译    曹隆凯 娄 佳

责任编辑 武晓燕

人民邮电出版社出版发行  北京市丰台区成寿寺路11号

邮编 100164  电子邮件 315@ptpress.com.cn

网址 http://www.ptpress.com.cn

读者服务热线:(010)81055410

反盗版热线:(010)81055315


Copyright © 2016 by O’Reilly Media, Inc.

Simplified Chinese Edition, jointly published by O’Reilly Media, Inc. and Posts & Telecom Press, 2018. Authorized translation of the English edition, 2016 O’Reilly Media, Inc., the owner of all rights to publish and sell the same.

All rights reserved including the rights of reproduction in whole or in part in any form.

本书中文简体版由O’Reilly Media, Inc.授权人民邮电出版社出版。未经出版者书面许可,对本书的任何部分不得以任何方式复制或抄袭。

版权所有,侵权必究。


Node.js是一套用来编写高性能网络服务器的JavaScript工具包。它可以让JavaScript在服务器端运行,因此,它可用来快速构建网络服务及应用的平台。

本书是学习Node编程的入门指南。全书共12章,由浅入深。本书首先介绍Node的基础知识、Node的核心功能、Node的模块系统和REPL等,然后讲解Node的Web应用、流和管道、Node对文件系统的支持、网络和套接字、子进程、ES6等相关知识,最后介绍全栈Node编程、Node的开发环境和产品环境以及Node在新设备上的应用。

本书适合有一定基础的JavaScript程序员阅读,也适合对学习Node应用开发感兴趣的读者学习参考。


O'Reilly Media通过图书、杂志、在线服务、调查研究和会议等方式传播创新知识。自1978年开始,O'Reilly一直都是前沿发展的见证者和推动者。超级极客们正在开创着未来,而我们关注真正重要的技术趋势——通过放大那些“细微的信号”来刺激社会对新科技的应用。作为技术社区中活跃的参与者,O'Reilly的发展充满了对创新的倡导、创造和发扬光大。

O'Reilly为软件开发人员带来革命性的“动物书”;创建第一个商业网站(GNN);组织了影响深远的开放源代码峰会,以至于开源软件运动以此命名;创立了《Make》杂志,从而成为DIY革命的主要先锋;公司一如既往地通过多种形式缔结信息与人的纽带。O'Reilly的会议和峰会集聚了众多超级极客和高瞻远瞩的商业领袖,共同描绘出开创新产业的革命性思想。作为技术人士获取信息的选择,O'Reilly现在还将先锋专家的知识传递给普通的计算机用户。无论是通过书籍出版、在线服务或者面授课程,每一项O'Reilly的产品都反映了公司不可动摇的理念——信息是激发创新的力量。

“O'Reilly Radar博客有口皆碑。”

        ——Wired

"O'Reilly凭借一系列(真希望当初我也想到了)非凡想法建立了数百万美元的业务。"

        ——Business 2.0

"O' Reill y Conference是聚集关键思想领袖的绝对典范。"

        ——CRN

“一本O' Rei lly的书就代表一个有用、有前途、需要学习的主题。"

        ——Irish Times

“Tim是位特立独行的商人,他不光放眼于最长远、最广阔的视野并且切实地按照Yogi Berra的建议去做了:‘如果你在路上遇到岔路口,走小路(岔路)。’回顾过去Tim似乎每一次都选择了小路,而且有几次都是一闪即逝的机会,尽管大路也不错。”

        ——Linux Journal


Node.js的出现已经有一段时间了,而且已经被很多大公司(LinkedIn、Yahoo!和Netflix)使用,但它仍然很年轻,以至于很多公司的中层管理人员在进行决策时对它存有疑虑。它的成功促使我们去创造一种更为复杂的JavaScript,也让我们可以没有后顾之忧地使用新的语言特性。反过来,最新版的JavaScript也推动了Node.js的组织改革和发布规范的设立。

Node.js重新定义了我们能用JavaScript干什么。现在,雇主不但要求JavaScript程序员能在我们所熟知的浏览器端进行开发,也要求他们能进行服务端开发。另外,Node.js创造了一种新的服务端语言,这种新的语言正在不断吸引着Ruby、C++、Java和PHP的服务端开发人员(尤其是其中一些会JavaScript的开发人员)。

我觉得Node.js很有趣。因为相对于很多其他环境而言,它的门槛很低,很容易创建和运行应用,用它尝试新想法也比较容易。Node项目所需要的环境配置比其他语言要简单和直观一些。只有PHP的环境配置的简单程度可以媲美Node,但即便如此,PHP还是需要和Apache结合起来,才能创建出对外可用的应用。

虽然听着简单,但Node.js也有复杂的地方。学习Node.js意味着你需要学习如何配置环境、掌握核心API,但同时,发现并掌握其复杂的一面也很重要。

我觉得本书的受众有两种人。

第一种是已经使用过各种库和框架搭建前端应用,现在想要将自己的JavaScript知识扩展到服务端的开发人员。

第二种是想要尝试新鲜事物,或者学习新技术的后端开发人员。这些开发人员从事过Java、C++、Ruby或者PHP的开发工作,但现在他们想要把时不时接触过的JavaScript知识体系化,并和已知的服务端知识结合起来。

这两种看似没有交集的读者有一个共同点,就是都使用JavaScript,或者可以更准确地称为ECMAScript。本书也确实要求读者对JavaScript比较熟悉。这两种读者的另外一个共同点是:都必须学习同样的Node基础知识,包括Node核心API。

然而,不同的读者,因为拥有不同的视角、不同的技能,所以学习体验也会不尽相同。为了协调一致,我会在本书从不同的视角进行讲解。比如说,一个C++或者Java程序员可能会有兴趣创建C++的Node插件,而这对于前端开发者来说可能没多少吸引力。同样,一些类似于“大端”(big-endian)的概念可能对后端开发者来说很熟悉,而对前端开发者来说可能是一头雾水。我没办法从每一种视角进行过于深入的讲解,但我会确保所有的读者都不会看不懂,或者感觉乏味。

有一件事我绝对不会做,就是强迫读者死记硬背。我们会讲到核心模块API,但是我不会逐个讲解其中的对象和函数,因为它们在Node网站上都有文档。相对而言,我会挑选每个模块中的重要方面进行讲解,或者针对Node中一些特定的重要功能进行讲解,从而给读者提供一个可以和其他Node开发人员进行交流的基础。当然了,熟能生巧,而且本书只是一个学习工具。读完本书之后,读者还需要继续对Node进行深入的探索,比如在Mongo-Express-Angular-Node(MEAN)技术栈上工作。Node中有很多方向可以选择,本书将会为读者提供一些建议。

聊聊Node文档  

写作本书之时,我和其他一些Node开发者正在进行一个关于Node.js网站现存问题的讨论,其中一点就是关于如何定义Node.js的“当前”版本,也就是说,当用户访问“这个”文档时,应该显示什么样的版本。

我最后一次参与这个讨论的时候,大家的计划是在/docs页面列出Node.js的所有当前的长期维护(LTS)版本,还要列出当前的稳定版本,同时要在页面顶端显示出当前文档的版本号。

最终,文档的维护人员想要针对每个页面生成一个版本差异(version diffs),但这将会是一个艰巨的任务。

本书出版之时,Node发布了6.0.0版本,它是当前版本,并且它不再将活跃开发分支称为稳定版(Stable)。因此,Node.js 6.0.x最终将被转至下一个LTS版本。

因为Node的版本如此之多,所以每当你访问Node API的文档网站时,一定要检查文档版本与你使用的Node版本是否一致。当然,查看一下新版文档中的新功能也没什么坏处。

 

 

Node.js就是Node 

官方名称Node.js实际上极少被人使用,所有人都称之为“Node”。本书中绝大部分地方也会使用Node来指代Node.js。

本书着重于基础,重点讲解Node和组成Node核心的一些模块,我也会稍微涉及一些第三方模块,并把npm作为扩展知识来讲解,但是本书最重要的目标还是为读者讲解Node的基础功能。有了这些基础知识,读者就可以在不同方向进行深入研究。

第1章主要是对Node的介绍,其中有一节介绍了如何安装Node。读者还可以对Node进行一些探索,包括使用Node文档中的代码创建一个Web服务器,接着使用我提供的代码创建一个稍微高级一点儿的Web服务器。我也会为读者中的C/C++程序员讲解如何创建Node插件。另外,讲解Node怎么能抛开它的历史不谈呢?更别说Node的历史还真是与众不同,因为它跳过了1.0版本,直接发布了4.0版本。

第2章主要讲解Node的核心功能,包括如何处理事件,以及各种功能赖以生存的global对象,还有Node的异步特性。另外,我还会讲解缓冲器(buffer)对象,也就是Node里用来在网络服务中进行数据传输的数据结构。

第3章会深入讲解Node的模块系统,包括深入研究npm——Node的一个第三方模块管理系统。这一章中,读者将会学习到如何将自己的应用与Node模块集成,以及如何创建自己的Node模块。最后,我会讲解Node功能中一个稍微高级点的内容——沙箱(sandboxing)。同时,为了让学习更有趣,我会介绍3个流行的Node第三方模块:Async、Commander和Underscore。

在第4章中,我会用整章的篇幅来讲解一个很有价值的学习工具,Node的互动式控制台——REPL,其本身也是一个开发环境。我会详细讲解如何使用这个工具,还会介绍如何创建自己的REPL。

在第5章,我们会探索Node的Web应用,包括去了解有哪些Node模块支持了Web开发。在这一章,读者可以学习如何构建一个功能完整的Web服务器,以及如何使用Apache代理将Node应用和Apache一起运行。

Node可以在多种环境下运行,如Windows,基于UNIX的OS X,还有Linux。这是因为Node提供了一系列可以屏蔽系统差异的功能,这部分内容我们会在第6章讲到。另外,我还会讲到Node中的流(stream)和管道(pipe)的基础知识。它们是一切输入输出的核心,另外本章还会介绍Node对文件系统的支持。

第7章全部是关于网络的。说到网络,就不能不涉及安全。这两部分内容总是会同时出现。我会介绍Node对TCP和UDP的支持,以及如何实现一个HTTPS服务器,这作为第5章学习实现HTTP服务器的补充。另外我还会介绍数字证书背后的机制,以及SSL(Secure Sockets Layer,安全套接层)的基础,还有它的升级版TLS(Transport Layer Security,传输层安全)。最后,我们会学习Node的加密模块,以及如何为密码加密。

我最喜欢的Node的功能之一,就是它可以通过子进程(child process)来调用系统功能。很多我喜欢的小型工具类应用,比如用来压缩文件的应用、图形处理应用(如ImageMagick)、从网站截图的应用,都不是特别大型的应用,无须复杂的云接口,但却是学习子进程的绝佳方式。我们会在第8章介绍子进程。

本书中大部分的示例代码,都是用大家熟知的JavaScript编写的。但是,Node.js和io.js分离又合并的原因,就在于对新版ECMAScript(比如ES6或者ECMAScript 2015)的支持。在第9章中,我会介绍当前Node版本所支持的功能、新功能的影响,以及什么时候应该用新功能取代旧功能。我还会介绍在使用JavaScript新功能时的一些陷阱。另外,我会讲解Bluebird的promise功能,这是本书中唯一一个脱离Node原生功能的部分。

第10章会讲解全栈Node编程(full-stack Node developement)所涉及的框架和功能。我会讲到Express—— 一个被普遍使用的Node组件,还会讲到MongoDB和Redis。我们还会学习组成全栈开发的另外两个框架:AngularJS和Backbone.js。

编程结束之后,你可能会想把自己的代码发布到产品环境中,第11章讲解了一些在开发环境和产品环境中需要用到的工具和技术,包括单元测试、负载测试和基准测试,以及一些基本的调试技术和工具。读者还会学到如何保证程序持续运行,以及如何在程序崩溃时自动重启。

第12章属于“饭后甜点”。在这一章,我会指引读者将已有的Node知识用于新的世界,包括属于物联网的微控制器和微计算机,以及一个并不运行在V8引擎上的新版本Node。

本书中使用了以下字体作为约定内容。

等宽字体(constant width

用于表示对代码的指代和段落内对程序元素的指代,比如变量名、函数名、数据库、数据类型、环境变量、声明和关键字。

粗体(bold)

用于表示用户应该手动输入的命令或者文本。

等宽斜体Constant width italic

表示用户应该使用自己的内容或者上下文相关的内容来替代等宽斜体文字。

 

这个图片表示一个提示或者建议。

 

 

这个图片表示一个一般说明。

 

 

这个图片表示一个警告或者注意事项。

本书的目的是为了帮助读者完成工作。一般而言,读者可以在自己的程序和文档中使用本书中的代码,而且也没有必要取得我们的许可。但是,如果要复制的是核心代码,则需要和我们打个招呼。例如,可以在无须获取我们许可的情况下,在程序中使用本书中的多个代码块。但是,销售或分发O’Reilly图书中的代码光盘则需要取得我们的许可。通过引用本书中的示例代码来回答问题时,不需要事先获得我们的许可。但是,如果产品文档中融合了本书中的大量示例代码,则需要取得我们的许可。

在引用本书中的代码示例时,如果能列出本书的属性信息是最好不过了。一个属性信息通常包括书名、作者、出版社和ISBN。例如:“learning Node, Second Edition, by Shelley Powers (O’Reilly). Copyright 2016 Shelley Powers, 978-1-491-94312-0.”

在使用书中的代码时,如果不确定是否属于正常使用,或是否超出了我们的许可,请通过permissions@oreilly.com与我们联系。

如果你想就本书发表评论或有任何疑问,敬请联系出版社。

美国:

O’Reilly Media Inc.

1005 Gravenstein Highway North

Sebastopol, CA 95472

中国:

北京市西城区西直门南大街2号成铭大厦C座807室(100035)

奥莱利技术咨询(北京)有限公司

Safari在线图书是一个按需订阅的数字图书馆。它至少有7 500本与技术和创意相关的图书和视频供你参考和搜索。

通过订阅,你可以在线阅读所有的页面和视频,甚至可以在手机或移动设备上在线阅读。你可以在图书出版前访问到它们,并给作者发送反馈。其他功能还包括:复制和粘贴代码、组织收藏夹、下载和标记章节、做笔记、打印等。

O’Reilly Media已经将本书的英文版上传到Safari在线图书服务。在safari官网上免费注册,你就可以访问本书所有章节以及类似主题的图书。

我要感谢帮助我写作此书的人:编辑,Meg Foley;技术评论人,Ethan Brown;文字编辑,Gillian McGarvey;校对人,Rachel Monaghan;索引人,Judy McConville;插画师,Rebecca Panzer;以及其他所有接触过此书的人!


本书由异步社区出品,社区(https://www.epubit.com/)为您提供相关资源和后续服务。

本书提供如下资源:

要获得以上配套资源,请在异步社区本书页面中点击,跳转到下载界面,按提示进行操作即可。注意:为保证购书读者的权益,该操作会给出相关提示,要求输入提取码进行验证。

作者和编辑尽最大努力来确保书中内容的准确性,但难免会存在疏漏。欢迎您将发现的问题反馈给我们,帮助我们提升图书的质量。

当您发现错误时,请登录异步社区,按书名搜索,进入本书页面,点击“提交勘误”,输入勘误信息,点击“提交”按钮即可。本书的作者和编辑会对您提交的勘误进行审核,确认并接受后,您将获赠异步社区的100积分。积分可用于在异步社区兑换优惠券、样书或奖品。

我们的联系邮箱是contact@epubit.com.cn。

如果您对本书有任何疑问或建议,请您发邮件给我们,并请在邮件标题中注明本书书名,以便我们更高效地做出反馈。

如果您有兴趣出版图书、录制教学视频,或者参与图书翻译、技术审校等工作,可以发邮件给我们;有意出版图书的作者也可以到异步社区在线提交投稿(直接访问www.epubit.com/selfpublish/submission即可)。

如果您是学校、培训机构或企业,想批量购买本书或异步社区出版的其他图书,也可以发邮件给我们。

如果您在网上发现有针对异步社区出品图书的各种形式的盗版行为,包括对图书全部或部分内容的非授权传播,请您将怀疑有侵权行为的链接发邮件给我们。您的这一举动是对作者权益的保护,也是我们持续为您提供有价值的内容的动力之源。

“异步社区”是人民邮电出版社旗下IT专业图书社区,致力于出版精品IT技术图书和相关学习产品,为作译者提供优质出版服务。异步社区创办于2015年8月,提供大量精品IT技术图书和电子书,以及高品质技术文章和视频课程。更多详情请访问异步社区官网https://www.epubit.com。

“异步图书”是由异步社区编辑团队策划出版的精品IT专业图书的品牌,依托于人民邮电出版社近30年的计算机图书出版积累和专业编辑团队,相关图书在封面上印有异步图书的LOGO。异步图书的出版领域包括软件开发、大数据、AI、测试、前端、网络技术等。

异步社区

微信服务号


虽然Node主要被当作服务器端应用程序来使用,但这并不是它唯一的用途。事实上,Node几乎可以被安装在任何机器上,也可以被用来做任何事情,比如它可以在你的计算机、平板电脑、智能手机甚至微型计算机上运行程序。

我在我的Linux服务器、Windows 10系统的计算机和微型计算机(树莓派)上都安装了Node,而且正在考虑给我另外一个Android平板电脑也安装Node,这样我就可以用Node在Arduino Uno微型控制器上编程了。另外,感谢IFTTT的Maker Channel(创造者频道),我可以借助它将Node集成到我的智能家居中。在计算机上,Node可以用来测试JavaScript,也可以作为接口调用ImageMagick来批量处理照片。事实上,不论是计算机还是服务器,Node都是一个可以做任何批处理操作的快捷工具。

当然,当我需要一个后端服务来绕开Apache服务器或者构建一个网站后端的时候,我还是会使用Node来进行服务器端的处理。

综上所述,Node开发环境的功能非常强大,同时也非常容易安装。而开始在Node公园中尽情“玩耍”之前,我们得先买“门票”:安装Node。

 

IFTTT

IFTTT是一个神奇的网站,你可以使用简单的if-then逻辑将公司、服务、产品通过触发器和执行器连接起来。连接的两个终端都叫频道,包括我们之前提到的创造者频道(Maker Channel)。

要安装Node,最好从Node.js的下载页面开始。在这里你可以下载到适用于Windows、OS X、SunOS、Linux以及ARM等平台的二进制文件(预编译的可执行文件)。这个页面也提供了个别平台的安装文件,这些安装文件可以大幅简化安装过程——特别是Windows版。如果你本地有编译环境,也可以下载源代码,然后直接编译Node。在我的Ubuntu服务器上,我就是这么做的。

你也可以使用平台对应的安装文件来安装Node,这样不仅方便安装,也方便更新(我们会在1.4节中深入讨论)。

如果你准备直接在本地环境中编译Node,那么必须先设置好本地的编译环境,并且安装合适的编译工具。比如在Ubuntu(Linux)上,就需要运行下面这条命令来安装所需的工具:

apt-get install make g++ libssl-dev git

在不同的平台上,第一次安装Node的过程会略有差异。比如,在Windows上安装Node时,安装文件不仅会安装Node,同时也会在本地创建一个用来运行Node的命令窗口。这是因为Node是一个命令行程序,不像典型的Windows程序那样拥有一个图形界面。要想在Arduino Uno上面用Node来编程,那你就需要安装Node和Johnny-Five,然后将二者结合起来对所连接的设备进行编程。

 

接受Windows世界中的默认设置

在Windows上安装Node时,最好接受默认安装路径和安装功能的设置。因为安装文件会将Node加入到PATH环境变量中,之后就可以直接输入node来运行Node,而不用输入整个安装路径。

在树莓派中安装Node时,需要下载对应的ARM版本,比如原版树莓派需要下载ARMv6,新版树莓派2需要下载ARMv7。下载好之后,从压缩包中将二进制文件解压出来,放在/usr/local目录下:

wget https://nodejs.org/dist/v4.0.0/node-v4.0.0-linux-armv7l.tar.gz
tar -xvf node-v4.0.0-linux-armv7l.tar.gz

cd node-v4.0.0-linux-armv7l

sudo cp -R * /usr/local/

你也可以在本地搭建编译环境,然后直接编译Node。

 

新的Node环境

既然说到了Arduino和树莓派,我会在第12章介绍在一些非传统环境(如物联网)中的Node的使用。

刚刚装完Node,你一定想试试吧。程序员的世界里有一个传统,就是学习一门语言时,从“Hello, World”开始。不论输出流是什么,这个程序只输出“Hello, World”这两个单词,借此展示如何创建程序、运行程序,以及如何对输入和输出进行处理。

对Node而言也是如此——在Node.js的网站上,文档的概要部分就包含了“Hello, World”的代码。这也是我们在本书中第一个要写的程序,只不过这里会略作调整。

让我们先来看看Node官方文档中的“Hello, World”程序。用你最喜欢的文本编辑器(我在Windows中用notepad++,在Linux中用Vim)创建一个文本文件,然后把下面这段JavaScript代码复制进去。

var http = require('http');

http.createServer(function (request, response) {
  response.writeHead(200, {'Content-Type': 'text/plain'});
  response.end('Hello World\n');
}).listen(8124);

console.log('Server running at http://127.0.0.1:8124/
');

然后将文件保存为hello.js。要运行这个程序,在OSX或者Linux中需要打开一个终端(terminal),在Windows下则需要打开Node命令行窗口。切换到文件所在的目录,然后输入这行命令就可以运行程序了。

node hello.js

程序通过console.log()函数调用将执行结果打印到命令行。

Server running at http://127.0.0.1:8124/

然后打开浏览器,在地址栏中输入http://localhost:8124/或者http://127.0.0.1:8124(如果是在自己的服务器上运行这个程序,也可以输入你自己的域名)。此时浏览器中应该显示一个简单朴素的Web页面,页面顶上有“Hello World”字样,如图1-1所示。

如果你是在Windows上面运行这个程序,那你很有可能会收到一个Windows防火墙的警告信息,如图1-2所示。取消公用网络,选择私人网络,然后单击“允许访问”按钮。

图1-1 你的第一个Node程序

图1-2 在Windows中设置允许访问Node程序

Windows会记住你的选择,所以这个操作做一次就够了。

要退出这个程序,你可以直接关掉终端或者命令行窗口(命令行窗口其实也是终端),也可以按Ctrl-C组合键。因为我们的程序是从终端前台运行的,所以其实除了按Ctrl-C组合键之外,你也没办法输入别的命令,同时关掉终端意味着Node进程也会被关闭。

 

如何获取稳定的Node运行环境

就现在而言,在终端前台运行Node没什么问题。你正在学习如何使用这个工具,但你不希望你的应用程序被其他人使用,而且希望在结束使用的时候可以终止它。我们会在第11章讲到如何创建一个更为稳定的Node运行环境。

回到我们的Hello World程序,JavaScript为我们创建了一个Web服务,我们在浏览器中访问它时,会显示包含“Hello World”的Web页面。在这个例子中我们使用了Node的好几个关键模块。

首先,它包含了运行一个简单的HTTP服务器所必需的模块:一个以HTTP命名的模块。Node的非核心功能是通过不同的模块来引入的。这些模块会对外暴露其特有的功能,这些功能可以被程序或者另外一个模块引用,就像你在其他编程语言中用过的类库一样。

var http = require('http');

 

Node模块、核心模块和HTTP模块

HTTP模块是Node的核心模块之一,讲解核心模块是本书的主要内容。我会在第3章深入地讲解Node模块和模块管理,然后在第5章讲解HTTP模块。

这个模块是通过Node的require声明引入的,其结果被赋值到一个局部变量。引入完成之后,我们就可以用这个局部变量来实例化Web服务,即http.createServer()函数。在函数的参数中,我们用到了回调函数(见例1-1),这是构成Node的基本概念之一。匿名函数会把Web端的请求(request)和响应(response)传递给对应的代码,如此一来就可以方便地处理请求并生成对应的响应了。

例1-1 Hello World回调函数

http.createServer(function (request, response) { 
  response.writeHead(200, {'Content-Type': 'text/plain'}); 
  response.end('Hello World\n'); 
}).listen(8124);

JavaScript是单线程的。那么Node是如何在单线程环境下模拟一个异步环境的呢?答案是事件循环(event loop),也就是通过在特定事件被触发时,调用相关的回调函数来完成。在例 1-1中,只要接收到一个Web请求,回调函数就会被执行。

创建Web服务的函数调用完成之后,console.log()会将消息打印到终端中。此时程序本身并不会结束或是阻塞,而是在等待接受Web请求。

console.log('Server running at http://127.0.0.1:8124/
');

 

关于事件循环和回调函数

我会在第2章中讲解更多关于Node事件循环、它对异步编程的支持以及回调函数的内容。

Web服务被创建,并且接收到一个Web请求之后,回调函数就会向浏览器发送一个纯文本的响应头(response header)和200的状态码(status code),然后发送Hello World这段信息,最后结束响应。

恭喜!你通过短短几行代码就用Node创建了第一个Web服务。但是,这远远不够,除非你唯一的兴趣就是向世界问好(Hello World!)。在本书中你将会学习如何写出更有用的Node程序,但是在结束Hello World的练习之前,让我们在这个基本程序上面做一点修改,让它变得更有意思。

上面的程序成功打印了一段静态文本,这说明:第一,程序是可以正常工作的;第二,它向我们展示了如何创建一个简单的Web服务。这个最基本的例子也展示了Node程序的几个关键元素。但是如果把它稍微丰富一下,它就会变得更有趣。我做了一点升级,写了第二个程序,加入了一些可变因素。

升级后的代码如例1-2所示。在新的代码中,我对传入的Web请求进行解析,然后查找一个参数,将参数中的名字(name)取出来,以便确定响应的内容。几乎所有的name都会有一个个性化的响应,除非你在参数中加入name=burningbird,此时服务器端将会返回一张图片。如果name参数没有被指定,那name参数就被设为“world”。

例1-2 升级版Hello World

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

http.createServer(function (req, res) {
   var name = require('url').parse(req.url, true).query.name;

   if (name === undefined) name = 'world';

   if (name == 'burningbird') {
      var file = 'phoenix5a.png';
      fs.stat(file, function (err, stat) {
         if (err) {
            console.error(err);
            res.writeHead(200, {'Content-Type': 'text/plain'});
            res.end("Sorry, Burningbird isn't around right now \n");
         } else {
            var img = fs.readFileSync(file);
            res.contentType = 'image/png';
            res.contentLength = stat.size;
            res.end(img, 'binary');
         }
      }); 
   } else {
      res.writeHead(200, {'Content-Type': 'text/plain'});
      res.end('Hello ' + name + '\n');
   }
}).listen(8124);
console.log('Server running at port 8124/');

使用?name=burningbird作为参数来访问我们的Web应用,将会得到下面的图片,如图1-3所示。

图1-3 Hello, Burningbird

升级后的Hello World程序并没有比原版增加多少代码,但还是有些区别。一开始,程序中就引入了一个名叫fs的新模块,即文件系统模块。在未来的几章里,你将对这个模块非常熟悉。另外还有一个模块,它的引入方式就不太一样了:

var name = require('url').parse(req.url, true).query.name;

模块暴露的属性可以被链式调用,因此我们可以把模块的引入和模块中方法的调用放在同一行。我们通常会在使用URL这个模块的时候用到这种方式,这个模块就是一个URL的工具集。

responserequest参数的变量名可以缩写为resreq,以便于日后使用。当我们解析完request,就得到了name的值,首先检测一下这个值是不是undefined。如果是,这个值就会被赋值为默认值world;而如果name不是undefined,我们会再检测它是否等于burningbird。如果不等于,那么程序的结果和我们在初级版程序中看到的很像,只不过返回的字符串中加入了我们提供的名字。

但是,如果name等于burningbird,我们就需要处理一张图片,而不是一段文字了。fs.stat()方法不但会验证文件是否存在,而且也会返回一个包含文件信息的对象,包括它的大小。这个值会被用来创建响应内容的头(content header)。

如果文件不存在,程序也会很优雅地处理这个情况:它会展示出一个友好的信息,告诉你这只鸟已经从笼子里飞走了,同时也会使用console.error()方法在控制台输出错误信息:

{ [Error: ENOENT: no such file or directory, stat 'phoenix5a.png']
  errno: -2,
  code: 'ENOENT',
  syscall: 'stat',
  path: 'phoenix5a.png' }

如果文件存在,那么我们就将图片读取出来并赋值给一个变量,然后在响应中返回,同时相应地调整头(header)的值。

fs.stats()方法使用标准的Node回调函数模式,即把错误值作为第一个参数——通常被称为errback。但是,读取图片部分的代码可能会让你搞不清楚。它看起来有点奇怪,和你在本章中看到的其他Node函数不太一样(很可能跟你在其他在线示例中看到的也不一样)。它的不同之处在于,我使用了一个同步函数readFileSync(),而不是它的异步版本readFile ()

对于大多数文件系统功能,Node同时提供同步和异步两个版本的函数。通常,在Node中的Web请求中使用同步操作是一种忌讳,但是Node确实提供了这样的功能。例1-3是同样一段代码的异步实现。

例1-3

fs.readFile(file, function(err,data) {
                res.contentType = 'image/png';
                res.contentLength = stat.size;
                res.end(data, 'binary');
              });

那什么时候应该用同步函数,什么时候又该用异步函数呢?在某些情况下,无论你使用哪种类型的函数,文件I/O都不会影响性能,同步函数可以让代码干净,也更易于使用。它还可以减少代码嵌套——代码嵌套是Node回调系统的一个特殊问题,我将在第2章更详细地介绍。

此外,虽然我没有在例子中使用异常处理,但是你可以将try … catch与同步函数结合使用。你不能将这种传统的错误处理方式与异步函数结合使用(因为匿名回调函数的第一个参数就是错误值)。

所以从第二个例子中,我们学到的重点是,Node中的I/O并不都是异步的。

 

文件系统和URL模块,缓冲器(buffer)以及异步I/O

我将在第5章更详细地介绍URL模块,在第6章讲解文件系统。但是请注意,文件系统的使用会贯穿整本书。缓冲器和异步处理的内容会在第2章中介绍。

在前面两节中,Node都是在命令行中调用的,而且不带任何参数。在继续下面的内容之前,我想简要介绍一些命令行参数。其他的参数会在需要时再介绍。

使用help参数(-h--help),可以展示出所有可以使用的选项和参数:

$ node --help

这个参数会列出Node的所有参数,同时展示使用语法:

Usage: node [options] [ -e script | script.js ] [arguments]
       node debug script.js [arguments]

要知道Node的版本信息,可以使用下面这个命令:

$ node -v or –-version

要查看某个Node应用的语法,可以使用-c参数。这个参数可以在不运行应用的情况下查看运行语法:

$ node -c or --check script.js

要查看V8参数,请输入:

$ node --v8-options

这个命令会返回几个不同的参数,包括--harmony参数。这个参数用于开启所有已完成的Harmony JavaScript功能。这包括已经实现但尚未纳入LTS或当前Node版本的所有ES6功能。

我最喜欢的Node参数是-p--print,它可以运行一行Node脚本并打印结果。如果你正在使用进程的环境变量(我们将在第2章进行更全面的讨论),那么这一参数将尤其有用。下面是一个例子,这个例子会打印出process.env属性的所有值:

$ node -p "process.env"

学习Node时,你可能更想在自己的本地环境中熟悉它,无论是Windows、OS X还是Linux。当你想让更多的人访问你的程序时,你将需要:找到一个可以运行Node程序的环境,例如我自己正在使用的虚拟专用网络(VPN),或者一个可以提供Node支持的主机。前者要求你在运行一个面向互联网的服务器方面具有一定经验,而后者可能会限制你所使用的Node程序的权限。

把Node应用部署在和WordPress一样的机器上是行不通的,因为Node有特殊的权限需求。虽然没有root或管理权限也可以运行Node,但最好有。此外,许多托管公司并不喜欢让你在多个端口上托管(host)应用,不论它会不会对其系统造成破坏。

在虚拟专用服务器(VPS)(例如我在Linode上的VPN)上部署Node,是一件很简单的事情。你的VPS具有root访问权限,只要你不危及可能位于同一机器上的其他用户,就可以做任何操作。提供VPS的大多数公司都能确保每个个人账户与其他账户隔离,也没有任何一个账户能够占用所有可用的资源。

但是,使用VPS的问题与你使用自己的服务器时所遇到的问题相同:你必须维护服务器,包括设置电子邮件系统和别的Web服务器——通常是Apache或Nginx——来处理防火墙、其他安全性问题以及电子邮件等。这可不是小问题。

不过,如果你有能力全面地管理一个互联网主机,VPS足以用来部署Node程序了。在你准备好将程序部署到产品环境之前,你可能会想要了解一下如何在云端环境部署应用程序。

如今,一个应用程序运行于云服务器,和运行在个人或群组计算机上一样常见。Node程序非常适合基于云端的实现方式。

当你在云上部署Node应用程序时,你通常需要在自己的服务器或PC上创建应用程序,进行测试,确保它一切正常,然后将应用程序推送到云服务器。Node的云服务器允许你使用任何数据库系统或其他系统的资源创建Node应用程序,但无须直接管理服务器。你可以专注于Node应用程序,而无须担心FTP、电子邮件服务器或一般服务器的维护。

Git和GitHub:Node部署的前提

如果你从未使用过Git源码控制系统,则需要将其安装到你的环境中并学习如何使用它。几乎所有Node应用程序的转换,包括将应用程序推送到云服务器,都是通过Git进行的。

Git是开源和免费的,并且易于安装。你可以通过访问Git网站来获取这个软件。在GitHub上还有一个互动指南,可以用来学习基本的Git命令。

说到Git,哪里有Git,哪里就有GitHub。Node.js的源代码就是维护在GitHub上的,大部分(也可能是全部)可用的Node模块也是一样。本书例子的源代码也可以在GitHub上找到。

GitHub可能是世界上最大的开源代码库。它绝对是Node世界的中心。它是一个商业化公司,但对大多数用户来说都是免费的。GitHub提供了很好的文档,帮助用户了解如何使用该网站。还有一些书籍和其他教程,可以帮你更快上手Git和GitHub。其中包括一本免费的Git电子书、Loeliger和McCullough的《Git版本控制》(O’Reilly)以及Bell和Beer的《GitHub入门》(O’Reilly)。

在云服务上托管Node应用程序的流程,对于所有云服务来说都非常相似。首先,在本地或在自己的服务器上创建Node应用程序。当你准备开始测试部署环境时,就需要寻找一个云服务了。我熟悉的云服务大多会要求你注册一个账户,创建一个新项目,如果云服务提供很多种托管软件环境的话,你还可以指定一个基于Node的环境。需要的话,你还可以指定一些其他资源,例如数据库访问。

一旦准备好部署,你就需要把应用程序推送到云上。你可以使用Git来推送应用程序,也可能需要使用云服务商提供的工具。举个例子,微软Azure云利用Git将应用程序从本地环境推送到云端,而Google的Cloud Platform则提供了一个工具来提供相同的功能。

 

寻找合适的托管服务

虽然可能有些过时,但是刚开始寻找合适的Node托管服务的时候,可以去看看GitHub的相关页面。

2014年,一组Node维护人员分离出来并形成了自己的Node.js分支,名为io.js。当时震惊了Node界(或至少有一些人大吃一惊)。分裂的原因是创建io.js的人觉得,一直对Node进行维护的公司Joyent在实现对Node的监管上步伐太慢。他们还认为Joyent在支持最新的V8引擎更新方面已经落后了。

幸运的是,两个组织已经解决了导致分裂的问题,并将他们的努力重新合并为一个仍然被命名为Node.js的产品。Node现在通过由Linux基金会赞助的管理性非营利组织Node基金会进行管理。因此,两个组织的代码合并起来,不再是第一个官方发布版本Node 1.0,而是Node 4.0。Node 4.0代表了缓慢节奏的Node 1.0和迅猛发展的io.js 3.0版本。

合并的一个结果是,Node开始基于严格的时间线来进行发布,同时版本号遵循语义化版本规范(Semver)。Semver使用3组数字来定义功能发布,每组数字都具有特定含义。例如,写这部分内容时,我在服务器上正在使用的Node.js的版本是4.3.2。这意味着:

我在Windows上使用5.7.1的稳定版本,而在Linux上使用6.0.0版进行测试。

Node基金会还支持另外一种发布方式,比我们熟悉的这种略显随意的发布方式更具稳定性,尽管它存在一些问题。它开始于Node.js v4的第一个LTS(长期支持)版本,一直到2018年4月之前都会获得支持。Node基金会于2015年10月底发布了第一个稳定版本Node.js v5。Node 5.xx只被支持到2016年4月,2016年4月它被Node.js v6替代。该策略是为了每6个月推出一个稳定新版(目前的最新版就是这个版本),但隔一个版本才会有一个LTS,比如Node v4。

 

6.0.0版作为当前版本发布

2016年4月,Node发布了6.0.0版本,取代了5.x版本,并在2016年10月转换为新的LTS。Node还将正在开发的版本从“稳定版”更名为“当前版”。

在2018年4月后,Node v4会进入维护模式。同时,将会出现新的向后兼容更新(称为semver-major bumps,主版本调整),以及安全性问题和bug修复。

 

本书涵盖哪个版本

本书涵盖了Node.js v4的LTS版本。在需要的地方,我会用注释标记出v4和v5/v6之间的差异。

无论你决定使用哪个LTS版本,每个新的安全性/bug修复发布之后,你都需要立即升级。然而,处理主版本更新则取决于你和/或你的组织。升级应该是向后兼容的,仅影响底层引擎的改进。不过,在升级和制定测试计划时,你还是需要将所有新版本都考虑进去。

你应该使用哪个版本呢?在企业或企业环境中,你很可能希望使用LTS版本,目前来说也就是Node.js v4。但是,如果你的环境能够更快地适应突破性变化,则可以使用最新的“Node当前版”以得到最新的v8和其他新特性。

 

测试和产品中的乐趣

我将在第11章讲解Node调试和测试,以及其他一些开发过程和产品程序。

随着发布计划的增加,使Node版本保持最新版尤为重要。幸运的是,升级过程毫不费劲,而且还有替代方案。

你可以通过下面这条命令来检查Node版本:

node -v

如果你用的是一个包安装软件,那么运行包更新程序就可以更新Node了,这同时也会更新你的服务器上的其他程序(Windows上不需要sudo):

sudo apt-get update
sudo apt-get upgrade --show-upgraded

如果你用的是安装软件,那么请遵循Node网站上提供的相关说明,否则你可能无法更新Node。

你也可以使用npm来更新Node,命令如下:

sudo npm cache clean -f
sudo npm install -g
sudo n stable

如果要在Windows、OS X或者你的树莓派上安装最新版Node,请在Node网站的下载页面中下载安装程序,并且运行。它会用新版覆盖旧版。

 

Node版本管理器

在Linux或者OS X环境中,你也可以使用Node版本管理器(Node Version Manager, nvm)工具来更新Node。

Node包管理器(Node package manager, npm)本身的更新频率甚至比Node还高。要更新npm,只需执行:

sudo npm install npm -g n

这个命令将会安装所有需要的软件的最新版。你可以通过这条命令检查npm的版本:

npm -v

请注意,这可能会导致某些问题,尤其是在团队环境中。如果你的团队成员使用的Node是用npm安装的,而你手动将npm升级到更新的版本,那么可能出现不一致的构建结果,而且这个问题不易被发现。

我将在第3章更详细地介绍npm,但现在请先记住,你可以使用以下命令将所有Node模块更新到最新版本:

sudo npm update -g

Node背后有一套JavaScript引擎。大多数JavaScript的实现使用的引擎是V8。V8最初是由Google为Chrome开发的,在2008年开源了。V8引擎是为了提高JavaScript的运行速度而创建的,它使用一个即时编译器(JIT)将JavaScript编译成机器代码而不是翻译它(多年来,JavaScript一直是这样被执行的)。V8引擎是用C++编写的。

 

微软的Node.js分支

Microsoft为Node创建了一个分支,以创建一个专门为物联网(IoT)设计的JavaScript引擎(名为Chakra)版本。我将在第12章中详细地介绍这个分支。

Node v4.0发布的时候支持了V8 4.5(也就是Chrome所使用的引擎版本)。Node维护者也一直致力于在每个新版V8发布后提供支持。这意味着Node现在支持许多新的ECMA-262功能(也称为ECMAScript 2015或ES6)。

 

Node v6的V8支持

Node v6支持V8 5.0版本,而未来的Node版本也会支持对应的新版V8。

在旧版Node中,要使用ES6的新特性,你需要在运行程序时加上harmony参数(--harmony):

node --harmony app.js

现在,ES6新特性的支持基于以下几个标准(引用自Node.js文档)。

我将会在第9章讲解Node对ES6的支持,以及如何高效地使用各项功能。现在,你可以快速了解一下以下内容,它们是Node所支持的ES6功能的一部分。

安装了Node之后,你就可以随意用它了,但你可能会好奇自己都安装了些什么。

虽然用于创建Node程序的语言是基于JavaScript的,但是Node很大程度上却是用C++实现的,你可能没有注意到这一点。如果你对C和C++ 语言很熟悉,那么你就可以用C/C++创建Node插件(add-on),来扩展Node的功能。

编写一个Node插件和编写一个传统的C/C++程序并不一样。一方面,有很多库,比如V8的库,可以供你使用。另一方面,编译插件所用的工具不是你平常所使用的工具。

Node文档中介绍插件的部分给我们提供了一个Hello World插件的例子。你可以看看这个简单例子的代码,如果你使用过C/C++语言的话,它看起来会很熟悉。完成了代码的编写之后,你将需要使用一个工具——node-gyp,来将插件编译为一个.node文件。

首先,这个工具会生成一个binding.gyp的配置文件,文件中使用类JSON的格式提供了插件的信息:

{
   "targets": [
     {
       "target_name": "addon",
       "sources": [ "hello.cc" ]
     } 
   ] 
}

下面的命令就是用来生成这个配置文件的:

node-gyp configure

它会创建一个相应的配置文件(对于UNIX系统来说是一个Makefile,对Windows而言是一个vcxproj文件),将其放在build/目录中。然后运行下面的命令来构建我们的Node插件:

node-gyp build

编译后的文件将安装在build/release目录中,以供使用。这时候,像其他的插件一样,你就可以在你的程序中引用这个插件了(详情请见第3章)。

  

维护原生模块

虽然这已经超出了本书的范围,但是如果你对创建原生模块(插件)有兴趣的话,你需要留意平台之间的区别。比如说,微软针对原生模块,在Azure上提供了专门的说明。知名的node-serialport模块的维护者将他所遇到过的模块维护中的挑战都列了出来。

当然了,如果你对C/C++并不熟悉,你也可以使用JavaScript来创建模块,这部分内容会在第3章讲解。但是如果你确实熟悉这些语言,那么插件可以是一种有效的扩展,特别是针对系统相关的需求。

要知道,从v0.8到v6.x,Node呈现出一种戏剧性的快速发展。如果在升级过程中出现任何问题,你可以安装NAN(Native Abstractions for Node.js。这个头文件会帮你在不同的Node.js版本之间实现平滑过渡。


虽然说基于浏览器的应用和Node应用都是在JavaScript的基础上构建的,但它们的环境却不同。Node和基于浏览器的JavaScript之间有一个本质的区别,就是二进制数据的缓存。的确,Node现在可以操作ES6的ArrayBuffer和类型化数组了。不过Node中大部分跟二进制有关的功能还是用Buffer类来实现的。

buffer是Node中的一个全局对象。另一个全局对象是global本身,不过Node中的global对象跟我们在浏览器中所用的global对象有着本质的不同。Node开发人员还能访问另一个全局变量——process,它帮我们在Node应用和其运行环境之间架起了桥梁。

Node中总算有一个东西是前端开发人员所熟悉的,那就是它的事件驱动的异步特性。但是Node与浏览器不同的是,我们要等待文件打开,而非等待用户单击按钮。

事件驱动也意味着,我们可以在Node中使用那些我们所熟悉的计数器函数。

 

模块和控制台

至于其他全局组件——requireexportsmoduleconsole,我会在本书后面的章节中介绍。requireexportsmodule这些全局组件会在第3章中介绍,console会在第4章中介绍。

Node中两个基本对象是globalprocess。这里的global对象有些类似于浏览器中的global对象,但两者有很大的区别。而process对象,只在Node中出现。

在浏览器中,如果你在最顶层声明一个变量,它就会被声明成全局(global)的。但在Node中却不是这样。当你在模块或者应用中定义变量的时候,变量不是全局的;它被限制只能在定义它的模块或者应用中使用。也就是说,你可以在模块和使用这个模块的应用中都定义一个叫作str的全局变量,而这两个变量不会有任何冲突。

为了更好地演示,我们创建一个简单的函数:将基数与另一个数字相加,然后返回结果。我们会用两种方式来实现这个方法:写一个可以在浏览器中运行的JavaScript库和一个可以被Node使用的模块。

写在JavaScript库中的代码,被保存在一个叫作add2.js的文件中。它声明一个base变量,给它赋值2,然后将它与传入的数字相加:

var base = 2; 

function addtwo(input) {
   return parseInt(input) + base;
}

接下来,我们来创建一个很简单的模块,它做了同样的事情,只不过使用了Node的语法。我会在第3章中详细介绍模块,现在,先把下面的代码保存到一个叫作addtwo.js的文件中:

var base = 2; 

exports.addtwo = function(input) {
  return parseInt(input) + base;
};

现在就可以演示两种环境下global的区别了。在Web页面中使用add2.js文件,add2.js文件中也定义了一个base变量:

<!DOCTYPE html>
<html>
   <head>
      <script src="add2.js"></script>
      <script>

         var base = 10; 
         console.log(addtwo(10));
      </script>
   </head> 
<body> 

</body>
</html>

在浏览器上打开页面,控制台上显示的是20,而不是期望的12。原因就是在浏览器的JavaScript里,所有定义在函数外面的变量,都被定义在全局对象中。所以当我们在页面中定义了一个新的base变量时,就覆盖了文件中的同名变量的值。

现在,我们在Node应用中使用addtwo模块:

var addtwo = require('./addtwo').addtwo; 

var base = 10;

console.log(addtwo(base));

Node应用中的结果是12。在Node应用中定义新的base变量并不会影响模块中的base变量,因为它们不在同一个全局命名空间里。

避免使用共享的命名空间,是一个很显著的改进,然而也不是万能的。事实上,global对象为所有环境都提供了一个可以访问Node对象和函数的机制,包括马上就要讲到的process对象。你可以自己试一下:把下面的代码放进文件中,然后运行你的应用。它会给出所有全局可用的对象和函数:

console.log(global);

process对象是Node环境中的基础组件,它提供了当前运行环境的信息。而且,通过process你可以操作标准输入/输出(I/O),可以终止一个Node程序,也可以在Node的事件循环(将在2.3.1节中讲到)结束的时候发信号。

process对象在本书的很多应用中都有涉及,你可以查看process的索引,以便找到所有使用了process的例子。现在,我们将深入研究process对象关于运行环境的内容,以及在任何时候都很重要的标准I/O。

process对象提供了对Node环境和其运行环境的信息的访问。要知道它都提供了哪些信息,我们可以使用-p参数来运行Node,它会执行一段脚本并立即返回结果。比如,想知道process.versions属性的值,可以在控制台(console)中输入:

$ node -p "process.versions"
{ http_parser: '2.5.0',
  node: '4.2.1',
  v8: '4.5.103.35',
  uv: '1.7.5',
  zlib: '1.2.8',
  ares: '1.10.1-DEV',
  icu: '56.1',
  modules: '46',
  openssl: '1.0.2d' }

 

命令行中的单引号和双引号

注意双引号的使用:在Windows的命令行窗口中必须使用双引号。由于双引号可以在任何环境下使用,所以请在所有脚本中都使用双引号。

各种Node组件和依赖的版本号都被列出来了,其中包括V8、OpenSSL(用来进行安全通信的库)、Node本身以及其他相关组件的版本。

process.env属性提供了超多信息,它告诉我们Node当前所处的开发/生产环境中的环境变量:

$ node -p "process.env"

这个运行结果在不同计算机架构中(比如Linux和Windows)的区别尤为有趣。

想知道process.release的值,可以运行下面的命令:

$ node -p "process.release"

这条命令的输出取决于你安装的Node版本。在长期维护版本和当前最新版本环境下,你都能获取到应用的名字和源代码的URL。但是在长期维护版本的环境下,你还能看到一个额外的属性:

$ node -p "process.release.lts"
'Argon'

不过,如果你在最新发布版本中访问同样的值,比如V6,你会看到一个不一样的输出:

$ node -p "process.release.lts"
undefined

这些运行环境的信息可以帮助开发人员理解在开发前和开发中,Node能看到什么变量。不过,这些信息中的大部分的数据是不能在应用中直接引用的,原因显而易见。因为在不同的Node版本中,它们的值可能并不一致。但是花点时间研究一下这些信息还是值得的。

而在应用程序中广泛使用的一些基本对象和函数,在Node的不同版本中应该保持一致。其中包括能否访问标准I/O的对象,以及用来正常关闭Node应用的函数。

标准流是一些预先建立的,用于应用和环境之间沟通的通道。标准流由标准输入(stdin)、标准输出(stdout)和标准错误(stderr)组成。在一个Node应用中,这些通道可以帮助Node应用和控制台之间进行通信。这也是一个可以让你和应用进行通信的方式。

Node通过以下3个process函数来支持这些通道。

这些流是无法在应用中关闭或者结束的,不过你可以从stdin输入流中获取输入,并写入stdout输出流和stderr错误流中。

process的I/O函数继承自EventEmitter,这部分我们将在2.3.3节介绍。顾名思义,它可以触发事件,相应地你也可以捕获事件并且处理数据。为了从process.stdin中读取数据,我们首先要为这些流设置编码,否则你将读取到缓冲器而不是字符串:

process.stdin.setEncoding('utf8');

接下来就可以监听readable事件了。当有很多数据可以读取时,该事件会通知我们。然后可以用process.stdin.read()函数读取数据,如果数据不为null,就用process.stdout.write()函数把数据打印到process.stdout

process.stdin.on('readable', function() {
   var input = process.stdin.read();

   if (input !== null) {
      // 打印文本
      process.stdout.write(input);
   }
});

其实不用设置编码也可以得到相同的结果——只要读取缓冲器,再原封不动地将其写入输出流即可。但是对于用户来说,这看上去是在操作文本(字符串),实际上并不是。接下来要介绍的process函数会演示其中的不同。

在第1章中,我们创建了一个非常基础的Web服务器来监听一个请求并打印信息。如果要结束这个程序,你需要通过信号(signal)来终止进程,或者用组合键Ctrl-C。现在有了process,你也可以通过在应用中调用process.exit()来结束它。你还可以在应用正常结束或者出错的时候发出不同的信号。

我们来修改一下这个简单的I/O应用,让它“监听”一个退出字符串,监听到之后就退出程序。例2-1中包含了应用的全部代码。

例2-1 演示Node中的标准输入/输出和退出程序

process.stdin.setEncoding('utf8');

process.stdin.on('readable', function() {
   var input = process.stdin.read();

   if (input !== null) {
      // edho the text
      process.stdout.write(input);

      var command = input.trim();
      if (command == 'exit')
         process.exit(0);

   }
});

当我们运行这个应用时,所有敲出来的内容都会被立刻输出。这时如果输入exit,程序会立刻结束,不需要使用组合键Ctrl-C。

如果删除程序前面的processs.stdin.setEncoding()的函数调用,应用就会出错。原因在于在缓冲器中没有trim()函数。我们可以先将缓冲器转换成字符串,然后执行trim

var command = input.toString().trim();

其实更好的做法是加上encoding,并去掉所有不需要的副作用。

 

流接口

process中的I/O对象是流接口的一种实现,我会在第6章中跟其他系统模块一起介绍。

顾名思义,process.stderr对象可以让你写入错误。那为什么不直接用process.stdout呢?其中的原因跟创建stderr对象的原因一样:用来区分那些我们期望的输出和用来记录错误的输出。在一些系统里,你甚至可以用不同的方式来处理stderrstdout的输出(比如将stdout中的内容输出到log文件中,而stderr的内容则输出到控制台)。

前面提到过,在Node中,还有很多对象和有用的函数都跟process相关,在本书会看到很多相关的内容。

在早期基于浏览器的JavaScript中,从来都不需要处理二进制数据(一个8位字节流)。起初,JavaScript是用来处理那些用来访问或者输出到告警(alert)窗口和表单的字符串的值。即便Ajax让这个初衷有所改变,但客户端和服务器端的通信还是基于字符串(Unicode,统一字符编码)的。

不过当我们对JavaScript的诉求变得更加复杂时,事情就有所变化了。我们可以使用的不止Ajax,还有WebSockets。此外,浏览器所支持的功能也得到了扩充,相比于简单的表单访问,我们现在有WebGL和Canvas等新技术。

针对这个问题,在JavaScript和浏览器中,解决方案是使用ArrayBuffer,并通过类型化的数组进行操作。而在Node中,解决方案是缓冲器。

一开始这两种解决方案是不一样的。但是,当io.js和Node.js合并到Node v4.0.0中后,Node也通过V8 v4.5获得对类型化数组的支持。Node缓冲器现在使用Uint8Array实现,这是一种支持8位无符号整数的类型化数组。但是这并不意味着你可以将它们互相替换使用。在Node中,Buffer类是大多数I/O使用的主要数据结构,如果换成另外一种类型化数组,你的程序就会出问题。此外,将Node缓冲器转换为类型化数组也许是可行的,但也会有问题。根据Buffer类的API文档,当你将缓冲器“转换”为类型化数组时:

所以,在Node中处理八位字节流时,这两种类型你都可以用,但在大多数情况下还是用缓冲器。那么,Node缓冲器到底是什么呢?

 

什么是八位字节流

为什么二进制或原始数据文件被称为八位字节流?八位字节是计算中的一个单位。长度为8bit(位),因此称为“八位字节”。在支持8位字节的系统中,八个位和一个字节是相同的。流只是一个数据序列。因此,二进制文件也就是一个八位字节序列。

Node缓冲器是存储于V8堆之外的原始二进制数据,通过Buffer类来管理。一旦分配了存储空间,就不能再修改空间的大小。

缓冲器是读写文件的默认数据类型:除非读写文件时指定一个编码,否则文件的读写都会通过缓冲器进行。

在Node v4中,你可以直接使用new关键字来创建一个缓冲器:

let buf = new Buffer(24);

但是要注意,和ArrayBuffer不一样的是,创建一个新的Node缓冲器并不会初始化其中的内容。如果你不知道一个缓冲器是不是包含特殊或者敏感的数据,为了防止被最终的结果搞得晕头转向,最好在创建缓冲器的时候,就为其填充好数据:

let buf = new Buffer(24);
buf.fill(0); // fills buffer with zeros

你也可以只填充部分数据,并指定起始和结束的位置就可以了。

 

在填充缓冲器的内容时指定编码

Node v5.7.0之后,你可以在调用buf.fill()时使用这个语法来指定编码:buf.fill(string[, start[, end]] [, encoding])

你也可以在创建新缓冲器的时候,直接向构造函数中传入一个字节数组,或者传入另一个缓冲器,又或者传入一个字符串。Node会复制这3种内容,用来创建新的缓冲器。对于字符串来说,如果编码不是UTF-8,你就需要指定编码。Node中字符串的默认编码是UTF-8(或者utf8、utf-8)。

let str = 'New String';
let buf = new Buffer(str);

我不想把Buffer类的所有方法都讲一遍,因为Node提供了详细的文档。但是其中一些功能还是值得我们仔细研究一下的。

 

Node v4和Node v5/v6的区别

rawraws这两种编码在v5和之后的版本中被删掉了。

在Node v6中,构造函数已被弃用,转而使用新的缓冲器方法来创建缓冲器:Buffer.from()、Buffer.alloc () 和Buffer.allocUnsafe ()

Buffer.from ()函数会复制传入的数组,然后将其装进缓冲器中返回。但是,如果传入一个具有可选的字节偏移量和长度的ArrayBuffer时,则缓冲器与ArrayBuffer会共享相同的内存。如果传入缓冲器,就会返回这个缓冲器内容的备份;传入字符串,就会返回字符串的备份。

Buffer.alloc()函数创建一个填充好的、且具有指定大小的缓冲器,而Buffer. alloc Unsafe()也会创建一个指定大小的缓冲器,但是这个缓冲器可能包含一些旧数据或者敏感信息,此时就需要使用buf.fill()来填充。

以下是相关的Node代码:

'use strict';

let a = [1,2,3];

let b = Buffer.from(a);

console.log(b);

let a2 = new Uint8Array([1,2,3]);

let b2 = Buffer.from(a2);

console.log(b2);

let b3 = Buffer.alloc(10);

console.log(b3);

let b4 = Buffer.allocUnsafe(10);

console.log(b4);

计算机会产生如下结果:

<Buffer 01 02 03>
<Buffer 01 02 03>
<Buffer 00 00 00 00 00 00 00 00 00 00>
<Buffer a0 64 a3 03 00 00 00 00 01 00>

请注意通过Buffer.alloc()得到的数据与通过Buffer.allocUnsafe()得到的数据之间的区别。

缓冲器可以转换成JSON或者字符串。为了演示,将以下内容输入一个Node文件中,然后用命令行运行这个文件:

"use strict";

let buf = new Buffer('This is my pretty example');
let json = JSON.stringify(buf);

console.log(json);

输出如下:

{"type":"Buffer",
"data":[84,104,105,115,32,105,115,32,109,121,32,112,114,101,116,
116,121,32,101,120,97,109,112,108,101]}

这段JSON说明了被转化的对象类型是Buffer类,后面紧跟着Buffer类中的数据。当然了,我们看到的是被存入缓冲器的字节序列,该序列是无法被阅读的。

 

ES6

大多数示例代码都是使用我们所熟悉的JavaScript写的。但是我会不时地提及ES6,而且在第9章我将会着重讲解Node与ES6(ECMAScript 2015)。

让我们把这个例子完善一下,可以通过解析JSON对象来重新获取缓冲器中的数据,也可以通过Buffer.toString()方法将其转化为字符串,如例2-2所示。

例2-2 将字符串转化为缓冲器再转化为JSON,然后再转回来

"use strict";

let buf = new Buffer('This is my pretty example');
let json = JSON.stringify(buf);

let buf2 = new Buffer(JSON.parse(json).data);

console.log(buf2.toString()); // this is my pretty example

console.log()方法会将最初用来转化为缓冲器的字符串打印出来。toString()方法默认将字符串转化为UTF-8编码,如果想要别的字符串类型,可以传入所需要的字符串类型:

console.log(buf2.toString('ascii')); // this is my pretty example

也可以指定转化字符串的起始和结束位置:

console.log(buf2.toString('utf8', 11,17)); // pretty

Buffer.toString()并不是唯一将缓冲器转化为字符串的方式。你也可以使用一个帮助类,即StringDecoder。这个类的唯一作用就是将缓冲器中的数据转化为UTF-8字符串。但它的实现方式略微灵活一些,而且结果是可逆的。如果使用buffer.toString()方法获取到的是不完整的UTF-8字符序列,那么它返回的也会是乱码。如果StringDecoder遇到一个不完整的字符序列,则会将它存到缓冲器中,直到序列变得完整,再输出结果。所以,如果你从流中以块为单位来获取UTF-8字符串,那么最好使用StringDecoder。

下例展示了两种字符串转换方式的区别。欧元符号(€)被编码为3个字节,但是第一个缓冲器中只包含前两个字节,第二个缓冲器包含第三个字节。

"use strict";

let StringDecoder = require('string_decoder').StringDecoder;
let decoder = new StringDecoder('utf8');

let euro = new Buffer([0xE2, 0x82]);
let euro2 = new Buffer([0xAC]);

console.log(decoder.write(euro));
console.log(decoder.write(euro2));

console.log(euro.toString());
console.log(euro2.toString());

使用StringDecoder的时候,打印到控制台上的第一行是空行,第二行显示了欧元符号(€),而使用buffer.toString()时,两行都是乱码。

你也可以使用buffer.write()来将字符串写入一个缓冲器中。当然了,缓冲器的大小一定要能容纳得下字符所占用的字节数。同样,欧元符号需要3个字节来表示(0xE2、0x82、0xAC):

let buf = new Buffer(3);
buf.write('€','utf-8');

这个例子也很好地展示了UTF-8字符的数量和缓冲器中所需要的字节的数量是不一样的。要是还有疑问,使用buffer.length可以很方便地检查缓冲器的大小:

console.log(buf.length); // 3

你可以通过一系列类型化的方法,以指定的偏移量来对缓冲器进行读写。下面的代码片段中有一些使用了这些方法的例子。该例将4个无符号8位整数写入了缓冲器,然后将它们读出,并且打印:

var buf = new Buffer(4);

// write values to buffer
buf.writeUInt8(0x63,0);
buf.writeUInt8(0x61,1);
buf.writeUInt8(0x74,2);
buf.writeUInt8(0x73,3);

// now print out buffer as string
console.log(buf.toString());

把以上代码复制到一个文件中,你就可以在自己的计算机上测试了。你也可以使用buffer.readUInt8()方法将所有8位整数读取出来。

Node支持对无符号和有符号的8位、16位、32位的整数进行读写,也支持对浮点型和双精度浮点型的读写。除了8位整数,你可以选择使用大端格式(big-endian)或者小端格式(little-endian)来读取。下面是Node支持的一部分方法。

字节序

字节序指的是数据被存储的格式:如果最高位被存储在最低的内存地址上,我们称之为大端格式;如果最低位被存储在最低的内存地址上,我们称之为小端格式。

图2-1引用于维基百科的字节序词条,很好地展示了两种格式之间的区别。

 

图2-1 展示了大端格式和小端格式的区别(图片由维基百科提供)

你也可以直接使用数组赋值的形式来写入8位整数:

var buf = new Buffer(4);

buf[0] = 0x63;
buf[1] = 0x61;
buf[2] = 0x74;
buf[3] = 0x73;

除了以特定的偏移量向缓冲器中读取和写入数据之外,你还可以使用buffer. slice()从一个旧的缓冲器中取出部分内容来创建一个新的缓冲器。这个功能最有趣的一点是,修改新缓冲器的内容,旧的缓冲器里面的内容也会随之变化。例2-3演示了如何操作:首先使用字符串创建一个缓冲器,取出缓冲器中的部分数据来创建一个新的缓冲器,然后修改新缓冲器中的内容。最后将两个缓冲器都打印到控制台,你就能看到原缓冲器也被修改了。

例2-3 展示新的缓冲器的修改如何影响到旧的缓冲器

var buf1 = new Buffer('this is the way we build our buffer');
var lnth = buf1.length;

// create new buffer as slice of old
var buf2 = buf1.slice(19,lnth);
console.log(buf2.toString()); // build our buffer

//modify second buffer
buf2.fill('*',0,5);
console.log(buf2.toString()); // ***** our buffer

// show impact on first buffer
console.log(buf1.toString()); // this is the way we ***** our buffer

如果你想测试两个缓冲器是否相等,可以使用buffer.equals()方法:

if (buf1.equals(buf2)) console.log('buffers are equal');

你还可以使用buffer.copy()函数将一个缓冲器的内容复制到另外一个里面去。可以全部复制,也可以部分复制,取决于你使用的参数。要注意的是,如果第二个缓冲器不够大,你可能只能复制第二个缓冲器能装下的部分:

var buf1 = new Buffer('this is a new buffer with a string');

// copy buffer
var buf2 = new Buffer(10);
buf1.copy(buf2);

console.log(buf2.toString()); // this is a

若想比较两个缓冲器,可以使用buffer.compare ()。这个方法的返回值表示两个缓冲器在词汇方面更大还是更小。如果后者更大,则返回–1,前者更大,则返回1。要是两个缓冲器内容相同,则返回0。

var buf1 = new Buffer('1 is number one');
var buf2 = new Buffer('2 is number two');

var buf3 = new Buffer(buf1.length);
buf1.copy(buf3);

console.log(buf1.compare(buf2)); // -1
console.log(buf2.compare(buf1)); // 1
console.log(buf1.compare(buf3)); // 0

还有一个Buffer类叫作SlowBuffer。当你需要将一个比较小的缓冲器中的内容保存较长时间的时候,你就需要用这个类。一般情况下,对于比较小的缓冲器(大小不超过4KB),Node会从一个预分配的内存块中创建它们。这样,垃圾回收机制就不需要担心这些小的内存块中的内容了。

SlowBuffer这个类允许你在预分配的内存块中创建小缓冲器,并且保存很长时间。可以想象,这种用法会导致明显的性能问题。所以,不到万不得已的时候,不要用它。

由于JavaScript是单线程的,这使得它天生就是同步的。也就是说JavaScript在运行中将会逐行执行,直到程序结束。而由于Node是基于JavaScript的,因此它继承了这种单线程同步行为。

不过,如果某些功能需要等待另外一些操作(比如打开文件,等待一个Web响应,或其他此类活动),而操作完成之前程序无法继续运行,那么这将成为服务端应用的重要缺陷。

防止这种阻塞的解决方案就是事件循环。

为了在应用程序中实现异步功能,可以采取以下两种方式之一。一种方式是将线程分配给每个耗时的进程,其余代码则并行运行。这个方法的问题在于线程很昂贵。它们不仅耗费资源,还会增加应用的复杂度。

第二种方法是采用事件驱动架构。在这种情况下,当耗时进程被调用时,应用并不会等它完成。相反,进程会通过触发一个事件来表示其结束。该事件会被添加到队列或事件循环中。所有依赖于这个事件的函数都会针对该事件注册一个监听函数,当事件被从事件循环中取出和处理时,依赖于这个事件的函数就会执行,同时系统会将事件相关的所有数据发给这个函数。

浏览器和Node中的JavaScript都采取了第二种方法。在浏览器中,如果在一个元素上添加一个click处理函数,实际上是注册(或订阅)了一个事件并且提供一个回调函数,当事件发生时调用这个回调函数,并让程序的其他部分继续运行。

<div id="someid"> </div>
<script>
   document.getElementById("someid").addEventListener("click",
                                        function(event) {
       event.target.innerHTML = "I been clicked!";
   }, false);
</script>

Node有自己的事件循环,但它不会去等诸如元素的点击事件这样的UI事件,而是去实现服务端的一些功能,主要是输入和输出(I/O)。其中包括一些跟文件相关的事件,比如打开文件,在过程完成的时候触发事件;将文件内容读入缓冲器,并在过程完成的时候通过触发事件通知客户端;或等待用户发出的Web请求。这些操作可能会消耗很多时间和资源。对资源的每次访问,都会将资源锁定从而禁止其他访问,直到访问结束。此外,基于Web的应用程序依赖于用户操作,有时也依赖其他应用程序的操作。

Node会按顺序处理事件队列中的所有事件。当遇到你感兴趣的事件时,它会调用你提供的回调函数,并传入与该事件相关的所有信息。

在第1章中我们创建了一个基本的Web服务器作为本书的第一个例子,其中就有事件循环。这里列出了之前的代码,以方便大家阅读:

var http = require('http');

http.createServer(function (request, response) {
  response.writeHead(200, {'Content-Type': 'text/plain'});
  response.end('Hello World\n');
}).listen(8124);

console.log('Server running at http://127.0.0.1:8124/')
;

在例2-4中,我修改了代码以将独立操作分解开来,并且增加了对在服务器创建、客户端连接过程中发生的事件和进程事件的监听。

例2-4 包括额外事件处理的基本Web服务器

var http = require('http');

var server = http.createServer();

server.on('request', function (request, response) {

   console.log('request event');

   response.writeHead(200, {'Content-Type': 'text/plain'});
   response.end('Hello World\n');
});

server.on('connection', function() {
   console.log('connection event');
});

server.listen(8124, function() {

   console.log('listening event');
});
console.log('Server running on port 8124');

注意,requestListener()函数也就是服务器请求回调函数,并没有在http. createServer()中被调用。相反,应用程序将新创建的HTTP服务器赋值给一个变量,然后用它来捕获以下两个事件:

在这两种情况下,事件都是用on()函数订阅的(HTTP的server类从EventEmitter类继承了这个方法)。我将在下一节中介绍这个父类,但现在,我们先来关注事件本身。在上面的示例中还有一个事件订阅,就是listening事件,它可以通过HTTP的server.listen()函数上的回调函数进行访问。

我们现在有了一个对象(HTTP服务器)和3个事件(requestconnectionlistening)。那么当应用程序被创建的时候和Web请求被发出的时候会发生什么事?

启动应用程序的时候,“Server is running on port 8124.”的消息会立即被打印出来。这是因为不管是在创建服务器、连接客户端,或者当我们开始监听请求时,应用程序都不会被阻塞。所以,第一次console.log()的消息实际上是在所有非阻塞异步函数执行之后完成的。

接下来的消息是“listening event.”。一旦创建了服务器,我们就要监听新的连接和请求,这件事是通过调用server.listen()函数来完成的。另外,不需要等待任何“服务器已创建”事件,因为http.createServer ()函数会立即返回。你可以通过在调用http.createServer()函数之后直接插入console.log()消息来进行测试。如果加上这一行代码,那么当启动应用程序时,它就会被打印到控制台。

在这个程序的上一个版本中,server.listen()是在http.createServer()函数之后被连续调用的,但这不是必须的。这样做只是为了更方便、让编程优雅,而它不是实现事件驱动的唯一写法。但是,server.listen()函数是一个具有回调的异步函数,其回调函数会在listening事件发出时被调用。因此,控制台中的消息会在“server is running on port 8124”这个消息之后显示

在客户端与Web应用程序连接之前,不会打印任何其他消息。然后,我们会收到Connection事件的消息,因为“Connection”是每个新客户端调用的第一个事件。接着是一个或两个request事件的消息。request事件的数量之所以不同,是因为每个浏览器向新网站发请求的方式不同。Chrome在发请求时,不仅会请求资源,而且会请求favicon.ico文件,所以应用程序会收到两个请求。Firefox和IE只请求资源,并不请求其他内容,所以应用程序只会收到一个请求。

如果在同一浏览器中刷新页面,那么只会收到request事件的消息。连接是早已经建立好的,会一直保持下去,直到用户关闭浏览器或发生超时(timeout)。使用不同的浏览器访问相同的资源时,会为每个浏览器触发一个单独的Connection事件。

用Chrome访问Web应用程序会在控制台中打印出以下信息:

如果你想把模块中或者程序中的函数改为异步函数,就需要使用特定的标准来定义它,我将在下面介绍这些内容。

为了演示回调函数的基本结构,请看例2-5。例2-5是一个完整的Node应用程序,它创建了一个对象,该对象有一个函数doSomething()。这个函数有两个参数:第一个参数必须是一个数字,第二个参数是一个回调函数。在doSomething()中,如果第一个参数不存在或者不是数字,那么该对象将创建一个新的Error对象,并在回调函数中返回。如果没有发生错误,则调用回调函数,将错误设置为null,并返回数据计算结果(在本例中将返回第一个参数的计算结果)。

例2-5中的关键是回调功能,已用粗体标出。

例2-5 上一个回调函数的基本结构

var fib = function (n) {
    if (n < 2) return n;
    return fib(n - 1) + fib(n - 2);
}; 

var Obj = function() { };

Obj.prototype.doSomething = function(arg1_) {
   var callback_ = arguments[arguments.length - 1];
   callback = (typeof(callback_) == 'function' ? callback_ : null); 
   var arg1 = typeof arg1_ === 'number' ? arg1_ : null; 
   if (!arg1) 
      return callback(new Error('first arg missing or not a number'));

      process.nextTick(function() {

         // block on CPU

         var data = fib(arg1); 
         callback(null, data); 
   }); 
} 

var test = new Obj();
var number = 10;

test.doSomething(number, function(err,value) {
      if (err)
         console.error(err);
      else
         console.log('fibonaci value for %d is %d', number, value);
});

console.log('called doSomething');

异步回调函数的第一个关键点是确保最后一个参数是一个回调函数,并且回调函数中的第一个参数是一个错误对象。如第1章所述,这种错误优先模式通常被称为errback。我们无法知道用户的意图,但是我们可以确保最后一个参数是一个函数,而且必须如此。

第二个关键点是在发生错误时创建新的Error对象,并将其作为结果返回给回调函数。在异步的世界中,我们不能依赖throw … catch,所以错误处理必须在回调函数的Error对象中进行。

最后一个关键点是在没有发生错误时调用回调函数,并传递函数的数据。不过,为了确保这种回调是异步的,我们需要在process.nextTick()函数中调用它。原因是process.nextTick()方法可以确保在调用函数之前清除事件循环。这意味着在调用阻塞功能(如果有的话)之前,所有的同步功能都已经被处理。在这个例子中,发生阻塞的地方不是I/O,而是那些CPU密集型的操作。调用值为10的斐波纳契序列函数可能不需要花太多时间,但是调用值为50或者更大数字的函数就可能需要一些时间,这取决于你的操作系统。斐波纳契函数就是在process.nextTick()中调用的,从而确保CPU密集型的功能被异步处理。

简而言之,只要这几个关键点被满足,其他一切都是可变的:

如果把number的值改成10,那么程序就会在控制台中打印如下信息:

called doSomething
[Error: first argument missing or not a number]

如果你去看Node安装的lib目录中的代码,那么将看到上面讲过的回调模式重复出现。虽然功能可能会改变,但这种模式保持不变。

这种方法非常简单,并且能确保异步方法的结果是一致的。

 

回调嵌套

使用回调函数很简单,但是也存在问题,其中包括回调的深度嵌套。我会在3.4.1节中讲深度嵌套和其解决方案。

之前我提到http.Server对象继承自另一个对象,我们从该对象获得了触发事件的能力。这个对象有一个很形象的名字:EventEmitter,接下来我们将详细介绍它。

在大多数情况下,Node是单线程

Node的事件循环是单线程的。然而,这并不意味着在后台没有多个线程在工作。

Node会调用一些功能,例如文件系统(fs),它是用C ++实现的,而不是JavaScript实现。fs会使用工作线程来完成其功能。另外,Node使用libuv库,它使用工作线程池来实现某些功能。具体的线程数是与操作系统相关的。

如果你坚持使用JavaScript并创建了JavaScript模块,就再也不必考虑工作线程和libuv了。事实上:我们已经被告知,完全不必担心工作线程。对我这样一个在多线程环境中工作过的人来说,完全没有问题。

不过,如果你有兴趣为Node开发扩展插件,那么就需要非常熟悉libuv。libuv的介绍将会是一个好的开始。

想了解更多关于有趣且隐蔽的Node多线程世界,我建议参考Stack Overflow上关于“什么时候使用线程池?”的答案。

对于很多Node核心模块,如果你翻开表面,去看它们的源码,会发现它们都使用了EventEmitter。只要看见一个对象触发事件,或者一个事件被on函数捕获,那一定就是EventEmitter在起作用。理解EventEmitter的工作方式和掌握它的使用方法,是Node开发中的两个重要组成部分。

EventEmitter激活了Node中的异步事件处理。为了演示其核心功能,我们来快速创建一个用于测试的应用程序。

首先,引入Events模块:

var events = require('events');

接着,创建一个EventEmitter的实例:

var em = new events.EventEmitter();

下面使用新创建的EventEmitter来执行两个简单的任务:设置事件处理函数来监听事件,然后触发该事件。在触发特定事件时,EventEmitter.on()的事件处理器会被调用。该方法的第一个参数是事件的名称;第二个参数是一个回调函数,用于执行一些功能:

em.on('someevent', function(data) { ... });

当满足特定条件时,事件会通过EventEmitter.emit()方法从对象上发出:

if (somecriteria) { 
   en.emit('data'); 
}

在例2-6中,我们会创建一个EventEmitter例子,每3s发出一个定时事件。它的事件处理函数会将带有计数器的消息输出到控制台。请注意EventEmitter.emit()中的counter参数与处理事件的EventEmitter.on()中的对应数据之间的关联性。

例2-6 EventEmitter功能的基础测试

var eventEmitter = require('events').EventEmitter;
var counter = 0;

var em = new eventEmitter();

setInterval(function() { em.emit('timed', counter++); }, 3000);

em.on('timed', function(data) {
  console.log('timed ' + data);
});

运行应用程序,定时事件消息就会不断地输出到控制台,直到应用程序终止。从这个简单应用程序中,我们可以看到事件是通过EventEmitter.emit ()函数触发的,而EventEmitter.on ()函数则可以捕获该事件并进行处理。

这是一个有意思的例子,但并不是非常有用。我们需要的是可以将EventEmitter的功能添加到现有对象中的能力,而不是在整个应用程序中使用EventEmitter的例子。而这正是http.Server和Node中大多数基于事件的类所需要的能力。

EventEmitter的功能是继承而来的,所以我们必须用另一个Node对象Util来启用继承功能。Util模块可以通过下面的方式被引入到应用程序中:

var util = require('util');

Util模块非常有用。我将在第11章介绍调试Node应用时,介绍它的大部分功能。它有一个函数util.inherits(),我们现在就要用到。

util.inherits ()函数是使一个构造函数能够继承另一个构造函数(也就是父构造函数)的原型方法。util.inherits ()的厉害之处在于,你还可以直接在构造函数中访问父构造函数。

util.inherits ()函数能够让我们在任何类中继承Node的事件队列功能,同样也可以继承EventEmitter

util.inherits(Someobj, EventEmitter);

通过在对象中使用util.inherits (),我们可以调用对象方法中的emit函数,并在对象实例上调用添加事件处理函数:

Someobj.prototype.someMethod = function() { this.emit('event'); };
...
Someobjinstance.on('event', function() { });

与其煞费苦心地去破译EventEmitter在抽象层面的工作原理,不如来看看例2-7,我在这个例子中创建了一个类——inputchecker,该类的作用就是继承EventEmitter的功能。构造器接收两个参数,一个人名和一个文件名。它的作用就是把人名分配到物品上,并用文件系统模块的createWriteStream方法创建了一个可写流的引用。

该对象还有一个方法——check,它会检查特定命令的输入数据。一个命令(wr :)用来触发写事件,另一个(en:)则用来触发结束事件。如果没有任何命令,则触发echo事件。该对象的实例对这3种事件都提供了事件处理器。捕获到写事件时,它会把内容写入一个文件;捕获到非命令的输入内容时,它会进行回显;捕获到结束事件时,它会使用process.exit来终止程序运行。

所有输入都来自标准输入(process.stdin)。输出使用了可写流,用这种方式可以在后台创建新的输出源,未来的写操作都会排队等待。如果你在这个程序中需要进行频繁的文件写入操作,那么这个方法更有效率。需要回显的输入内容则会被输出到process.stdout

例2-7 创建一个继承EventEmitter的基于事件的对象

"use strict";

var util = require('util');
var eventEmitter = require('events').EventEmitter;
var fs = require('fs');

function InputChecker (name, file) {
   this.name = name;
   this.writeStream = fs.createWriteStream('./' + file + '.txt',
      {'flags' : 'a',
      'encoding' : 'utf8',
      'mode' : 0o666});
};

util.inherits(InputChecker,eventEmitter);

InputChecker.prototype.check = function check(input) {

  // trim extraneous white space
  let command = input.trim().substr(0,3);

  // process command
  // if wr, write input to file
  if (command == 'wr:') {
     this.emit('write',input.substr(3,input.length));

  // if en, end process
  } else if (command == 'en:') {
     this.emit('end');
  // just echo back to standard output
  } else {
     this.emit('echo',input);
  }
}; 

// testing new object and event handling
let ic = new InputChecker('Shelley','output');

ic.on('write', function(data) {
   this.writeStream.write(data, 'utf8');
}); 

ic.on('echo', function( data) {
   process.stdout.write(ic.name + ' wrote ' + data);
}); 

ic.on('end', function() {
   process.exit();
}); 

// capture input after setting encoding
process.stdin.setEncoding('utf8');
process.stdin.on('readable', function() {
   let input = process.stdin.read();
   if (input !== null)
      ic.check(input);
});

注意,该代码还调用了process.stdin.on方法,因为process.stdin是继承自EventEmitter的众多Node对象之一。

 

严格模式下不存在八进制字面量

在例2-7中,因为要使用ES6的let语法,所以我用了严格(strict)模式。而正是由于使用了严格模式,所以不能在写入流文件描述标识符中使用八进制字面量(比如0666)。因此我用了符号0o666,这是一个ES6风格的字面量。

on()函数其实是EventEmitter.addListener的缩写,所以它们接收的参数是一样的,所以这段代码:

ic.addListener('echo', function( data) {
    console.log(this.name + ' wrote ' + data);
});

和这段代码是完全相等的:

ic.on('echo', function( data) {
   console.log(this.name + ' wrote ' + data);
});

你可以用EventEmitter.once()来监听下一个事件:

ic.once(event, function);

如果有超过10个监听器在监听同一个事件,就会产生一个警告(warning)。可以用setMaxListeners方法传入一个数字,来修改监听器的数量。数字0表示不限数量的监听器。

也可以用EventEmitter.removeListener()来移除监听器:

ic.on('echo', callback); 

ic.removeListener('echo',callback);

这段代码会从事件监听器数组中删除一个监听器,并保持原来的顺序。不过,如果因为某些原因使用EventEmitter.listeners()复制了事件监听器数组,那么一旦删除了某个监听器,就需要重新创建这个监听器数组。

在浏览器里,我们可以用setTimeout()setInterval()来作为定时器,Node中也有同样的功能。这两种定时器不完全相同,因为浏览器中的事件循环是由浏览器引擎维护的,而Node中的事件循环是由C++的库libuv来处理的,不过二者几乎没有差别。

Node的setTimeout()方法的第一个参数是一个回调函数,第二个参数是延迟时间(以ms为单位),同时还有一些可选的参数。

setTimeout(function(name) {
             console.log('Hello ' + name);
           }, 3000, 'Shelley');

console.log("waiting on timer...");

参数列表中的名字会被当成参数传给setTimeout()中的回调函数。延迟时间被设为3 000ms。因为setTimeout()方法是异步的,所以console.log()几乎立刻就会打印“waiting on timer…”的信息。

如果在创建计时器时就将它赋给一个变量,那么就可以取消计时。我修改了前面的Node应用程序,加入了快速取消和打印消息功能。

var timer1 = setTimeout(function(name) {
             console.log('Hello ' + name);
           }, 30000, 'Shelley');

console.log("waiting on timer...");

setTimeout(function(timer) {
             clearTimeout(timer);
             console.log('cleared timer');
           }, 3000, timer1);

这个计时器设置了一个很长的时间,足够新的计时器去调用回调函数来取消它了。

setInterval ()函数的操作方式与setTimeout ()的类似,但有两个不同之处。首先,setInterval ()函数在程序终止前会一直重复计时器。另外,可以使用clearInterval ()来清除定时器。接下来我们修改一下setTimeout ()的例子,用它来演示setInterval (),从这里可以清楚地看到在定时器取消之前消息被打印了9次。

var interval = setInterval(function(name) {
             console.log('Hello ' + name);
           }, 3000, 'Shelley');

setTimeout(function(interval) {
             clearInterval(interval);
             console.log('cleared timer');
           }, 30000, interval);

console.log('waiting on first interval...');

Node文档中提到,setTimeout不能保证回调函数精准地在n ms时(无论n是多少)被调用。在浏览器中使用setTimeout ()也是一样的,我们不能绝对控制运行环境,有很多因素可能会导致定时器的轻微延迟。在大多数情况下,我们其实感受不到定时器在运行时的时间差。但是,如果创建动画,就可以清楚地看到时间差造成的影响了。

有两个Node中特有的函数(即ref ()函数和unref ()函数)可以与计时器、中间层一起使用,在调用setTimeout ()setInterval ()时,这两个函数会被返回。如果在定时器上调用unref (),同时它是事件队列中唯一的事件,则定时器被取消,程序也可以终止。如果在同一个计时器对象上调用ref (),则程序会继续进行,直到定时器结束。

回到第一个例子,我们创建了一个比较长的定时器,接下来调用unref()看看会发生什么:

var timer = setTimeout(function(name) {
             console.log('Hello ' + name);
           }, 30000, 'Shelley');

timer.unref();

console.log("waiting on timer...");

运行这段代码,它会在控制台中打印消息,然后退出。这是因为用来设置定时器的setTimeout()是这段程序的事件队列中唯一的事件。如果我们再添加一个事件呢?修改代码,添加intervaltimeout,并在timeout上调用unref()

var interval = setInterval(function(name) {
             console.log('Hello ' + name);
           }, 3000, 'Shelley');

var timer = setTimeout(function(interval) {
            clearInterval(interval);
            console.log('cleared timer');
           }, 30000, interval);

timer.unref();

console.log('waiting on first interval...');

计时器可以继续运行,并清除中间层计时器。正是中间层触发的事件使得计时器继续运行直到计时器清除中间层。

Node中最后一组跟定时器相关的方法是Node特有的:setImmediate()clearImmediate()setImmediate()可以创建一个事件,不过这个事件的优先级高于那些被setTimeout()setInterval()创建的事件,而低于I/O事件。同时它们跟定时器无关。setImmediate()事件会在所有I/O事件发生后,且在任何定时器事件之前触发,并且它会保持在当前事件流中。如果在回调函数中调用它,那么它会在当前调用完成后被放进下一个事件循环中。这是一种不通过定时器而把事件添加到当前或者下一个事件循环中的方式。由于它的优先级比其他定时器事件更高,所以它比setTimeout (callback,0)更高效。

这与process.nextTick()函数很像,不同的是,process.nextTick()中的回调函数会在当前事件循环结束和所有新的I/O事件之前调用一次。就像在第2.3.2节中演示过的,Node异步编程的应用范围很广泛。

在客户端JavaScript应用中可能不常见到下面的代码:

val1 = callFunctionA();
val2 = callFunctionB(val1);
val3 = callFunctionC(val2);

函数是按顺序调用的,把上一个函数的结果传给下一个函数。由于所有函数都是同步的,所以不用担心函数的调用顺序出错,也不会出现意想不到的执行结果。

例2-8展示了一个相对常见的顺序编程的例子。程序使用了Node文件系统方法的同步版本来打开文件并获取数据,通过将“apple”的所有引用替换为“orange”来修改数据,并将生成的字符串输出到新文件。

例2-8 顺序同步应用

var fs = require('fs');

try {
   var data = fs.readFileSync('./apples.txt','utf8');
   console.log(data);
   var adjData = data.replace(/[A|a]pple/g,'orange');

   fs.writeFileSync('./oranges.txt', adjData);
} catch(err) {
  console.error(err);
}

由于可能出错,而我们不能保证错误在某个模块函数内会被处理,所以我们会把所有函数调用封装在try中,从而让异常处理更优雅或者至少能收集到更多信息。以下是一个当程序找不到要读取的文件时出错的例子:

{ [Error: ENOENT: no such file or directory, open './apples.txt']
   errno: -2,
   code: 'ENOENT',
   syscall: 'open',
   path: './apples.txt' }

上面这些信息可能不那么友好,但至少比下面这种信息好一些:

$ node nested2
fs.js:549
  return binding.open(pathModule._makeLong(path), stringToFlags(flags),
mode);
^ Error: ENOENT: no such file or directory, open './apples.txt'
      at Error (native)
      at Object.fs.openSync (fs.js:549:18)
      at Object.fs.readFileSync (fs.js:397:15)
      at Object.<anonymous>
                 (/home/examples/public_html/learnnode2/nested2.js:3:18)
      at Module._compile (module.js:435:26)
      at Object.Module._extensions..js (module.js:442:10)
      at Module.load (module.js:356:32)
      at Function.Module._load (module.js:311:12)
      at Function.Module.runMain (module.js:467:10)
      at startup (node.js:136:18)

如果要把这种同步顺序应用模式转换成异步实现,则需要一些修改。首先,要把所有函数替换成它的异步版本。但是,还必须考虑到每个函数在调用时不阻塞的事实,也就是说如果函数调用之间不互相依赖,那么我们就无法保证函数的调用顺序。唯一能保证函数按照一定顺序调用的方法就是嵌套回调

例2-9是例2-8中程序的异步版本。所有的文件系统函数调用都被其异步形式替换,并且函数调用通过嵌套回调保证了正确的调用顺序。另外,try…catch块也被删除了。

我们不能用try…catch,因为使用异步函数意味着try…catch块实际是在调用异步函数之前就被处理了。所以如果试图在回调函数中抛出一个错误,也就相当于在进程之外抛出一个错误并且捕捉它。相反,在异步调用中我们会直接处理错误:如果有错误,就处理它并返回;如果没有错误,就继续执行回调函数。

例2-9 将例2-8中的应用转化成异步嵌套回调

var fs = require('fs');
fs.readFile('./apples.txt','utf8', function(err,data) {
   if (err) {
      console.error(err);
   } else {

     var adjData = data.replace(/apple/g,'orange');

     fs.writeFile('./oranges.txt', adjData, function(err) {

        if (err) console.error(err);
     });
   }  
}); 

在例2-9中,程序会打开并读取输入的文件,只有当这两个动作都完成后,程序才会调用回调函数。在这个函数中,它会检查error是否有值。如果有,就在控制台中打印error对象。如果没有错误,则程序继续运行并调用异步的writeFile()函数。这里的回调函数只有一个参数,就是error对象。如果它不是null,也会被打印在控制台中。

如果有错误,程序会输出类似于下面信息的内容:

{ [Error: ENOENT: no such file or directory, open './apples.txt']
  errno: -2,
  code: 'ENOENT',
  syscall: 'open',
  path: './apples.txt' }

如果需要错误的堆栈信息,可以打印error对象的stack属性:

if (err) {
   console.error(err.stack);
}

会打印出如下结果:

Error: ENOENT: no such file or directory, open './apples.txt'
    at Error (native)

每增加一个顺序的异步函数调用,都会增加一级嵌套回调,而且可能会使错误处理更麻烦。在例2-10中,我们访问了一个文件夹下的一组文件。在每个文件中,我们通过replace方法用一个特殊的域名替换了一般的域名,并且将结果写回原始文件中。我们使用打开的写入流来为每个更改的文件维护日志。

例2-10 检索要修改的文件的目录列表

var fs = require('fs');
var writeStream = fs.createWriteStream('./log.txt',
      {'flags' : 'a',
       'encoding' : 'utf8',
       'mode' : 0666});

writeStream.on('open', function() {
   // get list of files
   fs.readdir('./data/', function(err, files) {

      // for each file
      if (err) {
         console.log(err.message);
      } else {
         files.forEach(function(name) {

            // modify contents
            fs.readFile('./data/' + name,'utf8', function(err,data) {

               if (err){
                  console.error(err.message);
               } else {
                  var adjData = data.replace(/somecompany\.com/g,
                             'burningbird.net');
                  // write to file
                  fs.writeFile('./data/' + name, adjData, function(err)
                    { 

                      if (err) {
                         console.error(err.message);
                      } else { 

                         // log write
                         writeStream.write('changed ' + name + '\n',
                         'utf8', function(err) {

                            if(err) console.error(err.message);
                         });
                      }
                  }); 
               }
            }); 
         });
      }
   });
}); 

writeStream.on('error', function(err) {
  console.error("ERROR:" + err);
});

首先,有一种新的用法:在调用fs.createWriteStream时,使用事件处理来处理错误。这样做的原因是createWriteStream是异步的,所以不能用传统的try…catch来处理错误。同时,这个函数也没有提供用来捕获错误的回调函数的参数。相反,我们会监听一个error事件并通过打印错误信息来处理它。接下来我们会寻找一个open事件(一个成功的操作)来进行文件操作。

应用程序会直接打印错误信息。

即使这段程序看起来像每次处理完一个文件才会继续下一个,不过记住,这里面的每个函数都是异步的。如果多运行几次这个程序并且查看log.txt文件,你会看到文件以不同的、看似随机的顺序处理。在我的data子文件夹中,有5个文件。连续运行3次程序会在log.txt中得到以下输出(为了清楚,下列输出插入了空行):

changed data1.txt
changed data2.txt
changed data3.txt
changed data4.txt
changed data5.txt

changed data2.txt
changed data4.txt
changed data3.txt
changed data1.txt
changed data5.txt

changed data1.txt
changed data2.txt
changed data5.txt
changed data3.txt
changed data4.txt

如果你需要在所有文件都修改完成之后做一些事情,那么就会出现另一个问题。forEach方法异步地调用了一个迭代回调函数,所以它并不会阻塞进程。在forEach后面加上如下语句:

console.log('all done');

这并不是真正地表示程序结束了,只是表明forEach不会阻塞进程。如果你同时还添加了console.log语句来纪录修改过的文件:

// log write
writeStream.write('changed ' + name + '\n',
'utf8', function(err) {

    if(err) {
      console.log(err.message);
    } else {
      console.log('finished ' + name);
    }
});

并且在forEach调用后添加如下语句:

console.log('all finished');

那么最终会得到如下的输出:

all finished
finished data3.txt
finished data1.txt
finished data5.txt
finished data2.txt
finished data4.txt

要解决这个问题,需要添加一个随着日志消息递增的计数器,当计数器数字和文件数组的长度相等时,再打印出all done的消息:

// before accessing directory
var counter = 0;
...
  // log write
  writeStream.write('changed ' + name + '\n',
  'utf8', function(err) {

     if(err) {
        console.log(err.message);
     } else {
        console.log ('finished ' + name);
        counter++;
        if (counter >= files.length) {
           console.log('all done');
        }
     }
});

接着你会得到预期的输出:所有文件都被更新后会显示一个all done的信息。

程序的运行没有问题——除非我们访问的目录中含有子目录和别的文件。如果程序将子目录也考虑在内,那么即使它还在继续处理别的内容,也会显示以下错误信息:

EISDIR: illegal operation on a directory, read

例2-11中,我们通过使用fs.stats方法返回一个用于表示UNIX stat命令的对象来防止这种类型的错误发生。这个对象中含有一些文件系统的信息,包括我们所处理的这个文件系统对象是否是文件。当然,fs.stats方法也是一个异步方法,它需要更多的回调嵌套。

例2-11 在每个目录对象中加入stats检查,确保它是一个文件

var fs = require('fs');
var writeStream = fs.createWriteStream('./log.txt',
      {flags : 'a',
       encoding : 'utf8',
       mode : 0666});

writeStream.on('open', function() {
var counter = 0;

// get list of files
fs.readdir('./data/', function(err, files) {

   // for each file
   if (err) {
      console.error(err.message);
   } else {
      files.forEach(function(name) {

         fs.stat('./data/' + name, function (err, stats) {

            if (err) return err;
            if (!stats.isFile()) {
               counter++; 
               return; 
            } 

            // modify contents
            fs.readFile('./data/' + name,'utf8', function(err,data) {

               if (err){
                  console.error(err.message);
               } else {
                  var adjData = data.replace(/somecompany\.com/g,
                          'burningbird.net');

                  // write to file
                  fs.writeFile('./data/' + name, adjData,
                                               function(err) {
                     if (err) {
                        console.error(err.message);
                     } else { 

                        // log write
                        writeStream.write('changed ' + name + '\n',
                         function(err) {

                           if(err) {
                              console.error(err.message);
                           } else {
                              console.log('finished ' + name);
                              counter++;
                              if (counter >= files.length) {
                                 console.log('all done');
                              }
                           }
                         }); 
                       }
                    }); 
                  }
               }); 
           });
        }); 
       }
   }); 
}); 

writeStream.on('error', function(err) {
  console.error("ERROR:" + err);
});

现在程序又能正常工作了,并且能很好地完成任务,但却很难阅读和维护。即使使用了一个return来进行错误处理,消除了一个条件嵌套,但依旧难以维护。有人将这种嵌套回调称为回调意大利面(callback spaghetti),还有人更形象地称为金字塔噩梦(pyramid of doom),这两个名词用来形容嵌套回调都很合适。

嵌套回调会让我们的代码结构越来越差,以至于越来越难将正确的代码放进正确的回调中。但是这也没办法,我们无法将回调的嵌套结构打散,因为所有的方法都必须按照以下顺序调用:

(1)开始查找目录;

(2)找到所有子目录;

(3)读取所有文件内容;

(4)修改文件内容;

(5)将结果写回原始文件。

我们希望能找到一种不依赖嵌套回调的方式来实现这一系列的函数调用。要达到这个目的,我们需要借助第三方模块或者其他方案。在第3章中,我会用异步模块来解决这个金字塔噩梦。在第9章中,我们会看到如何使用ES6的promise来解决这个问题。

 

还有一种方式是使用一个具名函数来作为每一个方法的回调函数。这样的话,就可以拍平金字塔,而且也很容易消除bug。不过,这种方式并不能解决所有问题,比如,它并不能让我们知道所有进程是什么时候结束的。所以,我们还需要一个异步控制的处理模块。


相关图书

Node.js 后端全程实战
Node.js 后端全程实战
Tomcat内核设计剖析
Tomcat内核设计剖析
写给PHP开发者的Node.js学习指南
写给PHP开发者的Node.js学习指南
Node学习指南
Node学习指南
Node应用程序构建——使用MongoDB和Backbone
Node应用程序构建——使用MongoDB和Backbone
Node.js入门经典
Node.js入门经典

相关文章

相关课程