yq 的 Add 操作符(+ / +=):数组拼接、数字加法、字符串连接与 Map 浅合并全指南
【免费下载链接】yqyq is a portable command-line YAML, JSON, XML, CSV, TOML, HCL and properties processor项目地址: https://gitcode.com/GitHub_Trending/yq/yq
本文基于 yq 官方操作符文档 add.md 编写,并结合作者仓库中 operator_add.go、operators.go 等源码实现与 operator_add_test.go 测试用例进行纵深印证。读完本文,你将掌握 yq 中
+与+=的完整语义——包括数组拼接、数值递增、字符串拼接、日期加时长、Map 浅合并以及自定义标签(Custom Tag)类型的处理,并能独立判断何时用+、何时用+=、何时改用*深度合并。
Add 操作符的类型分派规则
add操作符(对应+)的行为取决于左侧操作数(LHS)的类型,这一点是理解一切后续示例的基石。文档 add.md 给出了四条分派规则:
- arrays(序列):拼接(concatenate);
- number scalars(数字标量):算术加法;
- string scalars(字符串标量):字符串拼接;
- maps(映射):浅合并(shallow merge),若要深度合并请使用 multiply 操作符
*。
同时文档强调:使用+=作为“相对追加赋值”(relative append assign),适合自增等场景。.a += .x完全等价于.a = .a + .x,即先把 LHS 取出来与 RHS 相加,再写回 LHS 指向的位置。
从源码看,这一分派逻辑位于 operator_add.go 的add()函数:它先处理lhs == nil、rhs == nil以及 LHS 为!!null的边界情况,然后按lhsNode.Kind分别进入MappingNode → addMaps、SequenceNode → addSequences、ScalarNode → addScalars三条分支。若 RHS 类型与 LHS 不匹配(例如把序列加到映射上),会直接返回形如!!seq () cannot be added to a !!map (a)的错误——这一点在 operator_add_test.go 中有对应的expectedError测试用例佐证。
+=的实现则更值得关注:在 operators.go 的compoundAssignFunction中,yq 会遍历 LHS 匹配到的每个节点,对其做一次拷贝,然后构造一个等价于LHS = LHS + RHS的赋值操作节点再求值;operator_add.go 中的addAssignOperator正是把“计算函数”换成createAddOp后交给该通用函数执行。
数组拼接(Concatenate arrays)
当 LHS 是数组时,+将两个数组首尾相接。
给定sample.yml:
a: - 1 - 2 b: - 3 - 4执行:
yq '.a + .b' sample.yml输出:
- 1 - 2 - 3 - 4源码上,addSequences(operator_add.go)先保留 LHS 的 Tag 与样式,再把lhs.Content与 RHS 转换出的节点依次AddChildren拼接。其中 RHS 节点通过toNodes(operator_add.go)转换:若 RHS 是!!null则视为空序列(不产生任何元素);若 RHS 本身是序列则取其子节点;其他类型的标量/映射则包装成单个节点。yq '.a + .b'这种纯读取式求值不会修改原文件,对应测试见 operator_add_test.go。
拼接进已有数组(Concatenate to existing array)
用+=把数组追加到已有数组上,注意a原有的样式(styling)会被保留。
给定sample.yml:
a: [1,2] b: - 3 - 4执行:
yq '.a += .b' sample.yml输出(a仍是紧凑的流式风格[1, 2, 3, 4]):
a: [1, 2, 3, 4] b: - 3 - 4这里的“保留样式”在源码中有明确体现:toNodes在把非序列的 RHS 包装成节点时,会借用 LHS 第一个子节点的Style(operator_add.go),从而让追加的元素与已有元素风格一致;对应测试用例见 operator_add_test.go。
向数组拼接 null(Concatenate null to array)
给定sample.yml:
a: - 1 - 2执行:
yq '.a + null' sample.yml输出(null被忽略,数组保持不变):
- 1 - 2这正是toNodes中candidate.Tag == "!!null"时返回空节点列表的实现效果(operator_add.go),测试见 operator_add_test.go。
向已有数组追加元素(Append to existing array)
RHS 是单个标量时,+=把它作为一个元素追加到数组末尾,且样式从已有数组元素复制。
给定sample.yml:
a: ['dog']执行:
yq '.a += "cat"' sample.yml输出:
a: ['dog', 'cat']注意输出中新增的cat同样带单引号,这正是“样式复制”的直接证据:标量节点被包装时套用了 LHS 首个元素'dog'的引号风格。测试见 operator_add_test.go。
向已有数组前置元素(Prepend to existing array)
+不可交换(LHS 类型决定语义),所以前置要显式把新元素写在左边:
给定sample.yml:
a: - dog执行:
yq '.a = ["cat"] + .a' sample.yml输出:
a: - cat - dog这里["cat"]作为 LHS 数组在前,.a作为 RHS 在后,拼接结果自然是cat在前。测试见 operator_add_test.go。
向数组追加对象(Add new object to array)
当 RHS 是映射(对象)时,它作为单个元素追加进数组:
给定sample.yml:
a: - dog: woof执行:
yq '.a + {"cat": "meow"}' sample.yml输出:
- dog: woof - cat: meow相对追加(Relative append)
+=的“相对”特性配合通配遍历(如.a[])非常强大:可以对匹配到的每一个位置分别执行追加。
给定sample.yml:
a: a1: b: - cat a2: b: - dog a3: {}执行:
yq '.a[].b += ["mouse"]' sample.yml输出(a1.b、a2.b各自追加;a3原本没有b,yq 会为它自动创建b: [mouse]):
a: a1: b: - cat - mouse a2: b: - dog - mouse a3: b: - mouse注意a3: {}变成a3: {b: [mouse]}——这是“相对赋值 + 缺失路径自动补全”的典型效果,测试见 operator_add_test.go。之所以能自动补全缺失的b键,是因为+=展开后的赋值操作走的是 operator_assign.go 中assignUpdateOperator的UpdateAssign分支,该分支会基于 LHS 匹配结果直接更新对应候选节点。
字符串拼接(String concatenation)
LHS 是字符串时,+直接做文本拼接:
给定sample.yml:
a: cat b: meow执行:
yq '.a += .b' sample.yml输出:
a: catmeow b: meow源码addScalars(operator_add.go)中,当lhsTag == "!!str"时执行target.Value = lhs.Value + rhs.Value,并保留 LHS 的 Tag。更细节的是:若 RHS 是!!null,结果保持 LHS 原值(等价于字符串加空);若 RHS 是字符串而 LHS 不是,则结果 Tag 以 RHS 为准。测试见 operator_add_test.go。
数字加法:浮点(Number addition - float)
只要 LHS 或 RHS 之一是浮点数,整个计算就按浮点进行:
给定sample.yml:
a: 3 b: 4.9执行:
yq '.a = .a + .b' sample.yml输出:
a: 7.9 b: 4.9源码上,operator_add.go 的浮点分支通过strconv.ParseFloat解析两边后相加,并把结果 Tag 标为!!float。测试见 operator_add_test.go。
数字加法:整数(Number addition - int)
当 LHS 与 RHS都是整数时,才按整数运算:
给定sample.yml:
a: 3 b: 4执行:
yq '.a = .a + .b' sample.yml输出:
a: 7 b: 4源码中整数分支(operator_add.go)通过parseInt64解析,并且会保留 LHS 数值的原始格式(format)——例如十六进制或前导零等书写风格会随结果保留。测试见 operator_add_test.go。
数字递增(Increment numbers)
利用+= 1可以对任意匹配到的数值原地自增:
给定sample.yml:
a: 3 b: 5执行:
yq '.[] += 1' sample.yml输出:
a: 4 b: 6这是+=作为“相对追加赋值”最直接的应用:把.a += 1理解为“取.a的值加 1 再写回.a”。测试见 operator_add_test.go。
日期加法(Date addition)
yq 支持给日期加上时长(duration)。默认假设输入为RFC3339日期时间格式。
给定sample.yml:
a: 2021-01-01T00:00:00Z执行:
yq '.a += "3h10m"' sample.yml输出:
a: 2021-01-01T03:10:00Z时长字符串(如3h10m)使用 Go 标准库的time.ParseDuration解析;源码 operator_add.go 的addDateTimes完成“解析时长 → 解析当前时间 → 相加 → 按原格式回写”的完整链路。判断是否走日期分支的依据是:LHS 的 Tag 为!!timestamp,或者 LHS 是字符串且能被当前日期时间格式解析(operator_add.go)。测试见 operator_add_test.go。
关于日期时间的更多操作(格式化、时区、Unix 时间戳互转等),可参考仓库中的 datetime 操作符文档。
日期加法:自定义格式(Date addition - custom format)
当日期不是 RFC3339 格式时,需要用with_dtf指定解析格式(格式语法基于 Go 的time库):
给定sample.yml:
a: Saturday, 15-Dec-01 at 2:59AM GMT执行:
yq 'with_dtf("Monday, 02-Jan-06 at 3:04PM MST", .a += "3h1m")' sample.yml输出:
a: Saturday, 15-Dec-01 at 6:00AM GMTwith_dtf的第一个参数设置日期时间解析格式,第二个参数是要在该格式上下文中执行的任意 yq 表达式;格式字符串中的Monday、02-Jan-06、3:04PM、MST分别对应 Go 参考时间Mon Jan 2 15:04:05 MST 2006的各个组成部分。测试见 operator_add_test.go,更完整的格式与用法说明见 datetime.md。
与 null 相加(Add to null)
“加 null 就是返回 RHS”:当 LHS 为null时,+直接返回 RHS 本身。
运行:
yq --null-input 'null + "cat"'输出:
cat注意这里用了--null-input标志,表示不读取输入文件、直接以null作为初始上下文。对应源码是 operator_add.go 中lhsNode.Tag == "!!null"时执行lhs.CopyAsReplacement(rhs),测试见 operator_add_test.go。这个特性也解释了为何“给缺失字段做+运算”不会报错——缺失字段按null处理。
Map 的浅合并(Add maps to shallow merge)
当 LHS 是映射(Map)时,+执行浅合并(shallow merge):逐 key 处理,新 key 直接追加,已有 key 用 RHS 的值整体替换(不会递归合并子映射)。若需要递归深度合并,请使用*操作符(见 multiply-merge 文档)。
给定sample.yml:
a: thing: name: Astuff value: x a1: cool b: thing: name: Bstuff legs: 3 b1: neat执行:
yq '.a += .b' sample.yml输出:
a: thing: name: Bstuff legs: 3 a1: cool b1: neat b: thing: name: Bstuff legs: 3 b1: neat观察关键点:
a.thing整个被b.thing替换,所以value: x消失了、也没有legs: 3与name: Astuff并存的“合并”效果——这是浅合并与深合并的本质区别;a1: cool保留(LHS 独有 key);b1: neat被追加进来(RHS 独有 key)。
源码addMaps(operator_add.go)正是按此逻辑实现:先把 LHS 的所有键值对加入结果,再遍历 RHS 的键值对,通过findKeyInMap查找 key——找不到则AddKeyValueChild追加,找到了则用CopyAsReplacement整体替换该 key 的值。测试见 operator_add_test.go。
自定义标签类型:本质是字符串(Custom types that are really strings)
YAML 允许通过!tag给节点打上自定义标签。当遇到自定义标签时,yq 会尝试解码其底层类型再决定运算方式。
给定sample.yml:
a: !horse cat b: !goat _meow执行:
yq '.a += .b' sample.yml输出(按字符串拼接,且 LHS 的自定义标签!horse被保留):
a: !horse cat_meow b: !goat _meow源码中,addScalars对 LHS 先判断其 Tag 是否以!!开头;不是(即自定义标签)则调用guessTagFromCustomType()猜出底层类型(operator_add.go),计算完成后结果 Tag 仍沿用 LHS 的自定义标签。测试见 operator_add_test.go。
自定义标签类型:本质是数字(Custom types that are really numbers)
同样的“解码底层类型”规则也适用于数字:
给定sample.yml:
a: !horse 1.2 b: !goat 2.3执行:
yq '.a += .b' sample.yml输出:
a: !horse 3.5 b: !goat 2.31.2 + 2.3按浮点计算出3.5,结果保留!horse标签;b保持原样。测试见 operator_add_test.go。从测试用例还能看到更多扩展情形:自定义 int 相加保留!horse 5(operator_add_test.go)、自定义数组!horse [a] += !goat [b]得到!horse [a, b](operator_add_test.go),以及含锚点数组&horse [1] += 2追加后锚点仍保留(operator_add_test.go)——这些都印证了“先解码底层类型、再按类型分派、结果保留 LHS 自定义标签”的统一原则。
常见类型不匹配错误
在阅读 operator_add_test.go 时还能看到两类典型报错场景,可作为排错参考:
- 把序列加到映射:
.a += [2](当a是映射时)报!!seq () cannot be added to a !!map (a); - 把序列加到标量:
.a += [2](当a是字符串时)报!!seq () cannot be added to a !!str (a)。
这两条错误均来自 operator_add.go 中对MappingNode/ScalarNode分支的 RHS 类型校验,测试见 operator_add_test.go。
小结:如何选择+、+=与*
| 场景 | 推荐写法 | 语义 |
|---|---|---|
| 纯计算、不改文件 | yq '.a + .b' f.yml | 按 LHS 类型分派,输出结果 |
| 追加/自增/原地修改 | .a += .b | 等价于.a = .a + .b,写回原位置 |
| 深度合并映射 | .a *= .b | 递归合并,见 multiply-merge.md |
| 给日期加时长 | .a += "3h10m" | 默认 RFC3339,自定义格式用with_dtf |
| 向数组前置元素 | .a = ["cat"] + .a | +顺序敏感,新元素放左边 |
一句话总结:+是一个按 LHS 类型分派的多态操作符,+=是其“取当前值计算后写回”的语法糖。理解 operator_add.go 中add、addScalars、addSequences、addMaps、addDateTimes五条核心路径的分工,就能准确预判任何+表达式的输出,再结合 operator_add_test.go 中覆盖上述全部场景的TestAddOperatorScenarios测试用例,可以放心地把这些表达式应用到自己的 YAML/JSON 处理流水线中。
【免费下载链接】yqyq is a portable command-line YAML, JSON, XML, CSV, TOML, HCL and properties processor项目地址: https://gitcode.com/GitHub_Trending/yq/yq
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考