【学习】YAML语言详解与最佳实践

前言

YAML(发音为 /ˈjæməl/,递归缩写为 “YAML Ain’t Markup Language”,即”YAML不是一种标记语言”)是一种人类可读的数据序列化语言。它通常用于配置文件、对象持久化以及应用程序间的数据交换。由于其简洁的语法和对复杂数据结构的良好支持,YAML在DevOps工具(如Kubernetes、Ansible、Docker Compose)和许多其他领域中变得非常流行。本文旨在详细介绍YAML的语法、特性及其最佳实践,帮助读者更好地理解和使用YAML。

一、什么是YAML?

YAML的设计目标是易于人类阅读和编写,同时保持与常见编程语言数据结构的良好映射。它借鉴了多种语言的特性,包括XML的结构化、C语言的数据类型、Python的缩进以及Perl的文本处理能力。

(一)核心特点

  1. 人类可读性强:YAML的语法非常直观,使用空格缩进表示层级关系,避免了像JSON那样大量的括号和引号。
  2. 数据结构清晰:支持映射(键值对)、序列(列表)和标量(字符串、数字、布尔等)三种基本数据结构,可以轻松表示复杂的数据。
  3. 支持注释:可以使用#进行注释,方便解释配置或数据。
  4. 与JSON兼容:YAML是JSON的超集,这意味着任何合法的JSON文档也是合法的YAML文档。
  5. 支持多种数据类型:除了基本类型,还支持日期、时间戳、null,并允许通过标签显式指定类型。
  6. 高级特性:支持锚点和别名以减少冗余,支持多文档等。

(二)文件扩展名

YAML文件通常使用 .yaml.yml作为扩展名。

二、YAML基本语法

YAML的语法依赖于缩进和一些简单的符号。

(一)核心数据结构

YAML主要有三种基本的数据结构:

  1. 映射(Mappings / Dictionaries / Objects):表示键值对的集合。键和值之间用冒号加空格 (: ) 分隔。

    name: John Doe
    age: 30
    city: New York
  2. 序列(Sequences / Lists / Arrays):表示有序的值列表。每个列表项以连字符加空格 (- ) 开头。

    fruits:
      - Apple
      - Banana
      - Orange
    # 也可以写成行内格式
    colors: [Red, Green, Blue]
  3. 标量(Scalars):表示单个值,如字符串、数字、布尔值、日期、null等。

    # 字符串 (通常不需要引号)
    message: Hello, World!
    another_message: "This string has a : colon"
    single_quoted: 'This is a single-quoted string, useful for literal # and other special chars'
    
    # 数字
    integer_value: 123
    float_value: 3.14159
    
    # 布尔值
    is_active: true
    is_enabled: no # 'no' 通常也会被解析为 false
    
    # Null 值
    nothing: null # 或者 ~
    empty_value:
    
    # 日期和时间戳 (ISO 8601 格式)
    date_value: 2023-10-27
    datetime_value: 2023-10-27T10:30:00Z

(二)缩进规则

YAML使用空格进行缩进以表示层级结构。严禁使用制表符(Tab)进行缩进,因为不同的编辑器和系统对Tab的解释可能不同,会导致解析错误。

  • 缩进的空格数量并不固定,但同一层级的元素必须具有相同的缩进量。
  • 通常建议使用2个或4个空格作为一级缩进。
person:
  name: Alice      # 2空格缩进
  age: 25
  address:
    street: 123 Main St  # 嵌套的映射,再缩进2空格
    city: Anytown
  hobbies:
    - Reading       # 序列项
    - Hiking

(三)注释

#开头的行被视为注释,YAML解析器会忽略它们。注释可以单独一行,也可以跟在数据后面。

# 这是一个顶层注释
key: value # 这是一个行尾注释

(四)标量类型与风格

  1. 字符串(Strings)

    • 普通标量(Plain Scalars):大部分情况下,字符串不需要引号。
      my_string: This is a plain string
    • 单引号字符串(Single-Quoted Strings)' 包围的字符串。在单引号字符串中,如果需要表示一个实际的单引号字符,需要使用两个单引号 ('') 来转义。特殊字符如#:在单引号内不需要转义。
      single_quoted: 'This contains a single quote: O''Reilly'
      another: 'Special chars like : or # are literal here.'
    • 双引号字符串(Double-Quoted Strings)" 包围的字符串。支持使用反斜杠 (\) 进行C风格的转义,如 \n (换行), \t (制表符), \" (双引号), \\ (反斜杠) 以及Unicode转义 (\uXXXX\UXXXXXXXX)。
      double_quoted: "This is a line.\nThis is another line with a tab\t and a \"quote\"."
      unicode_char: "\u263A" # 微笑表情
    • 多行字符串
      • **字面量块(Literal Block Style)|**:保留块内的换行符,每行末尾的换行符都会被保留。
        literal_block: |
          Line 1
          Line 2
            Indented line
          Line 3
        解析结果: "Line 1\nLine 2\n Indented line\nLine 3\n"
      • **折叠块(Folded Block Style)>**:将块内的非空行末尾的换行符替换为空格,空行(或多个连续空行)会被转换成一个换行符。可以通过缩进来保留换行。
        folded_block: >
          This is a very long sentence
          that is folded over
          multiple lines.
        
          This will be a new paragraph.
            This line's leading spaces and newline are preserved.
        解析结果: "This is a very long sentence that is folded over multiple lines.\nThis will be a new paragraph.\n This line's leading spaces and newline are preserved.\n"
      • Chomping(行尾处理):可以在 |> 后面加上 -(strip,去除末尾所有换行)、+(keep,保留末尾所有换行)或不加(clip,去除末尾空行,但保留最后一个换行符,如果内容非空)。
        strip_chomping: |-
          text
        # 结果: "text" (无末尾换行)
        
        keep_chomping: |+
          text
        
        
        # 结果: "text\n\n\n" (保留所有末尾换行)
  2. 数字(Numbers)

    • 整数(Integers): 123, -456, 0, 0o17 (八进制), 0xC (十六进制)。
    • 浮点数(Floating-Point Numbers): 3.14, -0.01, .5 (即0.5), 1.23e+4 (科学计数法)。
    • 特殊浮点值: .inf (正无穷), -.inf (负无穷), .nan (非数字)。
  3. 布尔值(Booleans)

    • true / false
    • yes / no (常用,但取决于解析器是否遵循核心schema)
    • on / off (同上)
    • 为了避免歧义(见”挪威问题”),显式使用 truefalse 是最安全的。
  4. Null 值

    • null (标准)
    • ~ (波浪号,YAML特有)
    • 键后面没有值也会被解析为 null,例如:empty_key:
  5. 日期与时间戳(Dates and Timestamps)

    • YAML支持ISO 8601标准格式。
    • YYYY-MM-DD (日期)
    • YYYY-MM-DDTHH:MM:SSZ (UTC时间)
    • YYYY-MM-DD HH:MM:SS +/-HH:MM (带时区偏移的时间)
      date: 2001-12-15
      datetime_utc: 2001-12-15T02:59:43.1Z
      datetime_offset: 2001-12-14 21:59:43.10 -05:00
      spaced_datetime: 2001-12-14 21:59:43.10 -5 # 空格分隔也是允许的

三、YAML高级特性

(一)锚点 (&) 与别名 (*)

锚点和别名用于引用文档中的其他部分,避免重复定义,实现DRY (Don’t Repeat Yourself) 原则。

  • **锚点 (&anchor_name)**:用于标记一个节点。
  • **别名 (*anchor_name)**:用于引用之前通过锚点标记的节点。
defaults: &defaults
  adapter: postgres
  host: localhost

development:
  database: myapp_dev
  <<: *defaults # 将 defaults 的内容合并到这里

test:
  database: myapp_test
  <<: *defaults

在上面的例子中,&defaults 定义了一个锚点。*defaults 则引用了这个锚点的内容。

(二)合并与覆盖 (<<:)

<<: (合并键,Merge Key) 是一种特殊的键,它允许将一个或多个映射合并到当前映射中。其值可以是一个别名,也可以是一个序列的别名。
如果合并的映射与当前映射有相同的键,当前映射中的键值会覆盖被合并的键值。

anchor_map: &anchor_map
  key1: value1_from_anchor
  key2: value2_from_anchor

my_map:
  <<: *anchor_map
  key2: value2_overridden # 这个值会覆盖 anchor_map 中的 key2
  key3: value3_new

解析结果大致为:

my_map:
  key1: value1_from_anchor
  key2: value2_overridden
  key3: value3_new

如果 <<: 的值是一个序列,会按顺序合并,后面的会覆盖前面的。

(三)标签 (!!!) 与类型转换

标签用于显式指定节点的数据类型。YAML解析器会根据标签来决定如何解释数据并将其转换为相应的编程语言对象。

  • 全局标签(Global Tags):通常以 !! 开头,后跟类型名称,例如 !!str (字符串), !!int (整数), !!float (浮点数), !!bool (布尔), !!null, !!map (映射), !!seq (序列), !!timestamp (时间戳)。
    explicit_string: !!str 123    # 强制解析为字符串 "123"
    explicit_int: !!int "42"     # 强制解析为整数 42
    not_a_boolean: !!str no     # 避免 "no" 被解析为 false
  • 应用特定标签(Application-specific Tags / Local Tags):通常以 ! 开头,例如 !my-object!ruby/object:MyClass。这些标签的解释依赖于特定的应用程序或库。
    # 示例:自定义对象
    my_config: !Config
      param1: value1
      param2: 100
  • 标签解析:如果没有显式标签,YAML解析器会尝试根据标量内容的模式自动解析类型(例如,123 会被认为是整数,true 会被认为是布尔值)。

(四)多文档分隔 (---...)

一个YAML文件流可以包含多个独立的YAML文档。

  • **文档开始标记 (---)**:用于在一个流中开始一个新的文档。
  • **文档结束标记 (...)**:可选,用于显式结束一个文档。
# 文档 1
---
name: Document One
items:
  - itemA
  - itemB
...
# 文档 2
---
name: Document Two
data:
  key: value

这在流式处理或一次性发送多个配置块时非常有用。

(五)指令 (%YAML%TAG)

指令以 % 开头,在文档开始标记 (---) 之前定义。

  • %YAML 指令:指定文档遵循的YAML版本。
    %YAML 1.2
    ---
    # YAML 1.2 content
  • %TAG 指令:为标签前缀定义一个简写句柄。
    %TAG !e! tag:example.com,2000:app/  # 定义 !e! 作为 tag:example.com,2000:app/ 的句柄
    ---
    # 使用自定义标签
    my_object: !e!foo
      bar: baz
    解析后 !e!foo 相当于 tag:example.com,2000:app/foo

四、YAML与JSON的比较

特性 YAML JSON
可读性 非常高,使用缩进和简洁符号 较高,但括号和引号较多
注释 支持 (#) 不支持
数据类型 丰富 (字符串, 数字, 布尔, null, 日期, 时间戳) 基本 (字符串, 数字, 布尔, null, 数组, 对象)
复杂结构 锚点&别名, 合并键, 多行字符串 无内置支持
超集关系 是JSON的超集 是YAML的子集
严格性 相对宽松 (例如字符串通常不需引号) 严格 (字符串必须用双引号,键也必须是双引号字符串)
文件大小 通常比JSON略大 (因空格和可读性) 通常更紧凑
流行度 配置文件领域非常流行 Web API和数据交换领域占主导地位

五、YAML的常见应用场景

YAML因其可读性和对复杂配置的良好支持,广泛应用于以下场景:

  1. 配置文件
    • Kubernetes:资源定义(Pods, Services, Deployments等)。
    • Docker Compose:多容器应用定义。
    • Ansible:Playbooks和角色定义。
    • CI/CD工具 (如GitHub Actions, GitLab CI):流水线定义。
    • 各种应用的配置文件 (如 application.yml 在Spring Boot中)。
  2. 数据序列化与持久化:将对象状态保存为YAML格式,或在不同系统间传递数据。
  3. 日志文件:虽然不常见,但其可读性使其适用于某些类型的日志记录。
  4. 对象实例化/消息传递:在某些系统中,YAML用于描述要创建的对象或要发送的消息。

六、编写YAML的最佳实践与注意事项

  1. 使用空格缩进,不要使用Tab:这是最重要的一条,Tab会导致不可预测的解析错误。
  2. 保持缩进一致:通常使用2个或4个空格,并在整个文件中保持一致。
  3. 使用Linter和Validator:工具如 yamllint 可以帮助检查语法错误和风格问题。许多IDE也内置了YAML校验功能。
  4. 适当使用注释:解释复杂的配置或不明显的意图。
  5. 利用锚点和别名:对于重复的配置块,使用锚点和别名来保持DRY。
  6. 注意标量类型的自动转换(”挪威问题”)
    某些字符串可能会被意外地解析为布尔值或数字,例如国家代码 NO 可能被解析为布尔值 false,版本号 1.0 可能被解析为浮点数 1.0。如果需要确保它们是字符串,请使用引号或显式标签:
    country_code: "NO"   # 使用引号
    version: !!str 1.0  # 使用显式标签
  7. 键的唯一性:在同一个映射中,键必须是唯一的。重复的键可能导致解析错误或不可预测的行为(通常是后一个值覆盖前一个)。
  8. 字符串引号的选择
    • 如果字符串中不包含特殊字符,通常不需要引号。
    • 如果字符串中包含特殊字符(如 :, #, &, *, {, }, [, ], ,, ! 等)且希望它们作为字面量,或者字符串以特殊字符开头,或者可能引起歧义,请使用引号。
    • 单引号 (') 用于字面量字符串,仅需转义单引号本身 ('')。
    • 双引号 (") 支持反斜杠转义序列。
  9. 多行字符串的选择:根据是否需要保留换行符和行尾空格来选择 |>
  10. 文件编码:YAML文件推荐使用UTF-8编码。

七、在Java应用中使用YAML

YAML因其可读性和灵活性,在Java应用中,特别是Spring Boot等框架中,被广泛用作配置文件格式。Java社区提供了多个优秀的库来处理YAML的序列化和反序列化。其中最著名的有 SnakeYAML 和 Jackson Dataformat YAML。

(一)使用 SnakeYAML

SnakeYAML 是一个成熟的Java YAML解析和生成库。

1. 添加依赖

Maven:

<dependency>
    <groupId>org.yaml</groupId>
    <artifactId>snakeyaml</artifactId>
    <version>2.2</version> <!-- 请检查最新版本 -->
</dependency>

Gradle:

implementation 'org.yaml:snakeyaml:2.2' // 请检查最新版本

2. 解析YAML到Java对象(反序列化)

假设我们有如下 user.yaml 文件:

name: John Doe
age: 30
email: john.doe@example.com
address:
  street: 123 Main St
  city: Anytown
hobbies:
  - Reading
  - Hiking

对应的Java POJO类:

// User.java
public class User {
    private String name;
    private int age;
    private String email;
    private Address address;
    private List<String> hobbies;

    // Getters and Setters...
    // toString()...
}

// Address.java
public class Address {
    private String street;
    private String city;

    // Getters and Setters...
    // toString()...
}

解析代码:

import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.Constructor;

import java.io.InputStream;
import java.util.List;
import java.util.Map;

public class SnakeYamlReader {
    public static void main(String[] args) {
        Yaml yaml = new Yaml(new Constructor(User.class)); // 指定根类型
        InputStream inputStream = SnakeYamlReader.class
                .getClassLoader()
                .getResourceAsStream("user.yaml");
        User user = yaml.load(inputStream);
        System.out.println(user);

        // 如果不指定类型,默认加载为 Map
        Yaml yamlGeneric = new Yaml();
        InputStream inputStreamGeneric = SnakeYamlReader.class
                .getClassLoader()
                .getResourceAsStream("user.yaml");
        Map<String, Object> data = yamlGeneric.load(inputStreamGeneric);
        System.out.println(data);
        System.out.println(data.get("name"));
    }
}

注意:为了让SnakeYAML正确地将YAML映射到嵌套的自定义对象(如Address)和泛型集合(如List<String>),通常需要POJO类有标准的getter和setter方法,并且YAML中的键名与POJO的属性名匹配。对于复杂泛型,有时可能需要使用TypeDescription

3. 序列化Java对象到YAML

import org.yaml.snakeyaml.DumperOptions;
import org.yaml.snakeyaml.Yaml;

import java.io.FileWriter;
import java.io.IOException;
import java.io.StringWriter;
import java.util.Arrays;

public class SnakeYamlWriter {
    public static void main(String[] args) {
        User user = new User();
        user.setName("Jane Doe");
        user.setAge(28);
        user.setEmail("jane.doe@example.com");

        Address address = new Address();
        address.setStreet("456 Oak St");
        address.setCity("Otherville");
        user.setAddress(address);
        user.setHobbies(Arrays.asList("Coding", "Gaming"));

        // 配置输出选项
        DumperOptions options = new DumperOptions();
        options.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK); // 块风格,更易读
        options.setPrettyFlow(true); // 格式化输出
        options.setIndent(2); // 缩进2个空格

        Yaml yaml = new Yaml(options);

        // 输出到字符串
        StringWriter stringWriter = new StringWriter();
        yaml.dump(user, stringWriter);
        String yamlOutput = stringWriter.toString();
        System.out.println("--- YAML String ---");
        System.out.println(yamlOutput);

        // 输出到文件
        try (FileWriter fileWriter = new FileWriter("output_user.yaml")) {
            yaml.dump(user, fileWriter);
            System.out.println("YAML written to output_user.yaml");
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

这将生成一个格式良好的YAML文件。

(二)使用 Jackson Dataformat YAML

Jackson是一个非常流行的Java JSON处理库,通过 jackson-dataformat-yaml 模块,它也可以高效地处理YAML。

1. 添加依赖

Maven:

<dependency>
    <groupId>com.fasterxml.jackson.dataformat</groupId>
    <artifactId>jackson-dataformat-yaml</artifactId>
    <version>2.15.2</version> <!-- 请检查最新版本 -->
</dependency>
<!-- 如果用到Java 8 日期时间类型,可能还需要以下依赖 -->
<dependency>
    <groupId>com.fasterxml.jackson.datatype</groupId>
    <artifactId>jackson-datatype-jsr310</artifactId>
    <version>2.15.2</version> <!-- 与上面版本保持一致 -->
</dependency>

Gradle:

implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.15.2' // 请检查最新版本
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.15.2' // 可选,用于Java 8日期时间

2. 解析YAML到Java对象(反序列化)

使用与SnakeYAML示例中相同的 user.yamlUser.java, Address.java POJO。

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; // 如果使用了Java 8时间类型

import java.io.InputStream;
import java.util.List;

public class JacksonYamlReader {
    public static void main(String[] args) {
        ObjectMapper mapper = new ObjectMapper(new YAMLFactory());
        // 如果User类中有LocalDate等Java 8时间类型,需要注册模块
        // mapper.registerModule(new JavaTimeModule()); 

        try {
            InputStream inputStream = JacksonYamlReader.class
                    .getClassLoader()
                    .getResourceAsStream("user.yaml");
            User user = mapper.readValue(inputStream, User.class);
            System.out.println(user);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

Jackson通常能很好地处理嵌套对象和标准集合。

3. 序列化Java对象到YAML

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator;

import java.io.File;
import java.util.Arrays;

public class JacksonYamlWriter {
    public static void main(String[] args) {
        User user = new User(); // 假设User和Address类已定义并实例化,同SnakeYAML示例
        user.setName("Peter Pan");
        user.setAge(100); // Peter Pan never grows up!
        user.setEmail("peter.pan@neverland.com");
        Address address = new Address();
        address.setStreet("Second star to the right");
        address.setCity("Neverland");
        user.setAddress(address);
        user.setHobbies(Arrays.asList("Flying", "Adventures"));

        // 为了更好的YAML输出格式,可以配置YAMLFactory
        YAMLFactory yamlFactory = new YAMLFactory()
                .disable(YAMLGenerator.Feature.WRITE_DOC_START_MARKER) // 不写入 ---
                .enable(YAMLGenerator.Feature.MINIMIZE_QUOTES); // 尽量减少不必要的引号

        ObjectMapper mapper = new ObjectMapper(yamlFactory);
        // 为了更好的可读性,可以启用缩进
        mapper.enable(SerializationFeature.INDENT_OUTPUT);
        // 如果User类中有LocalDate等Java 8时间类型,需要注册模块并禁用时间戳输出
        // mapper.registerModule(new JavaTimeModule());
        // mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);

        try {
            // 输出到字符串
            String yamlString = mapper.writeValueAsString(user);
            System.out.println("--- Jackson YAML String ---");
            System.out.println(yamlString);

            // 输出到文件
            mapper.writeValue(new File("output_user_jackson.yaml"), user);
            System.out.println("YAML written to output_user_jackson.yaml");

        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

(三)选择哪个库?

  • SnakeYAML:是一个纯粹的YAML库,专注于YAML的解析和生成,对YAML规范的支持非常完整。如果你的项目主要处理YAML且不需要复杂的JSON集成,SnakeYAML是一个不错的选择。
  • Jackson Dataformat YAML:如果你已经在项目中广泛使用Jackson处理JSON,并且希望以类似的方式处理YAML,那么 jackson-dataformat-yaml 是一个自然的选择。它允许你重用Jackson的注解和配置,方便在JSON和YAML之间切换或同时支持两者。

在Spring Boot应用中,spring-boot-starter 默认包含了SnakeYAML,用于加载 application.yml 配置文件。如果你需要更细致地控制YAML处理或使用Jackson的特性,可以引入 jackson-dataformat-yaml

对于大多数常见的配置文件读取和简单对象的序列化/反序列化需求,这两个库都能很好地满足。

八、总结

YAML凭借其出色的可读性和强大的数据表示能力,已成为现代软件开发中不可或缺的一部分,尤其是在配置管理和DevOps领域。理解其基本语法、掌握高级特性,并遵循最佳实践,可以帮助开发者更高效地创建和维护结构清晰、易于理解的YAML文档。虽然它有一些需要注意的细节(如缩进和类型转换),但其带来的便利性远超这些小挑战。在Java等语言中,借助成熟的库可以方便地集成YAML处理能力。

九、参考资料