高性能、全生态、可扩展的跨平台脚本:babashka

本世纪的软件工程领域包括三大生态:以 LLVM 为核心的 C、C++、Rust、Swift,以及与 C 关系密切的 Python 和 Go,以 JVM 为核心的 Java、Kotlin、Scala、Clojure、Groovy 以及以 JSVM 为核心的 JavaScript、TypeScript 等。而尽管 JVM 生态的包管理工具 Maven 和 JS 生态的包管理工具 NPM 分别有 31M 和 6B 类库可供处理形形色色的任务,但仍然没有一个足够通用、好用和可扩展的脚本工具用来处理日常工作,替代拗口难懂的 Bash 脚本或 Windows 批处理脚本。

JSVM 的开发效率高但执行效率低,作为脚本合适但缺乏扩展性;JVM 的开发效率适中,执行效率较高但内存占用和初始启动耗时太长,作为脚本体验不佳;LLVM 的开发效率低,执行效率高,内存占用小,初始启动快,但没有人愿意写这样的脚本。

得益于 JVM Ahead-Of-Time, AOT 技术的发展,GraalVM 项目中的静态编译 JVM 的 SubstrateVM, SVM 似乎是解决虚拟机高内存消耗,低启动速度的曙光。但由于 JVM 承载应用中大量存在的反射和动态特性,以至于普通的 JVM 应用,比如 SpringBoot 程序很难直接静态链接到本地代码。社区的一种解决方案是另起炉灶,比如 Web 后端在 Spring 体系之外又诞生了以云原生为核心的 Micronaut 等,另一种解决方案则是积极改进和适配,比如 Spring 自身也在转型对于静态链接的支持。但这两种方案对于已有代码和生态库而言都不够友好。

对于“脚本”这个领域任务来说,SVM 是足够的,只要挑选出一批能够静态链接的类库,脚本就可以在这个小圈子里做到快速启动。如果依赖不能静态链接的类库,那么回退到 JVM 也是可以的。这种分层执行的能力保证了在向后兼容的基础上提供不可思议的能力:让“JVM”运行飞快,能够承载日常脚本任务。脚本最好是动态语言,写起来和改起来都会很方便,此外,这门语言应该跨 SVM 和 JVM 可用,即一些语言基础功能不能依赖 JVM 的动态能力,否则需要用户小心翼翼的保证其可以运行在 SVM 上会成为一种痛苦。得益于作为 LISP 方言的 Clojure 社区努力,诞生了 Small Clojure Interpreter, SCI 这一解释器,其独立于运行在 JVM 上生成 .class 字节码的 Clojure “正统”解释和编译器,可以运行在 SVM 上,并实现了 Clojure 基本完全的语言特性。babashka 是基于 SCI 的 Clojure 集成开发工具,其内置了包括 HTTP Client, Server, XML 和 CSV 解析,JSON 解析,HTML 生成等一系列 Clojure 类库,还通过 deps.clj 提供了对于 Clojure CLI 工具的一种替代,由于其本身是脱胎自 JVM 的 SVM,因此包括了大部分 Java 核心类库且摆脱了对 JVM 的依赖,可用于日常 Clojure 项目开发任务,或者作为脚本解释器执行各种脚本。

一个典型的 babashka 脚本以如下行作为开头,-Sdeps 提供了 deps.edn 字面表示,这里声明当前文件夹作为根目录,且依赖 etaoin 1.0.39 版本。此脚本直接 chmod +x 后执行时,env 会调用 babahska 的二进制程序 bb,后者在 .m2 目录下查找 etaoin 类库,如果找不到则连接 Maven 中央仓库和 Clojar 下载,之后缓存在本地,然后启动 SCI 在 SVM 上解释执行脚本。

#!/usr/bin/env bb -Sdeps '{:paths ["."] :deps {etaoin/etaoin {:mvn/version,"1.0.39"}}}'

如果一个项目依赖不可 AOT 的类库,那么 bb clojure 子命令可通过 Clojure CLI 处理依赖并在 JVM 上执行脚本(依赖 java 命令),当然,其启动速度和内存占用会大幅增加,就像普通的 JVM 应用一样。

#!/usr/bin/env bb clojure -Sdeps '{:paths ["."] :deps {etaoin/etaoin {:mvn/version,"1.0.39"}}}' -M

在 Windows 中,没有类 UNIX 的 env 机制,因此,我通过一个 Go 编写的简单程序 clj-runner 来模拟此行为,此程序解析 .clj 文件头行并调用 PowerShell 或 Bash 执行 bb xxx 命令。基于这个工具,在 Windows 下可以做到双击 .clj 文件执行脚本。

当然,babashka 通过内置的 babashka.deps 提供了在脚本中控制 classpath 的能力,因此,可以采用如下的代码动态判断和加载所需依赖,其可用在需要保持最新的或私有分发的类库场合,如下的写法而非直接 (require '[babashka.deps :as deps]) 保证了在非 babashka 的普通 Clojure 环境执行脚本也不会报错。

(when (= (-> *clojure-version* :qualifier) "SCI")
	(require 'babashka.deps)
	(let [deps (find-ns'babashka.deps)]
		((ns-resolve deps 'add-deps) '{:deps {etaoin/etaoin {:mvn/version, "1.0.39"}}})))
(require '[etaoin.api :as e])

对于脚本开发者而言,现在“Double Click” .clj 文件即可快速、高兼容性的运行 JVM 脚本。但是分发呢?基于 babashka 和 SVM 的分发和 JVM 一样容易。对于开放源代码应用,只需要通过 bb clojure -Sdeps xxx -Spath 导出依赖的类库文件,通过 bb -cp xxx uberjar library.jar -m MAIN_CLASS_HERE 来将这些类库文件打包为 fatJar 即可完成分发。之后像 JVM 一样直接执行:bb -jar library.jarbb -cp library.jar -m MAIN_CLASS_HERE 即可运行程序。下面是示例脚本:

for /F "tokens=*" %n IN ('bb clojure -Sdeps "{:deps {etaoin/etaoin {:mvn/version,\"1.0.39\"}}}" -Spath') DO @(bb -cp %n uberjar library.jar)
.\bb -cp .;library.jar -m inspur_learn

对于终端用户,可以直接将所有的二进制依赖、配置文件以及 babashka 二进制文件、类库 fatJar library.jar 和入口脚本 run.bat 放在一起直接分发,其无需依赖 JVM,压缩后包大小只有 18MB 左右。对于需要热更新的情况,可以在打包 library.jar 时排除那些需要动态变动和更新的 .clj 脚本文件,然后在 run.bat 中将这些独立的脚本文件目录作为 classpath 加载,这种方法可以直接对脚本文件修改、单独分发,无需频繁打包 library.jar,此外配置文件 .edn 可以直接整合在 .clj 脚本中,也比较方便。