我是前Sun公司Java SE团队的一名成员,在工作了10多年之后——2009年1月——也就是在甲骨文收购Sun公司之前,我离开了公司,然后迷上了Node.js.
我对Node.js的痴迷到了怎样的程度?自2010年以来,我撰写了大量有关Node.js编程的文章,出版了四本与Node.js开发有关的书籍,以及与Node.js编程有关的其他书籍和众多教程。
在Sun公司工作期间,我相信Java就是一切。我在JavaONE上发表演讲,共同开发了java.awt.Robot类,组织Mustang回归竞赛(Java 1.6版本的漏洞发现竞赛),协助推出了“Java发行许可”,这在后来的OpenJDK项目启动过程中起到了一定的作用。我在java.net(这个网站现已解散)上每周写一到两篇博文,讨论Java生态系统中所发生的主要事件,并坚持了6年。这些博文的主要主题是关于“保卫”Java,因为总有人在预言Java的“死期”。
在这篇文章中,我将会解释我这个Java死忠是如何变成一个Node.js和JavaScript死忠的。
但其实我并没有完全脱离Java。在过去的三年中,我编写了大量Java/Spring/Hibernate代码。但两年的Spring编码经历让我明白了一个道理:隐藏复杂性并不会带来简单性,它只会产生更多的复杂性。
有些工具是设计师花费数年磨砺和精炼的结果。他们尝试不同的想法,去掉不必要的属性,最终得到一个只带有恰到好处属性的工具。这些工具的简洁性甚至达到让人感到惊艳的程度,但Java显然不属于这一类。
Spring是一个非常流行的用于开发Java Web应用程序的框架。Spring(特别是Spring Boot)的核心目的是成为一个易于使用的预配置的Java EE栈。Spring程序员不需要直接接触Servlet、数据持久化、应用程序服务器就可以获得一个完整的系统。Spring框架负责处理所有这些细节,你只需要把精力放在业务编码上。例如,JPA Repository类为“findUserByFirstName”方法合成数据库查询——你不需要编写任何查询代码,只需按照特定方式给方法命名,并添加到Repository中即可,Spring将负责处理其余的部分。
这原本是一个伟大的故事,一种很好的体验,但其实并不然。
当你遇到Hibernate的PersistentObjectException时,你知道是哪里出了问题吗?你可能花了几天时间才找到问题所在,导致这个异常的原因是发给REST端点的JSON消息里带有ID字段。Hibernate想要自己控制ID值,所以抛出了这个令人感到困惑的异常。看,这就是过度简化所带来的恶果。除了这个,还有其他成千上万个同样令人感到困惑的异常。在Spring栈中,一个子系统套着另一个子系统,它们坐等你犯错,然后再抛出应用程序崩溃异常来惩罚你。
然后,你会看到满屏的堆栈跟踪信息,里面满是这样那样的抽象方法。面对这种级别的抽象,显然需要更多的逻辑才能找到你想要的内容。如此多的堆栈跟踪信息不一定是不好的,但它也是在提醒我们:这在内存和性能方面的开销究竟有多大?
而零代码的“findUserByFirstName”方法又是如何被执行的?Spring框架必须解析方法名称,猜测程序员的意图,构造类似抽象语法树的东西,生成一些SQL语句……那么完成这个过程需要多少开销?
在反反复复经历这样的过程之后,在花了大量时间学习你本不该学习的东西之后,你可能会得出相同的结论:隐藏复杂性并不会带来简单性,它只会产生更多的复杂性。
Spring和Java EE非常复杂,而Node.js却是一股清流。首先是Ryan Dahl在核心Node.js平台上所应用的设计美学。他追求别样的东西,花了数年时间磨练和改进了一系列核心的Node.js设计理想,最终得到一个轻量级的单线程系统。它巧妙地利用了JavaScript匿名函数进行异步回调,成为一个实现了异步机制的运行时库。
然后是JavaScript语言本身。JavaScript程序员似乎更喜欢无样板的代码,这样他们的意图才能发挥作用。
我们可以通过实现监听器的例子来说明Java和JavaScript之间的差别。在Java中,监听器需要实现抽象接口,还需要指定很多啰七八嗦的细节。程序员的意图的这些繁琐的样板中渐渐淹没。
而在JavaScript中,可以使用最简单的匿名函数——闭包。你不需要实现什么抽象接口,只需要编写所需的代码,没有多余的样板。
大多数编程语言都试图掩盖程序员的意图,这让理解代码变得更加困难。
但在Node.js中有一点需要注意:回调地狱。
在JavaScript中,我们一直难以解决两个与异步相关的问题。一个是Node.js中被称为“回调地狱”的东西。我们很容易就掉入深层嵌套回调函数的陷阱,每个嵌套都会使代码复杂化,让错误和结果的处理变得更加困难。但JavaScript语言并没有为程序员提供正确表达异步执行的方式。
于是,出现了一些第三方库,它们承诺可以简化异步执行。这是另一个通过隐藏复杂性带来更多复杂性的例子。
const async = require(‘async’); const fs = require(‘fs’); const cat = function(filez, fini) { async.eachSeries(filez, function(filenm, next) { fs.readFile(filenm, ‘utf8’, function(err, data) { if (err) return next(err); process.stdout.write(data, ‘utf8’, function(err) { if (err) next(err); else next(); }); }); }, function(err) { if (err) fini(err); else fini(); }); }; cat(process.argv.slice(2), function(err) { if (err) console.error(err.stack); });
这是个模仿Unix cat命令的例子。async库非常适合用于简化异步执行顺序,但同时也引入了一堆模板代码,从而模糊了程序员的意图。
这里实际上包含了一个循环,只是没有使用循环语句和自然的循环结构。此外,错误和结果的处理逻辑被放在了回调函数内。在Node.js采用ES 2015和ES 2016之前,我们只能做到这些。
Node.js 10.x中,等价的代码是这样的:
const fs = require(‘fs’).promises; async function cat(filenmz) { for (var filenm of filenmz) { let data = await fs.readFile(filenm, ‘utf8’); await new Promise((resolve, reject) => { process.stdout.write(data, ‘utf8’, (err) => { if (err) reject(err); else resolve(); }); }); } } cat(process.argv.slice(2)).catch(err => { console.error(err.stack); });
这段代码使用async/await函数重写了之前的逻辑。虽然异步逻辑是一样的,但这次使用了普通的循环结构。错误和结果的处理也显得很自然。这样的代码更容易阅读,也更容易编码,程序员的意图也更容易被理解。
唯一的瑕疵是process.stdout.write没有提供Promise接口,因此用在异步函数中时需要丢Promise进行包装。
回调地狱问题并不是通过隐藏复杂性才得以解决的。相反,是语言和范式的演变解决了这个问题。通过使用async函数,我们的代码变得更加美观。
当我还是Java的死忠时,我坚信严格的类型检查对开发大型的应用程序来说是有百利而无一害的。那个时候,微服务的概念还没有出现,也没有Docker,人们开发的都是单体应用。因为Java具有严格的类型检查,所以Java编译器可以帮你避免很多错误——也就是说可以防止你编译错误的代码。
相比之下,JavaScript的类型是松散。程序员不确定他们收到的对象是什么类型,那么程序员怎么知道该怎么处理这个对象?
但是,Java的严格类型检查同样导致了大量样板代码。程序员经常需要进行类型转换,或以其他方式确保一切都准确无误。程序员需要花很时间确保类型是准确的,所以使用更多的样板代码,希望通过及早捕获和修复错误来节省时间。
程序员不得不使用复杂的大型IDE,仅仅使用简单的编辑器是不行的。IDE为Java程序员提供了一些下拉列表,用于显示类的可用字段、描述方法的参数,帮助他们构建新的类和进行重构。
然后,你还得使用Maven……
在JavaScript中,不需要声明变量的类型,所以通常不需要进行类型转换。因此,代码更易于阅读,但可能会出现未编译错误。
这一点会让你更喜欢Java还是痛恨Java,取决于你自己。十年前,我认为Java的类型系统值得我们花费额外的时间,因为这样可以获得更多的确定性。但在今天,我认为代价太大了,使用JavaScript会要简单得多。
Node.js鼓励程序员将程序划分为小单元,也就是模块。模块虽小,却能从一定程度上解决刚刚提到的问题。
一个模块应该具备以下特点:
所有这些特点组合在一起,让Node.js模块更容易测试,并具有明确定义的范围。
人们对JavaScript的恐惧源自它缺乏严格的类型检查,所以可能很容易导致错误。但在具有清晰边界的模块中,受影响代码被限于模块内部。所以,大多数问题被安全地隐藏在模块的边界内。
松散类型问题的另一个解决方案是进行更多的测试。
你必须将节省下来的一部分时间(因为编写JavaScript代码更容易)用在测试上。你的测试用例必须捕获编译器可能捕获的错误。
对于那些想要在JavaScript中使用静态检查类型的人,可以考虑使用TypeScript。我没有使用TypeScript,但听说它很不错。它与JavaScript兼容,同时提供了有用的类型检查和其他特性。
但我们的重点是Node.js和JavaScript。
一想起Maven我就头大。据说一个人要么爱它,要么鄙视它,没有第三种选择。
问题是,Java生态系统中并没有一个核心的包管理系统。Maven和Gradle其实也很不错,但它们并不像Node.js的包管理系统那样有用、可用和强大。
在Node.js世界中,有两个优秀的包管理系统,首先是npm和npm存储库。
有了npm,我们就相当于有了一个很好的模式用来描述包依赖性。依赖关系可以是严格的(指定具体的版本),或者使用通配符表示最新版本。Node.js社区已经向npm存储库发布了数十万个包。
不仅仅是Node.js工程师,前端工程师也可以使用npm存储库。以前他们使用Bower,现在Bower已被弃用,他们现在可以在npm存储库中找到所有可用的前端JavaScript库。很多前端框架,如Vue.js CLI和Webpack,都是基于Node.js开发的。
Node.js的另一个包管理系统是yarn,它也是从npm存储库中拉取包,并使用与npm相同的配置文件。yarn的主要优点运行得更快。
曾几何时,Java和JavaScript都因为运行速度慢而横遭指责。
它们都需要通过编译器将源代码转换为由虚拟机执行的字节码。虚拟机通常会进一步将字节码编译为本地代码,并使用各种优化技术。
Java和JavaScript都有很大的动机让代码运行得更快。在Java和Node.js中,动机就是让服务器端代码运行得更快。而在浏览器端,动机是获得更好的客户端应用程序性能。
甲骨文的JDK使用了HotSpot,这是一个具有多种字节代码编译策略的超级虚拟机。HotSpot经过高度优化,可以生成非常快的代码。
至于JavaScript,我们不禁在想:我们怎么能期望在浏览器中运行的JavaScript代码能够实现复杂的应用程序?基于浏览器JavaScript实现办公文档处理套件似乎是件不可能实现的事情?是骡子是马,拉出来溜溜就知道了。这篇文章是我用谷歌文档写的,它性能非常好。浏览器端JavaScript的性能每年都在飞涨。
Node.js直接受益于这一趋势,因为它使用的是Chrome的V8引擎。
下面是Peter Marshall的演讲视频链接,他是谷歌的一名工程师,主要负责V8引擎的性能增强工作。他在视频中描述了为什么V8引擎使用Turbofan虚拟机替换了Crankshaft虚拟机。
V8引擎中的高性能JavaScript: https://youtu.be/YqOhBezMx1o
在机器学习领域,数据科学家通常使用R语言或Python,因为他们十分依赖快速数值计算。但由于各种原因,JavaScript在这方面表现很差。不过,有人正在开发一种用于数值计算的标准JavaScript库。
JavaScript中的数值计算: https://youtu.be/1ORaKEzlnys
另一个视频演示了如何通过TensorFlow.js在JavaScript中使用TensorFlow。它提供了一个类似于TensorFlow Python的API,可以导入预训练模型。它运行在浏览器中,可用于分析实时视频,从中识别出经过训练的对象。
基于JavaScript的机器学习: https://youtu.be/YB-kfeNIPCE
在另一个演讲视频中,IBM的Chris Bailey讨论了Node.js的性能和可伸缩性问题,特别是在Docker/Kubernetes部署方面。他从一组基准测试开始,演示了Node.js在I/O吞吐量、应用程序启动时间和内存占用方面远远超过Spring Boot。此外,得益于V8引擎的改进,Node.js每次发布的新版在性能方面都有显著的提升。
Node.js的性能和高度可伸缩的微服务: https://youtu.be/Fbhhc4jtGW4
在上面的这个视频中,Bailey说我们不应该在Node.js中运行计算密集型的代码。因为Node.js采用了单线程模型,长时间运行计算密集型任务会导致事件阻塞。
如果JavaScript的改进还无法满足你的应用程序的要求,还有其他两种方法可以将本地代码直接集成到Node.js中。最直接的方法是使用Node.js本地代码模块。Node.js工具链中包含了node-gyp,可用于处理与本地代码模块的链接。下面的视频演示了如何集成Rust库和Node.js:
JavaScript与Rust集成,远比你想象得简单: https://youtu.be/Pfbw4YPrwf4
WebAssembly可以将其他语言编译为运行速度非常快的JavaScript子集。WebAssembly是一种可在JavaScript引擎内运行的可执行代码的可移植格式。下面的视频做了一个很好的概述,并演示了如何使用WebAssembly在Node.js中运行代码。
在NodeJS中使用WebAssembly: https://youtu.be/hYrg3GNn1As
十年前,软件行业一直热议利用快速的JavaScript引擎实现富Internet应用程序,从而取代桌面应用程序。
这个故事实际上在二十多年前就已经开始了。Sun公司和Netscape公司达成了共识,在Netscape Navigator中使用Java小程序(Applet)。JavaScript语言在某种程度上是作为Java小程序的脚本语言而开发出来的。服务器端有Java Servlet,客户端有Java Applet,这样就可以在两端使用同样的一门编程语言。然而,由于各种原因,这种美好的愿望并没有实现。
十年前,JavaScript开始变得足够强大,可以实现复杂的应用程序。因此,RIA被认为是Java客户端应用程序的终结者。
今天,我们开始看到RIA的想法得以实现。服务器端的Node.js和两端都有的JavaScript让这一切成为可能。
当然,Java作为桌面应用程序平台的消亡并不是因为JavaScript RIA,而是因为Sun公司忽视了客户端技术。Sun公司把注意力放在要求快速服务器端性能的企业客户身上。当时我还在Sun公司任职,我亲眼看着这件事情发生。真正杀死Applet的是几年前在Java插件和Java Web Start中发现的一个安全漏洞。这个漏洞导致全球一致呼吁停止使用Java Applet和Java Web Start应用程序。
我们仍然可以开发其他类型的Java桌面应用程序,NetBeans和Eclipse IDE之间的竞争仍然存在。但是,Java在这个领域工作是停滞不前的,除了开发工具之外,很少有基于Java的应用程序。
JavaFX是个例外。
10年前,JavaFX意欲成为Sun公司对iPhone的反击。它用于开发基于Java的手机GUI应用程序,想把Flash和iOS应用程序打垮。然而,这一切都没有发生。JavaFX现在仍然可以使用,但没有了当初的喧嚣。
这个领域的所有兴奋点都发生在React、Vue.js和类似的框架上,JavaScript和Node.js在很大程度上要得益于此。
现在,开发服务器端应用程序有很多选择。我们不再局限于“P”开头的语言(Perl、PHP、Python)和Java,我们还有Node.js、Ruby、Haskell、Go、Rust等等。
至于为什么我会转向Node.js,很明显,我更喜欢在使用Node.js编程时的那种自由的感觉。Java成了负担,而Node.js没有这样的负担。如果我再次拿起Java,那肯定是因为有人付了钱。
每个应用程序都有其真实需求。只是因为个人喜欢而一直使用Node.js也不见得是对的。在选择一门语言或一个框架时总归是有技术方面的考量的。例如,我最近完成的一些工作涉及XBRL文档,由于最好的XBRL库是用Python实现的,所以就有必要学习Python。
英文原文: https://blog.sourcerer.io/why-is-a-java-guy-so-excited-about-node-js-and-javascript-7cfc423efb44
感谢覃云对本文的审校。