原创

外部资源文件改了却不生效:ResourceBundle 重载与旧快照保留

文案文件已经从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、手工清理还是更换读取层之前,先确定调用方真正需要什么更新保证。

这份实验的验证单元是一次完整变更:缓存旧值可观察,非法候选不能发布,成功候选一次替换。继续扩展时,优先围绕同一快照多字段读取、并发重载顺序和文件版本一致性增加测试,比再多写几个“读取成功”的例子更有价值。

正文到此结束
评论插件初始化中...
Loading...
本文目录