我为什么向后端工程师推荐Node.js

科普文一则,说说我对NodeJS(一种服务端JavaScript实现)的一些认识,以及我为什么会向后端工程师推荐NodeJS.

“Node.js 是服务器端的 JavaScript 运行环境,它具有无阻塞(non-blocking)事件驱动(event-driven)等的特色,Node.js 采用 V8 引擎,同样,Node.js 实现了类似 Apache 和 nginx 的web服务,让你可以通过它来搭建基于 JavaScript 的 Web App。”

上周末参与了CNodeJS社区的第一次北京聚会,现场气氛非常的好.而作为一名前端开发,我在后面的讨论环节讲了下我对NodeJS的看法,主要回答的问题是”我为什么会向后端工程师推荐NodeJS”.这其实是去年年底大团队技术总结的话题之一,包含在我之前发过的PPT:团队年终技术Review中.因为之前没有准备,当天仓促上阵,也不知道说清楚了没,不如就在这里再详细展开记录下.

我想不仅仅是NodeJS,当我们要引入任何一种新技术前都必须要搞清楚几个问题:
1.我们遇到了什么问题?
2.这项新技术解决什么问题,是否契合我们遇到的问题?
3.我们遇到问题的多种解决方案中,当前这项新技术的优势体现在哪儿?
4.使用新技术,带来哪些新问题,严重么,我们能否解决掉?

我们的问题:Server端阻塞
NodeJS被设计用来解决服务端阻塞问题.通过一段简单的代码解释何为阻塞:

Js代码 
  1. //根据ID,在数据库中Persons表中查出Name
  2. var name = db.query(“select name from persons where id=1”);
  3. //进程等待数据查询完毕,然后使用查询结果.
  4. output(“name”)

这段代码的问题是在上面两个语句之间,在整个数据查询的过程中,当前程序进程往往只是在等待结果的返回.这就造成了进程的阻塞.对于高并发,I/O密集行的网络应用中,一方面进程很长时间处于等待状态,一方面为了应付新的请求不断的增加新的进程.这样的浪费会导致系统支持QPS远远小于后端数据服务能够支撑的QPS,成为系统的瓶颈.而且这样的系统也特别容易被慢链接攻击(客户端故意不接收或减缓接收数据,加长进程等待时间).
如何解决阻塞问题
解决这个问题的办法是,建立一种事件机制,发起查询请求之后,立即将进程交出,当数据返回后触发事件,再继续处理数据:

Js代码 
  1. //定义如何后续数据处理函数
  2. function onDataLoad(name){
  3.     output(“name”);
  4. }
  5. //发起数据请求,同时指定数据返回后的回调函数
  6. db.query(“select name from persons where id=1”,onDataLoad);

我们看到按照这个思路解决阻塞问题,首先我们要提供一套高效的异步事件调度机制.而主要用于处理浏览器端的各种交互事件的JavaScript.相对于其他语言,至少有两个关键点特别适合完成这个任务.
为什么JS适合解决阻塞问题
首先JavaScript是一种函数式编程语言,函数编程语言最重要的数学基础是λ演算(lambda calculus) — 即函数可以接受函数当作输入(参数)和输出(返回值).
函数可以作为其他函数的参数输入的这个特性,使得为事件指定回调函数变得很容易.特别是JavaScript还支持匿名函数.通过匿名函数的辅助,之前的代码可以进行简写如下.

Js代码 
  1. db.query(“select name from persons where id=1”,function(name){
  2.     output(name);
  3. });

还有一个关键问题是,异步回调的运行上下文保持(称状态保持),我看一段代码来说明何为状态保持.

Js代码 
  1. //传统同步写法:将查询和结果打印抽象为一个方法
  2. function main(){
  3.     var id = “1”;
  4.     var name = db.query(“select name from persons where id=” + id);
  5.     output(“person id:” + id + “, name:” + name);
  6. }
  7. main();

前面的写法在传统的阻塞是编程中非常常见,但接下来进行异步改写时会遇到一些困扰.

Js代码 
  1. //异步写法:
  2. function main(){
  3.     var id = “1”;
  4.     db.query(“select name from persons where id=” + id,function(name){
  5.         output(“person id:” + id + “, name:” + name);//n秒后数据返回后执行回调
  6.     });
  7. }
  8. main();

细心的同学可以注意到,当等待了n秒数据查询结果返回后执行回调时.回调函数中却仍然使用了main函数的局部变量”id”,而”id”已经在n秒前走出了其作用域,这是为什么呢?熟悉JavaScript的同学会淡然告诉您:”这是闭包(closures)~”.
其实在复杂的应用中,我们一定会遇到这类场景.即在函数运行时需要访问函数定义时的上下文数据(注意:一定要区分函数定义时和函数运行时这样的字眼和其代表的意义,不然很快就会糊涂).而在异步编程中,函数的定义和运行又分处不同的时间段,那么保持上下文的问题变得更加突出了.
在这个例子中,db.query作为一个公共的数据库查询方法,把”id”这个业务数据传入给db.query,交由其保存是不太合适的.但聪明的同学们可以抽象一下,让db.query再支持一个需要保持状态的数据对象传入,当数据查询完毕后可以把这些状态数据原封不动的回传.如下:

Js代码 
  1. function main(){
  2.     var id = “1”;
  3.     var currentState = new Object();
  4.     currentState.person_id = id;
  5.     db.query(“select name from persons where id=” + id, function(name,state){
  6.         output(“person id:” + state.person_id + “, name:” + name);
  7.     },currentState);//注意currentState是db.query的第三个参数
  8. }
  9. main();

记住这种重要的思路,我们再看看是否还能进一步的抽象?可以的,不过接下的动作之前,我们先要了解在JavaScript中一个函数也是一个对象.一个函数实例fn除了具备可函数体的定义之外,仍然可以在这个函数对象实例之上扩展属性,如fn.a=1;受到这个启发我们尝试把需要保持的状态直接绑定到函数实例上.

Js代码 
  1. function main(){
  2.     var id = “1”;
  3.     var currentState = new Object();
  4.     currentState.person_id = id;
  5.     function onDataLoad(name){
  6.          output(“person id:” + onDataLoad.state.person_id + “, name:” + name);
  7.     }
  8.     onDataLoad.state = currentState ;//为函数指定state属性,用于保持状态
  9.     db.query(“select name from persons where id=” + id, onDataLoad);
  10. }

我们做了什么?生成了currentState对象,然后在函数onDataLoad定义时,将currentState绑定给onDataLoad这个函数实例.那么在onDataLoad运行时,就可以拿到定义时的state对象了.而闭包就是内置了这个过程而已.

在每个函数运行时,都有一个运行时对象称为Execution context,它包含如下variable object(VO,变量对象),scope chain(作用域链)和thisValue三部分.详见ECMA-262 JavaScript. The Core

其中变量对象VO,包含了所有局部变量的引用.对于main函数,局部变量”id”存储在VO.id内.看起来用VO来代替我们的currentSate最合适了.但main函数还可能嵌套在其他函数之内,所以我们需要ScopeChain,它是一个包含当前运行函数VO和其所有父函数scope的数组.
所以在这个例子中,在onDataLoad函数定义时,就为默认为其绑定了一个[[scope]]属性指向其父函数的ExecutionContext的ScopeChain.而当函数onDataLoad执行时,就可以通过[[scope]]属性来访问父函数的VO对象来找到id,如果父函数的VO中没有id这个属性,就再继续向上查找其祖先的VO对象,直到找到id这个属性或到达最外层返回undefined.也正是因为这个引用,造成VO的引用计数不为0,在走出作用域时,才不会被垃圾回收.
很多人觉得闭包很难理解,其实我们只要能明确需要区分函数定义和函数运行这两个时机,记住闭包让函数在运行时能够访问到函数定义时的所处作用域内的所有变量.或者说函数定义时能访问到什么变量,那么在函数运行时通过相同的变量名一样能访问到.

关于状态保持是本文的重点,在我看到的多数NodeJS的介绍文章,并没有详解这里,我们只是知道了要解决阻塞问题,但是JavaScript解决阻塞问题的优势在哪里,作为一个前端开发,我想有必要详细解释一下.

其实说到状态保持还有一个类似的场景,比如用户从A页面提交表单到B页面,如果提交数据校验不通过,则需要返回A页面,同时保持用户在A页面填写的内容并提示用户修改不对的地方.从提交到返回显示这也是一个包含网络交互的异步过程.传统网页,用户的状态通过请求传递到服务端,交由后端状态保持(类似交给db.query的currentSate).而使用Ajax的网页,因为并未离开原页面,那么服务端只要负责校验用户提交的数据是否正确即可,发送错误,返回错误处相关信息即可,这就是所谓前端状态保持.可以看到这个场景里边服务端做的事情变少了,变纯粹了.正如我们的例子中db.query不再存储转发第三个state参数,变得更轻量.

我们看到通过JavaScript函数式语言特性,匿名函数支持和闭包很漂亮的解决了同步编程到异步编程转化过程中遇到的一系列最重要的问题.但JavaScript是否就是最好的?这就要回答我们引用新技术时需要考虑的最后一个问题了
使用NodeJS是否带来额外的困扰,如何解决
性能真的是最好么?不用比较我们也可以得到结论NodeJS,做无阻塞编程性能较难做到极致.何为极致,处理一个请求需要占用多少内存,多少cpu资源,多少带宽,如果有浪费就不是极致.阻塞式编程浪费了大量进程资源只是在等待,导致大量内存和cpu的浪费.NodeJs好很多,但也正是因为一些闭包等JS内建机制也会导致资源的浪费,看下面的代码

Js代码 
  1. function main(){
  2.     var id = “1”;
  3.     var str = “…”; //这里存储一个2M的字符串
  4.     db.query(“select name from persons where id=” + id,function(name){
  5.         output(“person id:” + id + “, name:” + name);//n秒后数据返回后执行回调
  6.     });
  7. }
  8. main();

直到数据查询完成,变量str所使用的2M内存不会被释放,而str保持下去可能并没有意义.前面已经解释过闭包的原理,闭包并没有智能到只包起来今后可能被访问到的对象.即使不了解闭包的原理,也可以通过一段简单脚本验证这点:

Js代码 
  1. function main(){
  2.     var id = “1”;
  3.     var str = “…”; //这里存储一个2M的字符串
  4.     window.setTimeout(function(){
  5.         debugger;    //我们在这里设置断点
  6.     },10000)
  7. }
  8. main();

我们在回调函数当中只设置一个断点,并不指明我们要访问哪个变量.然后我们在控制台监视一下,id和str都是可以拿到的.
所以我来不负责任的预测一下,性能极端苛刻的场景,无阻塞是未来,但无阻塞发展下去,或者有更轻量的脚本引擎产生(lua?),或者V8JS引擎可能要调整可以disable闭包,或者我们可以通过给JS开发静态编译器在代码发布前优化我们的代码.

我之前谈到过JS静态编译器:“如果给JS代码发布正式使用前增加一个编译步骤,我们能做些什么”,动态语言的实时编译系统只完成了静态语言编译中的将代码转化为字节码的过程,而静态语言编译器的额外工作,如接口校验,全局性能优化等待.所以JS也需要一个静态的编译器来完成这些功能,Google利用ClouserComplier提供了系列编译指令,让JS更好的实现OO编程,我来利用静态编译器解决一些JS做细粒度模块化引入的性能方面的问题.而老赵最近的项目JSCEX,则也是利用JS发布前的编译环节重点解决异步编程的代码复杂度问题.
我们习惯于阻塞式编程的写法,切换到异步模式编程,往往对于太多多层次的callback嵌套弄得不知所措.所以老赵开发的JS静态编译器,借鉴F#的Computation Expressions,让大家遵守一些小的约定后,能够仍然保持同步编程的写法,写完的代码通过JSCEX编译为异步回调式的代码再交给JS引擎执行.
如果这个项目足够好用,那就也解决了一个使用NodeJS这种新技术,却加大编程复杂度这个额外引入的困扰.甚至可以沿着这个思路,在静态编译阶段优化内存使用.

NodeJS还要解决什么问题
说了这么多,无阻塞编程要做的还远不止这些.首先需要一个高效的JS引擎,高效的事件池和线程池.另外几乎所有和NodeJS交互的传统模块如文件系统,数据访问,HTTP解析,DNS解析都是阻塞式的,都需要额外改造.
正是NodeJS作者极其团队,认清问题问题以及JS解决这些问题方面的优势.基于高效的V8 JavaScript引擎,贡献了大量的智慧和精力解决上述大部分问题后才有NodeJS横空出世.
当前Node社区如此火热,千余开源的NodeJS模块,活跃在WebFramework,WebSocket,RPC,模板引擎,数据抓取服务,图形图像几乎所有工程领域.

后记
本文主要的信息来自nodejs作者在JSConf09,JSConf10上的分享.
而作为前端开发,着重讲了函数式编程,闭包对于无阻塞开发的重要意义.我期待这篇文章能够给前端和后端同学都带来收获.

同样作为前端开发,不得不再插几句,说说服务端JS能够解决的另一个问题:
当前的Web开发前后端使用不同的语言,很多相同的业务逻辑要前后端分别用不同语言重复实现.比如越来越多重度依赖JS的胖客户端应用,当客户浏览器禁用JavaScript时,则需要使用服务端语言将主业务流程再实现一次(这即是所谓的”渐进增强”).
当我们拥有了服务端JavaScript语言,我们自然就会想到能否利用NodeJS做到”一次开发,渐进增强”.解决掉这个为小量用户,浪费大量时间的恼人的问题.我们先要解决问题,这是使用NodeJS的最大动力.基于之前的统计,因为各种原因浏览器不支持JS的用户大概接近1%,至少淘宝绝对不会主动放弃这部分用户.至于在服务端也使用JS是否能够替掉LAMP架构,抑或NodeJS会对常见MVC架构带来何种冲击,V/C这些层是否能在前后端任意流动这些问题都是NodeJS解决问题后带来的额外话题.
“一次开发,渐进增强”这方面的实践,YAHOO仍然是先驱,早在一年多前开始YAHOO通过nodejs-yui3项目做了很多卓越的贡献,而淘宝自主开发的前端框架Kissy也有服务端运行的相关尝试,详见我的同事拔赤的分享.而接下来的几个月我也将在这方面做一些尝试,有一定积累后我将再写一篇文章更好的分析这个问题..

JS在诞生之时就不仅仅是浏览器端工具,如今JS能再一次回到服务端展示拳脚,感谢V8,感谢NodeJS作者,团队和社区的诸多贡献者,祝Node好运,JS好运.