[SpringBoot] gradle-kts下的依赖外置打包

9 阅读2分钟

1. 引言

众所周知, 在maven中我们会使用

<plugin>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-dependency-plugin</artifactId>
  ...config
</plugin>

这样的配置来简单地将依赖进行外置以减小jar体积,那对于配置更灵活的gradle-kts,我们该怎么办呢?

2. 前置知识

1. 依赖外置的原理

众所周知,bootJar任务会打出一个fat-jar。该jar内会包含该应用程序的所有运行时依赖
如果我们不选择打包这些依赖,JVM会从运行时参数里寻找class-path,从中依次遍历加载jar包。

2. 如何指定class-path?

  1. 通过指定环境变量
export CLASSPATH=/path/to/classes:/path/to/lib.jar
  1. 使用通配符
java -cp "/path/to/libs/*" MyMainClass
  1. 在MANIFEST.MF里指定
Manifest-Version: 1.0
Class-Path: lib/dependency1.jar lib/dependency2.jar
Main-Class: com.example.Main

在这里我以第三种方式进行演示。因为第三种方式可以在控制台里以一条命令运行。

3. 详细设计

首先我们需要定义两个任务:

// 清除现有的lib目录
tasks.register<Delete>("clearJar") {
    delete(layout.buildDirectory.dir("libs/lib"))
}
// 清除现有的res目录
tasks.register<Delete>("clearRes") {
    delete(layout.buildDirectory.dir("libs/res"))
}

因为我们生成jar的时候,会将所有的依赖全部打包进某个文件夹里。
假如你删除了某个依赖,如果不及时清理,最终的无用依赖包将会越来越多。

有删除就有创建,我们再定义两个任务:

// 将依赖包复制到lib目录
tasks.register<Copy>("copyJar") {
    dependsOn("clearJar")
    from(configurations.runtimeClasspath)
    into(layout.buildDirectory.dir("libs/lib"))
}

// 将资源文件复制到res目录
tasks.register<Copy>("copyRes") {
    dependsOn("clearRes")
    from("src/main/resources")
    into(layout.buildDirectory.dir("libs/res"))
}

此时复制操作和删除操作都已准备完毕,现在需要修改bootJar方法了:

tasks.bootJar {
    duplicatesStrategy = DuplicatesStrategy.EXCLUDE
    // 排除所有的jar
    exclude("*.jar")
    exclude("assets/**")
    exclude("static/**")
    exclude("application.properties")

    // lib目录的清除和复制任务
    dependsOn("clearJar", "clearRes")
    dependsOn("copyJar", "copyRes")

    // 指定依赖包的路径
    manifest {
        attributes(
            "Manifest-Version" to "1.0",
            "Class-Path" to configurations.runtimeClasspath.get().files.joinToString(" ") {
                "lib/${it.name}"
            } + " res/"
        )
    }
}

4. 完整代码

关键代码展示完毕,实际上面的代码不能直接用,还有多多少少一些小坑,如果要使用的话建议复制下面的代码片段:

// 清除现有的lib目录
tasks.register<Delete>("clearJar") {
    delete(layout.buildDirectory.dir("libs/lib"))
}
// 清除现有的res目录
tasks.register<Delete>("clearRes") {
    delete(layout.buildDirectory.dir("libs/res"))
}

// 将依赖包复制到lib目录
tasks.register<Copy>("copyJar") {
    dependsOn("clearJar")
    from(configurations.runtimeClasspath)
    into(layout.buildDirectory.dir("libs/lib"))
}

// 将资源文件复制到res目录
tasks.register<Copy>("copyRes") {
    dependsOn("clearRes")
    from("src/main/resources")
    into(layout.buildDirectory.dir("libs/res"))
}

tasks.jar {
    duplicatesStrategy = DuplicatesStrategy.EXCLUDE
}

tasks.bootJar {
    duplicatesStrategy = DuplicatesStrategy.EXCLUDE
    // 排除所有的jar
    exclude("*.jar")
    exclude("assets/**")
    exclude("static/**")
    exclude("application.properties")

    // lib目录的清除和复制任务
    dependsOn("clearJar", "clearRes")
    dependsOn("copyJar", "copyRes")

    // 指定依赖包的路径
    manifest {
        attributes(
            "Manifest-Version" to "1.0",
            "Class-Path" to configurations.runtimeClasspath.get().files.joinToString(" ") {
                "lib/${it.name}"
            } + " res/"
        )
    }
}

// 确保processResources任务不会将资源文件复制到classes目录
tasks.processResources {
    duplicatesStrategy = DuplicatesStrategy.EXCLUDE
}

5. 扩展——增量更新

既然把依赖进行外置,那一定也可以以停机——上传增量包——开机来减少上传jar所带来的带宽占用。
这里的代码没什么好说的,我全贴出来以供展示:

tasks.register("doPatchForLibHash") {
    inputs.files(layout.buildDirectory.file("build.hash"), fileTree("${layout.buildDirectory.get()}/libs"))
    doLast {
        //获取旧的md5值分组
        val oldFileMap = layout.buildDirectory.file("build.hash").get().asFile.bufferedReader()
            .lineSequence().chunked(2).associate {
                it[0] to it[1]
            }
        //遍历新的libs
        layout.buildDirectory.file("libs").get().asFile.walkTopDown().forEach { newFile ->
            //获取旧的MD5,key为新文件的相对路径
            val oldMD5 =
                oldFileMap[newFile.relativeTo(layout.buildDirectory.dir("libs").get().asFile).path] ?: return@forEach
            //比对,一样则删除
            if (oldMD5 == newFile.md5Digest().toHexString()) {
                newFile.delete()
            }
        }

        fun deleteEmptyDirectories(directory: File): Boolean {
            if (!directory.isDirectory) {
                return false
            }

            var success = true
            directory.listFiles()?.forEach { file ->
                if (file.isDirectory) {
                    success = deleteEmptyDirectories(file) && success
                }
            }

            if (success && directory.listFiles()?.isEmpty() == true) {
                if (!directory.delete()) {
                    success = false
                }
            }

            return success
        }

        deleteEmptyDirectories(layout.buildDirectory.file("libs").get().asFile)
    }
}

tasks.register("generateLibHash") {
    inputs.files(fileTree("${layout.buildDirectory.get()}/libs"))
    outputs.file(layout.buildDirectory.file("libs/build.hash"))

    doLast {
        val out = layout.buildDirectory.dir("libs/build.hash").get().asFile
        out.delete()
        out.parentFile.mkdirs()
        out.createNewFile()
        with(layout.buildDirectory.dir("libs").get().asFile) {
            walkTopDown().filter { it.isDirectory.not() }.forEach {
                val md5 = it.md5Digest().toHexString()
                val path = it.relativeTo(this).path
                out.appendText(
                    """
                    $path
                    $md5

                """.trimIndent()
                )
            }
        }
    }
}


fun File.md5Digest(): ByteArray {
    val md = MessageDigest.getInstance("MD5")
    val fis = FileInputStream(this)
    val buffer = ByteArray(1024)
    var numRead: Int
    while (fis.read(buffer).also { numRead = it } > 0) {
        md.update(buffer, 0, numRead)
    }
    fis.close()
    return md.digest()
}

需要在 tasks.bootJar {} 的 DSL 里添加:

finalizedBy("generateLibHash")

即可在bootJar后生成lib.hash。
增量包的获取需要先获取旧的lib.hash,放置在build/build.hash中。
然后运行gradle任务即可获得增量包。