DavidHerron译者
无明
我是前Sun公司JavaSE团队的一名成员,在工作了10多年之后——年1月——也就是在甲骨文收购Sun公司之前,我离开了公司,然后迷上了Node.js。
我对Node.js的痴迷到了怎样的程度?自年以来,我撰写了大量有关Node.js编程的文章,出版了四本与Node.js开发有关的书籍,以及与Node.js编程有关的其他书籍和众多教程。
在Sun公司工作期间,我相信Java就是一切。我在JavaONE上发表演讲,共同开发了java.awt.Robot类,组织Mustang回归竞赛(Java1.6版本的漏洞发现竞赛),协助推出了“Java发行许可”,这在后来的OpenJDK项目启动过程中起到了一定的作用。我在java.net(这个网站现已解散)上每周写一到两篇博文,讨论Java生态系统中所发生的主要事件,并坚持了6年。这些博文的主要主题是关于“保卫”Java,因为总有人在预言Java的“死期”。
在这篇文章中,我将会解释我这个Java死忠是如何变成一个Node.js和JavaScript死忠的。
但其实我并没有完全脱离Java。在过去的三年中,我编写了大量Java/Spring/Hibernate代码。但两年的Spring编码经历让我明白了一个道理:隐藏复杂性并不会带来简单性,它只会产生更多的复杂性。
1Java是负担,Node.js充满了乐趣有些工具是设计师花费数年磨砺和精炼的结果。他们尝试不同的想法,去掉不必要的属性,最终得到一个只带有恰到好处属性的工具。这些工具的简洁性甚至达到让人感到惊艳的程度,但Java显然不属于这一类。
Spring是一个非常流行的用于开发JavaWeb应用程序的框架。Spring(特别是SpringBoot)的核心目的是成为一个易于使用的预配置的JavaEE栈。Spring程序员不需要直接接触Servlet、数据持久化、应用程序服务器就可以获得一个完整的系统。Spring框架负责处理所有这些细节,你只需要把精力放在业务编码上。例如,JPARepository类为“findUserByFirstName”方法合成数据库查询——你不需要编写任何查询代码,只需按照特定方式给方法命名,并添加到Repository中即可,Spring将负责处理其余的部分。
这原本是一个伟大的故事,一种很好的体验,但其实并不然。
当你遇到Hibernate的PersistentObjectException时,你知道是哪里出了问题吗?你可能花了几天时间才找到问题所在,导致这个异常的原因是发给REST端点的JSON消息里带有ID字段。Hibernate想要自己控制ID值,所以抛出了这个令人感到困惑的异常。看,这就是过度简化所带来的恶果。除了这个,还有其他成千上万个同样令人感到困惑的异常。在Spring栈中,一个子系统套着另一个子系统,它们坐等你犯错,然后再抛出应用程序崩溃异常来惩罚你。
然后,你会看到满屏的堆栈跟踪信息,里面满是这样那样的抽象方法。面对这种级别的抽象,显然需要更多的逻辑才能找到你想要的内容。如此多的堆栈跟踪信息不一定是不好的,但它也是在提醒我们:这在内存和性能方面的开销究竟有多大?
而零代码的“findUserByFirstName”方法又是如何被执行的?Spring框架必须解析方法名称,猜测程序员的意图,构造类似抽象语法树的东西,生成一些SQL语句……那么完成这个过程需要多少开销?
在反反复复经历这样的过程之后,在花了大量时间学习你本不该学习的东西之后,你可能会得出相同的结论:隐藏复杂性并不会带来简单性,它只会产生更多的复杂性。
2Node.js是清流Spring和JavaEE非常复杂,而Node.js却是一股清流。首先是RyanDahl在核心Node.js平台上所应用的设计美学。他追求别样的东西,花了数年时间磨练和改进了一系列核心的Node.js设计理想,最终得到一个轻量级的单线程系统。它巧妙地利用了JavaScript匿名函数进行异步回调,成为一个实现了异步机制的运行时库。
然后是JavaScript语言本身。JavaScript程序员似乎更喜欢无样板的代码,这样他们的意图才能发挥作用。
我们可以通过实现监听器的例子来说明Java和JavaScript之间的差别。在Java中,监听器需要实现抽象接口,还需要指定很多啰七八嗦的细节。程序员的意图的这些繁琐的样板中渐渐淹没。
而在JavaScript中,可以使用最简单的匿名函数——闭包。你不需要实现什么抽象接口,只需要编写所需的代码,没有多余的样板。
大多数编程语言都试图掩盖程序员的意图,这让理解代码变得更加困难。
但在Node.js中有一点需要注意:回调地狱。
3没有完美的解决方案在JavaScript中,我们一直难以解决两个与异步相关的问题。一个是Node.js中被称为“回调地狱”的东西。我们很容易就掉入深层嵌套回调函数的陷阱,每个嵌套都会使代码复杂化,让错误和结果的处理变得更加困难。但JavaScript语言并没有为程序员提供正确表达异步执行的方式。
于是,出现了一些第三方库,它们承诺可以简化异步执行。这是另一个通过隐藏复杂性带来更多复杂性的例子。
constasync=require(‘async’);constfs=require(‘fs’);constcat=function(filez,fini){async.eachSeries(filez,function(filenm,next){fs.readFile(filenm,‘utf8’,function(err,data){if(err)returnnext(err);process.stdout.write(data,‘utf8’,function(err){if(err)next(err);elsenext();});});},function(err){if(err)fini(err);elsefini();});};cat(process.argv.slice(2),function(err){if(err)console.error(err.stack);});
这是个模仿Unixcat命令的例子。async库非常适合用于简化异步执行顺序,但同时也引入了一堆模板代码,从而模糊了程序员的意图。
这里实际上包含了一个循环,只是没有使用循环语句和自然的循环结构。此外,错误和结果的处理逻辑被放在了回调函数内。在Node.js采用ES和ES之前,我们只能做到这些。
Node.js10.x中,等价的代码是这样的:
constfs=require(‘fs’).promises;asyncfunctioncat(filenmz){for(varfilenmoffilenmz){letdata=awaitfs.readFile(filenm,‘utf8’);awaitnewPromise((resolve,reject)={process.stdout.write(data,‘utf8’,(err)={if(err)reject(err);elseresolve();});});}}cat(process.argv.slice(2)).catch(err={console.error(err.stack);});
这段代码使用async/await函数重写了之前的逻辑。虽然异步逻辑是一样的,但这次使用了普通的循环结构。错误和结果的处理也显得很自然。这样的代码更容易阅读,也更容易编码,程序员的意图也更容易被理解。
唯一的瑕疵是process.stdout.write没有提供Promise接口,因此用在异步函数中时需要丢Promise进行包装。
回调地狱问题并不是通过隐藏复杂性才得以解决的。相反,是语言和范式的演变解决了这个问题。通过使用async函数,我们的代码变得更加美观。
4通过明确定义的类型和接口提升清晰度当我还是Java的死忠时,我坚信严格的类型检查对开发大型的应用程序来说是有百利而无一害的。那个时候,微服务的概念还没有出现,也没有Docker,人们开发的都是单体应用。因为Java具有严格的类型检查,所以Java编译器可以帮你避免很多错误——也就是说可以防止你编译错误的代码。
相比之下,JavaScript的类型是松散。程序员不确定他们收到的对象是什么类型,那么程序员怎么知道该怎么处理这个对象?
但是,Java的严格类型检查同样导致了大量样板代码。程序员经常需要进行类型转换,或以其他方式确保一切都准确无误。程序员需要花很时间确保类型是准确的,所以使用更多的样板代码,希望通过及早捕获和修复错误来节省时间。
程序员不得不使用复杂的大型IDE,仅仅使用简单的编辑器是不行的。IDE为Java程序员提供了一些下拉列表,用于显示类的可用字段、描述方法的参数,帮助他们构建新的类和进行重构。
然后,你还得使用Maven……
在JavaScript中,不需要声明变量的类型,所以通常不需要进行类型转换。因此,代码更易于阅读,但可能会出现未编译错误。
这一点会让你更喜欢Java还是痛恨Java,取决于你自己。十年前,我认为Java的类型系统值得我们花费额外的时间,因为这样可以获得更多的确定性。但在今天,我认为代价太大了,使用JavaScript会要简单得多。
5使用易于测试的小模块来扫除bugNode.js鼓励程序员将程序划分为小单元,也就是模块。模块虽小,却能从一定程度上解决刚刚提到的问题。
一个模块应该具备以下特点:
自包含——将相关代码打包到一个单元中;
强壮的边界——模块内部的代码可以防止外部代码入侵;
显式导出——默认情况下,代码和模块中的数据不会导出,只将选定的函数和数据暴露给外部;
显式导入——声明它们依赖哪些模块;
可能是独立的——可以将模块公开发布到npm存储库或其他私有存储库,方便在应用程序之间共享;
易于理解——更少的代码意味着更容易理解模块的用途;
易于测试——小模块可以轻松进行单元测试。
所有这些特点组合在一起,让Node.js模块更容易测试,并具有明确定义的范围。
人们对JavaScript的恐惧源自它缺乏严格的类型检查,所以可能很容易导致错误。但在具有清晰边界的模块中,受影响代码被限于模块内部。所以,大多数问题被安全地隐藏在模块的边界内。
松散类型问题的另一个解决方案是进行更多的测试。
你必须将节省下来的一部分时间(因为编写JavaScript代码更容易)用在测试上。你的测试用例必须捕获编译器可能捕获的错误。
对于那些想要在JavaScript中使用静态检查类型的人,可以考虑使用TypeScript。我没有使用TypeScript,但听说它很不错。它与JavaScript兼容,同时提供了有用的类型检查和其他特性。
但我们的重点是Node.js和JavaScript。
6包管理一想起Maven我就头大。据说一个人要么爱它,要么鄙视它,没有第三种选择。
问题是,Java生态系统中并没有一个核心的包管理系统。Maven和Gradle其实也很不错,但它们并不像Node.js的包管理系统那样有用、可用和强大。
在Node.js世界中,有两个优秀的包管理系统,首先是npm和npm存储库。
有了npm,我们就相当于有了一个很好的模式用来描述包依赖性。依赖关系可以是严格的(指定具体的版本),或者使用通配符表示最新版本。Node.js社区已经向npm存储库发布了数十万个包。
不仅仅是Node.js工程师,前端工程师也可以使用npm存储库。以前他们使用Bower,现在Bower已被弃用,他们现在可以在npm存储库中找到所有可用的前端JavaScript库。很多前端框架,如Vue.jsCLI和Webpack,都是基于Node.js开发的。
Node.js的另一个包管理系统是yarn,它也是从npm存储库中拉取包,并使用与npm相同的配置文件。yarn的主要优点运行得更快。
7性能曾几何时,Java和JavaScript都因为运行速度慢而横遭指责。
它们都需要通过编译器将源代码转换为由虚拟机执行的字节码。虚拟机通常会进一步将字节码编译为本地代码,并使用各种优化技术。
Java和JavaScript都有很大的动机让代码运行得更快。在Java和Node.js中,动机就是让服务器端代码运行得更快。而在浏览器端,动机是获得更好的客户端应用程序性能。
甲骨文的JDK使用了HotSpot,这是一个具有多种字节代码编译策略的超级虚拟机。HotSpot经过高度优化,可以生成非常快的代码。
至于JavaScript,我们不禁在想:我们怎么能期望在浏览器中运行的JavaScript代码能够实现复杂的应用程序?基于浏览器JavaScript实现办公文档处理套件似乎是件不可能实现的事情?是骡子是马,拉出来溜溜就知道了。这篇文章是我用谷歌文档写的,它性能非常好。浏览器端JavaScript的性能每年都在飞涨。
Node.js直接受益于这一趋势,因为它使用的是Chrome的V8引擎。
下面是PeterMarshall的演讲视频链接,他是谷歌的一名工程师,主要负责V8引擎的性能增强工作。他在视频中描述了为什么V8引擎使用Turbofan虚拟机替换了Crankshaft虚拟机。
V8引擎中的高性能JavaScript: