qinghuan
← 回到知识探索

后端现场 / 可下载小项目

明明加载了法语文件,为什么有些文字仍来自基础文件?

用程序自己创建的三份 properties,分别观察资源选择、父链补键、彻底缺失和禁止默认地区回退。

展开本文目录
  1. 下载完整练习包
  2. 第一步:给每层准备不同的键
  3. 第二步:选中的地区不是每个键的出处
  4. 第三步:containsKey 为真,也可能是父层提供的
  5. 第四步:禁止默认地区重试,仍然可以使用父链
  6. 运行单文件,按实际输出逐项核对
  7. 记住这三点
  8. 别把例子用过头
  9. 自己试一小步
  10. 查原始资料
开始前,会这些就够了
  • 知道 Map 式的键和值,以及 properties 文件的简单格式。
  • 理解类路径中的资源、try-with-resources 与文件生命周期。
  • 先读 IO:字节、字符、缓冲与资源关闭:先理解文件资源读取及关闭,再跟踪资源文件的查找范围。
遇到不熟悉的词,先看这 2 个解释
资源父链
较具体的文案表没提供某个键时,可以向较通用的一层继续查。
默认地区回退
目标地区的查找失败后,是否按默认地区另开一轮候选查找。
打开完整术语小抄 ↗

后台通知服务根据用户地区选择文案。日志说已经加载 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 编译运行,输出一致。每项断言失败都会终止程序;下方是本次真实输出。

展开收起代码 · 小实验java · 43 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · java
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 用法不加检查地移入命名模块。

自己试一小步

如果要检查“每个地区必须自己提供哪些文案”,如何把继承结果与本地定义分开?

  1. 列出三个地区文件各自已有和缺失的键,先预测最终读取结果。
  2. 在自己的临时文件中增加一个必填键和一个允许继承键。
  3. 编写检查器分别报告本地缺失和父链彻底缺失,不把可见键直接当作本地定义。
  4. 提交键表、程序、输出与文件清理证据。

做到这些,就算完成

  • 同一个键在三个层级的来源可以解释。
  • 没有修改 JVM 全局默认地区来掩盖输入不明确。
  • 资源、加载器、缓存与临时目录的生命周期有明确结束点。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。