Bndtools 实战指南:OSGi 模块化工程的依赖管理与调试
Bndtools 这个工具第一次接触的人十有八九会被它那一堆概念和配置文件劝退。我当初也是打开一个现成的工程看到bnd.bnd、cnf目录、.bndrun文件脑子里第一反应是这都什么跟什么。但真正用下来会发现它解决的其实是一个非常具体的问题在 OSGi 这套模块化运行时之上如何让依赖管理、构建、调试、打包这几件事变得可重复、可自动化。如果你正在做基于 OSGi 的模块化系统或者你手上有一个需要长期维护、模块边界清晰的中大型 Java 工程Bndtools 值得你花时间啃下来。这篇内容我会从实际使用的角度把 Bndtools 的项目结构、核心配置、依赖解析、运行调试、打包发布这几块讲透中间穿插我自己踩过的坑和总结出来的操作习惯目标是让你看完能直接上手一个可跑通的项目而不是停留在知道有这么个东西的层面。1. 先搞清楚 Bndtools 到底在管什么很多人把 Bndtools 当成一个普通的 Eclipse 插件或者构建工具这个理解偏差会直接导致后面用得很别扭。要理解它得先理解它背后的两个东西Bnd和OSGi。Bnd 本身是一个独立的工具链核心能力是读取和生成 JAR 包的元数据尤其是 OSGi 规范里那些Import-Package、Export-Package、Bundle-Version之类的头信息。Bndtools 则是把这套能力集成进 IDE主要是 Eclipse也有基于 VS Code 的衍生方案让你在图形界面里完成原本要手写命令行参数才能做的事。1.1 Bnd 与 OSGi 的关系元数据驱动的模块化OSGi 的模块化不是靠目录结构或者命名约定实现的而是靠 JAR 包内部META-INF/MANIFEST.MF里的元数据来声明我对外暴露哪些包我需要哪些包。运行时框架比如 Felix、Equinox读取这些头信息决定哪些 bundle 能启动、哪些依赖缺失。这就带来一个很现实的问题这些元数据如果靠人手写几乎不可能维护正确。一个中等规模的工程动辄几十个 bundle包级别的导入导出关系错综复杂版本范围还要精确控制手写 manifest 是灾难。Bnd 的价值就在这里它从你的源码和 classpath 里自动分析出应该导入哪些包、导出哪些包然后生成正确的 manifest。Bndtools 把这个过程可视化、工程化了。你在 IDE 里写代码它实时帮你算依赖缺什么、多什么、版本冲突在哪都能提前暴露。1.2 Bndtools 工程和普通 Java 工程的结构差异普通 Java 工程你习惯的是src/main/java加一个pom.xml或者build.gradle。Bndtools 工程的骨架不太一样核心是这么几个东西cnf目录这是整个工作区的配置中心全称是 configuration。里面放着仓库定义、构建路径、目标运行环境等共享配置。一个工作区只有一个cnf所有项目共用。每个 bundle 项目下的bnd.bnd这是单个 bundle 的构建描述文件声明这个 bundle 的版本、导出的包、依赖的包、包含的资源等。.bndrun文件运行描述文件声明我要用哪些 bundle 跑起来用哪个框架启动参数是什么。它和bnd.bnd是两回事前者管构建后者管运行。这个结构初看繁琐但它把构建一个 bundle和运行一组 bundle这两件事彻底解耦了。你可以构建出十个 bundle然后用三个不同的.bndrun文件组合出三种运行场景互不干扰。这个设计在需要多环境调试的时候特别有用。提示cnf目录不要手动去改里面的缓存和临时文件但cnf/ext下的仓库配置和cnf/build.bnd是你可以也应该去定制的。1.3 为什么不用 Maven 或 Gradle 直接搞定这个问题我被问过很多次。答案是Maven 和 Gradle 解决的是构建产物的问题Bndtools 解决的是运行时模块关系的问题两者不在一个层面上。你当然可以用 Maven 配合 bnd-maven-plugin 来生成 OSGi 元数据事实上很多团队就是这么干的。但 Bndtools 的优势在于开发期的即时反馈你在 IDE 里改一个包的导出声明它立刻告诉你哪个下游 bundle 会受影响你往.bndrun里加一个 bundle它立刻解析出还缺哪些依赖。这种边写边验证的体验纯靠命令行构建是给不了的。我的建议是如果你的团队已经在用 Maven 或 Gradle 做 CI完全可以让 Bndtools 负责开发期的解析和调试构建产物交给 CI 里的 bnd 插件生成两边用同一套bnd.bnd配置不会打架。2. 工作区初始化与第一个 bundle 的落地理论说再多不如动手跑一遍。这一节我按实际操作顺序把从零建工作区到跑起第一个 bundle 的完整链路走一遍中间该注意的地方我会标出来。2.1 工作区创建时那几个选项的含义在 IDE 里新建 Bndtools 工作区会让你选目标运行环境Target Platform和框架。这里有个容易忽略的点目标运行环境决定了你编译时能看到的 API 边界。如果你选的是某个 OSGi 框架的某个版本那么编译期你只能引用这个框架暴露出来的包超出范围的引用会直接报错。这个约束是好事它逼着你在开发期就守住模块边界而不是等到运行时才发现某个包在目标环境里根本不存在。框架选择上Felix 和 Equinox 是最常见的两个。Felix 轻量、启动快适合做嵌入式或者对启动时间敏感的场景Equinox 是 Eclipse 体系的老牌实现生态成熟。选哪个不影响你写 bundle 代码因为 bundle 本身是框架无关的只依赖 OSGi 规范。我个人的习惯是开发期用 Felix因为它报错信息更直白排查依赖问题快。2.2cnf/build.bnd里值得改的几项工作区建好后cnf/build.bnd是全局构建配置。默认内容不多但有几项建议你一开始就调整# 编译级别按你的 JDK 版本设 javac.source: 17 javac.target: 17 # 生成的 bundle 版本策略默认是时间戳建议改成语义化 Bundle-Version: 1.0.0.${tstamp} # 源码包是否单独生成 -sources: trueBundle-Version这一项特别说一下。默认用时间戳做版本后缀好处是每次构建版本都不同坏处是版本号不可读、不好追溯。我一般改成主版本.次版本.修订号.${tstamp}的形式既保证唯一性又能一眼看出这是哪个功能阶段的产物。-sources: true则是让构建时顺带生成源码包调试的时候能直接跳到源码省得手动挂。2.3 新建 bundle 项目时的目录约定新建一个 Bndtools 项目选 Bundle 类型它会生成这样的结构my.bundle/ ├── bnd.bnd ├── src/ │ └── com/example/... └── test/ └── com/example/...注意这里的src不是 Maven 那种src/main/java就是平铺的src。Bndtools 默认把src当源码根test当测试源码根。这个约定你可以改在bnd.bnd里用-includeresource或者src属性调整但除非有特殊理由建议顺着默认来省得后面配置构建路径时多绕弯。bnd.bnd初始内容大概是这样Bundle-Name: My Bundle Bundle-SymbolicName: my.bundle Bundle-Version: 1.0.0 Export-Package: com.example.api这里Bundle-SymbolicName是 bundle 的唯一标识全局不能重复命名上建议用反域名加模块名和 Java 包名一个思路。Export-Package声明对外暴露的包只写你真正想给别人用的包内部实现包不要导出这是模块化设计的基本纪律。2.4 依赖声明-buildpath与-testpathBndtools 里声明编译依赖用的是-buildpath测试依赖用-testpath。写法是引用仓库里的 bundle 坐标-buildpath: \ osgi.core;version[7.0,8),\ org.apache.felix.scr;version[2.1,3)这里有几个实操要点。第一版本范围用区间写法[7.0,8)表示 7.0 及以上、8 以下左闭右开。不要写死单个版本否则依赖树稍微一动就冲突。第二反斜杠续行是 bnd 的语法每行末尾的逗号不能少。第三-buildpath里的依赖只影响编译不影响运行时运行时用什么 bundle 是在.bndrun里决定的这个分离一定要记牢。注意-buildpath里引用的 bundle 必须能在已配置的仓库里找到。如果报无法解析先去cnf/ext/repositories.bnd里确认仓库地址配对了或者本地仓库里确实有这个 bundle。2.5 第一个可运行 bundle 的最小闭环写一个最简单的 Activator 或者用 Declarative Services 声明一个组件然后建一个.bndrun文件把它跑起来。.bndrun的核心内容是-runfw: org.apache.felix.framework -runee: JavaSE-17 -runrequires: \ osgi.identity;filter:(osgi.identitymy.bundle) -runbundles: \ org.apache.felix.scr-runfw指定运行框架-runee指定执行环境-runrequires声明我需要哪些 bundle 参与运行-runbundles是解析后实际要启动的 bundle 列表。这里的关键操作是写完-runrequires后点 IDE 里的Resolve按钮Bndtools 会自动算出完整的依赖闭包并填进-runbundles。不要手写-runbundles让它算算完你检查一遍有没有多余的或者版本不对的再手动微调。跑起来之后控制台会打印框架启动日志和 bundle 状态。如果某个 bundle 显示 INSTALLED 而不是 ACTIVE八成是依赖没满足去看它的 manifest 里Import-Package有没有解析不到的包。3. 依赖解析报错时的排查链路依赖解析是 Bndtools 使用过程中最容易卡住的地方报错信息有时候很含糊。这一节我把常见的几类问题和排查顺序整理出来你按这个链路走大部分问题能定位到根因。3.1 Unable to resolve 的三种典型成因第一类是包级依赖缺失。某个 bundle 的Import-Package声明了一个包但运行环境里没有任何 bundle 导出这个包。排查方法是在.bndrun的解析结果里看那个 bundle 的状态或者用 bnd 的命令行工具跑一次bnd resolve看详细报告。第二类是版本范围不匹配。A bundle 需要com.example.api的[2.0,3)版本但仓库里只有 1.5 版本。这种报错会明确写出期望范围和实际可用范围对着改就行。第三类是包被多个 bundle 重复导出。两个 bundle 都导出了同一个包运行时框架不知道该用哪个解析就会失败或者行为不确定。这种情况在多人协作、模块拆分不彻底的时候特别常见。解决办法是明确包的归属只让一个 bundle 导出其他 bundle 通过Import-Package引用。3.2 用-runrequires的过滤器精确定位-runrequires支持 LDAP 风格的过滤器这个能力很多人没用起来。比如你只想让某个特定 symbolic name 的 bundle 参与运行-runrequires: \ osgi.identity;filter:(osgi.identitymy.bundle),\ osgi.wiring.package;filter:(osgi.wiring.packagecom.example.api)第二种写法是直接按包需求来声明意思是我需要一个能提供com.example.api包的 bundle。这在你不确定具体用哪个实现 bundle 的时候很有用让解析器自己去挑满足条件的。过滤器写得好解析结果会干净很多不会把一堆不相关的 bundle 拉进来。3.3 解析结果里的可选依赖陷阱Bndtools 解析时会把Import-Package里标记为resolution:optional的依赖当作可选的缺了也不报错。这个机制本意是好的但容易埋雷开发期解析通过运行时某个可选依赖恰好没装功能就静默失效了。我的做法是开发调试阶段把所有 optional 依赖临时改成必需等确认功能完整了再改回去。改的方法是在bnd.bnd里用-fixupmessages或者直接在源码的包引用上控制更直接的办法是看解析报告里哪些包被标了 optional逐个确认是否真的可以缺。3.4 一个真实的排查案例拆解之前遇到过一个情况.bndrun解析一直报某个 bundle 无法启动但报错信息只说missing requirement没说是哪个包。排查过程是这样的先在 IDE 的解析视图里展开那个 bundle 的 requirements发现它需要一个org.osgi.service.component包版本范围[1.3,2)。然后去仓库里搜这个包发现有两个 bundle 都导出了它一个版本 1.4一个版本 1.2。1.2 不满足范围1.4 满足但 1.4 那个 bundle 自身又依赖了另一个没在运行环境里的包形成链式缺失。定位到根因后解决办法不是硬塞依赖而是换了一个更轻量的实现 bundle它只导出org.osgi.service.component且没有额外依赖。这个案例说明解析报错的表面现象往往是链式的要顺着 requirements 一层层往下看直到找到那个真正缺失的叶子节点。报错现象可能原因排查动作无法解析某 bundle包级依赖缺失展开 requirements 看具体包名版本范围冲突仓库内版本不满足核对期望范围与实际可用版本包重复导出模块边界不清确认包归属只保留一个导出方运行时静默失效optional 依赖缺失临时改为必需验证功能完整性4. 运行配置与调试的实战细节.bndrun跑通只是开始真正在日常开发里高频使用的是调试能力。Bndtools 在这块的体验做得比较细但有些配置项不点开看根本不知道存在。4.1 启动参数与 JVM 选项的注入位置.bndrun里可以用-runvm传 JVM 参数比如-runvm: -Xmx512m, -Dfile.encodingUTF-8, -agentlib:jdwptransportdt_socket,servery,suspendn,address*:5005这里有个细节-runvm的多个参数用逗号分隔但参数内部如果本身含逗号比如某些 agent 配置要用引号包起来或者转义。我踩过一次坑-agentlib那串里带了逗号没处理结果参数被截断调试端口根本没起来排查了半天以为是防火墙问题。另外-runvm里的参数是追加到框架启动命令上的不是替换。如果你需要完全控制启动命令用-runprogramargs和-runvm配合别指望一个属性搞定所有。4.2 远程调试端口的稳定配置做 OSGi 调试远程调试端口是刚需。上面那串-agentlib:jdwp就是干这个的。几个实操建议端口别用默认的 8000 或者 5005容易和本机其他进程冲突我一般用 5 开头的五位数suspendn表示启动时不挂起等调试器如果你需要从第一行代码就开始断点改成suspendyaddress*:端口里的星号表示监听所有网卡本机调试用localhost:端口更安全。配好之后在 IDE 里建一个远程 Java 应用调试配置指向同一个端口就能连上去打断点了。注意 bundle 的类加载器隔离OSGi 里每个 bundle 有自己的类加载器断点打在某个 bundle 的类里调试器要能正确关联到那个 bundle 的源码否则会出现断点不生效或者跳到错误位置的情况。解决办法是在调试配置里把源码路径映射对或者直接用 Bndtools 自带的调试启动器它自动处理了这层映射。4.3 热部署与 bundle 重载的边界Bndtools 支持在运行中重新部署修改过的 bundle这个功能在调试业务逻辑时非常省时间。但它的边界要清楚改方法体、改内部实现热部署通常没问题改包的导出声明、改 bundle 的 symbolic name、改依赖关系热部署大概率失效必须重启框架。原因是这些改动涉及类加载器和解析结果运行时框架没法在不重启的情况下重新计算依赖图。我的习惯是小改动用热部署快速验证涉及结构性的改动直接重启别在热部署上浪费时间排查为什么改了没生效。重启一个 Felix 框架通常也就几秒钟比纠结热部署的边界快得多。4.4 日志与框架事件的观察窗口调试 OSGi 应用光看业务日志不够框架层面的事件bundle 启动、停止、依赖解析才是定位问题的关键。Bndtools 的运行视图里能看到每个 bundle 的状态变化但更细的事件需要开框架的日志。Felix 可以用org.apache.felix.log这个 bundle 把框架日志桥接到统一的日志系统配置一下日志级别bundle 的启动顺序、依赖满足情况都能看到。我一般会在.bndrun里额外加上日志 bundle然后调高org.osgi.framework相关包的日志级别这样 bundle 状态从 INSTALLED 到 RESOLVED 到 ACTIVE 的每一步都有记录出问题时对着时间线看比猜快得多。5. 打包发布与 CI 衔接的落地方式开发调试跑通之后最终要产出可部署的 bundle 或者一个完整的运行分发包。Bndtools 在这块提供了几种产物形态选哪种取决于你的部署场景。5.1 单个 bundle 的导出与版本管理导出单个 bundle最直接的方式是在 IDE 里右键项目选导出或者在命令行用 bnd 的bnd jar命令。产出的 JAR 里 manifest 是自动生成的Export-Package、Import-Package、Bundle-Version都按bnd.bnd的配置来。版本管理上有个经验bundle 版本和包版本要分开管。Bundle-Version是整个 bundle 的版本Export-Package里每个包还可以单独声明版本比如Export-Package: com.example.api;version2.1.0。包版本反映的是 API 兼容性bundle 版本反映的是整体发布节奏。下游依赖你的时候如果只关心 API就按包版本约束如果关心整体行为就按 bundle 版本约束。这个区分在长期维护的工程里非常重要能避免我只改了个内部实现结果下游全挂了的情况。5.2 生成可执行分发包executable jar如果你的部署场景是一个 jar 包扔过去就能跑Bndtools 支持把.bndrun连同所有依赖 bundle 打成一个可执行 JAR。配置方式是在.bndrun里加-runpath: ... # 导出时指定主类 -runproperties: launcher.maincom.example.Launcher然后用 bnd 的export功能生成。生成的 JAR 内部结构是一个启动器主类加上所有 bundle 嵌在jar/目录下启动时由内嵌的框架加载。这种分发包的好处是部署简单坏处是体积大所有依赖都打进去了而且更新单个 bundle 需要重新打整个包。注意可执行分发包里的 bundle 版本是打包那一刻的快照后续要更新依赖必须重新执行导出流程不能指望运行时动态拉取。5.3 与 CI 流水线的对接要点CI 里用 bnd 命令行工具做构建核心命令是bnd build或者针对单个项目的bnd jar。几个对接要点第一cnf目录要纳入版本控制但里面的缓存目录比如cnf/cache要排除。缓存是本地解析的中间产物不同机器上不一致提交上去只会制造冲突。第二仓库配置要能在 CI 环境里解析。如果cnf/ext/repositories.bnd里引用了本地文件路径的仓库CI 机器上大概率没有要么改成远程仓库地址要么在 CI 脚本里先把依赖仓库准备好。第三构建产物的版本号要可追溯。前面提到的${tstamp}方案在 CI 里要确保时间戳来源一致最好用 CI 系统提供的构建号或者提交哈希做后缀比纯时间戳更有追溯价值。# CI 里典型的构建命令示例 bnd build --workspace /path/to/workspace # 或者针对单个项目 cd my.bundle bnd jar5.4 发布到内部仓库的流程构建出的 bundle 最终要进内部仓库供其他项目引用。Bndtools 支持配置发布仓库在cnf/ext/repositories.bnd里定义一个可写的仓库然后用 IDE 的Release功能或者命令行bnd release把 bundle 推上去。发布前建议做两件事一是确认Bundle-Version已经按发布规范递增二是跑一遍依赖解析确保新版本不会破坏下游的版本范围约束。这两步花不了几分钟但能省掉下游一堆升级后跑不起来的工单。6. 几个让我少走弯路的操作习惯用 Bndtools 时间长了会形成一些固定的操作习惯这些习惯单看都是小事但累积起来对效率影响很大。6.1 把bnd.bnd当代码一样 reviewbnd.bnd里的每一行都影响构建产物和运行时行为但很多人改它的时候很随意加个依赖、改个导出改完就跑不检查影响面。我的做法是bnd.bnd的改动和 Java 代码改动一样走 review尤其是Export-Package和-buildpath的变更要确认没有意外暴露内部包、没有引入不必要的依赖。这个习惯在多人协作的工程里能挡掉很多莫名其妙多出来一个依赖的问题。6.2 定期清理解析缓存Bndtools 的解析结果有缓存正常情况下能加速但遇到明明改了配置却还是旧结果的情况八成是缓存没刷新。清理的位置在cnf/cache目录直接删掉重启 IDE 就行。我一般在大改依赖关系之后主动清一次避免拿着旧解析结果做判断。6.3 用独立的.bndrun隔离不同调试场景不要把所有 bundle 都塞进一个.bndrun里跑。按场景拆一个跑核心服务一个跑集成测试一个跑最小复现环境。这样出问题时能快速切到最小环境定位而不是在一个几十个 bundle 的大环境里大海捞针。.bndrun文件本身很轻多建几个没有成本收益却很明显。6.4 关注Import-Package的实际生成结果bnd.bnd里你可以不写Import-Package让 bnd 自动分析。但自动分析的结果一定要看尤其是在用了反射、动态加载、SPI 这类机制的时候自动分析可能漏掉或者多算。我的习惯是每次构建后打开产出的 JAR看一眼 manifest 里的Import-Package和预期对一遍。多花这一分钟能避免运行时才发现的ClassNotFoundException。6.5 版本范围宁紧勿松声明依赖版本范围时新手容易写成[1.0,)这种无上界的觉得兼容性好。实际上无上界意味着未来任何大版本更新都可能被拉进来一旦上游做了不兼容变更你的工程会在某次构建时突然挂掉而且很难定位。正确的做法是给出明确的上界比如[1.0,2)大版本升级时主动评估、主动改范围。这个纪律在依赖多的工程里尤其重要。7. 关于学习路径的一点个人看法Bndtools 的学习曲线确实陡但陡的地方不在工具本身而在 OSGi 那套模块化思维。工具的操作几天就能熟思维方式的转变需要时间。我的建议是不要一上来就啃规范文档而是找一个能跑的最小工程先把它跑起来然后逐个改配置、观察结果变化用改一个变量看一个结果的方式建立直觉。等直觉有了再回头补规范细节会顺畅很多。另外Bndtools 的很多行为是约定优于配置的默认值背后都有合理的设计意图。遇到不符合预期的地方先想想是不是自己没顺着约定来而不是急着改配置覆盖默认行为。大部分时候顺着约定走问题会少很多。这套工具链在模块化工程里的价值用久了自然能体会到前期投入的学习成本会在后期维护阶段成倍地还回来。