构建工具:制作一个小型 Kotlin 应用
有时候你需要解决问题,但你真的不想用 Bash 脚本。最近,我决定用一个简单但便宜的 JVM 应用替换掉一个功能强大但成本高昂的 Gradle 任务。¹这个Gradle 任务平均每次构建需要 2 分钟,我的团队上个月累计花了 43 个小时等待它完成。而替换它的 Kotlin 应用只需要大约 300 毫秒就能完成,我预计下个月我们累计等待它的时间不会超过 8 分钟。时间就是金钱,我估计这在未来 12 个月内可以挽回 10 万美元的开发人员生产力损失。
这不是那个故事。2
本文并非讲述如何构建这样的应用,而是介绍如何使用 Gradle 构建应用。我们将学习如何使用 `gradle-build`application和 `gradle- distributionbuild` 插件来构建应用并打包发行版;如何使用 `gradle-build`shadow插件将其转换为胖 JAR 包;以及如何使用Proguard对整个应用进行压缩。最终,我们将一个 150 万的 JAR 包压缩成一个 1.2 千的“胖”JAR 包,最终二进制文件的大小减少了 99.2%。
看到99.2这个数字,我立刻想起杰克·沃顿(Jake Wharton)差不多一年前也写过一篇非常类似的文章。他的文章值得一读。我的这篇文章与他不同的是,我会一步一步地解释如何使用Gradle实现类似的结果。
所有代码都在Github上。(Github已移除ICE。)
本项目使用 Gradle 7.1.1 构建。这一点很重要,因为如果您使用的是 Gradle 6.x,则需要对代码进行一些更改。
该应用程序
这篇文章并非关于应用本身,但我们需要一些东西来构建,所以…… 3
// echo/src/main/kotlin/mutual/aid/App.kt
package mutual.aid
fun main(args: Array<String>) {
val echo = args.firstOrNull() ?: "Is there an echo in here?"
println(echo)
}
真正的代码
我一直强调的一点是,构建工程本身就是一个独立的领域。我之所以这么说,是因为构建工程是我的收入来源。让我们来看看构建代码!
使用 Gradle 构建应用程序
这款application插件让这一切变得非常简单。
// echo/build.gradle
plugins {
id 'org.jetbrains.kotlin.jvm' version '1.5.20'
id 'application'
}
group = 'mutual.aid'
version = '1.0'
application {
mainClass = 'mutual.aid.AppKt'
}
现在我们可以构建并运行这个小应用程序了。
$ ./gradlew echo:run
Is there an echo in here?
或者如果我们想定制我们的信息……
$ ./gradlew echo:run --args="'Nice weather today'"
Nice weather today
我们可以运行程序并提供任何我们想要的参数。这是任何 JVM 应用程序最基本的构建模块。
将应用程序转化为分发
假设你希望其他人实际运行你的应用程序,你应该将其打包成一个发行版。我们来使用distribution插件来实现这一点。
$ ./gradlew echo:installDist
$ echo/build/install/echo/bin/echo "Unless you're out West, I hear it's pretty hot out there"
Unless you're out West, I hear it's pretty hot out there
(令人欣慰的是,在这个版本中,我们可以去掉非常不美观的--args=...语法。)
我耍了个小花招。我们不需要安装任何新插件,因为application插件本身已经负责安装distribution它了。后者会添加一个任务,installDist将发行版安装到你的项目build目录中。以下是完整的发行版内容:
$ tree echo/build/install/echo
echo/build/install/echo
├── bin
│ ├── echo
│ └── echo.bat
└── lib
├── annotations-13.0.jar
├── echo-1.0.jar
├── kotlin-stdlib-1.5.20.jar
├── kotlin-stdlib-common-1.5.20.jar
├── kotlin-stdlib-jdk7-1.5.20.jar
└── kotlin-stdlib-jdk8-1.5.20.jar
我们可以看到,它已经收集了运行时类路径上的所有 jar 包,包括我们刚刚构建的新 jar 包echo-1.0.jar。除了这些 jar 包之外,我们还有两个 shell 脚本,一个用于 *nix 系统,一个用于 Windows 系统。这些脚本使用了与 Gradle 相同的模板gradlew[.bat],因此它们应该相当稳定可靠。
我们已经开始看到问题了。我们的应用虽然很小,但却占用了完整的 Kotlin 运行时环境,尽管它只用到了很少的资源。那个lib目录的大小达到了 1.7MB。这一切仅仅是为了输出一个字符串!不仅如此,我们真正需要的只是程序(jar 文件)和一个可以从命令行轻松调用它的脚本,却要处理这么多单独的文件,这实在令人恼火。
在 Shadow 插件上进行分层
他们说,当你遇到一个问题并决定用正则表达式解决时,你现在又多了两个问题。Shadow插件更是把这个问题推向了极致:根据Alec Strong 的这篇文章来看,当你尝试用阴影来解决问题时,你现在至少会遇到五个问题。
说清楚点,我是在开玩笑。Shadow Gradle插件的维护者John Engelman为社区做出了贡献,他免费开源地提供了这个工具。如果Shadow插件用起来不方便,那是因为它解决的是一个难题。
// echo/build.gradle
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
plugins {
id 'org.jetbrains.kotlin.jvm' version '1.5.20'
id 'application'
id 'com.github.johnrengelman.shadow' version '7.0.0'
}
def shadowJar = tasks.named('shadowJar', ShadowJar) {
// the jar remains up to date even when changing excludes
// https://github.com/johnrengelman/shadow/issues/62
outputs.upToDateWhen { false }
group = 'Build'
description = 'Creates a fat jar'
archiveFileName = "$archivesBaseName-${version}-all.jar"
reproducibleFileOrder = true
from sourceSets.main.output
from project.configurations.runtimeClasspath
// Excluding these helps shrink our binary dramatically
exclude '**/*.kotlin_metadata'
exclude '**/*.kotlin_module'
exclude 'META-INF/maven/**'
// Doesn't work for Kotlin?
// https://github.com/johnrengelman/shadow/issues/688
//minimize()
}
说到难题,可以看到我在迭代过程中遇到了一些问题。如果你想让我的工作更轻松,请随意点赞!
我认为最有趣的部分是 ` fromand`exclude语句。`and`from语句指定shadow要打包的内容:实际的编译输出,加上运行时类路径。这些exclude语句对于缩小我们庞大的 jar 文件体积至关重要。
我们现在可以运行这个胖 jar 包并验证它是否仍然有效(该runShadow任务是由shadow插件添加的,因为它与插件集成application):
$ ./gradlew echo:runShadow --args="'I don't mind billionaires going into space, but maybe they could just stay?'"
I don't mind billionaires going into space, but maybe they could just stay?
最后,我们可以检查 fat jar 本身(当我们运行该任务时,此任务也会隐式运行runShadow):
$ ./gradlew echo:shadowJar
# Produces output at echo/build/libs/echo-1.0-all.jar
如果我们查看它的大小,会发现它只有 150 万:比最初的 170 万减少了约 12%。不过,我们还可以做得更好。
在 Proguard 上分层
我知道我知道。Proguard 已经过时了,感觉像是 2019 年或者R8发布那会儿的东西了。但它是免费的,开源的,而且它有一个 Gradle 任务,我可以很轻松地配置它。(而且shadow它的minify()函数不起作用😭)
设置如下:
// echo/build.gradle
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
import org.gradle.internal.jvm.Jvm
import proguard.gradle.ProGuardTask
buildscript {
repositories {
mavenCentral()
}
dependencies {
// There is apparently no plugin
classpath 'com.guardsquare:proguard-gradle:7.1.0'
}
}
plugins {
id 'org.jetbrains.kotlin.jvm' version '1.5.20'
id 'application'
id 'com.github.johnrengelman.shadow' version '7.0.0'
}
由于我完全不明白的原因,Proguard 没有作为 Gradle 插件打包,而是作为 Gradle 任务打包,因此要将其添加到构建脚本的类路径中,我必须求助于这种古老的黑魔法。
Android 平台有相应的插件。我怀疑肯定有人也开发过 JVM 项目的插件。但是一想到要找到一个绝对能兼容 Gradle 7 的插件就让我感到绝望,所以我决定写这篇博文。
现在我们已经可以访问该ProGuardTask任务了。我们该如何使用它呢?
// echo/build.gradle
def minify = tasks.register('minify', ProGuardTask) {
configuration rootProject.file('proguard.pro')
injars(shadowJar.flatMap { it.archiveFile })
outjars(layout.buildDirectory.file("libs/${project.name}-${version}-minified.jar"))
libraryjars(javaRuntime())
libraryjars(filter: '!**META-INF/versions/**.class', configurations.compileClasspath)
}
/**
* @return The JDK runtime, for use by Proguard.
*/
List<File> javaRuntime() {
Jvm jvm = Jvm.current()
FilenameFilter filter = { _, fileName -> fileName.endsWith(".jar") || fileName.endsWith(".jmod") }
return ['jmods' /* JDK 9+ */, 'bundle/Classes' /* mac */, 'jre/lib' /* linux */]
.collect { new File(jvm.javaHome, it) }
.findAll { it.exists() }
.collectMany { it.listFiles(filter) as List }
.toSorted()
.tap {
if (isEmpty()) {
throw new IllegalStateException("Could not find JDK ${jvm.javaVersion.majorVersion} runtime")
}
}
}
// proguard.pro
-dontobfuscate
-keep class mutual.aid.AppKt { *; }
这有点拗口。由于ProGuardTask它没有通过插件注册和配置,我们需要自己完成这些操作。首先,我们需要告诉它我们的规则,这些规则非常简单:我们只想压缩文件,并且要保留主类入口点。接下来,我们需要告诉它我们的输出injars,也就是任务的输出shadowJar:这就是要压缩的内容。(重要的是,我使用的语法意味着任务依赖项由 Gradle 确定,而无需使用 ` dependsOn.`。)该outjars函数非常简单地告诉任务将压缩后的 jar 文件输出到哪里。最后,我们还有 `.` libraryjars,我将其视为编译我的应用程序所需的类路径。这些内容不会被打包到输出中。其中最复杂的部分是 ` .`javaRuntime()函数。Proguard 的 GitHub 项目提供了一个更简单的示例,如果您愿意,可以使用它。
开始运行吧。
$ ./gradlew echo:minify
如果我们现在检查一下echo/build/libs/echo-1.0-minified.jar,就会发现它只有 12K 🎉
就像我们验证完整 jar 文件是否可运行一样,我们可以创建一个JavaExec任务并运行我们压缩后的 jar 文件:
tasks.register('runMin', JavaExec) {
classpath = files(minify)
}
然后运行它:
$ ./gradlew echo:runMin --args="'How would space guillotines even work?'"
How would space guillotines even work?
不过,我们还没完成。我们还需要将这个精简后的应用程序打包成发行版并发布。
瘦胖体型的形成
这两个application插件都shadow注册了一个“zip”任务(分别是 ` distZipzip` 和shadowDistZip`zip`)。但它们都不是我们需要的,因为它们没有打包我们压缩后的 jar 包。幸运的是,它们都是 Gradle 核心类型的任务Zip,配置起来相对容易。
// echo/build.gradle
def startShadowScripts = tasks.named('startShadowScripts', CreateStartScripts) {
classpath = files(minify)
}
def minifiedDistZip = tasks.register('minifiedDistZip', Zip) {
archiveClassifier = 'minified'
def zipRoot = "/${project.name}-${version}"
from(minify) {
into("$zipRoot/lib")
}
from(startShadowScripts) {
into("$zipRoot/bin")
}
}
我们首先要做的就是接管该startShadowScripts任务(稍后会解释该任务的作用)。shadowJar我们希望它使用我们压缩后的 jar 包作为类路径,而不是使用任务生成的默认类路径。语法classpath = files(minify)与之前的类似,injars(shadowJar.flatMap { it.archiveFile })因为它也包含了任务依赖信息。由于minify`<classpath>` 是一个`<classpath>` TaskProvider,files(minify)它不仅设置了类路径,还将该minify任务设置为了startShadowScripts任务的依赖项。
接下来,我们创建自己的Zip任务,minifiedDistZip并以类似于基础任务的方式构建它distZip。如果我们查看最终产品,就更容易理解它:
$ ./gradlew echo:minifiedDistZip
$ unzip -l echo/build/distributions/echo-1.0-minified.zip
Archive: echo/build/distributions/echo-1.0-minified.zip
Length Date Time Name
--------- ---------- ----- ----
0 07-11-2021 17:02 echo-1.0/
0 07-11-2021 17:02 echo-1.0/lib/
12513 07-11-2021 16:59 echo-1.0/lib/echo-1.0-minified.jar
0 07-11-2021 17:02 echo-1.0/bin/
5640 07-11-2021 17:02 echo-1.0/bin/echo
2152 07-11-2021 17:02 echo-1.0/bin/echo.bat
--------- -------
20305 6 files
我们的压缩包包含一个经过压缩的 jar 文件,以及两个脚本,一个用于 *nix 系统,一个用于 Windows 系统。具体的路径很重要,因为生成的脚本包含一个echo属性:echo.batCLASSPATH
CLASSPATH=$APP_HOME/lib/echo-1.0-minified.jar
这里有个小陷阱,浪费了我大约20分钟的时间。你可能已经注意到这一点了。
def zipRoot = "/${project.name}-${version}"
开头/非常重要!没有开头,压缩包里的内容路径会非常混乱。幸运的是,我现在对 Gradle 的了解已经足够,知道什么时候该止步不前,继续前进。
发布我们的精简版
现在我们有了一个包含压缩后 jar 文件的 zip 文件,这个 zip 文件本身只有 15K,相比任务生成的原始 1.5M zip 文件,这是一个巨大的改进distZip。我们只需要自动化最后一件事,那就是发布这个压缩包。我们将添加maven-publish插件,然后进行配置:
// echo/build.gradle
plugins {
id 'org.jetbrains.kotlin.jvm' version '1.5.20'
id 'application'
id 'com.github.johnrengelman.shadow' version '7.0.0'
id 'maven-publish'
}
publishing {
publications {
minifiedDistribution(MavenPublication) {
artifact minifiedDistZip
}
}
}
这会添加一些发布任务,包括publishMinifiedDistributionPublicationToMavenLocal……我们可以运行它并检查输出:
$ ./gradlew echo:publishMinifiedDistributionPublicationToMavenLocal
$ tree ~/.m2/repository/mutual/aid/echo/
~/.m2/repository/mutual/aid/echo/
├── 1.0
│ ├── echo-1.0-minified.zip
│ └── echo-1.0.pom
└── maven-metadata-local.xml
我们甚至得到了一个 pom 文件,因此我们可以通过引用其 Maven 坐标从存储库中解析此构件mutual.aid:echo:1.0。
总结
本文旨在提供一个使用 Gradle 构建小型 Kotlin 应用的极简示例,该应用需满足以下要求:零依赖、尽可能小巧,并可发布以便潜在用户轻松访问。如果您愿意阅读文档并进行实验,还可以实现更多功能。
提醒一下,完整的源代码可以在这里找到。
尾注
1我的意思是,Kotlin 应用速度非常快,因为它利用了系统知识,并使用正则表达式解析构建脚本来获取其元数据。相比之下,Gradle 任务的智能之处在于它使用了 Gradle 自身的自省功能。但遗憾的是,它的速度慢了 400 倍。我们的折衷方案是在 CI 中运行该任务,以验证 Kotlin 应用的正确性,而我们的开发人员每天都在使用该应用。2我
可能会在获得公司沟通部门批准后的几个月内讲述这个故事。敬请期待!3欢迎提交PR。
参考
- 应用程序插件:https://docs.gradle.org/current/userguide/application_plugin.html
- 分发插件:https://docs.gradle.org/current/userguide/distribution_plugin.html
- Shadow插件:https://github.com/johnrengelman/shadow
- Proguard 任务:https://www.guardsquare.com/manual/setup/gradle
- Proguard JVM 应用示例:https://github.com/Guardsquare/proguard/blob/master/examples/gradle/applications.gradle