Java 在企业应用中占据主导地位。但在云上,Java 比一些竞争对手的使用成本更高。原生编译降低了在云中使用 Java 的成本:它创建的应用程序启动速度更快,占用的内存更少。
原生编译给 Java 用户带来了许多问题:原生 Java 对开发有什么影响?我们什么时候应该改用原生 Java?什么时候不应该?我们应该使用什么框架来开发原生 Java?这个系列将回答这些问题。
这篇文章是 InfoQ 系列文章“原生编译为Java提速”的一部分。感兴趣的读者可以订阅RSS以接收通知。
微服务架构的日益普及让人想起电影《壮志凌云》中的一句名言:“我感到了渴望,对速度的渴望!”就运行云服务而言,缩小容器、缩短启动时间和降低资源占用率已经变得越来越重要。
长期以来,Java 一直因启动慢、依赖多(无论是否全部使用)以及资源需求大而受到批评,而 JVM 和应用服务器进一步增大了资源需求。
传统的解释型 Java 服务对于真正的微服务平台,尤其是无服务器 API 来说,已经不是那么理想了。
这正是原生 Java 真正闪光的地方......
原生 Java 非常适合 Kubernetes、微服务和无服务器组件。在开发新服务或将较大的单体应用分解成较小的服务时,也是很理想的机会。
采用原生 Java 不一定要使用“大爆炸”的方式——可以一次切换一个服务。这样可以将风险降到最低。而且,随着时间的推移,技术的进一步成熟,这将帮助我们建立信心。
一开始,这一举措可能会让人不知所措,但它与现在的传统 Java 开发差别不大。
Logicdrop为业务自动化和数据智能开发了一个多合一平台,使企业能够设计自己的解决方案并部署到云中。我们的平台最初是使用 Spring Boot 和 Drools 开发的,现已从头重新设计过,只使用Quarkus和 Kogito,并主要部署本地 Java 可执行文件。
在切换到原生 Java 之前,在云原生基础设施中运行越来越多的 Spring Boot 服务变成了一件很有挑战性的事,更不用说规模扩大带来的成本增加了。无论功能如何,容器的大小总是在 1GB 左右,因为它们需要一个 JVM 并包括一整套的依赖关系(无论是否使用)。启动时间平均为 15 到 30 秒,而且由于资源限制,每个节点只能运行少数几个服务。
在迁到 Quarkus 之后,生成的本地可执行文件明显变小,启动速度明显加快,并且总体上使用的资源更少。容器大小压缩后不到 50MB,不到 1 秒钟就可以做好接收请求的准备。不管是在成本还是性能方面,这些收益都使原生 Java 成为关注容器大小和启动时间的环境的理想选择。
吞吐量不太重要,我们也发现切换后大致相同。由于扩展速度更快,每个节点部署的服务更多,所以水平扩展足以适应任何变化。
下面举个我们节省成本的例子。在亚马逊的 Kubernetes 服务 EKS 中的一个集群上,通过五个节点运行多个 Spring Boot 服务的成本将近 5000 美元/年。切换到原生 Java 可以减少近 50%的成本,因为只需要一半的资源。扩大到所有集群,这将为我们节约巨额的成本!
原生 Java 令人印象深刻。GraalVM 使得 Java 可以与其他“更轻更快”的技术栈相媲美,同时保留了我们所熟悉的 Java 结构。而 "更轻更快 "在云中至关重要。
本地 Java 可执行文件也更安全。GraalVM 通过剥离未使用的类、方法和字段,缩小了攻击面。
新增的微服务是原生 Java 的理想起点,因为可以从头开始编写,利用成熟的原生库。
在决定将什么切换到原生 Java 时,可以从以下这些先决条件入手:
正如本系列中有关GraalVM的那篇文章所言,可能需要额外的配置来正确处理 Java 动态特性(如反射)。如果没有这些额外的元数据,在把一个库作为本地可执行文件的一部分使用时可能会失败。所以,根据我们的经验,一个 Java 库要么兼容原生 Java,要么不兼容。
使用一个提供了一套精选库的框架,方便我们知道在原生 Java 中什么有效什么无效。遗憾的是,其他 Java 库的情况就比较难判断了:目前,判断一个库是否兼容原生 Java 的唯一方法是在一个本地可执行文件中运行它。大多数情况下,如果有任何问题,很快就会暴露出来。
Apache Ignite 就是这样一个库,它在原生 Java 中运行失败了,因为它依赖于底层的 Java API。在某些 Spring Boot 服务中,我们仍然使用它进行缓存,但现在已经在本地可执行文件中用 Redis 取代了它。
了解哪些库兼容原生 Java 是决定在原生 Java 中使用什么库的一个重要因素。对于不兼容的库,我们要么使用一个替代品,要么重新实现其功能。
幸运的是,大多数 Java 应用程序通常都会依赖于框架中已经存在的类似功能——日志、REST API、JSON 等。举例来说,这些 API 已经存在于 Quarkus 中,并且与原生 Java 兼容:
可以看出,许多常用的库已经可以用于原生 Java。而且,这个清单还在继续增加。
不过,并不是所有的服务都适合于原生 Java。有些库和代码会因为迁移过度麻烦而不值得。对于这些服务,最好的办法是保持原样,以后再重新评估。
根据我们的经验,在以下情况下,迁移到原生 Java 是没有意义的:
关于动态 Java 的说明:GraalVM 不支持动态代理,因为本地可执行文件在构建时需要所有的类。至于反射,它是支持的。当有元素不能在构建时无法解析时,就会有一个可以在普通的 JVM 上运行的代理,负责追踪反射和动态代理对象的使用。
只要复杂度、工作量和风险超过了迁移到原生 Java 所带来的直接好处,我们就把这些服务放到待办事项中,以后再说。这类服务属于少数。
选择一个原生框架就像选择一个初始小精灵(Pokemon):每一个都有它的优缺点。因此,选择的时候需要仔细考虑长期使用的问题。
原生 Java 可以用于普通 Java 开发。但对于大多数组织来说,应该选择基于一个框架来构建,因为那样可以减少模板代码,并提供一套精选的 API,进而节省时间和精力。此外,每个框架都能使你免于构建本地可执行文件的繁琐过程,进一步降低复杂性和学习曲线。
所选择的框架应该完全支持 GraalVM,提供一个支持原生 Java 的丰富的生态系统,并以对企业有意义的方式简化本地可执行文件的构建。考虑到这一点,目前只有三个 Java 框架做到了——Quarkus、Micronaut 和 Helidon。
有些框架甚至可以“按传统方式”在 JVM 中运行,同时还可以利用 GraalVM 的一些优化。当应用程序或服务不能完全本地运行时,这可能是一个不错的退路。
在评估了现有的框架后,我们选择了 Quarkus。它是启动和运行速度最快的框架。它充分利用了 Java 标准,文档非常好。我们需要的所有功能都开箱即用,而且社区也可以提供很大的帮助和支持。这就是为什么在短短几个月内,我们整个后端团队就从 Spring Boot 转到了 Quarkus。
切换到原生 Java 并不像人们想象的那样可怕——开发经验基本保持不变。不过有些过程需要稍微改变下,以便更好地交付本地可执行文件。
对于日常开发,我们还是像往常一样开发 Java 服务:编写 Java 代码,并使用 IDE 或命令行工具来测试和调试。构建本地可执行文件会给这个过程带来额外的步骤和新的注意事项。
下面是一个面向本地可执行文件的典型过程:
与传统 Java 开发不同,构建本地 Java 可执行文件是资源密集型的——即使是在一个相当大的工作站上,构建一个服务也需要 2 到 10 分钟!
而与传统 Java 开发不同,创建一个 WAR 或 JAR 文件还不够:每个操作系统都需要自己的本地可执行文件。由于本地可执行文件内联了自己的代码和属性,每个环境也需要自己的本地可执行文件。例如,Swagger 可能会在过渡环境中暴露,但在生产环境中却没有。因此,过渡环境的可执行文件在构建时需要包含 Swagger 依赖,而生产环境的可执行文件则不需要。对于任何不能在运行时处理的属性或配置也是如此。如果只针对 Linux 容器,那么构建就会大大简化。
最好是在需要时才在开发者机器上构建本地 Java 可执行文件。这可能是在一个重要的功能即将合并之前,或者在出现问题需要调试的时候。相反,依靠 CI/CD 管道来减轻针对不同目标的构建和测试任务,降低这个过程的干扰性,减少开发人员的压力。
我们之前提到过,使用本地可执行文件的容器要小得多,需要的资源也少得多。这样我们就能够将多个预览环境部署到集群中,而不是仅仅依赖单个的共享环境。开发人员现在可以同时测试所有的服务,针对他们特定的配置在单独的环境中进行原生构建,而不会影响到其他人。在传统 Java 开发中,这也是可以做到的,但由于受到云资源的限制,成本要高得多。
例如,我们一开始通常只有三个环境:开发、过渡和生产。使用本地可执行文件,我们现在可以有 20 个以上的预览环境,每个环境都构建并配置了所需的所有服务(目前约 20 个)。因此,我们现在可以并行运行 20 个甚至更多的预览环境,总共可以暴露出 400 个服务,而不是共享一个只能容纳 20 个服务的开发环境。
当问题出现在本地可执行文件中时,就需要对本地可执行文件进行调试。这需要一些额外的设置和工具,并有一个 GraalVM。不过,一旦设置好了,就和使用现如今流行的 IDE 调试 Java 没有什么区别了。
调试开始时,要附加到正在运行的本地进程,将 IDE 与 Java 源文件链接起来,最后单步调试代码。一旦附加到进程,就可以进行所有常规操作:设置断点、创建监控、检查状态等。
幸运的是,自从我们踏上原生 Java 之旅以来,这些工具已经有了长足的进步。例如,Visual Studio Code 为 Quarkus 和 GraalVM 提供了优秀的扩展,提供了完备的 Java 开发和调试能力,并包含了 GraalVM 运行时。这个扩展也实现了与 VisualVM 的集成,这样就可以分析本地可执行文件了。
根据GraalVM FAQ,IntelliJ、Eclipse 和 Netbeans 也支持 GraalVM。万不得已的时候,可以使用 GNU Debugger 来调试本地可执行文件。
测试本地 Java 可执行文件与测试传统 Java 服务类似。但我们有必要理解其中的细微差别。
本地可执行文件测试有一个明显的不利因素,就是本地可执行文件的静态性和封闭性。一些依赖 Java 动态特性的行之有效的测试方法,如模拟库,在这里就无法使用了。对源代码的任何修改都需要首先构建一个新的本地可执行文件,这个过程也比传统 Java 慢得多。
GraalVM 还设法尽可能多地内联和/或删除代码。这可能会导致许多问题。
一个误删代码的例子是 Jackson JSON 序列化。我们的 JUnit 测试报告显示,在开发过程中,序列化是正常的。但本地可执行文件缺少特定的嵌套模型,而且没有抛出异常。原因是 GraalVM 从可执行文件中删除了一些模型,因为它认为这些模型从未使用过。修复方法很简单:在 GraalVM 中注册任何用于 JSON 有效载荷的类。这样就可以防止它们被排除在本地可执行文件之外。我们还扩展了测试,彻底检查了有效载荷,并舔加了更多的烟雾测试。
动态特性(如反射)是另一个需要密切关注的领域。在某些情况下,异常可能不会被抛出,或者某些功能问题直到可执行文件部署后才会显现。
除此之外,我们发现,就保证本地可执行文件的预期功能和有效载荷的正确性来说,端点测试是绝佳的方式。不管是在 JVM 还是在本地可执行文件中运行,从特定服务的入口点开始测试,都是在最重要的地方验证功能的一个好方法。
转向原生 Java 从来都不是我们最初的目标。我们只是想重构现有的平台,在更大程度上拥抱云原生,为即将到来的特性做准备,并更好地利用大规模 Kubernetes 集群。
我们相信,选择 Quarkus 是我们有史以来最好的决策之一。它使得采用原生 Java 变得非常容易。通过前期的计划和努力,在构建了一些原型之后,我们很快就发现,一头扎进原生 Java 是可能的,而且只付出很少的努力,就自然地发生了。
当然,这个过程肯定会遇到一些挑战。相比于传统的开发和交付,也肯定会有一些变化。但是,与现如今的 Java 服务开发相比也不会有太大的区别。对我们来说,切换到原生 Java 仅仅是对现有流程的一个扩展。
最后,任何微服务通常都会受益于更快的启动时间和更少的资源占用。原生 Java 的优势(特别是在 Kubernetes 中),加上成本的节约和效率的显著提升,是我们转向原生 Java 的原因。
本地 Java 可执行文件将 Java 提升到了一个新的水平。如果机会真的出现了,而且条件合适,那就非常值得付出一些努力,开始使用 GraalVM!
KimJohn Quinn 是 Logicdrop 的联合创始人和技术专员。他的时间分成了两部分,一部分用于研究新技术以及如何实现最佳应用,一部分用于领导产品和云架构团队。最近,他迷上了 Quarkus(因为它真的让 Java 开发重新变得有趣)和精简 DevOps 流程。
Rakesh Raja 是 Logicdrop 的一名计算机科学家,专注于专家系统。在过去的三年里,他一直在帮助开发平台(最近转向了 Quarkus 和 Kogito)。现在,他在带头推行一些需要借助于复杂的业务自动化解决方案(综合运用 BPM、决策表和规则)的举措。
Jason Moehlman 是 Logicdrop 的一名数据科学家,负责分解复杂的数据密集型流程,以便将其转化为更有效的规则执行模型。他还在可观测性领域花了相当多的时间,对从 Logicdrop 云原生基础设施中收集的大量数据进行交叉分析(slicing and dicing)。
了解更多软件开发与相关领域知识,点击访问 InfoQ 官网:https://www.infoq.cn/,获取更多精彩内容!