qinghuan
← 回到知识探索

后端现场 / 可下载小项目

通知模板里的单引号,为什么让 {0} 不再替换?

用七组输出分清模板语法、参数数据和数字子格式的创建时机,避免把文案直接当成普通字符串拼接。

展开本文目录
  1. 下载完整练习包
  2. 第一步:单引号在模板里可以是一条指令
  3. 第二步:参数中的花括号,不等于模板中的花括号
  4. 第三步:占位符仍在,不只一种原因
  5. 第四步:地区属性和已经创建的数字格式不是一个对象
  6. 运行单文件,按实际输出逐项核对
  7. 记住这三点
  8. 别把例子用过头
  9. 自己试一小步
  10. 查原始资料
开始前,会这些就够了
遇到不熟悉的词,先看这 2 个解释
模板引用
某些字符在模板里被标记为原样文字,暂时不解释为占位符。
子格式创建时机
模板里的数字部分交给一个单独格式器处理,它可能早已按旧地区创建。
打开完整术语小抄 ↗

通知模板写成 User's {0},参数传入 Ada,结果却是 Users {0}。参数明明在数组里,为什么既丢了单引号,又留下占位符?原因需要从模板解释开始找,而不是先怀疑数据库或用户姓名。

这一篇把模板、参数数组和地区规则固定为可以直接查看的值。程序只生成字符串,不发送通知。每组对照改变一个条件,最后再看一个容易一起出现的问题:对象的 locale 改了,数字输出为什么还没变。

把例子带到自己的电脑上

下载完整练习包

需要 JDK 17 或更新版本,使用 Java 标准库;制作时实测 Windows / JDK 17.0.20.1 / 21.0.12。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。

下载 ZIP · 5 个文件
包里有什么
  • MessagePatternDemo.java:完整可运行示例
  • README.md:运行说明与独立练习
  • observed.stdout.txt:本次实际输出
  • verification.json:源码和产物验证记录
  • SHA256SUMS.txt:内容摘要

在这个文件夹打开 PowerShell,逐行运行

java -version
javac -version
javac -encoding UTF-8 --release 17 MessagePatternDemo.java
java -cp . MessagePatternDemo

解压到独立练习文件夹,在含 MessagePatternDemo.java 的目录打开终端运行。Windows 固定内存模板与参数;单引号、字面占位符、普通参数不再解释、缺参、花括号错误、number子格式重建;JDK17/21各9项断言。未测choice、日期、复数规则、并发或发送通知。

找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。

第一步:单引号在模板里可以是一条指令

第一组模板的单引号只有一个。实际输出是 Users {0},说明后面的占位符没有按参数位置执行。将它改为连续两个单引号,输出才成为 User's Ada。

模板解释器会把单引号用于引用文字;连续两个单引号表示一个实际的单引号。未闭合的引用可能被当成到模板结尾才结束,所以不是所有错误文案都会在创建对象时抛异常。

因此编译通过、对象创建成功和最终文案正确是不同的检查。发布模板之前至少要用实际参数生成一遍,检查哪些字符被引用、哪些占位符真正生效。

第二步:参数中的花括号,不等于模板中的花括号

程序把模板写为带引用的 {0} 再接一个正常 {0},输出分别是原文 {0} 和 Ada。这组对照展示同样几个字符处在不同模板位置时,含义可以不同。

下一组把 {1}'$1 当作普通字符串参数放入 user={0}。输出原样保留这段参数,没有再把里面的 {1} 执行一次。这是本例普通字符串参数的一次格式化结果;没有使用 choice 子格式的递归能力。

所以不要在参数数据上盲目执行模板转义,更不要把用户数据先拼进模板再重新解释。让模板负责结构,让参数负责填入的值,能让这两条路径更容易核对。

第三步:占位符仍在,不只一种原因

缺少第一个引用结尾时,本例的 {0} 因引用规则保留;参数数组缺少第二个元素时,本例的 {1} 也保留。肉眼看到花括号不能直接断定是同一个错误。

源码单独保存了缺参结果和不平衡花括号的异常。排查时应记录模板原文、参数数量、各参数类型以及实际输出,区分引用、缺参和语法错误。

真实通知还应检查业务上哪些参数必须存在。这里仅展示库的行为,不把输出中残留占位符视为可以发送给用户的成功消息。

第四步:地区属性和已经创建的数字格式不是一个对象

最后一组使用带 number 类型的模板。创建时选择 US,得到 1,234.50;只调用 setLocale(GERMANY) 后,结果仍是 1,234.50;再应用原模板,才得到 1.234,50。

这说明本例数字子格式在模板解析时已经创建。修改父对象的地区属性,不会自动改写此前创建的子格式。代码重新应用模板,让数字子格式按新地区重建。

本例每次创建独立的格式器,没有让多个线程同时修改同一个对象。范围只包含普通字符串、一个数字子格式和两种地区;日期、choice、多语言复数及通知发送没有实跑。

运行单文件,按实际输出逐项核对

保存为 MessagePatternDemo.java,在源码目录执行下方命令;需要完整 JDK 17 或更新版本。无需 Maven、网络或第三方依赖。

2026 年 10 月 18 日在 Windows 上分别以 Microsoft OpenJDK 17.0.20.1 与 21.0.12、--release 17 编译运行,输出一致。每项断言失败都会终止程序;下方是本次真实输出。

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

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

小实验 · java
import java.text.MessageFormat;
import java.util.Locale;
public class MessagePatternDemo {
    static int checks;
    static void check(boolean ok) { if(!ok) throw new AssertionError(); checks++; }
    static String render(String pattern, Object... values) {
        return new MessageFormat(pattern, Locale.US).format(values);
    }
    public static void main(String[] args) {
        String broken=render("User's {0}","Ada");
        String fixed=render("User''s {0}","Ada");
        check(broken.equals("Users {0}")); check(fixed.equals("User's Ada"));
        System.out.println("single_quote="+broken+"; doubled_quote="+fixed);
        String literal=render("'{0}' / {0}","Ada");
        check(literal.equals("{0} / Ada"));
        System.out.println("quoted_placeholder="+literal);
        String data=render("user={0}","{1}'$1");
        check(data.equals("user={1}'$1"));
        System.out.println("argument_data="+data);
        String missing=render("first={0}; second={1}","Ada");
        check(missing.equals("first=Ada; second={1}"));
        System.out.println("missing_argument="+missing);
        boolean invalid=false;
        try { new MessageFormat("value={0",Locale.US); } catch(IllegalArgumentException e) { invalid=true; }
        check(invalid); System.out.println("unbalanced_brace=IllegalArgumentException");
        String pattern="{0,number,#,##0.00}";
        MessageFormat format=new MessageFormat(pattern,Locale.US);
        String before=format.format(new Object[]{1234.5});
        format.setLocale(Locale.GERMANY);
        String afterSet=format.format(new Object[]{1234.5});
        format.applyPattern(pattern);
        String afterApply=format.format(new Object[]{1234.5});
        check(before.equals("1,234.50")); check(afterSet.equals(before)); check(afterApply.equals("1.234,50"));
        System.out.println("number: US="+before+"; setLocale_only="+afterSet+"; reapply_pattern="+afterApply);
        System.out.println("PASS checks="+checks);
    }
}

怎样运行:在源码所在目录,先单独执行 javac -encoding UTF-8 --release 17 MessagePatternDemo.java;编译成功后,再执行 java -cp . MessagePatternDemo。两条命令分别回车。2026-10-18 已在 Windows、JDK 17.0.20.1 / 21.0.12 编译运行此份源码并通过检查。Windows 固定内存模板与参数;单引号、字面占位符、普通参数不再解释、缺参、花括号错误、number子格式重建;JDK17/21各9项断言。未测choice、日期、复数规则、并发或发送通知。

运行后对照结果
single_quote=Users {0}; doubled_quote=User's Ada
quoted_placeholder={0} / Ada
argument_data=user={1}'$1
missing_argument=first=Ada; second={1}
unbalanced_brace=IllegalArgumentException
number: US=1,234.50; setLocale_only=1,234.50; reapply_pattern=1.234,50
PASS checks=9

记住这三点

  • 模板中的单引号和普通参数数据中的单引号走不同解释路径。
  • 未替换的占位符可能来自引用,也可能来自缺少参数。
  • 修改 locale 不会自动更新已经创建的子格式。

别把例子用过头

  • 把翻译文案当作任意字符串,不做带参数的输出检查。
  • 将用户数据拼入模板后再次解释。
  • 共享可变格式器,并假设只改 locale 就能切换所有输出。

自己试一小步

怎样给一组通知模板做发布前检查,分清缺参、引用和数据原样保留?

  1. 自己编写四个含单引号、花括号和多个占位符的模板,先写预期句子。
  2. 分别使用完整参数、缺少参数和带特殊字符的普通字符串参数。
  3. 新增一种显式地区的数字显示,比较新建格式器与修改旧格式器。
  4. 提交模板表、参数表、实际输出和未覆盖的格式类型。

做到这些,就算完成

  • 模板语法与参数原文分开记录。
  • 检查结果不能只依赖是否抛异常。
  • 没有发送通知,也没有把普通字符串参数结论推广到choice递归。

查原始资料

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