模块版本规范
本页说明的是你的模块所使用的版本号。UltiTools-API 自身的版本号遵循另一套约定,见与框架版本号的区别。
版本号的选择
判断的依据是一个问题:服主换上新的 JAR 之后,还需不需要做别的事?
| 含义 | 服主需要做什么 | |
|---|---|---|
| MAJOR | 升级需要人工介入 | 手动修改配置、迁移数据、重新学习被改名或删除的命令与权限节点,或者先升级 UltiTools |
| MINOR | 新增功能,向后兼容 | 直接替换 JAR。原有配置继续可用,新功能可能需要在配置中开启 |
| PATCH | 修复与内部改动,包括仅涉及 CI 与构建的改动 | 直接替换 JAR,无需其它操作 |
之所以用「服主要做什么」而不是「改动有多大」作为标准,是因为两者并不相关。重写模块的内部实现,只要配置文件与命令保持不变,就是一次 PATCH;而重命名一个权限节点即使只改了一行,也是 MAJOR,因为所有授予过该节点的服务器都会失去这项权限。
检查清单
- [ ] 服主需要手动修改配置文件吗?→ MAJOR
- [ ] 已有数据需要迁移吗?→ MAJOR
- [ ] 有命令或权限节点被删除或改名吗?→ MAJOR
- [ ]
plugin.yml的api-version提高了吗?→ MAJOR - [ ] 以上四项都不是,但有新功能?→ MINOR
- [ ] 以上四项都不是,也没有新功能?→ PATCH
第四项覆盖的是前三项照顾不到的情况,因为此时模块本身没有任何变化。提高 api-version 意味着使用旧版框架的服主无法直接替换 JAR,必须先升级 UltiTools,按上面的标准这属于 MAJOR。即使这次发布只是一次源码未改的重新构建也是如此,而描述符变更正是会导致这类发布的情形。
与框架版本号的区别
UltiTools-API 的 COMPATIBILITY.md 说明,它的版本号是产品阶段的标识,而不是严格的 semver 约定,框架的 MINOR 版本可能移除 API。
向框架本身贡献代码,而不只是使用它
如果你打算向 UltiTools-Reborn 提交 pull request,请注意它的贡献语言政策和本文档站不同:新增的注释、javadoc、workflow 注释与 PR 标题正文都要求以英文为主、中文作为补充。 CI 会对 src/main 的注释与 javadoc、workflow 文件与一个测试包强制执行这条规则,对照一份很小的、明确列出的白名单。 这是框架仓库自己的政策,不是这个双语文档站的要求。
这看起来与上面的规则矛盾,实际上并不矛盾,两者的区别值得先理解清楚。
框架的版本号会被解析和链接。Maven 依据它选择构件,已编译的下游插件在运行时链接到它的类。这两件事都是兼容性问题,而一个需要回答兼容性问题的数字不能是自由格式的标识。
模块的版本号同样会被程序读取,但用途只有比较两个版本的先后,不用于判断兼容性:
| 使用方 | 用途 |
|---|---|
PluginManager.hasNewerVersionLoaded | 代码通过 PluginManager#register(...) 再注册一个已加载模块(同一个主类)的实例时,比较两个版本,拒绝较旧的那个 |
PluginManager.unregisterSupersededVersions | 同样是这种情况下,正在注册的实例更新时,卸载被它取代的已加载版本 |
UpdateManager.checkModuleUpdates | 比较已加载版本与已发布版本,提示有更新可用 |
三者都通过 VersionComparatorUtil.compare 判断 A 是否大于 B,都不关心这个差异属于 MAJOR、MINOR 还是 PATCH。
模块目录里同一个模块的两个 JAR 不会走到前两项。所有模块 JAR 共用一个类加载器,两个副本解析到的是同一个类,不会并排加载。由哪个副本提供类取决于文件名而不是版本号,见同一模块的两个副本。
也没有任何机制从 Maven 解析一个模块供另一个模块使用。官方模块中没有一个在 pom 中声明对兄弟模块的依赖;以多模块工程构建的模块(例如 UltiBot)依赖的是它自己的子模块,那属于该构建的内部结构,不是模块之间的依赖。因此约束框架版本号的那条理由不适用于模块的版本号。如果你把自己的模块发布出去供他人编译和链接,这条理由就同样适用于你的模块:你的版本号从此需要回答兼容性问题,此时需要的是一套比框架更严格、能够保证兼容性的约定,而不是框架那套更宽松的约定。
所以两者的区别在于:版本号的顺序会被程序使用,而 MAJOR、MINOR、PATCH 的含义不会。由此产生一条强制要求,其余部分交给编写者判断。
唯一的强制要求
版本号必须单调递增并保持可比较。从 1.10.0 退回 1.9.0,或者中途更换编号方案,会导致更新检查漏掉更新的版本或提示一个更旧的版本,也会导致对同一模块的第二次注册保留错误的实例。
除此之外,版本号的形态是写给服主看的信息,所以它的规则可以与框架不同,两者都不算错。
对 UltiTools-API 的 pin
模块将框架声明为 provided:
<dependency>
<groupId>com.ultikits</groupId>
<artifactId>UltiTools-API</artifactId>
<version>${ultitools.version}</version>
<scope>provided</scope>
</dependency>provided 表示模块在编译时使用 pin 指定的版本,在运行时使用服务器上实际安装的框架。这种不对称是这套机制的关键:
- 针对较旧的 API 编译时,编译器写入字节码的每一个符号在那个版本中都存在,因此模块不会因为引用了比服务器更新的内容而出现
NoSuchMethodError。但它仍可能因为其它原因出现同样的异常,见框架引起的兼容性中断。 - 针对较新的 API 编译时,模块可能引用到服务器上的框架没有的方法,第一次执行到该调用点时出现
NoSuchMethodError。
第一条的适用范围需要注意。它描述的是静态链接的引用在一个方向上的情况,不是一条普遍保证:之后的框架版本仍然可能移除或者修改模块正在使用的内容,而通过反射访问的内容从一开始就不在这条保证的范围内,因为编译器没有记录它们。
因此 pin 落后于最新正式版属于正常状态,不是需要修正的偏差。只在模块确实开始使用新 API 时才提高 pin。
pin 与 api-version
pin 不是模块的运行时下限。这是两个互相独立的数字,其中只有一个会被检查:
| 数字 | 决定什么 | 由谁检查 |
|---|---|---|
pom.xml 中的 UltiTools-API 版本 | 字节码记录的是哪一版的描述符 | 没有。它是 provided,不会进入 JAR,框架在运行时也读不到它 |
plugin.yml 中的 api-version | 声明的运行时下限 | PluginManager.isUltiToolsVersionCompatible。这是模块被放行前唯一被检查的框架版本 |
提高 pin 不会提高下限。两个数字不一致本身不是问题:只要字节码引用的成员在声明的下限中都已存在,针对更新的 pin 构建出的产物照样可以在该下限上运行。真正会出问题的是字节码引用了 api-version 所声明的框架版本没有的符号,这时那个服务器会放行这个 JAR,并在执行到对应调用点时失败。
模块被放行前检查的项目不止这一个:JAR 会先经过结构校验,同一模块已经加载了更新的实例时也会被拒绝(这只发生在代码通过 PluginManager#register 第二次注册同一个主类时),完整的判断是 passesCompatibilityGates 中的 !hasNewerVersionLoaded && isUltiToolsVersionCompatible。其中只有 api-version 与「能在哪些框架版本上运行」有关,所以本页只讨论它。
框架引起的兼容性中断
较旧的 pin 只提供上一节所说的那一条保证。模块自身的代码没有任何改动,仍然可能出现链接错误,因为字节码链接到的目标由服务器上安装的框架决定,不由模块的构建过程决定。
构建成功不等于兼容
一次成功的构建只能说明源码仍然可以编译。导致已发布 JAR 出错的是字节码中记录的描述符,而编译过程不会显示它。
JLS 的二进制兼容性一章列出了可能造成这类问题的全部变更,比下面两种更多。下面两种是本项目实际发生过的,属于举例,不是穷举。
API 被移除
框架的 MINOR 版本可能移除 API,具体见 COMPATIBILITY.md。出现哪种链接错误取决于被移除的内容:移除类型得到 NoClassDefFoundError,移除方法、构造器或字段得到 NoSuchMethodError 或 NoSuchFieldError。第二种并非假设,当前的移除清单中包含一个构造器,不只有类型。
提高 pin 并重新构建会产生两种结果,在运行之前无法确定是哪一种:
- 构建失败。移除因此暴露出来,你在这里发现问题,而不是在服主的服务器上,接下来需要从被移除的 API 迁移出去。
- 构建通过。某个保留下来的成员接管了这次调用,重新构建出的产物已经修复。
这两种结果都不会修复已经发布出去的那个 JAR,它会一直出错,直到你发布重新构建的版本。第二种结果因此更需要注意:构建通过看起来像是没有问题,而实际上修复品在你手中,需要你主动发布出去。
构建通过并不难出现,因为这次检查只覆盖源码中明确写出名字的内容。源码隐式引用到的部分都可能被重新解析到别处:
- 重载接管了调用。
m(String)被移除,m(Object)保留,未经修改的源码会编译到保留的那个上。 - 被移除的类型没有出现在源码中。
factory.create().run()中create()的返回类型由编译器推断,把create()改为返回替代类型之后源码仍然可以编译,而旧的字节码中引用的还是被删除的那个类型。
只有在发布提高后的 pin 时才会产生代价:针对更新的框架构建可能记录更新的描述符,这意味着需要同时提高 api-version,而那会让仍在使用旧版框架的服务器无法升级。因此提高 pin 适合用来做检查,不适合用来做修复:在一次临时构建中提高 pin,观察哪些地方编译不通过,从这些 API 迁移出去,然后单独决定正式发布使用的 pin 是否需要变动。
这种情形的应对方式是关注废弃通告,在移除正式发布之前完成迁移。
从废弃通告里读出移除期限
自 v6.3.0 起,框架里每一个 @Deprecated(forRemoval = true) 成员都携带一个 {@removeIn X.Y.Z} javadoc 标签,指明移除会落在哪个具体版本。 一旦项目版本到达该目标而成员仍在源码中声明,框架自己的 CI 就会在构建期失败,这个标签是被强制执行的承诺。 完整列表见框架仓库的 compatibility/DEPRECATIONS.md,一次性查看所有 ANNOUNCED 状态的移除项及其期限。
描述符变更
这种情形更难发现,因为没有任何内容被移除,也没有可以标记为废弃的对象。方法的描述符同时包含参数类型和返回类型,其中任何一项发生变化,都会在相同的名字下产生一个不同的符号。
这种情况发生过两次。
6.1.1 到 6.2.0,一个 MINOR 版本。 移除 Spring 时修改了 UltiToolsPlugin 上一个字段的类型,由 Lombok 生成的 getContext() 返回类型随之改变。所有已经编译的、调用了该方法的模块都得到 NoSuchMethodError。这次改动没有移除任何内容,也没有标记废弃,从源码上看是一次内部整理。公开字段的类型发生变化时同理:已编译的 getfield 仍然带着旧的描述符,失败于 NoSuchFieldError。
6.2.0 到 6.2.1,一个 PATCH 版本。 数据 API 上的 AbstractDataEntity 被替换为 BaseDataEntity<String>,改变了 5 个类型上共 14 个公开成员的描述符:DataOperator 的 exist(T)、getById、insert(T)、update(T),Query 的 first(),以及它们的实现类。这次改动没有移除也没有新增任何成员,每个受影响的成员都保留了原来的名字。
第二次发生在 PATCH 版本,说明没有哪个版本级别可以免于这种情况。版本策略安排的是有意进行的移除,而非预期的二进制中断按定义就不在安排之内,PATCH 也不例外。
只使用 .query()….first() 的模块没有调用任何 DataOperator 方法,同样会受到影响,而一份按 DataOperator 组织的清单不会覆盖到这类模块。
双向影响
一个符号保留名字而改变描述符时,两个方向都会受影响。同一个模块、同一份源码,只改变 pin:
| 构建时的 pin | 运行在框架 6.2.0 上 | 运行在框架 6.2.1 及以后 |
|---|---|---|
| 6.2.0 | 正常 | NoSuchMethodError,查找 (AbstractDataEntity),该符号已不存在 |
| 6.2.1 | NoSuchMethodError,查找 (BaseDataEntity),该符号尚未存在 | 正常 |
因此「针对新框架重新构建」并不是一次能让旧服务器保持原状的修复,它改变的是哪一侧可以运行。两个方向出错的原因相同,所以具体的处理方式取决于缺失符号引用的是哪一代类型,见输出的判读。
需要执行的操作
只重新构建并不足够。pin 仍然停留在旧版本时,构建会重新生成旧的描述符,新产物的失败方式与之前完全相同。因此只要保留原来的直接调用点,就需要把 pin 提高到包含新描述符的框架版本并重新构建。下面的第三种方式可以避开这个前提。
这只完成了一半,而且按上面那张表,是没有被检查的那一半。重新构建出的 JAR 记录的是新的描述符,它在更旧的框架上会抛出 NoSuchMethodError。如果 api-version 保持不变,旧服务器会正常放行这个新 JAR,然后在第一次调用时出错。因此需要同时提高 api-version,而按本页开头的检查清单,这会使该次发布成为 MAJOR。
提高到产物实际需要的版本,而不是机械地跟随 pin。提高 pin 本身并不意味着产物需要更新的框架:如果这次重新构建只是把某次调用重新指向了两个版本中都存在的成员,或者你提高 pin 只是为了做检查,那么字节码可能仍然可以在原有的下限上运行,此时提高 api-version 会把一次兼容的修复变成一次没有必要的 MAJOR 发布。下一节说明如何确认这一点;在没有确认的情况下,跟随 pin 是保守的取值方式。
检查产物
「这个 JAR 能否在框架 X 上运行」是可以确认的。确认方式不是重新构建,而是把字节码引用的符号与该框架实际声明的内容作比较:
# 在 UltiTools-Dev-Doc 的 checkout 中执行
python3 scripts/symcheck.py 你的模块.jar UltiTools-API-<你声明的下限>.jar退出码为 0 表示没有缺失的符号。非零时会列出缺失的内容,该 JAR 在那个框架上第一次执行到对应调用点时就会出错。
比较的对象应当是你在 api-version 中声明的版本,而不是你 pin 的版本。这是两个不同的问题,而前者才是用户会遇到的那个。
输出的判读
先区分两种情形,它们需要的处理不同:
- 这个名字完全不存在,没有任何重载保留下来,或者类本身不在。这属于 API 被移除,参照该版本的移除清单,需要的是源码迁移。
- 存在同名的符号,只是描述符不同。这属于描述符变更,此时才适用下面的表格。
对于描述符变更,观察缺失符号引用的是哪一代类型:
| 缺失符号引用的类型 | 说明 | 处理方式 |
|---|---|---|
较新的类型,例如 BaseDataEntity | 产物需要的版本高于它声明的下限 | 提高 api-version,pin 不需要改动 |
较旧的类型,例如 AbstractDataEntity | pin 停留在旧版本,产物比声明的下限更旧 | 提高 pin 并重新构建。此时提高 api-version 没有作用,反而会让情况更糟 |
这两种情况容易混淆,而处理方式相反,因此值得先把符号本身读清楚。
脚本不能代替你作这个判断。它接收的是一个模块 JAR 和一个框架 JAR,可以确认某个符号不存在,但无法确认原因,也读不到你的 pin。
还有两种情况脚本会明确说明而不作推断。继承链超出这两个 JAR 范围的引用会被列为「无法判定」,并且不影响退出码;这种情况出现在框架类继承自 API JAR 未包含的类型时,例如服务端 API 或某个界面库,此时模块调用继承来的方法是正常的。另外,如果 javap 本身执行失败,脚本会直接终止,而不是继续分析不完整的输出。
手工检查还会遇到一个细节:常量池中记录的是调用点上接收者的静态类型,而不是声明该成员的类。在自己的插件类中调用继承来的框架方法时,记录下来的所属类是你自己的类,形如 com/example/MyPlugin.getContext:()…。因此只筛选所属类已经位于框架包内的引用,会遗漏所有继承调用,而插件都继承 UltiToolsPlugin,这类调用占多数。
手工检查的局限
对单个类执行 javap -s 可以查看某个成员的描述符,但这里的问题是整个 JAR 中有没有引用到目标框架不存在的符号,这需要比较两个构件。
同时支持两个框架版本
无法同时适配两侧的是静态链接的调用点,描述符在编译期就已确定,一个调用点只匹配一侧。可选的方式有三种,代价依次递增:
- 接受更高的下限。这是默认的选择:旧服务器继续使用旧版本的 JAR,新版本服务新框架。
- 按框架版本区间发布不同的构件,同时维护两条线。
- 编写适配层,改用反射调用(
getMethod("getContext").invoke(plugin)返回Object,再以同样的方式访问getBean),或者按框架版本延迟加载不同的适配器。反射调用点不会链接到任何一个描述符,因此一个构件可以同时运行在两侧。代价是这条路径失去了编译期检查,问题只在运行时暴露,而且框架下一次修改该成员时不会产生任何构建警告。
第三种方式是可行的,不必因为它排在最后就排除它,但它把构建期的失败换成了运行时的失败,因此只在确实需要继续支持旧服务器时才值得采用。
这也说明了「pin 落后属于正常状态」的适用范围。那条规则说的是不要在没有理由的情况下改动 pin,而描述符变更是一个理由,上面的判断过程指向这里的其它情形也是。
其它链接错误
如果遇到的链接错误不属于上面两种情形,那么它属于 JLS 中的其它类别,例如实例方法被改为 static 会得到 IncompatibleClassChangeError,成员的可见性被收窄会得到 IllegalAccessError,此外还有更多种类。可以用一个问题判断方向:框架是否移除了某些内容?
- 是。那么它应当出现在移除清单上。如果不在,请提交一个 issue,这属于流程上的疏漏,不只是你这一侧的问题。
- 否。尝试上面所说的重新构建流程:提高 pin,重新构建,提高
api-version。如果重新构建时编译不通过,说明这次变更同时破坏了源码兼容性,需要的是迁移而不是重新构建。
新增的注解属性
缺失的方法或字段会以链接错误的形式明确失败,缺失的注解属性则不会:JVM 会丢弃运行时注解类型中没有声明的属性,并且不输出任何日志。自 v6.3.0 起,@Scheduled(config = ..., periodKey = ..., delayKey = ...) 和 @CmdCD(config = ..., key = ...) 可以从配置项读取间隔或冷却时间。使用了它们的模块在 6.2.x 框架上仍然可以加载,此时被绑定的 @Scheduled 只会在加载时执行一次,而不是按间隔重复执行,被绑定的 @CmdCD 则完全不会执行冷却。
提高 pin 无法避免这种情况,原因见 pin 与 api-version。使用了任一种绑定的模块必须声明 api-version: 630,这样旧框架会在加载时拒绝它。为了让这个错误在你开发所用的版本上就暴露出来,6.3.0 自身也会拒绝使用了绑定、却声明了更低 api-version 的模块,并给出模块名、绑定和所需的下限。@Scheduled(period = 6000)、@CmdCD(60) 这类已有的字面量用法不受影响,无需修改。
同一模块的两个副本
自 v6.3.0 起,UltiTools 按文件名顺序(按普通字符串比较)读取 plugins/UltiTools/plugins/ 中的 JAR,并按同样的顺序构建模块类加载器。v6.3.0 之前的顺序是文件系统列出的顺序,Java 并不规定这个顺序,所以同一个目录在另一台机器上可能加载另一个副本。
模块 JAR 由其 plugin.yml 的 main: 项识别。两个或更多 JAR 声明同一个 main: 类时,类来自按上述顺序排在最前、且带有这个类的那个 JAR,其余副本被拒绝。启动时会输出一条警告,列出每一个副本以及类实际加载自哪个 JAR:
[UltiTools-API] 以下 JAR 文件都声明了同一个模块主类 com.example.MyModule:MyModule-1.0.0.jar, MyModule-1.1.0.jar。该模块的类只从 MyModule-1.0.0.jar 加载,其余副本不会加载;请只保留其中一个。这里不比较版本号。上例中较旧的 1.0.0 继续运行,原因是它的文件名排在前面,与两份 plugin.yml 中写的版本无关。v6.3.0 之前,每个被拒绝的副本各自输出一条错误日志,而不是这一条警告。
/upm uninstall 会删除所有副本。自 v6.3.0 起,它删除每一个 plugin.yml 的 main: 指向该模块主类的 JAR,不论这个 JAR 声明的 name: 是什么,并在回复中列出每一个被删除的文件。
只要这样的副本会胜出,/upm update 就拒绝更新该模块。自 v6.3.0 起,它在下载任何东西之前,先在模块目录中查找另一个声明了该模块 main:、且文件名排在新 JAR 文件名之前的 JAR。找到时,新版本在这里永远无法加载:那个副本会继续提供模块的类,每次启动都会把更新回滚。所以命令不暂存任何东西,并在回复中列出新 JAR 和每一个这样的副本。移走它们后再次执行 /upm update 即可,中间不需要重启。文件名排在新 JAR 之后的副本不会阻止更新,上文的启动警告仍会列出它。
在服务器上更新和卸载
自 v6.3.0 起,/upm update <模块> 不再在服务器运行期间替换 JAR。它把新版本下载到服务器根目录下的 .ultikits/upm-transactions/ 并记录下来,在下次启动之前模块目录不会有任何变化。下次启动时,在任何模块加载之前,旧 JAR 被移到一旁保留,新 JAR 被移入。模块加载完成后,只有观察到你的模块确实从新 JAR 以新版本加载,这次更新才会保留;否则新 JAR 被移除、旧 JAR 放回原处,启动日志列出两个版本,并说明恢复的版本会在下次启动时加载。
对模块作者来说,这有两点影响:
- 新 JAR 的
plugin.yml必须声明 UltiCloud 为它提供的那个版本。声明了其它版本的下载会在任何记录写入之前被拒绝。 - 无法加载的发布,例如出现链接错误或
registerSelf()抛出异常,会被回滚,而不是留在服务器上。
移动失败时,例如目录只读或文件被占用,模块目录保持不变,当前版本照常加载。启动日志会报告这一情况,下次对该模块执行 /upm update 时也会再次报告。plugins/ 与服务器根目录不在同一个文件系统上时,移动会被拒绝,并输出一条列出两个目录的错误日志。
自 v6.3.0 起,更新只会移动、替换或删除 SHA-256 与其记录一致的文件:下载的 JAR,或它移到一旁保留的旧 JAR。如果这些文件中的某一个被其它东西改动了,或者更新需要空出的位置上出现了文件(例如新 JAR 文件名下的一份副本),更新不会对任何文件做任何操作。它会把记录保留为“等待管理员处理”的状态,启动日志会列出每一个不符合的文件,以及预期的哈希和实际找到的哈希。之后的启动只会重复这条日志,不做其它操作;在处理之前,对该模块执行 /upm update 和 /upm uninstall 都会被拒绝。处理方法:检查日志中列出的文件,从 .ultikits/upm-transactions/ 取回需要的 JAR,然后删除日志中列出的更新记录及其同名文件夹。
/upm uninstall <模块> 通过框架完整的卸载流程卸载该模块的每一个已加载实例,删除它的 JAR,并取消该模块仍在等待下次启动的更新,不论命令里用的是该模块的哪一个名称。当下无法删除的 JAR,例如在 Windows 上运行中的服务器会一直占用每一个模块 JAR,会被记录下来,并在下次启动时、任何模块加载之前删除。
各模块的现状
大部分模块仍然是 1.0.0,因为它们还没有过需要作这个判断的发布。有两个模块在本页存在之前各自作了判断,而且结论相反:一个用 MINOR 表示 bug 修复,另一个用 MAJOR 表示新功能,都不符合上面的表格。它们会按模块逐个校正,而不是回溯修改版本号,因为已经发布的版本号服主可能已经记录在某处。