逃离Maven:再记Solon与Gradle的牵线搭桥之旅

flyway 天生与 native-image 不对付,在 solon 下也是如此。gradle 的大手近些年越伸越远,但在国内,还是 maven 的天下。本文主要是上一篇《轻装上阵-暂别JVM:小记Solon和它的朋友们在native-image下的爱恨情仇》的续集,继续解决 flyway 如何在 naitve-image 环境下与 solon 打配合。以及尝试让 gradle 的大手再往 solon 伸一些。

上一篇博客我们留下了两个烂摊子,分别是 flyway 的 sql 文件索引问题和原生编译工具链问题。正如我在上篇博客所说,我真的是个 java 新手。尤其是在 solon 方面😠当然,这些问题都是在 native-image 下的,所以本篇文章我们真的要和 jvm 世界说拜拜了。

编译,但是略施小计

这次我们走点阳关道。

gradle 插件

上次我们提到,只使用 gradle 编译会导致二进制文件启动后直接退出,表现得像是没有找到任何 Bean。我们简单思索片刻不难注意到,类似于 SpringBoot 和 Solon 这种主要以依赖注入的方式实现的框架,在启动时必然会有一个扫描 Bean 的作用。

扫描!

GraalVM Native Image 基于 封闭世界 假设。其要求所有需要访问的代码和资源都必须在编译时可知。任何动态和不可知的需求在运行时都是不允许的,比如反射、classpath扫描。

会不会是 solon-aot 会在编译前执行一些类似于我们在上篇文章提到的 flyway-resources.idx 文件,在运行时通过对索引文件的扫描来达到类似于 Bean 扫描的结果?

重新观察 solon-aot 源码,我们不难发现其入口类为 SolonAotProcessor。其中自然包含对 Bean 和 资源文件 的扫描、建索引。为了达到在 native-image 运行时扫描 Bean 的操作,我们不能在 native-image 环境下也使用适用于 jvm 的扫描方式,需要”重新实现”一套基于文件索引的扫描。就像我们在上篇文章中实现的 IndexedResourceProvider 一样。

继续阅读源码可知,solon 自带了这些实现,其在检测到当前为 native-image 环境时,会读取 reflect-config.json 作为补充项用于扫描,这些元数据由 SolonAotProcesser 在编译期得到,仅使用 agent 不会创建这些用户扫描的元数据。我们需要在编译期实现完成对 SolonAotProceser 的调用。为此我们必须要实现一套适用于 gradle 的 solon-aot 插件。但是从 opensolon 的仓库我们不难发现,这里是 maven 的天下……😭看来只能自己实现了

buildSrc

上个月在 solon 群里闲聊的时候,突然提到了这个。群主说有人做了 gradle 插件,但是 gradle 插件发布太啰嗦,一直没发到中央仓库。仓库地址:Gitee/Github。这俩甚至都只有一个 star(都是我🤣)……

gradle 的插件发布确实麻烦,除了我代劳发布插件外,我们还可以在项目工程根目录下创建一个 buildSrc 文件夹。gradle 允许将其作为本项目的依赖。即便是 gradle 插件也可以正常引入运行。接下来,我们的工作变得简单起来:

  1. 创建一个 buildSrc 目录
  2. 直接拷贝 solon-gradle-plugin 仓库的代码到 buildSrc 目录
    solon-gradle-plugin 仓库是两个项目,但是buildSrc不让这么写。所幸这两个项目也比较简单,我们直接先复制文件较多的那个项目,再把文件较少的项目的 java 文件也复制到新文件夹中即可。最终的路径应该类似于:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
~/Documents/code/XiaomiAlbumSyncer/server feat/native* ❯ tree buildSrc 
buildSrc
├── build.gradle.kts
├── gradle
│   └── wrapper
│   ├── gradle-wrapper.jar
│   └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
└── src
└── main
└── java
└── org
└── noear
└── solon
└── gradle
├── dsl
│   └── SolonExtension.java
├── plugin
│   ├── JavaPluginAction.java
│   ├── KotlinPluginAction.java
│   ├── NativeImagePluginAction.java
│   ├── PluginApplicationAction.java
│   ├── ResolveMainClassName.java
│   ├── SinglePublishedArtifact.java
│   ├── SolonAotPlugin.java
│   ├── SolonNativePlugin.java
│   ├── SolonPlugin.java
│   └── WarPluginAction.java
├── tasks
│   ├── aot
│   │   └── ProcessAot.java
│   ├── bundling
│   │   ├── ResolvedDependencies.java
│   │   ├── SolonArchive.java
│   │   ├── SolonArchiveSupport.java
│   │   ├── SolonJar.java
│   │   └── SolonWar.java
│   └── run
│   └── SolonRun.java
├── tools
│   └── MainClassFinder.java
└── util
├── Assert.java
└── IoUtils.java

18 directories, 26 files
~/Documents/code/XiaomiAlbumSyncer/server feat/native* ❯
  1. 在我们的项目工程中像正常引入依赖一样,直接写 id("org.noear.solon.native") 引入插件

应该还有一些别的改动,但都比较细碎,如果你看到这篇文章的时候,solon-gradle-plugin 还没有发布到中央仓库,可以参考 本项目-XiaomiAlbumSyncer。一切准备就绪,运行 ./gradlew clean nativeCompile 静候佳音。

再寻一条 flyway

上篇文章我们自己在 gradle 中实现了

  • sql 的索引文件生成
  • 基于文件索引的 resourceProvider

既然我们已经可以在 gradle 下完成 solon 的原生编译,那么我们完全可以使用 solon 提供的适用于 native-image 的资源扫描工具ResourceUtil.scanResources(String resExpr)。其实现思路与我们的 IndexedResourceProvider 几乎相同。

  1. 在编译期间收集所有资源文件,将其写入到文件 solon-resource.json 中。
  2. 在运行时检测当前是否为 native 环境,如果是,则解析匹配索引文件中的项目,以此完成扫描的操作。

我们要做的工作也非常简单:

  1. 确保我们的 sql 文件被 solon 的原生编译工具扫描并索引
  2. 重写 IndexedResourceProvider 使其直接使用 ResourceUtil.scanResources 完成扫描

向 solon 注册资源文件

solon-aot 提供了一个接口,我们可以实现它来完成扫描、索引、嵌入这些操作。

1
2
3
4
5
6
7
8
9
10
11
12
@Configuration
class NativeImageRegister : RuntimeNativeRegistrar {

override fun register(
context: AppContext?,
metadata: RuntimeNativeMetadata?
) {
if (metadata == null) return
metadata.registerResourceInclude(MIGRATION_SQL_PATTERN_IN_NATIVE)

}
}

其中 MIGRATION_SQL_PATTERN_IN_NATIVE 是一个常量,值为 db/.*。表示资源路径下的 db 文件下的所有文件。我们的迁移文件就在其中。

1
2
3
4
5
6
7
8
~/Documents/code/XiaomiAlbumSyncer main ❯ tree server/src/main/resources/db
server/src/main/resources/db
└── migration
├── V0.0.1__init.sql
├── V0.4.0__timeline.sql
└── V0.4.1__timeline_map.sql

2 directories, 3 files

注册之前(选读)

你可能会问,为什么要注册 db 下的所有文件?不能只注册 db/migration 下的所有文件吗?

哈哈哈🤣我也是这么想,但是我发现除非明确写出单个文件的完整路径(eg. db/migration/V0.4.1__timeline_map.sql),否则不管我怎么注册,怎么换表达式的写法,都注册不上。

执行这里的注册代码是在编译期运行的,准确来说是在编译的前置步骤,对其调试并不是一个容易的事情。但好在其入口函数 SolonAotProcessor 比较简单,我们可以从编译工具(gradle 或者 maven)的日志中找到传给入口函数的参数。然后直接用 IDEA 打开这个 solon-aot 插件,启动这个 processor。

简单 debug 后可以发现在 SolonAotProcessor第225行处,其使用第一个路径作为父路径,再用剩余的内容作为匹配表达式。坦率地说,我看不懂这段代码,也不明白为什么要这么写。😭最终的结果就是我们只能在第一层文件夹匹配,不能做多层文件夹的匹配…

也可能是我 java 写太少了,毕竟我还是个 java 新手😋

原生扫描

使用 solon 的 ResourceUtil.scanResources 扫描非常简单,我们不需要各种乱七八糟的读取,这个接口返回的就是一个 List。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
class IndexedResourceProvider(
private val classLoader: ClassLoader = Thread.currentThread().contextClassLoader,
private val encoding: Charset = StandardCharsets.UTF_8,
) : ResourceProvider {

// 缓存索引内容
@Volatile
private var cachedIndex: List<String>? = null

override fun getResource(name: String): LoadableResource? {
// 直接按绝对路径加载(如 db/migration/V1__init.sql)
classLoader.getResource(name) ?: return null
// 这里使用 Flyway internal 的 ClassPathResource 简化实现
return ClassPathResource(null, name, classLoader, encoding)
}

override fun getResources(prefix: String, suffixes: Array<out String>): MutableCollection<LoadableResource> {
val index = loadIndexOrEmpty()
if (index.isEmpty()) {
return mutableListOf()
}

val matched = index.asSequence()
.filter { path -> startsWithAndEndsWith(path, prefix, suffixes) }
.mapNotNull { path ->
// 再次确认资源存在(native 下只要已被打包,一般能找到)
classLoader.getResource(path) ?: return@mapNotNull null
ClassPathResource(null, path, classLoader, encoding) as LoadableResource
}
.toList()

return matched.toMutableList()
}

private fun startsWithAndEndsWith(filename: String, prefix: String, suffixes: Array<out String>): Boolean {
if (!filename.substringAfterLast('/').startsWith(prefix)) {
return false
}
for (suf in suffixes) {
if (filename.endsWith(suf)) {
return true
}
}
return false
}

private fun loadIndexOrEmpty(): List<String> {
val cached = cachedIndex
if (cached != null) {
return cached
}
synchronized(this) {
val again = cachedIndex
if (again != null) return again

// 由 Solon AOT 在构建期生成的资源索引文件
return ResourceUtil.scanResources(MIGRATION_SQL_PATTERN_IN_NATIVE).toList()
}
}
}

写在最后

至此,我们已经走通了所有通往 native-image 的路径。原生编译带来的最大好处是极致的启动速度和略优的内存占用。就此新项目而言,内存占用从 jvm 的``100M-200M下降到20M - 120M`。应用层的内存占用获取还有较大的优化空间。但更多的这只是一次对新兴技术的尝试和探索。

好吧,其实原生编译也不是什么新鲜事

最后的最后也是介绍一下本项目 XiaomiAlbumSyncer,这是一个用于 全量/增量/定时 下载小米云服务中的相册到本地 的应用程序,最近的版本新增了 时间线对比 功能,将同一相册的增量同步时间优化到极低的水平。还支持 exif 填充,用于在外部应用中正确显示图片的真实保存时间。

或许下一篇文章就是 XiaomiAlbumSyncerimmich 的协同部署分享😋


逃离Maven:再记Solon与Gradle的牵线搭桥之旅
https://coooolfan.com/2025/11/05/solon-and-native-image-2/
作者
Coolfan
发布于
2025年11月5日
许可协议