Bndtools 这个工具,我在好几个中型 Java 项目里都用过,从最早的 Eclipse 插件时代一直跟到现在的独立工作流。很多人第一次听到 Bnd 或者 Bndtools,第一反应是“这玩意儿跟 Maven 有什么区别”,第二反应是“OSGi 是不是已经过气了”。这两个问题其实都问到了点子上,但答案没那么简单。Bnd 本质上是一个字节码分析加 JAR 文件构建工具,它的核心能力是读取编译后的 class 文件,算出这个 JAR 对外暴露了什么包、依赖了什么包,然后把这些元数据写进 MANIFEST.MF 里。Bndtools 则是在 Bnd 之上搭起来的一套开发环境,提供工作区管理、依赖解析、运行时启动和调试的一体化体验。
这套东西解决的问题很具体:当你的项目从一个大 JAR 拆成十几个甚至几十个模块的时候,模块之间的依赖关系会变得极其复杂,谁依赖谁、谁暴露了哪个包、运行时到底加载了哪个版本,靠人脑根本管不过来。Bnd 用“包级可见性”这个粒度来管理依赖,比 Maven 的“构件级”粒度细得多,也比 Java 9 模块系统更早落地。适合谁来学?如果你在做多模块 Java 项目、需要严格控制模块边界、或者维护一个需要长期演进的基础框架,Bndtools 值得花时间啃下来。如果你只是写一个单体应用,那确实没必要上这套工具,Maven 或 Gradle 足够用。
1. 先搞清楚 Bnd 到底在干什么
1.1 从 JAR 文件的本质说起
一个 JAR 文件说白了就是一个 ZIP 包,里面装着 class 文件和资源文件。但 JAR 的 MANIFEST.MF 里可以写很多元数据,其中最关键的一行是Export-Package,它声明了这个 JAR 对外暴露哪些 Java 包。另一行是Import-Package,声明了这个 JAR 需要从外部引入哪些包。Bnd 的核心工作就是自动算出这两行该写什么。
为什么这件事重要?假设你写了一个工具类库,里面有个com.example.util.StringHelper类,它依赖了org.slf4j.Logger。如果你手动维护 MANIFEST,你得记得把org.slf4j加到Import-Package里,还得确保版本范围写对了。一旦漏了,运行时就是NoClassDefFoundError。Bnd 的做法是直接扫描 class 文件的常量池,看到引用了org.slf4j.Logger,就自动在Import-Package里加上org.slf4j,版本范围根据你配置的策略自动推导。这个“扫描字节码推导依赖”的能力,是 Bnd 最底层的价值。
我刚开始用的时候觉得这不过是省了几行手写配置,后来项目模块数量上到三十多个,才体会到自动推导的威力。因为模块之间的依赖关系是动态变化的,今天 A 模块引了 B 模块的某个包,明天重构又去掉了,手动维护根本跟不上节奏。Bnd 每次构建都重新算一遍,永远不会漏。
1.2 Bndtools 和 Bnd 的关系
Bnd 是一个命令行工具加一套 Java API,你可以把它理解成“引擎”。Bndtools 是建立在 Bnd 之上的 IDE 集成和工作区管理框架,最早是 Eclipse 插件,现在也有独立于 IDE 的使用方式。Bndtools 管的事情更上层:它定义了一个“工作区”的概念,工作区里可以放多个项目,每个项目有自己的bnd.bnd配置文件,工作区级别有一个cnf目录存放仓库配置和构建插件。
这个分层设计的好处是职责清晰。Bnd 只管“给定一堆 class 文件,产出一个合规的 JAR”,不关心你的项目怎么组织。Bndtools 管“多个项目之间怎么协作、依赖从哪个仓库解析、运行时怎么启动”。我在实际项目里经常只用 Bnd 的命令行模式做 CI 构建,本地开发才用 Bndtools 的完整工作区,两者可以拆开用。
1.3 和 Maven、Gradle 的定位差异
这里必须说清楚,Bndtools 不是 Maven 的替代品,两者的抽象层次不一样。Maven 管的是“构件”级别的依赖:你需要commons-lang3的 3.12.0 版本,Maven 去仓库下载这个 JAR 放到 classpath 上。Bnd 管的是“包”级别的依赖:你的模块需要org.apache.commons.lang3这个包,Bnd 去仓库里找哪个 JAR 提供了这个包,然后把这个 JAR 加进来。
这个差异在模块化场景下非常关键。假设仓库里有两个 JAR 都提供了org.apache.commons.lang3包,但版本不同,Maven 的 classpath 机制会把两个都放上去,谁先加载谁生效,这就是经典的“JAR 地狱”。Bnd 的解析器会明确选择一个提供者,并且在运行时通过 OSGi 的类加载隔离机制保证只有选中的那个包可见。当然,这套机制的前提是你接受 OSGi 的运行时模型,如果你的项目不需要运行时模块隔离,那 Bnd 的包级解析优势就发挥不出来。
2. 工作区搭建与项目结构设计
2.1 工作区目录的规划逻辑
Bndtools 工作区的标准结构是这样的:根目录下有一个cnf文件夹,里面放ext目录(构建插件)、repo目录(本地仓库缓存)和build.bnd文件(工作区级配置)。每个项目是根目录下的一个子文件夹,里面有自己的bnd.bnd和src目录。
我建议在动手之前先把目录规划想清楚。cnf目录是整个工作区的“心脏”,它决定了所有项目共享的构建配置、仓库地址和插件版本。如果你有多个团队共用一个代码库,cnf的变更要格外谨慎,因为一个配置改错会影响所有项目。我的习惯是把cnf/build.bnd里的配置分成三段:第一段是全局的编译选项(Java 版本、编码、调试信息),第二段是仓库声明(指向本地缓存和远程仓库),第三段是插件配置(比如代码格式化、静态检查)。每段之间用注释隔开,后来的人一看就明白。
项目命名也有讲究。Bndtools 的项目名会直接变成产出的 JAR 的 Bundle-SymbolicName,所以名字要遵循反向域名规则,比如com.example.core、com.example.web。不要用project1、test这种名字,后期排查依赖问题时你会感谢自己当初起了个好名字。
2.2 bnd.bnd 文件的关键配置项
每个项目的bnd.bnd是核心配置文件,我挑几个最常改的项说。Bundle-Version控制版本号,支持1.0.0.${tstamp}这种占位符写法,tstamp会在构建时替换成时间戳。Export-Package如果不写,Bnd 默认不导出任何包,这一点和很多人直觉相反——很多人以为写了包就会自动导出,实际上必须显式声明。我一般写成Export-Package: com.example.core.api,只导出 API 包,实现包留在内部。
Private-Package用来声明“打进 JAR 但不导出”的包。这个配置项容易被忽略,但很重要。假设你的模块内部有个com.example.core.internal包,你不想让它被外部引用,就把它放进Private-Package。Bnd 会把它打进 JAR,但不会出现在Export-Package里,外部模块看不到它。这比 Java 的package-private访问控制强得多,因为它是跨 JAR 生效的。
Import-Package通常留空让 Bnd 自动算,但有些场景需要手动干预。比如你依赖了某个包但不想让 Bnd 自动推导版本范围,可以写成Import-Package: org.slf4j;version="[1.7,2)"。方括号表示闭区间,圆括号表示开区间,这个语法和 Maven 的版本范围类似但不完全一样,写的时候要小心。
2.3 仓库配置的实操细节
工作区的仓库配置在cnf/build.bnd里,用-plugin指令声明。最常见的配置是本地文件仓库加远程仓库的组合。本地仓库指向cnf/repo目录,Bndtools 会把解析到的 JAR 缓存到这里。远程仓库一般指向一个 HTTP 地址,格式是aQute.bnd.deployer.repository.FixedIndexedRepo或者aQute.bnd.deployer.repository.RepoIndex。
这里有个坑我踩过:远程仓库的索引文件格式必须和 Bnd 的解析器匹配。如果你的仓库是 Nexus 或 Artifactory 搭的,它们默认提供的索引格式可能不被 Bnd 直接识别,需要在仓库端开启“OSGi 索引”支持,或者用 Bnd 提供的bindex工具自己生成索引文件。我当时的做法是在 CI 流水线里加一步,用bindex扫描本地 Maven 仓库生成索引,然后发布到静态文件服务器上,Bndtools 从这个静态索引解析依赖。这套方案跑了一年多,稳定可靠。
3. 依赖解析与运行时启动的完整流程
3.1 解析器的工作原理
Bndtools 的依赖解析器是整个工具链里最精妙的部分。它的输入是一组“需求”(Requirement),输出是一组“能力”(Capability)的匹配方案。每个 JAR 在 Bnd 眼里都是一组能力的集合:它提供了哪些包、哪些服务、哪些特性。解析器的任务是在仓库里找到一组 JAR,使得所有需求都被满足,且没有冲突。
这个过程和 SAT 求解器很像,实际上 Bnd 底层用的就是一种约束求解算法。当你点下“Resolve”按钮的时候,解析器会遍历所有候选 JAR,尝试组合,遇到冲突就回溯。项目规模小的时候瞬间完成,模块多了可能要等几秒。我遇到过一次解析卡住的情况,查下来是两个 JAR 都提供了同一个包但版本范围不兼容,解析器在反复尝试组合。解决办法是在bnd.bnd里显式指定Import-Package的版本范围,把候选范围缩小。
解析结果会生成一个.bndrun文件,里面记录了选中的 JAR 列表和启动参数。这个文件可以提交到代码库,保证团队成员用同一套运行时依赖。我强烈建议把.bndrun纳入版本管理,否则每个人本地解析出来的结果可能不一样,调试时会出现“在我机器上是好的”这种经典问题。
3.2 运行时启动的配置要点
.bndrun文件里最关键的几行是-runee(运行时的 Java 版本)、-runfw(框架类型,一般是 Equinox 或 Felix)、-runbundles(启动时加载的 Bundle 列表)。-runbundles通常由解析器自动填充,但你可以手动调整启动顺序和起始级别。
启动级别(start level)这个概念值得单独说。OSGi 框架启动时,Bundle 按照 start level 从小到大依次启动。默认级别是 0,所有 Bundle 同时启动。如果你的模块之间有启动顺序依赖,比如数据库连接池要在业务模块之前初始化,就需要给连接池 Bundle 设一个更小的 start level。配置写法是-runbundles: com.example.pool;startlevel=1, com.example.web;startlevel=2。这个细节在官方教程里往往一笔带过,但实际项目里几乎一定会用到。
还有一个容易忽略的点是-runproperties,用来传系统属性。比如-runproperties: log.level=DEBUG,运行时可以通过System.getProperty("log.level")读到。我习惯把环境相关的配置都放这里,而不是硬编码在代码里,切换环境时只改.bndrun文件就行。
3.3 调试与热部署的实操记录
Bndtools 的调试体验是我留在它身边的重要原因。在 Eclipse 插件模式下,你可以直接在.bndrun文件上右键“Debug”,IDE 会自动启动框架并挂上调试器,断点、变量查看、热代码替换都能用。热替换的前提是方法签名不变,改了方法体可以直接生效,不用重启框架。这个体验在开发业务逻辑时非常顺手。
独立工作流下没有 IDE 的图形化调试,但可以用bnd run命令加-runjdwp参数开启远程调试端口,然后用任意支持 JDWP 的调试器连上去。配置写法是-runjdwp: transport=dt_socket,server=y,suspend=n,address=1044。suspend=n表示不等待调试器连接就启动,适合需要快速验证的场景;suspend=y则适合排查启动阶段的问题,框架会停在启动前等你连上。
热部署方面,Bndtools 支持“自动重载”模式,修改源码后重新编译,框架会自动检测到 Bundle 变化并重新加载。这个功能在bnd.bnd里通过-runtrace和-runreload相关配置控制。实测下来,自动重载对简单的类修改很有效,但如果改了Export-Package或者 Bundle 的元数据,还是得重启框架,因为包级可见性的变化需要重新解析。
4. 常见问题排查与避坑经验
4.1 依赖解析失败的典型场景
解析失败是新手最常遇到的问题,报错信息往往很长,但核心就那么几类。第一类是“找不到提供某个包的 JAR”,报错里会写Unable to resolve: missing requirement osgi.wiring.package; filter="(osgi.wiring.package=xxx)"。这说明仓库里没有 JAR 提供这个包,要么是仓库配置漏了,要么是这个包确实不存在。排查方法是去仓库索引里搜这个包名,确认有没有对应的 JAR。
第二类是“版本范围冲突”,报错里会出现两个 JAR 对同一个包要求了不兼容的版本范围。比如 A 要求org.slf4j版本[1.7,1.8),B 要求[1.8,2.0),解析器找不到同时满足两者的版本。解决办法要么升级 A 的依赖,要么在bnd.bnd里放宽版本范围。我一般倾向于升级依赖,因为版本冲突往往意味着某个模块太旧了。
第三类是“使用了Private-Package里的包”,报错是NoClassDefFoundError但解析阶段不报错。这种情况是因为Private-Package的包在编译时可见(同一个项目内),但运行时其他 Bundle 看不到它。排查方法是检查Export-Package是否漏了某个包。
4.2 运行时类加载问题的排查思路
OSGi 的类加载模型和普通 Java 应用完全不同,每个 Bundle 有自己的类加载器,默认只能看到自己Import-Package里声明的包。这导致很多在普通 Java 里能跑的代码,在 OSGi 里会抛ClassNotFoundException。最常见的场景是反射加载类,比如Class.forName("com.example.Foo"),如果com.example没有被当前 Bundle 导入,就会失败。
排查这类问题的第一步是确认当前 Bundle 的Import-Package里有没有目标包。可以在运行时用bnd提供的诊断命令查看 Bundle 的元数据,或者直接在框架控制台里执行headers <bundle-id>看导入导出情况。第二步是检查目标类所在的 Bundle 有没有导出这个包。两步都确认了还不行,就要考虑类加载器的委派模型问题——有些框架会配置成“父加载器优先”,这时候即使导入了包,也可能加载到父加载器里的旧版本。
我踩过最坑的一次是ServiceLoader加载 SPI 实现失败。原因是 SPI 配置文件在META-INF/services下,但 Bnd 默认不会把这个目录打进 JAR 的导出范围,导致运行时找不到。解决办法是在bnd.bnd里加-includeresource: META-INF/services/*,显式把 SPI 配置包含进来。这个坑官方文档里提了一句,但很容易漏看。
4.3 版本管理的实操建议
版本号在 OSGi 世界里不是装饰品,而是解析器做决策的依据。Bundle-Version的格式是major.minor.micro.qualifier,其中 major 变更表示不兼容的 API 变化,minor 表示新增功能但兼容,micro 表示 bug 修复。这个语义和语义化版本规范一致,但 OSGi 对它的使用更严格——解析器会根据版本范围自动选择兼容的 JAR。
我的建议是给每个导出包单独设版本,而不是整个 Bundle 一个版本。写法是Export-Package: com.example.api;version="1.2.0"。这样当 API 包发生不兼容变化时,只升这个包的 major 版本,其他包不受影响。Bundle 级别的版本可以粗放一些,跟着项目整体节奏走。
还有一个细节是-snapshot后缀的使用。开发阶段版本号带-SNAPSHOT,发布时去掉。Bnd 对 snapshot 版本的处理和 Maven 类似,解析时会优先选最新的 snapshot。但要注意,snapshot 版本不应该出现在生产环境的.bndrun里,否则每次启动都可能加载到不同的 JAR。我在 CI 里加了一条检查,扫描.bndrun文件,发现 snapshot 就报错阻断构建。
4.4 常见问题速查表
| 问题现象 | 可能原因 | 排查动作 | 解决方式 |
|---|---|---|---|
| 解析报 missing requirement | 仓库缺少提供该包的 JAR | 在仓库索引搜包名 | 补充仓库或添加依赖 JAR |
| 解析报版本冲突 | 两个 JAR 要求不兼容版本范围 | 看报错里的版本区间 | 升级依赖或放宽范围 |
| 运行时 ClassNotFound | 包未在 Import-Package 中 | 查看 Bundle 元数据 | 补充导入声明 |
| SPI 加载失败 | META-INF/services 未打包 | 检查 JAR 内容 | 加 -includeresource |
| 热替换不生效 | 改了方法签名或元数据 | 对比修改前后 | 重启框架 |
| 启动顺序错误 | start level 配置不当 | 检查 -runbundles | 调整 startlevel |
5. 从开发到发布的完整工作流
5.1 本地构建与 CI 集成
本地开发时,Bndtools 的增量构建很快,改一个类只重新编译受影响的部分。但 CI 环境通常没有 IDE,需要用命令行构建。Bnd 提供了bnd build或者通过 Gradle 插件biz.aQute.bnd.gradle来驱动构建。我推荐用 Gradle 插件,因为它的任务依赖管理更成熟,能和现有的 CI 流水线无缝衔接。
Gradle 插件的配置大概是这样:在build.gradle里应用biz.aQute.bnd.builder插件,然后每个子项目声明bnd.bnd的位置。构建产物是标准的 JAR,可以直接发布到 Maven 仓库。这里有个细节要注意:Bnd 产出的 JAR 的 MANIFEST 里包含了Export-Package和Import-Package,但 Maven 仓库的元数据(pom.xml)不会自动生成。如果下游有 Maven 项目要引用这些 JAR,需要额外配置发布插件生成 pom。
CI 流水线里我一般加三个检查点:第一,解析必须成功,用bnd resolve命令验证.bndrun能解析出完整方案;第二,版本号不能有 snapshot;第三,导出的包必须有版本号。这三个检查能拦住大部分低级错误。
5.2 发布产物的组织方式
发布的时候,我习惯把产物分成两类:API 包和实现包。API 包单独发布,版本号严格遵循语义化版本;实现包可以合并发布,版本号跟着项目走。这样下游项目引用 API 包时,版本范围可以写得很明确,不会因为实现包的频繁变更导致解析抖动。
仓库的组织也有讲究。我一般设两个仓库:一个 release 仓库,只放正式版本;一个 snapshot 仓库,放开发版本。Bndtools 工作区配置里两个仓库都声明,但解析时优先从 release 仓库找。这样开发阶段可以用 snapshot 快速迭代,发布时自动切到 release 版本。
5.3 团队协作中的配置管理
多人协作时,cnf目录的变更最容易出问题。我的做法是把cnf/build.bnd拆成两部分:一部分是团队共享的基础配置,提交到主分支;另一部分是个人本地的覆盖配置,放在cnf/local.bnd里,不提交。Bnd 支持配置文件的包含机制,build.bnd里写-include: local.bnd,本地文件存在就加载,不存在就跳过。这样每个人可以有自己的仓库地址和调试配置,又不会污染团队共享的配置。
.bndrun文件的冲突是另一个高频问题。因为解析结果里的 JAR 列表可能因为本地缓存不同而有差异,合并时经常冲突。我的经验是.bndrun里只保留-runrequires(需求声明),让解析器在构建时重新算-runbundles,而不是把解析结果硬编码进去。这样文件内容稳定,冲突少,而且能保证每次构建都用最新的依赖方案。
6. 一些不那么显然的实操心得
6.1 关于包设计的一点经验
用 Bnd 时间长了,会不自觉地改变包的设计习惯。以前写 Java 习惯按层分包(controller、service、dao),用 Bnd 之后更倾向于按 API 和实现分包。因为Export-Package的粒度是包,如果你把 controller 和 service 混在一个包里导出,外部模块就能看到本不该看到的类。按 API 和实现分开,API 包导出,实现包设为Private-Package,边界自然就清晰了。
另一个习惯是给每个导出包配一个packageinfo文件,里面写包的版本号和文档说明。Bnd 会读取这个文件里的版本号作为Export-Package的版本,比在bnd.bnd里手写更不容易漏。packageinfo的格式很简单,第一行是版本号,后面是自由文本描述。这个文件放在包的源码目录下,跟着代码一起走,重构时不容易丢。
6.2 构建性能的优化空间
项目模块多了之后,构建时间会明显变长。我做过一次优化,把构建时间从三分钟压到四十秒,主要做了三件事。第一,关掉不必要的字节码分析。Bnd 默认会分析所有 class 文件的常量池,但有些第三方 JAR 不需要重新分析,可以在bnd.bnd里用-donotcopy排除。第二,启用并行构建。Gradle 插件的--parallel参数能让多个子项目同时构建,前提是子项目之间没有构建顺序依赖。第三,缓存解析结果。Bndtools 的解析器支持缓存,把解析结果存到本地,下次构建直接复用,省掉重新求解的时间。
不过要注意,缓存有时候会导致“改了配置但没生效”的假象。如果发现配置改了但构建结果没变,先清缓存再试。我一般把清缓存的操作做成一个脚本,怀疑缓存问题时一键执行。
6.3 什么时候不该用 Bndtools
说了这么多好处,也得说说它的适用边界。如果你的项目是单体应用,模块数量不超过五个,那 Bndtools 带来的复杂度大于收益。OSGi 的类加载模型需要额外的学习成本,团队里如果有人不熟悉,排查问题的效率会明显下降。这种情况下用 Maven 的多模块或者 Gradle 的 subproject 就够了。
另一个不适合的场景是依赖大量第三方库且这些库没有 OSGi 元数据。虽然 Bnd 能自动给普通 JAR 生成元数据,但生成的质量参差不齐,有时候会把不该导出的包导出,导致运行时冲突。如果项目里这类库占比很高,维护成本会很高。我的判断标准是:如果超过三成的依赖需要手动调 OSGi 元数据,就要重新评估是否值得上这套工具。
6.4 一个容易被忽略的调试技巧
最后分享一个排查依赖问题的笨办法,但特别有效:在框架启动后,用控制台命令bundle:list列出所有 Bundle 的状态,找到状态不是ACTIVE的那个,然后用bundle:diag <id>看它的诊断信息。诊断信息会明确告诉你哪个导入没满足、哪个包解析失败。这个命令比看日志快得多,尤其是在启动阶段大量 Bundle 同时报错的时候,能快速定位到根因。
还有一个技巧是在bnd.bnd里临时加-debug选项,构建时会输出详细的解析过程,包括每个包的候选 JAR 和版本比较。这个输出很啰嗦,但排查版本冲突时是唯一能看清解析器决策路径的手段。用完记得删掉,不然构建日志会被刷屏。
我在实际项目里用 Bndtools 的体会是,它的学习曲线前陡后平。头两周会被各种解析报错和类加载问题折磨,但一旦理解了包级可见性和解析器的工作方式,后面就很少出问题了。这套工具最大的价值不是省了多少配置,而是强迫你把模块边界想清楚——哪些包该暴露、哪些该隐藏、版本怎么演进,这些问题在单体应用里可以糊弄过去,在 OSGi 环境里糊弄不了。如果你正在维护一个需要长期演进的多模块系统,花时间把 Bndtools 吃透是值得的。