在 Rust 中“尊重原格式”地修补 YAML

Lobsters Hottest 工具

摘要

本文评估了多款 Rust 库,旨在找出能够在编程修改 YAML 文件时保留原始格式与注释的最佳工具。

<p><a href="https://lobste.rs/s/fwutce/respectful_yaml_patching_rust">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/05/11 17:08

# 在 Rust 中进行“尊重原有格式”的 YAML 修补 来源:https://verrchu.github.io/blog/2-respectful-yaml-patching-in-rust/ 2026 年 5 月 8 日 以编程方式修补 YAML 文件在原理上很简单:解析、修改、序列化。理想情况下,这个过程还应该是*尊重原有格式*的——即,保留初始文件的以下属性: 1. **格式。**同一个 YAML 值可以用多种方式表示:映射和列表的缩进方式、空行是否分隔各个节、字符串的引号方式等等。例如,一个列表可以用*块*风格或*流*风格表示。通用的 YAML 库通常在序列化时选择一种规范形式,并将其应用到整个文档。 2. **注释。**YAML 的一个符合人体工程学的优势是,一个值可以附带一个行内注释,解释*为什么*要这样设置。注释在反序列化阶段通常会被丢弃,因此没有机会被序列化回来。 丢失这两种属性中的任何一种都会造成损害。丢失注释实际上就是丢失历史上下文。格式错乱可能导致生成的文件无效,或者消除为特定情况精心选择的布局(例如,将有意使用的流列表变成块列表)。 选择流行的通用 YAML 库是明显的做法,但没有一个能同时保留两者: - **`serde_yaml`** (https://github.com/dtolnay/serde-yaml) 已不再维护 (https://github.com/dtolnay/serde-yaml/commit/3ba8462f7d3b603d832e0daeb6cfc7168a673d7a),而且早在停止维护之前,功能请求 (https://github.com/dtolnay/serde-yaml/issues/145) 就以超出范围为由被拒绝了。 - **`yaml-rust2`** (https://github.com/Ethiraric/yaml-rust2) 不会保留注释;功能请求 (https://github.com/Ethiraric/yaml-rust2/issues/21) 被关闭,并注明相关工作将改为在 saphyr (https://github.com/Ethiraric/yaml-rust2/issues/21#issuecomment-2204512056) 中进行。 - **`saphyr`** (https://github.com/saphyr-rs/saphyr) 是同一维护者开发的 yaml-rust2 的精神继承者;注释支持已列入计划 (https://github.com/saphyr-rs/saphyr/issues/12) 但尚未发布。 因此,需要一个更小众的工具。 ## 候选方案 在 crates.io (https://crates.io/) 和 lib.rs (https://lib.rs/) 上搜索声称支持注释保留的库,找到了四个候选方案: - **`yamlpath`** (https://github.com/zizmorcore/zizmor/tree/main/crates/yamlpath) + **`yamlpatch`** (https://github.com/zizmorcore/zizmor/tree/main/crates/yamlpatch)——保留注释和格式 (https://github.com/zizmorcore/zizmor/blob/main/crates/yamlpath/src/lib.rs#L1) 的路由(`yamlpath`)和补丁操作(`yamlpatch`)。 - **`yaml-edit`** (https://github.com/jelmer/yaml-edit)——根据其 README (https://github.com/jelmer/yaml-edit#readme),保留*格式、注释和空白*。 - **`rust-yaml`** (https://github.com/elioetibr/rust-yaml)——README 有专门的注释保留 (https://github.com/elioetibr/rust-yaml#-comment-preservation-implemented) 部分。 - **`yamp`** (https://github.com/sanjeevprasad/yamp)——README 将注释保留 (https://github.com/sanjeevprasad/yamp#1-comment-preservation-first) 列为项目的设计目标之一。 ## 实验 以下示例使用了一个交易机器人的简化配置。资产被分组到命名组中,并带有一个通用`default`组: ```yaml # outer comment asset_groups: group_abc: # group_abc comment - BTC - ETH - SOL # group_xyz outer comment group_xyz: - DOGE # asset comment - PEPE default: # default group inner comment - 1INCH - ATOM - LINK ``` 这里使用的玩具命令行工具支持两种操作: - `list-assets ASSET1,ASSET2`——将列出的资产追加到`default`组,按字母顺序排列。 - `delist-assets ASSET1,ASSET2`——从这些资产所在的组中移除它们。如果某个组变空,则完全删除该组。 ## 列出资产 第一个测试是一次`list-assets`调用,包含四个资产,旨在一次性触发三种情况: ``` list-assets 1INCH,BTC,XRP,BNB ``` - `1INCH`已在`default`中→ 无操作。 - `BTC`已在`group_abc`中→ 同样无操作。 - `XRP`和`BNB`是新的,应被添加到`default`组,并按字母顺序与现有项目一起排序。 期望的输出: ```yaml # outer comment asset_groups: group_abc: # group_abc comment - BTC - ETH - SOL # group_xyz outer comment group_xyz: - DOGE # asset comment - PEPE default: # default group inner comment - 1INCH - ATOM - BNB - LINK - XRP ``` **`yamlpath`+`yamlpatch`**——完全匹配 ```yaml # outer comment asset_groups: group_abc: # group_abc comment - BTC - ETH - SOL # group_xyz outer comment group_xyz: - DOGE # asset comment - PEPE default: # default group inner comment - 1INCH - ATOM - BNB - LINK - XRP ``` **`yaml-edit`**——外部注释被丢弃,"default" 缩进错误 ```yaml asset_groups: group_abc: # group_abc comment - BTC - ETH - SOL # group_xyz outer comment group_xyz: - DOGE # asset comment - PEPE default: # default group inner comment - 1INCH - ATOM - BNB - LINK - XRP ``` **`rust-yaml`**——存在多个问题,不合格 ```yaml # outer comment asset_groups: group_abc: - BTC - ETH - SOL group_xyz: - DOGE - PEPE default: - 1 - 1INCH - ATOM - BNB - INCH - LINK - XRP # group_abc comment # outer comment # outer comment # group_abc comment ``` 注释分散了(有些出现在文件末尾,有些重复),`1INCH`被拆分成了两个列表项(`- 1`和`- INCH`),有意保留的空白和`DOGE`上的行内注释都丢失了。该库自身的`comment_preservation_demo.rs` (https://github.com/elioetibr/rust-yaml/blob/main/examples/comment_preservation_demo.rs) 在未经修改运行时会表现出相同的注释分散行为。 **`yamp`**——解析问题,不合格 此处未显示输出,因为输入中的某些注释会扰乱 yamp 的解析器。 `list-assets`是两种操作中较简单的一种,因为它只涉及单个组且只添加。`yamlpath`+`yamlpatch`完全精确地往返了文件。`yaml-edit`确实违反了两种属性,但严重程度不足以仅凭此测试就将其排除。 ## 下架资产 `delist-assets`是要求更高的操作:可以修改任何组,可以移除任何资产,可以整个组完全移除。测试: ``` delist-assets DOGE,PEPE,BTC,SOL,ATOM,SHIB ``` 这涵盖了所有有趣的情况: - `DOGE`和`PEPE`都是`group_xyz`的成员。移除两者应该使该组为空,这意味着整个`group_xyz`组必须被移除。 - `BTC`和`SOL`从`group_abc`中移出,只剩下`ETH`。 - `ATOM`从`default`中移除。 - `SHIB`根本不在文件中;应该无操作。 期望的输出: ```yaml # outer comment asset_groups: group_abc: # group_abc comment - ETH default: # default group inner comment - 1INCH - LINK ``` **`yamlpath`+`yamlpatch`**——接近,只有一处注释被重新排列 ```yaml # outer comment asset_groups: group_abc: # group_abc comment - ETH # group_xyz outer comment default: # default group inner comment - 1INCH - LINK ``` 当移除现在为空的`group_xyz:`键时,上一行上的独立注释并没有随之移除。相反,它以行内注释的形式迁移到了最近的内容行上。输出的 YAML 是有效的,没有注释丢失,但该注释现在附加到了错误的列表项上。 **`yaml-edit`**——逻辑结构改变,不合格 ```yaml asset_groups: group_abc: # group_abc comment - ETH # group_xyz outer comment default: # default group inner comment - 1INCH - LINK ``` `default:`上的缩进变化不仅仅是表面问题:`default`现在*嵌套在*`group_abc`内部,而不是作为同级项。两个顶级组被折叠成了一个。 `yamlpath`+`yamlpatch`生成了有效的 YAML,但遗留的注释违反了“尊重原有格式”的属性——考虑到其他库的状况,这或许并不是致命的缺陷。 ## 获胜者 `yamlpath`+`yamlpatch`是目前可用选项中最好的。它并不完美,但正在积极维护,并且通过一些变通方法和妥协可以使其正常工作。以下是我在尝试将其用于实际用例时遇到的一些注意事项。 ### `Op::Replace` 对序列不起作用 `yamlpatch-replace-list.rs` ````rust use std::collections::HashSet; use anyhow::Context as _; use clap::Parser; use yamlpatch::{Op, Patch, apply_yaml_patches}; const INPUT: &str = "\ asset_groups: default: - 1INCH - ATOM - LINK "; #[derive(Parser)] struct Args { /// Comma-separated assets to list (i.e. add to `default` if missing). #[arg(long)] assets: String, } fn main() -> anyhow::Result<()> { let args = Args::parse(); let new_assets: Vec<String> = args .assets .split(',') .map(|s| s.trim().to_string()) .collect(); // parse old assets let parsed: serde_yaml::Value = serde_yaml::from_str(INPUT).unwrap(); let default_old = parsed .get("asset_groups") .and_then(|v| v.get("default")) .and_then(|v| v.as_sequence()) .unwrap() .iter() .map(|v| v.as_str().unwrap().to_string()) .collect::<Vec<String>>(); // construct new assets let mut default_new = Vec::<String>::from_iter(HashSet::<String>::from_iter( default_old.iter().cloned().chain(new_assets), )); default_new.sort(); let new_seq: Vec<serde_yaml::Value> = default_new .into_iter() .map(serde_yaml::Value::String) .collect(); let doc = yamlpath::Document::new(INPUT.to_string()).unwrap(); let patch = Patch { route: yamlpath::route!("asset_groups", "default"), operation: Op::Replace(serde_yaml::Value::Sequence(new_seq)), }; let new_doc = apply_yaml_patches(&doc, &[patch]).context("apply patches")?; print!("{}", new_doc.source()); Ok(()) } ```` ``` $ cargo run --example yamlpatch-replace-list -- --assets 1INCH,BTC,XRP,BNB Error: apply patches Caused by: 0: YAML query error: input is not valid YAML 1: input is not valid YAML ``` ### 更新列表需要变通方法 由于`Op::Replace`无法用于序列,端到端更新一个列表需要变通方法:先将整个期望的列表追加到末尾,然后从前面逐一移除原始项目: `yamlpatch-rotate-replace-list.rs` ````rust use std::collections::HashSet; use anyhow::Context as _; use clap::Parser; use yamlpatch::{Op, Patch, apply_yaml_patches}; const INPUT: &str = "\ asset_groups: default: - 1INCH - ATOM - LINK "; #[derive(Parser)] struct Args { /// Comma-separated assets to list (i.e. add to `default` if missing). #[arg(long)] assets: String, } fn main() -> anyhow::Result<()> { let args = Args::parse(); let new_assets: Vec<String> = args .assets .split(',') .map(|s| s.trim().to_string()) .collect(); // parse old assets let parsed: serde_yaml::Value = serde_yaml::from_str(INPUT).unwrap(); let default_old = parsed .get("asset_groups") .and_then(|v| v.get("default")) .and_then(|v| v.as_sequence()) .unwrap() .iter() .map(|v| v.as_str().unwrap().to_string()) .collect::<Vec<String>>(); // construct new assets let mut default_new = Vec::<String>::from_iter(HashSet::<String>::from_iter( default_old.iter().cloned().chain(new_assets), )); default_new.sort(); let mut patches: Vec<Patch> = Vec::new(); for item in default_new { patches.push(Patch { route: yamlpath::route!("asset_groups", "default"), operation: Op::Append { value: serde_yaml::Value::String(item), }, }); } for _ in 0..default_old.len() { patches.push(Patch { route: yamlpath::route!("asset_groups", "default", 0usize), operation: Op::Remove, }); } let doc = yamlpath::Document::new(INPUT.to_string()).unwrap(); let new_doc = apply_yaml_patches(&doc, &patches).context("apply patches")?; print!("{}", new_doc.source()); Ok(()) } ```` ``` $ cargo run --example yamlpatch-rotate-replace-list -- --assets 1INCH,BTC,XRP,BNB asset_groups: default: - 1INCH - ATOM - BNB - BTC - LINK - XRP ``` ### 它不能很好地处理流风格列表 `yamlpatch-flow-list.rs` ````rust use std::collections::HashSet; use anyhow::Context as _; use clap::Parser; use yamlpatch::{Op, Patch, apply_yaml_patches}; const INPUT: &str = "\ asset_groups: default: [1INCH, ATOM, LINK] "; #[derive(Parser)] struct Args { /// Comma-separated assets to list (i.e. add to `default` if missing). #[arg(long)] assets: String, } fn main() -> anyhow::Result<()> { let args = Args::parse(); let new_assets: Vec<String> = args .assets .split(',') .map(|s| s.trim().to_string()) .collect(); // parse old assets let parsed: serde_yaml::Value = serde_yaml::from_str(INPUT).unwrap(); let default_old = parsed .get("asset_groups") .and_then(|v| v.get("default")) .and_then(|v| v.as_sequence()) .unwrap() .iter() .map(|v| v.as_str().unwrap().to_string()) .collect::<Vec<String>>(); // construct new assets let mut default_new = Vec::<String>::from_iter(HashSet::<String>::from_iter( default_old.iter().cloned().chain(new_assets), )); default_new.sort(); let mut patches: Vec<Patch> = Vec::new(); for item in default_new { patches.push(Patch { route: yamlpath::route!("asset_groups", "default"), operation: Op::Append { value: serde_yaml::Value::String(item), }, }); } let doc = yamlpath::Document::new(INPUT.to_string()).unwrap(); let new_doc = apply_yaml_patches(&doc, &patches).context("apply patches")?; print!("{}", new_doc.source()); Ok(()) } ```` ``` $ cargo run --example yamlpatch-flow-list -- --assets 1INCH,BTC,XRP,BNB Error: apply patches Caused by: Invalid operation: append operation is not permitted against flow sequence route: Route { route: [Key("asset_groups"), Key("default")] } ``` ## 结论 `yamlpath`+`yamlpatch`是唯一真正接近此处定义的“尊重原有格式”修补的选项。尽管它不能开箱即用的涵盖所有情况,但在实践中非常有用。 --- *完整的随附源代码可在此处 (https://github.com/verrchu/blog/tree/main/content/2-respectful-yaml-patching-in-rust) 找到。使用 `rustc 1.95.0` 构建。使用的库版本:`yamlpath` 1.24.1,`yamlpatch` 1.24.1,`yaml-edit` 0.2.1,`rust-yaml` 0.0.5,`yamp` 0.1.0。*

相似文章

为YAML辩护

Lobsters Hottest

Posit的一篇博文为YAML辩护,反驳当前普遍认为TOML更优的共识,回顾了配置格式的历史,并指出YAML的规范与工具已经演进,解决了过去的批评。

YAML:问题一大堆

Hacker News Top

一篇讽刺文章,指出YAML在DevOps和编程环境中的各种缺陷和怪癖,例如解析错误和Kubernetes等工具中的配置错误。

这不是YAML规范的错,但是

Lobsters Hottest

这篇文章讨论了对YAML的常见批评,比如其庞大的规范、不一致的库实现以及隐式类型问题,并分享了作者在项目中的YAML使用个人经历。

用 Rust 重写

Hacker News Top

本文评估了2026年的‘Rewrite It In Rust’运动,讨论了现实世界中的性能提升、诸如新错误和平台支持等挑战,并提倡增量重写而非完全重写。