因工作原因不得不接触Node.js,虽然说对于好程序员来说语言并不重要,但在完成一项特定的工作时选择适合使用场景的语言还是十分重要的。在学习和使用Node.js的过程中有很多感悟,修正了一些原有的偏见,也产生了一些新的偏见,不过最重要的还是对很多东西有了新的认识。
说起Node.js,大部分人(我)的第一反应就是它是使用JavaScript的又一个Web框架罢了,JavaScript是一门很烂的语言,所以这个框架肯定也很难用。看到这里先别急着骂我,这样的想法对,但也不对。从严谨的角度上说,除了开发语言采用JavaScript外,它还是一个严格的event-driven的服务框架,同时也只有单线程运行的能力(在不引入worker的前提下),当然由于语言采用的JavaScript,引入这两点也是十分自然的。从事实角度出发,JavaScript和其它语言的最大区别就是它在Web中的地位几乎无法撼动,时至今日JavaScript依然是使用人数最多的语言,这就决定了Node.js的生态必然是百花齐放(不一定是好事),尽管JavaScript刚诞生时在设计上存在很多至今仍被人吐槽的问题,但在一代又一代人的努力下,最新标准的JavaScript也算是有了一些现代语言该有的样子,甚至如果更进一步使用JavaScript的改进版的话(没错我指的是TypeScript),开发体验甚至比部分其它语言还要好。
谈谈JavaScriptPermalink
从名字就可以看出,Node.js和JavaScript是深度绑定的,那么首先我们就聊一下JavaScript。JavaScript在刚诞生时其目标只是在浏览器中对DOM做一些简单的操作、在有UI事件(用户点击按钮)时运行一些比较简单的逻辑,因此它的特点主要是粗糙、简单、易用。后来的事我们都知道了,互联网革命一发不可收拾,而JavaScript阴差阳错的就成为了浏览器钦定语言,因为顾虑向前兼容性,浏览器都把自己绑定在了这门一言难尽的语言上,并在这条歪路上越走越远…
尽管在前一段中将JavaScript诋毁的像是奇迹般蒙对了所有选择题并拿到全班第一名的差生,但如今的JavaScript还是吸取了很多现代语言的特性,并尽量将遗留的糟粕都藏在了新人看不到的地方,每年ECMAScript都会发布新的标准,也让前端开发者的生活越来越好过(需要考虑向前兼容性的除外),现代的JavaScript已经几乎可以说是一门还不错的语言了。当然,“还不错”并不能让JavaScript在一众语言中脱颖而出,最重要的是它拥有全世界最大的开发者群体,这就表示它的生态圈是无与伦比的。除了NPM上数不胜数的库之外,JavaScript还有Google开发的V8编译器的加持,这使得它比同样是动态语言的Python、Ruby等语言的速度高了一个数量级,这就奠定了JavaScript在服务器上的优势,Node.js的出现也不奇怪了。
JavaScript Benchmark Statistics from here
Node.js是什么Permalink
从本质上看,Node.js主要是依托V8引擎强大的性能,并在此基础上拓展了服务器环境所需要的网络IO、文件IO、系统调用等功能,这就决定了V8引擎的性质往往也是Node.js的性质,比如单线程、事件触发、JIT优化等运行的特征,V8在底层又是使用libuv所实现的,因此在IO密集型应用上可以取到很好的效果。从某种意义上来说,Node.js兼具了JavaScript的语言易用性以及libuv处理网络事件的高性能,从而使其具有了实用性。
当然Node.js也有不擅长的地方,这也是源自其单线程事件触发特性的原因,如果要开发一个CPU密集型应用的话,那么Node.js绝对是排倒数的,因为Node.js的eventloop只有一个线程,当有长时间运算的代码在运行时,其它所有的事件都会被阻塞且无法被触发,相当于整个服务都处于无法响应的情况,这是不能接受的。一个缓解这种情况的方法是在计算密集的代码中定时手动释放控制权,让eventloop可以处理其它的事件再继续计算,但这仍然不能解决只有一个线程、计算能力有限的问题。另一个方法是使用worker thread来进行计算密集的函数的运行,在需要做的计算比较零散简单时使用worker thread也是一种权益之计,不过考虑到JavaScript本身性能不及很多编译执行语言以及worker thread还是一个很新的功能,当需要开发一个主要组成为计算密集代码的服务时还是需要转向Java、C++、Go等语言。
Node.js Architecture
async和awaitPermalink
接触过异步编程的同学对这两个方法应该并不陌生,没错,在Node.js中进行异步编程时也是使用async/await来实现的,并且使用Node.js就必须使用异步编程(没有多线程的选项)。当然除了async/await你也可以选择其它的异步编程方式,比如接下来介绍的在一些古老的库中还会看到的历史实践,JavaScript中的异步编程是一步步发展过来的(和其它语言一起),最早的callback形式是最直接也和底层实现(event-driven)最接近的,而现在看到的async/await让编写程序变得更加方便,但不了解原理的话就只知道涉及IO操作时需要用到它,而对背后发生了什么一头雾水(当然说明这个设计十分成功,让编程人员可以脱离底层的细节)。
最早的JavaScript只有回调(callback),由于JavaScript的实现是事件触发式的,它没有阻塞(blocking)操作的概念,如果要调用一个IO函数的话,由于没有办法立即获得返回的结果,你只能再额外的提供回调函数来告诉JavaScript的引擎当这个IO函数返回数据时要如何处理它,最经典的大概就是setTimeout函数
setTimeout(function() {
console.log('beep')
}, 1000)
但这样的话就出现了一个问题,那就是当我们有很多IO调用时我们的回调函数会有很多层嵌套,代码会变得很难读,这就是所谓的回调地狱(callback hell),下面仅给出一个简单的例子展示可能出现的情况。
A.call((result) => {
doSomething(resultA)
B.call(resultA, (resultB) => {
doSomethingMore(resultB)
C.call(resultB, (resultC) => {
doSomethingcc(resultC)
}, errCallback)
}, errCallback)
}, errCallback)
function errCallback(err) {
console.log(err)
}
回调地狱不仅让人头疼,而且程序员很容易在多层嵌套中迷失自己,并写出正常情况下不会出现的bug。为了解决这个情况,人们又发明了Promise来替换回调函数,从本质上来说,Promise主要是改变了传递回调函数的方式和IO操作之间参数传递的方式,我们重新组织代码并将以前的回调函数替换为Promise的形式,那么上面的代码最后就可以最终重构为这样的形式。
A.call()
.then((resultA) => {
doSomething(resultA)
return B.call(resultA)
})
.then((resultB) => {
doSomethingMore(resultB)
return C.call(resultB)
})
.then((resultC) => {
doSomethingcc(resultC)
})
// 注册错误处理函数
.catch((err) => {
console.log(err)
})
可以看到代码中嵌套的结构消失了,Promise成功将程序的结构变得扁平化并更加符合我们顺序执行的思考习惯,使用then和catch就优雅的实现了传递值和捕捉异常的功能,那么可不可以再进一步让异步编程变得和普通编程几乎一样呢?实际上,使用JavaScript的程序员能做的到Promise基本就结束了,但语言的开发人员要做更进一步却是可以的,在原有JavaScript的基础上,ES2017中规范了async/await函数的使用,将易写、可读的异步编程y引入了JavaScript的世界中,本质上async/await相对于Promise并没有添加什么新的东西,而是相当于为Promise增加了一层语法糖,底层还是使用的Promise来实现,只不过编译器通过分析语法树自动将其编译为了Promise形式而已,上面的Promise实现使用async实现是这样的:
try {
const resultA = await A.call()
doSomething(resultA)
const resultB = await B.call(resultA)
doSomethingMore(resultB)
const resultC = await C.call(resultB)
doSomethingcc(resultC)
} catch (err) {
console.log(err)
}
现代的Node.js应用基本都是使用async/await方式进行异步编程的,这种方式具备更好的可读性且不会影响本来的性能。和多线程(包括应用级线程)的服务器框架相比,async/await基本没有丧失太多的可读性和易用性,且某种意义上将IO操作使用async分离出来也是一个优点。无论如何,Node.js异步单线程的本质就决定了只能使用异步编程,区别只是用什么方式而已。
所有围绕Node的一切Permalink
当开始Node.js开发的时候接触到的第一个工具就是它的包管理器——npm,关于npm已经有很多批判和抱怨了,这让它似乎变成了千夫所指的存在。有趣的是作为一个包管理器,npm本身似乎并不存在什么无可救药的缺点,npm本身的依赖管理和别的工具并无太大区别,一个项目无非由一个定义项目依赖的package.json和安装依赖的node_modules文件夹组成,对于每一个项目依赖都是安装在其本地的node_modules目录下,这可以有效防止不同项目的依赖冲突的情况,是一个很好的实践。
当我们批判node_modules太重时,我们实际上是在指责JavaScript生态圈的包开发者们肆无忌惮的添加依赖,甚至在有时明明只需要使用一两个十分简单甚至可以手写的函数,开发者却会去添加一个十分重的库作为依赖,进而引入更多该库所依赖的依赖,造成万劫不复的局面。和同样作为动态语言的Python的情况对比(pip本身有更多的问题)就可以很容易的推断出来,Node.js的困境主要是由于缺少一个官方的强大的标准库,这促成了实现各种功能的库百花齐放的局面,而大家在需要一个通用的功能(如深度拷贝、生成随机整数)时第一反应是npm install一个库,甚至实现相同功能的库还可能存在多个不同的替代品(不同的库为了实现相同的功能还可能引用不同的依赖),也难怪node_modules会不断膨胀了。
广为流传的node_modules笑话
由于其定位的原因,Node.js服务往往处于业务当中最靠前的位置,在大家都提倡微服务架构的今天也渐渐在业务开发中占有了一席之地。但在业务逻辑复杂较为复杂JavaScript的一些问题就暴露出来了,很多人声称JavaScript的动态类型性质让我们得以快速开发,但实际上动态类型给项目的维护和开发带来了不少问题,下面会给出两个论点:
首先,JavaScript的简单易写性主要来源于它对象的动态性,其原型机制和Object的灵活让程序员可以肆意操作代码中的对象,从而不打草稿的快速写出代码。其次,让变量拥有动态类型几乎没有任何好处(你几乎不会想让一个局部变量先是number类型然后再变成string类型,也不会想让一个函数有时返回数字有时返回字符串),但却会丧失静态类型所带来的所有好处(编译器自动补全、类型静态检查)。关于动态类型的危害性我可以再写一篇文章出来,这里就不多赘述,幸运的是我们有了TypeScript,它在尽可能兼容JavaScript的前提下引入了强大的类型系统,大大提升了Node.js服务的开发体验和可维护性,至少再也不会出现因为不小心写错变量名而出现undefined的bug的情况了。
目前有人维护的绝大多数库都支持TypeScript的类型,也有很多库将源代码渐渐迁移到了TypeScript实现,总之,如果你要使用Node.js开发服务的话,使用TypeScript是不会错的。
总结Permalink
说了这么多,当我们要创建一个新的项目时,以下几点可能会让你考虑使用Node.js:
- 完善的生态、各式各样的库
- JavaScript语言上手简单、开发速度快(进阶可以使用TypeScript来保证类型安全和Null safety)
- IO处理高效,比其它动态语言执行效率高
而以下几点则会让你不想使用Node.js:
- 有CPU密集计算的需求
- 讨厌JavaScript(以及TypeScript)这样的动态语言
- 希望语言有强大的标准库,而不希望在难以保证质量的第三方库中做选择
Node.js相对而言还是一门不太成熟的技术,有点鸡肋的官方的文档、不成熟的标准库、还在持续增加的feature都给学习这门技术增加了难度,不过现在来说至少已经是production-ready的水平了,毕竟对于业务开发的程序员来说,能用就行。