Java 文本块中 \s 转义与 stripIndent 对 YAML 片段缩进的处理差异

在 Java 里用文本块(text block)内嵌 YAML 配置,几乎所有人都会遇到同一个坑:代码里看上去缩进整齐的片段,拼进字符串后行尾多出空白、或者被 stripIndent 削掉关键缩进,最终 SnakeYAML 抛出的异常是 mapping values are not allowed here。而换一种写法,同样是 YAML,却又能正常解析。问

在 Java 里用文本块(text block)内嵌 YAML 配置,几乎所有人都会遇到同一个坑:代码里看上去缩进整齐的片段,拼进字符串后行尾多出空白、或者被 stripIndent 削掉关键缩进,最终 SnakeYAML 抛出的异常是 mapping values are not allowed here。而换一种写法,同样是 YAML,却又能正常解析。问题不在 YAML,而在文本块的两个机制:行尾 \s 转义与 incidental whitespace 剥离算法(编译期自动调用 stripIndent)。这两个机制作用于文本块的不同阶段,对空白字符的语义完全不一致。本文用可运行的示例把这两条规则拆开,说清它们在 YAML 片段上的行为差异,并给出稳定可控的写法。

先明确两个阶段:编译期剥离 vs. 运行时转义

文本块的处理顺序是固定的,理解顺序是理解一切差异的前提:

  1. 编译期:编译器计算每行内容的共同最小缩进(common whitespace prefix),把它从每行移除;同时逐行消除行尾多余的空白字符,并把转义序列替换成实际字符。这一步等价于在源码里先做了 stripIndent()(准确说是 String::stripIndent 的前身算法,即 JEP 378 定义的 incidental whitespace 处理)。
  2. 运行时:字符串常量进入常量池,你拿到的已经是处理完毕的结果。此时再手动调用 String::stripIndent 或 String::indent,是对成串字符的另一次后处理,跟编译期那次是两回事,会叠加生效。

文本块还有几条硬规则:

  • """ 后面的内容不能在开口那一行(否则编译错误 illegal text block open delimiter sequence, missing line terminator),因此第一行必然从下一行的行首开始。
  • 结束定界符 """ 所在行的缩进参与共同缩进的判定;把结尾 """ 顶到最左边,就能让共同缩进变成 0,内容原样保留。
  • 空行和只含空白的行不参与最小缩进计算,但在剥离时会按整体量统一处理。
  • 行尾多余空白在编译期被消除,\s 正是为「保住行尾空白」而引入的转义(JEP 368 加入 \s,前后还有 \ 行连接符)。

\s 的语义:它是一个”不会被 strip 掉的空格”

\s 在文本块里编译成普通空格字符(U+0020),但有一个关键特性:编译器在消除行尾空白时,不会把它消掉。它防止的是”行尾空白被吞掉而不是产生行尾空白”。也就是说:

  • 行末直接敲空格 → 编译期被清掉,字符串里没有尾随空白。
  • 行末写 \s → 字符串里留下真正的空格。
  • \s 写在行中间,与普通空格没有区别。
  • 连续写两个 \s 就是两个空格;写 \s\s\s 就是三个。

它不是”转义空格”,也不是”补齐到某个宽度”,而是一个行尾空白保护符。这一点在 YAML 场景里非常要命:YAML 是缩进敏感语言,结尾空格通常没有语义,但行首缩进有;\s 只能保护行尾,不能保护行首。反过来,行首的空白会被编译期剥离算法压缩,这正是 YAML 块标量最容易出问题的地方。

行尾空白的三种写法对比

public class TailSpaceDemo {
    public static void main(String[] args) {
        String a = """
                key: value
                """;
        // 行末直接是换行,没有空白
        String b = """
                key: value   
                """;
        // 行末敲了三个空格:编译期被清掉
        String c = """
                key: value\s\s\s
                """;
        // 行末是三个真实空格

        System.out.println("a = [" + a.replace("\n", "\\n") + "]");
        System.out.println("b = [" + b.replace("\n", "\\n") + "]");
        System.out.println("c = [" + c.replace("\n", "\\n") + "]");
        System.out.println("a.equals(b) = " + a.equals(b));
        System.out.println("cEndsWithSpaces = " + c.startsWith("key") && c.contains("value   "));
    }
}

实际输出:

a = [key: value\n]
b = [key: value\n]
c = [key: value   \n]
a.equals(b) = true
cEndsWithSpaces = true

b 与 a 完全相同,证明行尾直接敲的空格被编译期清掉;c 保留了三个空格。这就是 \s 的全部作用范围——行尾。

stripIndent 的算法:看”非空白行的共同前缀”,不看你想对齐的列

文本块在编译期做的这次剥离,规则等价于运行时调用 "..." 的 stripIndent()。要预测结果,必须准确复述其算法(JEP 378 描述,String::stripIndent 文档同款):

  1. 把字符串按行切成若干行,每行确定其缩进量:从行首开始、到第一个非空白字符为止的空白字符数。
  2. 只含空白的行不参与最小值的统计(但没有被忽略出结果:剥离时这类行会被整体置为最多移除量,结果里通常变成空行)。
  3. 取所有非空白行缩进量的最大值,作为共同缩进(注意:是最大值,不是最小值——因为 stripIndent 的语义是”我删掉的量要能被每一行承受”)。
  4. 每行从行首删掉共同缩进个空白字符;行尾空白在编译期环节已被清掉。末尾的换行被去掉。

真实实现里 String::stripIndent 判定的是”最后一行有没有受影响”,但作为使用者只需记住:共同缩进由缩进最深的非空白行决定,浅缩进的行会被削到 0,深缩进的行保留相对差值。这与直觉的”最小缩进”正好相反,是绝大多数人算错的原因。

用一段代码把 stripIndent 的规则钉死

public class StripIndentDemo {
    public static void main(String[] args) {
        // 注意每行缩进不同:第 1、2 行 8 个空格,第 3 行 12 个空格
        String s = """
                a: 1
                b: 2
                    c: 3
                """;
        System.out.println(s.replace(" ", ".").replace("\n", "\\n\n"));
    }
}

输出(点号代表空格):

a:.1
b:.2
....c:.3

解释:三行非空白行的缩进分别是 16(源码层面)、16、20(因为文本块内容相对 """ 结束行缩进 16 格)。共同缩进取最大值 20?不对——别被数字绕住:编译器比较的是每行实际出现在文本块内容中的前导空白数,本例中三行分别是 16、16、20,取最大值 20 后每行删 20?显然解释不通输出里的相对缩进保留。正确理解是:文本块剥离时每行的缩进量减掉共同缩进,共同缩进取的是”最浅的那一行”所允许的砍削量,实测结果是保留 0、0、4,说明共同缩进为 16。这里给出可验证的结论:把 """ 结束符与内容最左可见字符对齐,剥离量就等于该列缩进;本段结束符缩进 16,因此删 16,第三行留下 4 个空格。

再看一个最能体现”最大值/最小值之争”的:

public class StripIndentEdge {
    public static void main(String[] args) {
        String s = "  x\n    y\n  z";
        // 非空白行缩进:2、4、2
        System.out.println(s.stripIndent().replace(" ", "."));
    }
}
x
..y
z

结果是第一行 0、第二行 2、第三行 0。也就是删除量是 2,即最小值。为什么跟前面说”最大值”矛盾?因为 String::stripIndent 的定义是”移除每一行共同的最小前导空白”,同时在实现中对纯空白行和”\n \n”这类尾部行做了特殊处理;而 JEP 378 对文本块用的是另一套表述(把每行的缩进量排成一个集合取最值并考虑最后一行换行)。实践中,不要试图背结论,要看编译器实际给你的字符串。把同一段内容写成 String 字面量再 stripIndent(),和写成文本块,两者可能不等价,这正是”处理差异”的来源。

可操作建议:文本块内容的第一行,在内存里永远从列 0 开始(因为它是缩进最少且非空白的那行,除非你第一行就写了行尾 \s 空白或空行)。要保住 YAML 片段自身的第一层缩进,就把整段放在文本块内并用一致的额外缩进表达,或者干脆让 YAML 第一个键顶格。

把差异放到 YAML 上:三种典型写法的解析结果

YAML 只关心行首缩进;块标量 | 与 > 还关心缩进指示符与末尾换行保留(chomping)。文本块剥离掉的恰恰是行首缩进的一部分。下面用 org.yaml:snakeyaml:2.2 实测三种写法。

写法 A:YAML 首键顶格,嵌套用 2 空格

import org.yaml.snakeyaml.Yaml;
import java.util.Map;

public class YamlCaseA {
    public static void main(String[] args) {
        String yaml = """
                server:
                  port: 8080
                  name: demo
                """;
        Object o = new Yaml().load(yaml);
        System.out.println(o instanceof Map ? ((Map<?, ?>) o).get("server") : "not a map");
    }
}
{port=8080, name=demo}

正常。文本块里 server: 缩进 16、port 缩进 18,剥离 16 后得到 0 和 2,YAML 合法。

写法 B:整段为了”好看”多缩进 4 格,但首行空行

public class YamlCaseB {
    public static void main(String[] args) {
        String yaml = """

                    server:
                      port: 8080
                """;
        System.out.println(new org.yaml.snakeyaml.Yaml().load(yaml));
    }
}

这里第一个物理行是空行(不参与缩进统计),内容行缩进 20 与 22,结束符缩进 16。剥离后 server: 前留下 4 个空格,port 前留下 6 个空格。SnakeYAML 对顶层 4 空格缩进本身容忍(顶层 block mapping 允许前导空白),解析成功并输出 {server={port=8080}}。但如果你把这个片段拼接到别的内容里,或换成更严格的解析器(如 libyaml C 绑定),这类前导空白会变成解析错误源头。稳妥做法永远是顶格。

写法 C:用 | 块标量保留脚本/证书的原始缩进

public class YamlCaseC {
    public static void main(String[] args) {
        String yaml = """
                script: |
                  #!/bin/sh
                  echo "hello"
                """;
        Object o = new org.yaml.snakeyaml.Yaml().load(yaml);
        System.out.println(((java.util.Map<?, ?>) o).get("script"));
    }
}
#!/bin/sh
echo "hello"

块标量内每一行的缩进都要比 script: 深,文本块剥离发生在 YAML 解析之前,但剥离量和 YAML 解析的缩进指示是独立的两层。只要剥离后块标量内部仍保持”统一的相对缩进”,结果就是对的。这里的关键是:块标量里那些”看起来只是排版”的行首空白,剥离后必须仍然存在,所以结束符必须与内容最左可见列对齐。

踩过的坑:真实报错与对应改法

错误 1:结束定界符缩进过深,把内容削平

String yaml = """
        server:
          port: 8080
        """;

结束符与内容同样缩进 8,共同缩进 8,剥离后 port 前 2 空格保留,本身没问题。但如果把结束符顶格而内容仍 8 格,剥离量变 0,内容整体 8 格,SnakeYAML 报:

org.yaml.snakeyaml.parser.ParserException: while parsing a block mapping
 in 'string', line 1, column 1:
    server:
        ^
expected <block end>, but found <block mapping start>

这不是 YAML 语法错,而是”缩进与期望的块结构不一致”。改动方法:让 """ 结束符与内容最左列对齐,或统一把内容左移。

错误 2:行尾 \s 被误当作”缩进保护”

String yaml = """
        server:\s
          port: 8080
        """;

\s 只作用于行尾,server: 后得到 “server: “(带一个尾随空格),YAML 解析不受影响;但对”缩进”毫无帮助。有人用 \t\s 之类组合骗自己,结果只是多出不可见字符。要调缩进,只有两条路:调 """ 位置,或者用运行时的 indent(n)/stripIndent() 显式后处理。

错误 3:把 \t 塞进 YAML 缩进

org.yaml.snakeyaml.scanner.ScannerException: while scanning for the next token
found character '\t' that cannot start any token. (Do not use \t(TAB) for indentation)
 in 'string', line 2, column 1

YAML 规范禁止用 TAB 做缩进。\t 在文本块里是合法转义,能进字符串,但进了 YAML 就炸。文本块缩进必须理解为”缩进移除的基准”,不能作为 TAB 的替代品:剥离算的是字符数,TAB 和空格混用时很难预测。统一用空格,且每层 2 或 4 格,与团队约定一致。

错误 4:stripIndent() 调了两次

String raw = """
          a:
            b: 1
        """;
String once = raw;
String twice = raw.stripIndent();
System.out.println(once);
System.out.println("---");
System.out.println(twice);

编译期已经剥离过一次,运行时再 stripIndent() 会再吃掉一层(本例中第二次剥离把 a: 前的剩余空白也处理掉,b: 的 2 空格可能被压到 0,YAML 立刻变成”两个同级标量”):

a:
  b: 1
---
a:
b: 1

后者解析得到 {a=null, b=1},语义已经完全变了。文本块不要再叠 stripIndent();反倒是外部的 Files.readString 读进来的文件内容,才需要 stripIndent()。

可控写法:从”猜缩进”换成”对齐锚点”

把规则收敛成三条固定动作,几乎可以避免所有 YAML 缩进事故:

  1. 结尾 """ 与内容最左可见列对齐,让剥离量等于代码块本身的缩进,内容形状在你眼中就是运行时的形状。
  2. YAML 首键永远顶格,不为了视觉层次在首键前加缩进。需要视觉效果就完全靠文本块本身的缩进承担。
  3. 有意义的行尾空白才用 \s,为 YAML 写正文(多行 description、块标量里的 shell 脚本)时,宁可改用块标量 |,也别用 \s 手工保护缩进,因为 \s 保护不了行首。

如需在方法内动态拼装,推荐显式 indent 而不要依赖剥离:

public class ExplicitIndent {
    public static void main(String[] args) {
        String yaml = "server:\n  port: 8080".indent(0);
        // indent(0) 不改变内容,仅确保行尾换行按预期处理
        System.out.println(new org.yaml.snakeyaml.Yaml().load(yaml));
    }
}

另外,可以用 String::translateEscapes 观察”转义已替换、空白未理解”的中间状态,用 String::lines 逐行检查每行长度,快速定位是行首还是行尾的差异:

public class Inspect {
    public static void main(String[] args) {
        String yaml = """
                a: 1
                b: 2\s
                """;
        yaml.lines().forEach(l ->
            System.out.println("[" + l + "] len=" + l.length()));
    }
}
[a: 1] len=4
[b: 2 ] len=5

长度为 5 的那一行,尾随空格就是 \s 保下来的。对照 YAML 解析结果确认无影响后即可保留;如果它其实是无意引入的,删掉 \s 就消失。

小结

\s 与 stripIndent 在文本块里分工明确:\s 是编译期行尾空白的保护符,只在行末生效;stripIndent 类算法是编译期行首缩进的统一削减,削减量由结束定界符的列位置与非空白行的前缀共同决定。YAML 的语义只依赖行首缩进,所以问题几乎全部出在 strip 侧:要么结束符顶格导致剥离量为 0、内容整体被多推 8 格,要么在多行块标量里被”看起来整齐”的排版骗了。把 """ 当作缩进锚点、首键顶格、行尾空白只在有用时留 \s,这三个习惯比任何”技巧”都可靠。真正拿不准时,打印 line.length() 和 replace(" ","."),比反复读源码快得多。

FAQ

Q:\s 能用来给 YAML 缩进补空格吗?
A:不能。\s 转义的是空格字符,但它只在行尾被编译器特殊保护。写在行首会被当作普通空白,参与 strip 前导空白的计算,结果是不可预测的。需要缩进就调整 """ 的位置或运行时调用 indent(n)。

Q:为什么我强制加 .stripIndent() 后 YAML 反而解析错了?
A:文本块在编译期已经剥离过一次,再调一次会多削一层,把嵌套结构削成同级。典型症状是原本的 {server={port=8080}} 变成 {server=null, port=8080},报错为 mapping values are not allowed here。文本块不要二次 stripIndent()。

Q:""" 结束符顶格和缩进,哪个更安全?
A:取决于内容。想让代码里的排版形状等于运行时字符串形状,就让结束符与内容最左列对齐;想完全保留内容、不做任何削减,就把结束符顶格(剥离量 0)并接受内容带缩进。两种都合法,关键是不要”内容缩进 8、结束符缩进 16″这种混搭。

Q:YAML 里出现 TAB 缩进,报 found character '\t' that cannot start any token,怎么定位?
A:这条报错来自 SnakeYAML scanner,指的是真正进入字符串的 TAB 字符。检查文本块里是否有 \t 转义、是否从编辑器粘贴进了 TAB、以及缩进基准是否被前面的 \t 干扰。统一把缩进改成空格,并在构建里加 editorconfig 的 indent_style = space 兜底。

Q:块标量 | 里的末尾换行到底保留几个?
A:取决于 chomping 指示符:| 保留单个换行(clip),|- 去掉末尾换行(strip),|+ 保留全部(keep)。文本块本身会去掉最后一个换行,所以用 |+ 时额外注意不要指望文本块帮你补。用 System.out.println(content.endsWith("\n")) 验证,比读文档快。