开始前,会这些就够了
- 知道
Map式的键和值,以及 properties 文件的简单格式。 - 理解类路径中的资源、try-with-resources 与文件生命周期。
- 先读 IO:字节、字符、缓冲与资源关闭:先理解文件资源读取及关闭,再跟踪资源文件的查找范围。
后台通知服务根据用户地区选择文案。日志说已经加载 fr_CA,但最终有的字段来自法语文件,有的又显示基础文案。这不一定是缓存串了,也不一定是整个文件加载失败。
本实验用三份非常短的配置表表示基础、法语、加拿大法语三个层级。每个值都写明来源,先看选中了哪一层,再逐个询问键在哪里找到,避免把“选择文件”和“查找键”混成一步。
把例子带到自己的电脑上
下载完整练习包
需要 JDK 17 或更新版本,使用 Java 标准库;制作时实测 Windows / JDK 17.0.20.1 / 21.0.12。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。
下载 ZIP · 5 个文件包里有什么
- BundleParentDemo.java:完整可运行示例
- README.md:运行说明与独立练习
- observed.stdout.txt:本次实际输出
- verification.json:源码和产物验证记录
- SHA256SUMS.txt:内容摘要
在这个文件夹打开 PowerShell,逐行运行
java -version
javac -version
javac -encoding UTF-8 --release 17 BundleParentDemo.java
java -cp . BundleParentDemo解压到独立练习文件夹,在含 BundleParentDemo.java 的目录打开终端运行。Windows 自建3份ASCII properties,隔离URLClassLoader、fr_CA父链/fr_FR候选/ja_JP基础资源、缺失键;JDK17/21各11项断言且清理目录。未测命名模块、复杂地区规则、热更新、缓存过期或翻译质量。
找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。
第一步:给每层准备不同的键
程序创建自己的临时目录,写入 Messages.properties、Messages_fr.properties 和 Messages_fr_CA.properties。基础层有 hello 与 help,法语层只覆盖 hello,加拿大法语层只提供 currency。
这些值是 ROOT_HELP、FR_HELLO 等实验标记,目的是清楚显示来源;它们不是可直接用于产品的翻译。源码使用 ASCII 内容,避免把文件编码问题引入这一轮对照。
加载器只面向本次临时目录,父加载器设为 null。这样测试资源不会意外与项目里某份同名 Messages 文件混在一起。程序结束时清缓存、关加载器、删除自己新建的文件。
第二步:选中的地区不是每个键的出处
请求 fr_CA 时,getLocale 返回 fr_CA;currency 来自最具体的一层。但查询 hello 时,这一层没有键,于是得到法语父层的 FR_HELLO;查询 help 时继续得到基础层 ROOT_HELP。
输出中三个字段同时出现,足以说明这不是“找不到整个文件就全部换成基础文件”。当前资源可以拥有父链,逐键查找时才按层级补足。
因此仅记录 getLocale 不能审计翻译是否完整。它告诉你选择的资源地区,并不能证明每个可见键都在最具体的文件中定义。
第三步:containsKey 为真,也可能是父层提供的
实验确认 ca.containsKey("help") 为真,但我们亲自写入的 fr_CA 文件只有 currency。keySet 又包含 currency、hello、help 三个键,显示可见键范围包括父层。
如果目标是“检查加拿大法语翻译是否漏项”,直接看资源对象能否读到键会把继承结果当成本地翻译。应另行读取或检查目标资源自己的定义,再和需要的键表比较。
完全不存在的 unknown 键在父链中也找不到,最终抛 MissingResourceException。缺少一层的定义和所有层都没有键,是两种不同结果。
第四步:禁止默认地区重试,仍然可以使用父链
本例显式使用 getNoFallbackControl,并且只允许 properties 格式。它让默认地区的另一轮查找停止参与,但没有把候选地区与父链全部关闭。
因此请求没有专属文件的 fr_FR 时仍能选中 fr;请求 ja_JP 时仍能回到基础资源。这两个结果展示的是本次三份文件与指定控制策略下的行为,不需要修改整台 JVM 的默认地区。
实跑限于类路径、properties 资源和普通的法语/日语地区组合。命名模块、复杂脚本地区、热更新、缓存过期和真实翻译质量都没有在这里验证;命名模块也不能直接照搬此 Control 重载。
运行单文件,按实际输出逐项核对
保存为 BundleParentDemo.java,在源码目录执行下方命令;需要完整 JDK 17 或更新版本。无需 Maven、网络或第三方依赖。
2026 年 10 月 18 日在 Windows 上分别以 Microsoft OpenJDK 17.0.20.1 与 21.0.12、--release 17 编译运行,输出一致。每项断言失败都会终止程序;下方是本次真实输出。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
import java.net.URLClassLoader;
import java.nio.file.*;
import java.util.*;
public class BundleParentDemo {
static int checks;
static void check(boolean ok) { if (!ok) throw new AssertionError(); checks++; }
public static void main(String[] args) throws Exception {
Path dir=Files.createTempDirectory("qinghuan-bundles-");
try {
Files.writeString(dir.resolve("Messages.properties"),"hello=ROOT_HELLO\nhelp=ROOT_HELP\n");
Files.writeString(dir.resolve("Messages_fr.properties"),"hello=FR_HELLO\n");
Files.writeString(dir.resolve("Messages_fr_CA.properties"),"currency=CA_CURRENCY\n");
var control=ResourceBundle.Control.getNoFallbackControl(ResourceBundle.Control.FORMAT_PROPERTIES);
try (var loader=new URLClassLoader(new java.net.URL[]{dir.toUri().toURL()},null)) {
try {
ResourceBundle ca=ResourceBundle.getBundle("Messages",Locale.CANADA_FRENCH,loader,control);
check(ca.getLocale().equals(Locale.CANADA_FRENCH));
check(ca.getString("currency").equals("CA_CURRENCY"));
check(ca.getString("hello").equals("FR_HELLO"));
check(ca.getString("help").equals("ROOT_HELP"));
check(ca.containsKey("help"));
System.out.println("selected="+ca.getLocale()+"; currency="+ca.getString("currency")+"; hello="+ca.getString("hello")+"; help="+ca.getString("help"));
check(new TreeSet<>(ca.keySet()).equals(new TreeSet<>(Set.of("currency","hello","help"))));
System.out.println("visible_keys="+new TreeSet<>(ca.keySet()));
ResourceBundle fr=ResourceBundle.getBundle("Messages",Locale.FRANCE,loader,control);
check(fr.getLocale().equals(Locale.FRENCH));
System.out.println("request=fr_FR; selected="+fr.getLocale()+"; hello="+fr.getString("hello"));
ResourceBundle jp=ResourceBundle.getBundle("Messages",Locale.JAPAN,loader,control);
check(jp.getLocale().equals(Locale.ROOT));
System.out.println("request=ja_JP; selected=ROOT; help="+jp.getString("help"));
boolean missing=false;
try { ca.getString("unknown"); } catch(MissingResourceException e) { missing=true; }
check(missing); check(control.getFallbackLocale("Messages",Locale.JAPAN)==null);
System.out.println("unknown_key=MissingResourceException; default_locale_retry=disabled");
} finally { ResourceBundle.clearCache(loader); }
}
} finally {
try(var paths=Files.walk(dir)) { for(Path p:paths.sorted(Comparator.reverseOrder()).toList()) Files.delete(p); }
}
check(!Files.exists(dir));
System.out.println("PASS checks="+checks+"; temporary_files_cleaned=true");
}
}
怎样运行:在源码所在目录,先单独执行 javac -encoding UTF-8 --release 17 BundleParentDemo.java;编译成功后,再执行 java -cp . BundleParentDemo。两条命令分别回车。2026-10-18 已在 Windows、JDK 17.0.20.1 / 21.0.12 编译运行此份源码并通过检查。Windows 自建3份ASCII properties,隔离URLClassLoader、fr_CA父链/fr_FR候选/ja_JP基础资源、缺失键;JDK17/21各11项断言且清理目录。未测命名模块、复杂地区规则、热更新、缓存过期或翻译质量。
运行后对照结果
selected=fr_CA; currency=CA_CURRENCY; hello=FR_HELLO; help=ROOT_HELP
visible_keys=[currency, hello, help]
request=fr_FR; selected=fr; hello=FR_HELLO
request=ja_JP; selected=ROOT; help=ROOT_HELP
unknown_key=MissingResourceException; default_locale_retry=disabled
PASS checks=11; temporary_files_cleaned=true记住这三点
- 资源地区选择和逐键父链查找是两个步骤。
containsKey与keySet的可见键可以包含父层。- 禁止默认地区重试不等于禁止基础资源或父链。
别把例子用过头
- 把
getLocale当成每个键的来源证明。 - 用最终能否读到键来判断单份翻译文件是否完整。
- 把类路径的 Control 用法不加检查地移入命名模块。
自己试一小步
如果要检查“每个地区必须自己提供哪些文案”,如何把继承结果与本地定义分开?
- 列出三个地区文件各自已有和缺失的键,先预测最终读取结果。
- 在自己的临时文件中增加一个必填键和一个允许继承键。
- 编写检查器分别报告本地缺失和父链彻底缺失,不把可见键直接当作本地定义。
- 提交键表、程序、输出与文件清理证据。
做到这些,就算完成
- 同一个键在三个层级的来源可以解释。
- 没有修改 JVM 全局默认地区来掩盖输入不明确。
- 资源、加载器、缓存与临时目录的生命周期有明确结束点。
查原始资料
想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。
- ResourceBundle:选择资源与逐键父链 (新标签页)↗
支持资源选择、父链查询及可见键范围。
- ResourceBundle.Control:候选地区与默认地区回退 (新标签页)↗
支持 no-fallback 与候选父链的区别。
- PropertyResourceBundle:properties资源 (新标签页)↗
支持资源文件、键与编码范围说明。