皇帝的新衣:Node.js

jopen 10年前

现在有很多人非难Node.js(例如著名的Node.js is cancer),但是反对者往往误解其中所传达的信息并用一些无关的观点进行反驳。更麻烦的是现在有两类人在使用Node.js,第一类人需要一个高并发的服务器来同时处理大量的连接(例如HTTP代理、Websocket聊天服务器等等),第二类人是重度依赖于JavaScript,他们在浏览器、服务器、数据库甚至洗衣机上都用JS。

我想在这里对那些关于Node.js的奇怪的、有误导性的观点一一进行反驳。

Node.js超级快!


这还不够准确,我们把它分成两个独立的声明:

a. 跑在V8上的JavaScript很快!

V8的开发者值得你去称赞,因为V8让JavaScript快的让人难以置信。多快?从测试比赛上看只比Java慢1到5倍(没错是“慢”)。

如果你仔细看他们的测试,你会发现V8自带了一个很好的正则表达式引擎。结论?Node.js最适合用来完成需要大量正则表达式、CPU繁重的工作。

如果我们把那个测试比赛当作信条,那什么语言/实现通常会比JavaScript/V8快呢?一看,就是一些开发效率很低的语言:Java、Go、 Erlang(HiPE)、Clojure、F#、Haskell(GHC)、OCaml、Lisp(SBCL),都是不能用来写服务器的。

更好的是Node.js不需要使用多核,因为解释器是单线程的(评论肯定会说你可以同时跑多个Node.js进程,而其他语言都不可以这么做)。

b. Node.js是非阻塞的!并发性很好!事件驱动!

有些时候,我很怀疑人们是否真的知道他们自己在说些啥。

Node.js在这点甚是奇葩,因为你完全没得到轻量级线程所带来的便捷,而且还要自己完成轻量级线程已经帮你做好的事。因为JavaScript对任何种类的并发都没有直接支持,结果就是一堆使用回调的库函数。编程语言研究者会发现这是蹩脚版的延续传递风格(continuation-passing style (Sussman and Steele 1975)),CPS本来是用来应对递归时栈的增长,不过在Node.js里是应对语言不直接支持并发的问题。

是的,Node.js能在一个线程里高效地处理大量连接,但是它不是第一个也不是唯一个能这样做的运行时系统,看看Vert.x、Erlang、Stackless Python、GHC、Go……

更重要的是,大部分人都用Node.js来实现最小化的可行产品(MVP),因为他们觉得这样可以为未来的大量用户提供一个更快的网站。(当然加载500K的Backbone.js和其他各种各样的库算不上高性能,不过不用介怀的。)

Node.js让并发变的简单!

JavaScript 没有内建任何的和并发相关的语言特性,也不支持元编程,Node.js也不能化腐朽为神奇。你只好手工管理全部的延续,或者借助(很多不同的)库来把 JavaScript的语法应用到极致。(顺带一提,我觉得shoud.js既可怕又方便。)这就像是现代版本的因为语言里面没有for循环,所以只好用 GOTO语句。

来看一下对比吧。

在node.js里,你可能要写这样的函数:

function dostuff(callback) {    task1(function(x) {      task2(x, function(y) {        task3(y, function(z) {          if (z < 0) {            callback(0);          } else {            callback(z);          });      });    });  }
太美了我都不敢看。换Q promise试试:

function dostuff() {    return task1()      .then(task2)      .then(task3)      .then(function(z) {        if (z < 0) {          return 0;        } else {          return z;        });  }
好看多了,但还是很笨。一个副作用:如果你忘记在链式调用的最后加上".done()",Q会把你的异常都吞了,而且还有其他不太明显的问题。当然了,大部分Node.js的库都不用Q,所以你是要老老实实地用回调。如果take2不返回Q promise会怎样?

function dostuff() {    return task1()      .then(function(x) {        var deferred = Q.defer();        task2(x, deferred.resolve);        return deferred;      })      .then(task3)      .then(function(z) {        if (z < 0) {          return 0;        } else {          return z;        }      })      .done();  }
上面的代码是错的,你看出问题了吗?而且,我们还忘了异常处理,修改版:

function dostuff() {    return task1()      .then(function(x) {        var deferred = Q.defer();        task2(x, function(err, res) {          if (err) {            deferred.reject(err);          } else {            deferred.resolve(res);          }        });        return deferred.promise;      },      function(e) {        console.log("Task 1 failed.");      })      .then(task3, function(e) {        console.log("Task 2 failed.");      })      .then(function(z) {        if (z < 0) {          return 0;        } else {          return z;        }      },      function(e) {        console.log("Task 3 failed.");      })      .done();  }
错误处理和业务交织在一起,这还有趣吗?

在Go,你的代码会写成这样:

func dostuff() int {    z := task3(task2(task1())))    if z < 0 {      return 0    }    return z  }
或者加上错误处理:

func dostuff() int, err {    x, err := task1();    if err != nil {      log.Print("Task 1 failed.")      return 0, err    }    y, err := task2(x);    if err != nil {      log.Print("Task 2 failed.")      return 0, err    }    z, err := task3(y);    if err != nil {      log.Print("Task 3 failed.")      return 0, err    }    if z < 0 {      return 0;    }    return z;  }
Go版和Node.js版的实现的功能基本等价,除了Go还处理了等待和控制权转让。在Node.js里,我们必须手工管理延续因为我们要和内建的控制流对着干。

噢,在这实现这些东西之前,你还要学会不要错误地使用process.nextTick,不然你的API的用户会很不爽。在这个讲究“精益”和MVP的时代,谁有时间去学这些建立在让人难以理解的运行时上的抽象渗漏问题。

又顺带一提,Q是很慢的(至少网上是这么说的)。看看这个测试,它对比了21种处理异步调用的方式的性能。

难怪人们喜欢Node.js,它给了你轻量级线程的性能以及x86汇编的清晰和可用性。

当人们指出Node.js手工处理控制流很麻烦,反对者就会说:“用函数库去处理,例如async.js”。于是你开始用库函数去并行执行一堆任务或者组合两个函数,这其实就你在任何多线程语言里所做的事,只是更糟糕而已。

 LinkedIn迁移到Node.js,服务器从30台减到3台!

引用Hacker News上的一句:“我把垃圾车换成了摩托,现在我开车快多了!”。

PayPal 和沃尔玛换到Node.js之后也得到很好的收益。当然,他们是在对比两个完全不同的东西来让Node.js看起来更好。在他们好到难以置信的故事里,他们从一个庞大的企业级代码库换到一个重头开始写的Node.js应用。这有理由不变快吗?他们换到几乎任何其他东西都会得到性能提升。

在Linkedin的案例里,他们之前的代理都跑在并发度为1的Mongrel上。就像从用1个手指敲QWERTY键盘切换到10个手指敲Dvorak键盘,然后认为这全归功于Dvorak键盘布局更好。

这是一个经典的夸大的广告:真实的故事被误解,扭曲地去让不知情的人产生误解。

Node.js可以再利用你的JavaScript专业知识!


为了更准确,我们也要把它分成两点:

a. 前端开发者也能进行后端开发!

以前JavaScript被用在哪里?主要是浏览器端的前端代码,让按钮加上动画,把JSON变成精美的用户界面等等。在后端用JavaSctipt,你可以你的UI开发者去hack后端关键的网络代码,因为两边都是JS,没什么东西要学的!(对吧?)

直到他们发现不能像平常那样return(因为并发),不能不能像平常那样throw/catch(因为并发),而且全部东西都是基于回调的,会返回Q promise,会返回原生的promise、genator、pipe或其他的奇怪东西,因为这是Node.js。(记得告诉他们要检查类型声明)

你要相信前端开发者学习后端开发的能力。如果不同语言就是一个障碍,那么要明白怎样正确结合各种回调/promise/generator也不是一件简单的事。

 b. 我们可以在前端和后端共享代码!

那你就要把服务器端能使用的语言特性限制到浏览器所支持的特性。例如你的代码不能用JS 1.7的generator,直到浏览器也支持,而且我们也知道等它普及还要好几年。

事实上,如果不远离浏览器的JS,Node的根本就没办法从本质上得到提高。Node.js有很多坑需要用库去填,但是因为它和一个叫JavaScript的语言绑定在一起,它不能直接在语言层面上出处理这些问题。

这是很尴尬的情况,语言本来就没给你到来太多东西,而你又不能改变这个语言,所以你只好一直npm install band-aid。

通过执行某些编译步骤来把新的语言特性转换到旧的语言,这种情况可以得到改善,你也就可以在服务器写新的代码,同时可以运行在正常的JavaScript 上。你的选择可能是95%都是JavaScript的新语言(TypeSctipt、CoffectSctipt)或者完全不是JavaSctipt的语言(ClojureSctipt)。

更值得担心的是这意味这你实际上是混淆前端和后台的职责。事实上,你的后台成为了是一个囊括验证、处理等等功能的JSON API,而且这个API会有多个消费者(包括第三方)。例如,当你要造个iPhone和Android应用,你必须决定是用Obj-C、Java或者C# 实现一个原生应用还是用Phonegap/Cordova把你的Backbone.js/Angular.js单页面应用包装起来。根据不同的部署平台,这时在服务器和客户端共享的代码可能会成为不利因素。

NPM很好用!


我觉得NPM已经到了一个“不坏”的状态,这已经领先于很多包管理工具。就像是大多数的生态系统,NPM上有多个实现同样功能的包。例如你需要一个库来向Android推送通知。在NPM,你能找到:gcm、node-gcm、node-gcm-service、dpush、gcm4node、libgcm和ngcm,更不用提那些支持多个推送服务的库。哪个可靠?哪个已经停止开发?最后,你选了下载量最大的那个(为什么结果不能按流行度排序呐?)。

NPM过去经常宕机,看着很多公司突然间就不能部署代码也是一件好玩的事。现在NPM上线时间已经好了很多,但是谁知道它会不会打断你的部署进程

过去,我们成功部署代码而且不用在部署阶段引入对一个新生的、由志愿者运行的、从头实现的仓库的依赖。我们甚至在本地保存一份函数库的代码。

我倒不担心NPM,某种程度上它是生态系统的一部分而不是语言的一部分,而且通常情况都能满足要求。

用Node.js时我效率更高!敏捷!快速!MVP!

似乎Node.js程序员心里都有一个奇怪的二分法:你在用mod_php或者可怕的JavaEE,所以是又大又慢的;你在用Node.js,所以是精益和快速的。这可能就是为什么你很少看到有人吹他怎样从Python换到Node.js。自然,如果你来自一个到处都是 AbstractFactoryFactorySingletonBean的过度工程的系统,Node.js的缺乏结构反而是清新的。但只是因为这样就说 Node.js更高效是错误的——因为他们无视了全部的坑。

一个Node.js新人可能会这么做:

  1. 这个函数可能失败,我要抛一个异常,所以我会写 throw new Error("it broke");
  2. 那个异常没有被我的try-catch捕获!
  3. process.on("uncaughtException")又好像可以
  4. 但是得到不是想象中堆栈轨迹,StackOverflow说这可能违背了最佳实践
  5. 也许我要试一下domain?
  6. 哦,回调通常以错误作为第一个参数,我要回去改改我的函数调用
  7. 有人告诉我应该用promise
  8. 把例子看了十来二十遍,我觉得应该可以了
  9. 不过它还是吃了我的异常。不,我还要在最后加上.done()

一个Python程序会这么做:

  1. raise Exception("it broke");

这是一个Go程序员:

  1. 我要把err加到返回类型声明,还要在return语句加一个返回值

Noed.js中有很多东西会阻碍你实现MVP。MVP不是担心能不能快40ms返回一个HTTP响应或者你的DigitalOcean机子能同时处理多少连接。你没有时间去成为一个并发编程的专家(而且明显你现在也不是,如果是的话你就不会用Node了)。

这里有一篇关于从Python换到Node.js的文章。最有价值的一句:“这种推迟的编程模式更难理解和调试。如果开发者不能完全理解Twised,他就会犯很多无知的错误,最后会死的很惨”。所以他们换到另一个困难的系统,如果你不能理解它然后又犯了些无知的错误,它一样会微妙地挂掉。

我热爱Node.js!Node.js就是生活!


如果你组织Node.js活动时需要演讲者,我随时都进行付费的演讲演出。详细请用邮箱联系我。

这些观点不代表也我的公司和同事,也没有经过他们的复审。你也可以把全部文字都加上<sarcasm>标签。

发布  funnuy 2014-06-19  原文 notes.ericjiang.com