在 Rust 中“尊重原格式”地修补 YAML
摘要
本文评估了多款 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辩护
Posit的一篇博文为YAML辩护,反驳当前普遍认为TOML更优的共识,回顾了配置格式的历史,并指出YAML的规范与工具已经演进,解决了过去的批评。
YAML:问题一大堆
一篇讽刺文章,指出YAML在DevOps和编程环境中的各种缺陷和怪癖,例如解析错误和Kubernetes等工具中的配置错误。
这不是YAML规范的错,但是
这篇文章讨论了对YAML的常见批评,比如其庞大的规范、不一致的库实现以及隐式类型问题,并分享了作者在项目中的YAML使用个人经历。
用 Rust 重写
本文评估了2026年的‘Rewrite It In Rust’运动,讨论了现实世界中的性能提升、诸如新错误和平台支持等挑战,并提倡增量重写而非完全重写。
@jarredsumner:在 bun 的 Rust 重写过程中我最喜欢的测试失败:TOML 和 YAML 解析器的栈溢出测试失败了,因为它现在可以比测试预期更深…
Jarred Sumner 分享了 Bun 的 Rust 重写过程中一个最喜欢的测试失败:TOML 和 YAML 解析器的栈溢出测试失败了,因为 Rust 实现能够处理比预期更深层次的嵌套。