科普文一则,说说我对 Node.js 的一些认识,以及我作为前端工程师为什么会向后端工程师推荐 Node.js。
“Node.js 是服务器端的 JavaScript 运行环境,它具有无阻塞 (non-blocking) 和事件驱动 (event-driven) 等的特色,Node.js 采用 V8 引擎,同样,Node.js 实现了类似 Apache 和 nginx 的 web 服务,让你可以通过它来搭建基于 JavaScript 的 Web App。”
我想不仅仅是 Node.js,当我们要引入任何一种新技术前都必须要搞清楚几个问题:
- 我们遇到了什么问题?
- 这项新技术解决什么问题,是否契合我们遇到的问题?
- 我们遇到问题的多种解决方案中,当前这项新技术的优势体现在哪儿?
- 使用新技术,带来哪些新问题,严重么,我们能否解决掉?
我们的问题:Server 端阻塞
Node.js 被设计用来解决服务端阻塞问题. 下面通过一段简单的代码解释何为阻塞:
Js 代码:
// 根据 ID,在数据库中 Persons 表中查出 Name var name = db.query("select name from persons where id=1"); // 进程等待数据查询完毕,然后使用查询结果。 output("name")
这段代码的问题是在上面两个语句之间,在整个数据查询的过程中,当前程序进程往往只是在等待结果的返回. 这就造成了进程的阻塞. 对于高并发,I/O 密集行的网络应用中,一方面进程很长时间处于等待状态,另一方面为了应付新的请求不断的增加新的进程. 这样的浪费会导致系统支持 QPS 远远小于后端数据服务能够支撑的 QPS,成为了系统的瓶颈. 而且这样的系统也特别容易被慢链接攻击 (客户端故意不接收或减缓接收数据,加长进程等待时间)。
如何解决阻塞问题
可以引入事件处理机制解决这个问题。在查询请求发起之前注册数据加载事件的响应函数,请求发出之后立即将进程交出,而当数据返回后再触发这个事件并在预定好的事件响应函数中继续处理数据:
Js 代码:
// 定义如何后续数据处理函数 function onDataLoad(name){ output("name"); } // 发起数据请求,同时指定数据返回后的回调函数 db.query("select name from persons where id=1",onDataLoad);
我们看到若按照这个思路解决阻塞问题,首先我们要提供一套高效的异步事件调度机制. 而主要用于处理浏览器端的各种交互事件的 JavaScript。相对于其他语言,至少有两个关键点特别适合完成这个任务。
为什么 JS 适合解决阻塞问题
首先 JavaScript 是一种函数式编程语言,函数编程语言最重要的数学基础是λ演算 (lambda calculus) – 即函数对象可以作为其他函数对象的输入 (参数) 和输出 (返回值)。
这个特性使得为事件指定回调函数变得很容易。特别是 JavaScript 还支持匿名函数。通过匿名函数的辅助,之前的代码可以进行简写如下:
Js 代码:
db.query("select name from persons where id=1",function(name){ output(name); });
还有另一个关键问题是,异步回调的运行上下文保持 (本文暂称其为"状态保持")。我们先来看一段代码来说明何为状态保持:
Js 代码:
// 传统同步写法:将查询和结果打印抽象为一个方法 function main(){ var id = "1"; var name = db.query("select name from persons where id=" + id); output("person id:" + id + ", name:" + name); } main();
前面的写法在传统的阻塞是编程中非常常见,但接下来进行异步改写时会遇到一些困扰:
Js 代码:
// 异步写法: function main(){ var id = "1"; db.query("select name from persons where id=" + id,function(name){ output("person id:" + id + ", name:" + name);//n 秒后数据返回后执行回调 }); } main();
细心的朋友可能已经注意到,当等待了 n 秒数据查询结果返回后执行回调时。回调函数中却仍然使用了 main 函数的局部变量"id",而"id"似乎应该在 n 秒前走出其作用域。为什么此时"id"仍然可以访问呢,这是因为 JavaScript 的另外一个重要语言特性:闭包 (Closures)。接下来我来详解闭包的原委。
在复杂的应用中,我们一定会遇到这类场景。即在函数运行时需要访问函数定义时的上下文数据 (注意:一定要区分函数定义时和函数运行时两个不同的时刻)。特别是在异步编程模型中,函数的定义和运行又分处不同的时间段,那么保持上下文的问题变得更加突出了。因为我们在任务执行一半时把资源交出去没有问题,但当任务需要再次继续时我们必须还原现场。
在这个例子中,db.query 作为一个公共的数据库查询方法,把"id"这个业务数据传入给 db.query,交由其保存是不太合适的。但我们可以稍作抽象,让 db.query 再支持一个需要保持状态的数据对象传入,当数据查询完毕后可以把这些状态数据原封不动的回传。如下:
Js 代码:
function main(){ var id = "1"; var currentState = new Object(); currentState.person_id = id; db.query("select name from persons where id=" + id, function(name,state){ output("person id:" + state.person_id + ", name:" + name); },currentState);// 注意 currentState 是 db.query 的第三个参数 } main();
记住这种重要的思路,我们再看看是否还能进一步的抽象?可以的,不过接下的动作之前,我们还要了解在 JavaScript 中一个函数也是一个对象。一个函数实例 fn 除了函数体的定义之外,我们仍然可以在这个函数对象实例之本身扩展其他属性,如 fn.a=1; 受到这个启发我们尝试把需要保持的状态直接绑定到函数实例上:
Js 代码
function main(){ var id = "1"; var currentState = new Object(); currentState.person_id = id; function onDataLoad(name){ output("person id:" + onDataLoad.state.person_id + ", name:" + name); } onDataLoad.state = currentState ;// 为函数指定 state 属性,用于保持状态 db.query("select name from persons where id=" + id, onDataLoad); }
我们做了什么?生成了 currentState 对象,然后在函数 onDataLoad 定义时,将 currentState 绑定给 onDataLoad 这个函数实例。那么在 onDataLoad 运行时,就可以拿到定义时的 state 对象了。JavaScript 的闭包特性就是内置了这个过程而已。
在每个 JavaScript 函数运行时,都有一个运行时内部对象称为 Execution Context,它包含如下 Variable Object(VO, 变量对象), Scope Chain(作用域链) 和"this" Value 三部分。如图:
图片来自 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,在走出作用域时,才不会被垃圾回收。
很多朋友觉得闭包较难理解,其实我们只要能明确的区分函数定义和函数运行两个时机,那么闭包就是让函数在运行时能够访问到函数定义时的所处作用域内的所有变量,或者说函数定义时能访问到什么变量,那么在函数运行时通过相同的变量名一样能访问到。
关于状态保持是本文的重点,在我看到的多数 Node.js 的介绍文章中并没有详解这里,我们只是知道了要解决阻塞问题,但是 JavaScript 解决阻塞问题的优势到底在哪里,作为一名前端工程师,我想有必要花一些篇幅详细解释一下。
而之所以我叫它”状态保持”因为还有一个非常相似的场景可以类比:
用户从 A 页面提交表单到 B 页面,如果提交数据校验不通过,则需要返回 A 页面,同时保持用户在 A 页面填写的内容并提示用户修改不对的地方。从提交到校验出错再返回继续填写是一个包含网络交互的异步过程,这相当于填写表单这个任务过会儿再继续。
在传统网页开发中,用户的状态通过请求传递到服务端,交由后端状态保持 (类似交给 db.query 的 currentSate)。而使用 Ajax 的网页,因为并未离开原页面,那么服务端只要负责校验用户提交的数据是否正确即可,发送错误,返回错误处相关信息即可,这就是所谓前端状态保持。可以看到这个场景里边服务端做的事情变少了,变纯粹了。正如我们的例子中 db.query 不再存储转发第三个 state 参数,变得更在轻量。
我们看到通过 JavaScript 函数式语言特性,匿名函数支持和闭包很漂亮的解决了同步编程到异步编程转化过程中遇到的一系列最重要的问题。但 JavaScript 是否就是最好的?这就要回答我们引用新技术时需要考虑的最后一个问题了。
使用 Node.js 是否带来额外的困扰,如何解决?
Node.js 性能真的是最好么?不用比较我们也可以得到结论,Node.js 做无阻塞编程性能较难做到极致。何为极致?处理一个请求需要占用多少内存,多少 cpu 资源,多少带宽,有丁点浪费就不是极致。阻塞式编程浪费了大量进程资源只是在等待,导致大量内存和 cpu 的浪费。在这方面 Node.js 好很多,但也正是因为一些闭包等 JavaScript 内建机制也会导致资源的浪费,看下面的代码:
Js 代码:
function main(){ var id = "1"; var str = "..."; // 这里局部变量 str 存储一个 2M 的字符串 db.query("select name from persons where id=" + id,function(name){ output("person id:" + id + ", name:" + name);//n 秒后数据返回后执行回调 }); } main();
至少整个数据查询过程中,变量 str 所使用的 2M 内存并不会被释放,而 str 保持下去可能并没有意义。前面已经解释过闭包的原理,闭包并没有智能到只包起来今后可能被访问到的对象。即使不了解闭包的原理,也可以通过一段简单脚本验证这点:
Js 代码:
function main(){ var id = "1"; var str = "..."; // 这里存储一个 2M 的字符串 window.setTimeout(function(){ debugger; // 我们在这里设置断点 },10000) } main();
我们在回调函数当中只设置一个断点,并不指明我们要访问哪个变量。然后我们在控制台监视一下,id 和 str 都是可以拿到的。
所以我来猜想一下,性能极端苛刻的场景,无阻塞是未来,但无阻塞发展下去,或者有更轻量的脚本引擎产生,或者 JavaScript 引擎可能要调整可以 disable 闭包,或者我们要通过给 JS 开发静态编译器在代码发布前自动优化我们的代码。
静态编译是如今 JavaScript 技术领域的又一个热点,我们都知道 JavaScript 是解释型脚本语言,在运行时自动编译。但是运行时编译只是将代码转为机器码执行,却并未覆盖传统编译型语言在编译阶段所做的任务。比如,语法检查,接口校验,全局性能优化等等。
最常见的 JavaScript 静态编译就是脚本压缩工具,在代码发布到线上之前,我们通过各种压缩工具,将代码压缩,达到减少网络传输量的问题。而在这个时间点,已经有越来越多的事情可做,比如:Google 利用 ClouserComplier 提供的系列编译指令,让 JavaScript 更好的实现 OO 编程。也有 GWT,CoffeeScript 这样的项目,将其他语言编译为 JavaScript。在淘宝我们在代码静态编译阶段来解决因 JavaScript 细粒度模块化改造引入各种性能问题,也用来对第三方提供 JavaScript 代码进行一定的安全检查。
我们期待前面的代码经过静态编译器编译后变成如下结果:
Js 代码:
function main(){ var id = "1"; var str = "..."; // 这里局部变量 str 存储一个 2M 的字符串 db.query("select name from persons where id=" + id,function(name){ output("person id:" + id + ", name:" + name); }); str = ""; // 通过这一行,及时释放不必要的内存占用。 } main();
除了性能方面的担忧,使用 Node.js 进行编程增加了代码编写的复杂度。因为我们习惯于阻塞式编程的写法,切换到异步模式编程,往往对于太多多层次的 callback 函数嵌套弄得不知所措。老赵最近开发了项目 JSCEX 正是要解决这个问题,它让大家在遵守一些小的约定后,能够仍然保持同步编程的写法进行代码开发。写完的代码同样通过静态编译器编译成异步回调式模式的代码再交给 JavaScript 引擎执行。
Node.js 还要解决什么问题
说了这么多,无阻塞编程要做的还远不止这些。首先需要一个高效的 JS 引擎,高效的事件池和线程池。另外几乎所有和 Node.js 交互的传统模块如文件系统,数据访问,HTTP 解析,DNS 解析都是阻塞式的,都需要额外改造。
Node.js 作者极其团队,正是认清问题所在以及 JS 解决这些问题方面的优势。基于 Google 开源的高效 JavaScript 引擎 V8,贡献了大量的智慧和精力解决上述大部分问题后才有 Node.js 横空出世。
当前 Node 社区如此火热,千余开源的 Node.js 模块,活跃在 WebFramework,WebSocket,RPC,模板引擎,数据抓取服务,图形图像几乎所有工程领域。
后记
本文主要的信息来自 Node.js 作者在 JSConf09 , JSConf10 上的分享。 而作为前端开发,着重讲了函数式编程,闭包对于无阻塞开发的重要意义。我期待这篇文章能够给前端和后端工程师都带来收获。
同样作为前端开发,不得不再插几句,说说服务端 JS 能够解决的另一个问题:当前的 Web 开发前后端使用不同的语言,很多相同的业务逻辑要前后端分别用不同语言重复实现。比如越来越多重度依赖 JavaScript 的胖客户端应用,当客户浏览器禁用 JavaScript 时,则需要使用服务端语言将主业务流程再实现一次,这即是前端常说的”渐进增强”。
当我们拥有了服务端 JavaScript 语言,我们自然就会想到能否利用 Node.js 做到”一次开发,渐进增强”。解决掉这个为小量用户,浪费大量时间的恼人的问题。这方面的实践,YAHOO 仍然是先驱,早在一年多前开始 YAHOO 通过 nodejs-yui3 项目做了很多卓越的贡献,而淘宝自主开发的前端框架 Kissy 也有服务端运行的相关尝试。
JavaScript 在诞生之时就不仅仅是浏览器端工具,如今 JavaScript 能够再一次回到服务端展示拳脚,感谢 V8,感谢 NodeJS 作者,团队和社区的诸多贡献者,祝 Node 好运,JavaScript 好运。
关于作者
李穆,前端工程师,就职于淘宝广告技术部架构组,淘宝花名:李牧,专注淘宝广告引擎和业务系统前端开发。个人博客: http://limu.iteye.com
感谢霍太稳对本文的审校。
给InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家加入到 InfoQ 中文站用户讨论组中与我们的编辑和其他读者朋友交流。
评论