原创

MessageFormat 的单引号为什么会让占位符停止替换

资源文件里明明写着 {0},参数数组也包含用户名,最终消息却原样输出了 {0}。先别把问题归到参数顺序:MessageFormat 把 ASCII 单引号当作模式语法,'{0}' 恰好是在要求它输出一个字面的花括号片段。

这种故障经常发生在给用户名补引号以后。改动前是 User {0},改动后是 User '{0}',人看到的是标点变化,格式器看到的却是“这段不再是占位符”。Java 字符串本身不需要转义单引号,反而让这一层差异更容易被忽略。

把下面程序保存为 MessageQuotes.java,在 Java 17 中执行 java MessageQuotes.java。几组模式和参数放在同一处,可以直接比较哪一层输入改变了结果。

import java.text.MessageFormat;
import java.util.Locale;

public class MessageQuotes {
    static String render(String pattern, Object... args) {
        return new MessageFormat(pattern, Locale.ROOT).format(args);
    }
    public static void main(String[] args) {
        System.out.println("quoted=" + render("User '{0}' is offline", "Mina"));
        System.out.println("literal-quote=" + render("User ''{0}'' is offline", "Mina"));
        System.out.println("unclosed=" + render("User '{0} is offline", "Mina"));
        System.out.println("brace=" + render("Value '{' {0} '}'", "x"));
        System.out.println("apostrophe=" + render("L'utilisateur {0}", "Mina"));
        System.out.println("fixed=" + render("L''utilisateur {0}", "Mina"));
        System.out.println("user-data=" + render("User {0}", "O'Neil {7}"));
        String actual = render("User ''{0}'' is offline", "Mina");
        if (!actual.equals("User 'Mina' is offline")) {
            throw new AssertionError(actual);
        }
    }
}
quoted=User {0} is offline
literal-quote=User 'Mina' is offline
unclosed=User {0} is offline
brace=Value { x }
apostrophe=Lutilisateur {0}
fixed=L'utilisateur Mina
user-data=User O'Neil {7}

第一行的 quoted 没有替换用户名,是预期的模式解释。第二行把每个字面单引号写成两个单引号,才能在引号之间继续识别 {0}。这与反斜线没有关系,向 Java 字符串里随手加 \' 并不能替代 MessageFormat 的规则。

更容易漏测的是未闭合单引号。MessageFormat 会把它视为一直引用到模式末尾,未必在构造时抛出异常。因此“资源都能成功加载”不够证明模板正确;unclosed 那行就是合法构造、错误文案的例子。

自然语言也可能碰上模式字符

法语示例 L'utilisateur {0} 里的单引号,看起来完全不像程序符号,但它仍会影响后面的占位符。写入资源的应是 L''utilisateur {0},渲染后才保留一个单引号并替换姓名。

弯引号与 ASCII 单引号是不同字符,不能因为编辑器里看起来接近就混用。让翻译人员只能靠肉眼记忆特殊规则,通常不够可靠。更实用的做法是在资源说明中给出一个已验证例子,并对所有消息运行固定参数的渲染测试,尤其检查含引号、花括号、数字和日期子格式的条目。

样例断言比较的是完整预期消息,不是只检查字符串里有没有 {0}。有些模板本来就要输出字面花括号,统一把“残留占位符”判错也不准确;反过来,参数缺失时也可能留下未替换的占位符。测试应知道这条消息的用途和参数合同,而不只是套一个通用正则。

翻译资源怎样做一次有含义的回归

如果消息来自properties文件,至少选择一组真实用途的资源,让测试经历“读取文件→取得模式→填入参数→比较整句”这条路径。只在Java代码里验证一个手写字符串,不能保护后来被翻译或编辑的资源文件。下面的MessageResourceTest.java会创建临时资源,完成三种语义断言后删除文件。

import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.text.MessageFormat;
import java.util.*;
public class MessageResourceTest {
    record Case(String key, String expected) {}
    static String format(String pattern) {
        return new MessageFormat(pattern,Locale.ROOT).format(new Object[]{"Mina"});
    }
    public static void main(String[] args) throws Exception {
        Path file = Files.createTempFile("messages-", ".properties");
        try {
            Files.writeString(file,"welcome=Hello ''{0}''\nowner=L''utilisateur {0}\nliteral=Parameter '{0}'\n",StandardCharsets.UTF_8);
            Properties resources = new Properties();
            try (Reader reader = Files.newBufferedReader(file,StandardCharsets.UTF_8)) { resources.load(reader); }
            for (Case sample : List.of(new Case("welcome","Hello 'Mina'"),
                    new Case("owner","L'utilisateur Mina"),new Case("literal","Parameter {0}"))) {
                String actual = format(resources.getProperty(sample.key()));
                if (!actual.equals(sample.expected())) throw new AssertionError(sample.key()+": "+actual);
                System.out.println(sample.key()+"=verified");
            }
            String literal = resources.getProperty("literal");
            System.out.println("naive-repair="+format(literal.replace("'","''")));
            String broken = format("Hello '{0}'");
            System.out.println("broken resource="+(!broken.equals("Hello 'Mina'") ? "detected" : "missed"));
        } finally { Files.deleteIfExists(file); }
    }
}
welcome=verified
owner=verified
literal=verified
naive-repair=Parameter 'Mina'
broken resource=detected

welcome需要带字面单引号的姓名,owner保留自然语言撇号,literal则真的想显示字面{0}。三个合同看起来都涉及引号,修复方式却不能用同一条全局替换。程序把literal里的每个单引号翻倍后,反而激活了占位符,并显示出原本不想要的引号。

因此不要在加载所有翻译文件时统一replace,把它当作自动去错。编辑器原稿、properties读取与MessageFormat解释是不同层次;转义了哪一层应能解释清楚。本例明确用UTF-8 Reader读取文件,字符编码不是这个故障的额外变量,没有把资源内容的单引号误当作文件格式本身要求的转义。

资源测试应对每个键保留预期参数数量、代表性输入和完整输出。数字、日期或choice子格式还需要它们自己的类型与边界样本,不能只把所有参数都换成同一个字符串。翻译里允许调整参数顺序时,测试要核对信息仍然对应正确对象,而不是禁止所有占位符移动。

此外,缓存资源时应说明修改何时生效。旧进程一直使用旧模式,会让线上文案与刚检查的文件不同;这属于资源发布与缓存失效,不能再用单引号规则解释。排查时先确定实际渲染键、地区、加载版本和pattern,再用固定参数重现,避免在另一个语言文件里修好了本来没被使用的条目。

示例没有依赖异常才能发现坏模式。broken resource那项构造和渲染都成功,只有完整文案断言能发现姓名没有进入结果。这正是需要内容回归的理由:语法可接受与用户能看到正确消息,本来就不是同一个验收条件。

参数内容不会再次变成模板

最后一行把 O'Neil {7} 作为参数传入。这里的单引号和花括号属于数据,格式器不会再把参数字符串递归解释成新的模式。因此不应对所有用户名做单引号翻倍:这样反而会把多出来的引号显示给用户。

模式来自受控资源,参数来自业务对象,这条界线应保留。若先把用户输入拼进 pattern,再构造 MessageFormat,就主动把数据移进了语法层;正确修复是停止拼接模式,而不是试图对所有自然语言字符串做全局替换。

MessageFormat 也不负责输出媒介的转义。把格式化结果放进 HTML、JSON 或终端时,仍应由相应的输出层处理字符。解决占位符被引用的问题,不等于解决了页面编码或结构化输出的问题。

有并发访问时,还要考虑对象本身的使用方式。示例每次创建格式器,不会在多个线程之间共享可变实例;若服务为了复用而缓存 MessageFormat,需要使用隔离或同步方案。先用几条真实资源和固定参数把渲染合同测清楚,再决定缓存,能避免把文案错误藏进一个难以观察的公共工具类里。

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