外部资源文件改了却不生效:ResourceBundle 重载与旧快照保留
- 发布时间:2026-10-23 22:36:30
- 本文热度:浏览 2 赞 0 评论 0
- 文章标签: Java ResourceBundle 配置重载
- 全文共1字,阅读约需1分钟
文案文件已经从one改成two,同一个Java进程继续getBundle,拿到的却还是one。资源放在外部目录,只改变了部署位置,没有自动取消ResourceBundle的缓存。
如果只为临时验证,清缓存再读取就能看见变化;若要做可用的重载入口,还差一项重要协议:新资源不合法时,当前使用者继续得到上一份完整值,而不是半份新配置或null。
本教程围绕两项外部设置做一个小管理器:label必须非空,maxItems必须在1到1000之间。读取后封装成不可变Config,只有全部通过才替换当前快照。它用于解释受控重载,不提供目录监听,也不读取任何真实秘密。
一次重载经过哪些状态
| 步骤 | 磁盘内容 | 直接缓存读取 | 管理器当前快照 |
|---|---|---|---|
| 首次 | one / 10 | one | 尚未创建 |
| 修改文件 | two / 20 | 仍可能one | 尚未创建 |
| 初始化重载 | two / 20 | 清本loader缓存后重新取 | two / 20 |
| 错误更新 | broken / 0 | 新资源可读,但校验失败 | 保留two / 20 |
| 正确更新 | three / 30 | 新候选完整通过 | three / 30 |
表里区分“文件可读”与“可以发布”。0能被解析成整数,但违反业务范围,因此不能因为读取没有IOException就当作可用配置。
项目范围和运行办法
使用完整JDK 17,将代码保存为 BundleReload.java,执行 java BundleReload.java。程序创建临时外部目录和一份Settings.properties,用独立URLClassLoader读取,结束时关闭loader并清理目录。
本例按 classpath 的未命名模块运行,使用自定义 Control 限定资源格式。已经采用命名模块的项目不能直接使用同一 getBundle 重载:Java 17 会抛出 UnsupportedOperationException。模块迁移时应重新设计资源提供方式,例如使用 ResourceBundleProvider。
import java.net.*;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Stream;
public class BundleReload {
static final ResourceBundle.Control POLICY = ResourceBundle.Control
.getNoFallbackControl(ResourceBundle.Control.FORMAT_PROPERTIES);
record Config(String label, int maxItems) { }
static final class Manager {
private final ClassLoader loader;
private final AtomicReference<Config> current = new AtomicReference<>();
Manager(ClassLoader loader) { this.loader = loader; reload(); }
synchronized void reload() {
ResourceBundle.clearCache(loader);
ResourceBundle values = ResourceBundle.getBundle("Settings", Locale.ROOT, loader, POLICY);
String label = values.getString("label");
int maxItems = Integer.parseInt(values.getString("maxItems"));
if (label.isBlank() || maxItems < 1 || maxItems > 1000)
throw new IllegalArgumentException("invalid candidate settings");
Config candidate = new Config(label, maxItems);
current.set(candidate);
}
Config get() { return current.get(); }
}
static void write(Path file, String label, int max) throws Exception {
Files.writeString(file, "label=" + label + "\nmaxItems=" + max + "\n");
}
public static void main(String[] args) throws Exception {
Path dir = Files.createTempDirectory("bundle-reload-");
Path file = dir.resolve("Settings.properties"); write(file, "one", 10);
try (URLClassLoader loader = new URLClassLoader(new URL[]{dir.toUri().toURL()}, null)) {
ResourceBundle first = ResourceBundle.getBundle("Settings", Locale.ROOT, loader, POLICY);
System.out.println("first=" + first.getString("label"));
write(file, "two", 20);
System.out.println("cached=" + ResourceBundle.getBundle("Settings", Locale.ROOT, loader, POLICY).getString("label"));
Manager manager = new Manager(loader);
System.out.println("initial=" + manager.get());
write(file, "broken", 0);
try { manager.reload(); throw new AssertionError(); }
catch (IllegalArgumentException expected) { System.out.println("invalid=REJECTED"); }
System.out.println("after_failed=" + manager.get());
write(file, "three", 30); manager.reload();
System.out.println("after_success=" + manager.get());
ResourceBundle.clearCache(loader);
} finally {
try (Stream<Path> paths = Files.walk(dir)) {
for (Path path : paths.sorted(Comparator.reverseOrder()).toList()) Files.deleteIfExists(path);
}
}
}
}
核对输出:
first=one
cached=one
initial=Config[label=two, maxItems=20]
invalid=REJECTED
after_failed=Config[label=two, maxItems=20]
after_success=Config[label=three, maxItems=30]
前两行先证明缓存行为,后几行才验证管理器。这样不会因为示例一开始就清缓存,反而把原始故障隐藏了。
为什么针对 loader 清缓存
ResourceBundle缓存的身份不只是文件路径。基名、Locale、加载器及相关查找上下文都会参与资源定位。同一进程里的其他应用或库可能也在使用bundle,无范围clearCache会影响更多内容。
这里创建专属loader,只清它的缓存。目录没有加入系统classpath,其他组件也不会意外搜索到这份Settings。缓存清理与资源所有权因此处于同一作用域。管理器停止以后,先确保不再有调用,再关闭loader;关闭本身不代表外部还持有的Config值失效。
本例用ROOT和不做默认Locale额外回退的属性资源策略,避免服务器地区影响设置来源。若数据本来是国际化文案,则应该保留所需语言候选链,并决定每种语言独立重载还是整组发布。不能把样本ROOT策略不加判断地用于所有翻译包。
Control还提供TTL与重载判断入口,但那属于另一种策略:何时允许检查、怎样判断需要重新加载、加载失败怎样处理,都要具体实现。把TTL设成很短,不等于自动拥有一致快照、错误保留和版本回退。这里选择显式reload,是为了让一次变更具有清楚的验证与发布动作。
候选验证不能写进当前对象
reload先得到一份bundle,读取两个字段,校验后创建Config,最后才current.set。label读取成功、maxItems解析失败时,当前Config没有被修改。读者也不会看到新label配旧maxItems的组合。
如果改成一个可变配置对象,先setLabel再setMaxItems,异常或并发读取都可能留下混合版本。即使每个字段都是volatile,也不提供两字段共同快照。不可变对象加一次引用替换,把组合状态的边界表达得更直接。
get只读取AtomicReference一次并返回Config。调用者若需要同一业务操作内使用多个设置,应先保存这个Config引用,再读各字段;每次取字段都重新调用manager.get,重载恰好发生在中间时,仍可能组合不同快照。
reload使用synchronized,避免两个重载请求同时校验与发布,较早开始却较晚完成的候选覆盖更新版本。多文件与多实例场景还需要版本号和发布协调,本例只负责一个管理器实例中的顺序,不声称替整个集群完成原子变更。
失败保留旧值,仍然要把失败说出来
main里的非法值测试明确收到REJECTED,同时after_failed仍显示two。它没有把失败吞成成功,也没有把current清空。实际入口应返回失败原因或记录带版本的结果,让操作方知道新文件尚未生效。
“保留旧值”与“悄悄继续”不是同一策略。需要监控最近一次成功加载时间、当前版本和失败类别,避免磁盘文件已经多次变更,服务却一直使用无人知晓的旧值。展示时也别把磁盘修改时间当生效时间;只有成功发布当前快照的时刻才对应运行状态。
如果首次启动时没有合法旧快照,不能套用保留策略继续返回null。构造器直接执行reload,失败就不交付管理器对象。初始化失败与运行中更新失败采用不同后果,符合它们各自是否还有可用状态的事实。
文件替换和多文件一致性不由缓存解决
外部写入者如果原地截断再写,读取端可能碰见半份文件。候选验证能挡住部分格式错误,却不能发现所有恰好语法合法的混合内容。生产写入通常应在同一文件系统完成临时文件写入与受控替换,并核对具体存储对原子移动的支持。
多个文件更麻烦:清缓存以后逐个读取,可能拿到不同发布批次。应使用版本目录、清单或一次性完整包,让候选绑定同一发布版本,再验证和切换。频繁调用clearCache不会替这项一致性提供帮助。
属性文件本身也不是强类型协议。缺键、非法整数和越界都需要显式拒绝;重复键如何处理、编码是否严格、注释与转义怎样解释,还要按所选加载入口核对。ResourceBundle有自己的属性包加载规则,不要与直接Properties.load的行为混淆。
什么时候应该换一个更直接的工具
ResourceBundle的主要能力是资源与地区选择。若只是读取一份业务配置,不需要语言父链,直接用明确编码Reader读取Properties,再绑定和校验,通常更容易控制;框架应用也可能已有成熟配置绑定机制。
本例保留ResourceBundle,是为了说明已有代码为何不刷新,以及怎样为它补上最小可用的候选快照协议,而不是推荐拿它重新搭建完整配置中心。选择TTL、手工清理还是更换读取层之前,先确定调用方真正需要什么更新保证。
这份实验的验证单元是一次完整变更:缓存旧值可观察,非法候选不能发布,成功候选一次替换。继续扩展时,优先围绕同一快照多字段读取、并发重载顺序和文件版本一致性增加测试,比再多写几个“读取成功”的例子更有价值。