Linux 基础体系 · 第 46/85 篇。示例面向现代主流 Linux 发行版;发行版差异、权限和生产风险会明确说明。

命令行结构化数据:jq、yq、JSON、YAML、流式处理和安全更新

在 Linux 自动化中,配置文件、API 响应、容器清单、CI 参数和状态文件经常以 JSON 或 YAML 表示。它们看起来像“文本”,但真正的处理对象是具有层次结构的数据:

{
  "service": {
    "name": "api",
    "replicas": 3,
    "ports": [8080, 8443]
  }
}

如果使用 grepsed 或简单的正则表达式修改这类内容,脚本实际上是在猜测格式。字段可能换行、重新排序、嵌套、出现重复键,字符串中也可能包含与语法相似的字符。jqyq 的核心价值,是把输入解析为结构化数据,再依据路径、类型和条件进行变换。

本文使用以下工具约定:

  • jq:处理 JSON。
  • yq:文中命令以 Go 实现的 Mike Farah yq v4 为例。不同项目也有名为 yq 的工具,例如 Python 包 kislyuk/yq,命令参数和表达式语法并不完全相同。
  • 示例面向现代主流 Linux 发行版。
  • 使用 bash 语法;涉及 flockmktemp 等命令时,会说明其实现和移植边界。

一、先区分文本、序列化格式和数据结构

1.1 JSON 和 YAML 表示什么

数据结构是内存中的对象,例如:

  • 对象:键到值的映射;
  • 数组:有顺序的值序列;
  • 字符串;
  • 数字;
  • 布尔值;
  • 空值。

JSON 是这种结构的一种严格序列化格式。JSON 对象、数组和标量分别对应:

{
  "name": "web",
  "enabled": true,
  "ports": [80, 443],
  "limits": null
}

JSON 的重要约束包括:

  1. 对象键必须是字符串;
  2. 字符串使用双引号;
  3. 布尔值只能是 truefalse
  4. 空值是 null
  5. 不能写注释;
  6. 一个 JSON 文本通常表示一个 JSON 值。

因此,下面内容是一个 JSON 文本:

{"a": 1}

但下面内容不是一个完整的单一 JSON 文档:

{"a": 1}
{"a": 2}

它可以是 JSON LinesNDJSON,即“每行一个独立 JSON 值”,但不能直接当作一个 JSON 数组交给普通 jq 处理。

YAML 也是结构化数据的序列化格式,但语法更加宽松:

service:
  name: web
  enabled: true
  ports:
    - 80
    - 443

YAML 通常支持:

  • 缩进表示层次;
  • 注释;
  • 多文档;
  • 锚点和别名;
  • 多种标量写法;
  • 标签和类型系统。

这种灵活性带来兼容性和安全边界:不同 YAML 解析器对日期、数字、标签、重复键和锚点的处理可能不同。因此,YAML 文件“看起来正确”,并不等于所有工具会把它解释成同一个数据结构。

1.2 YAML 不是“带注释的 JSON”

YAML 的一部分语法可以表示 JSON,但二者的语义并不完全相同。例如:

enabled: yes
date: 2024-01-01

某些 YAML 版本或解析器可能把 yes 解释为布尔值,把日期解释为带类型的值;另一些实现可能把它们保留为字符串。为了避免跨工具歧义,配置文件中应显式写出字符串:

enabled: "yes"
date: "2024-01-01"

YAML 中以下值也容易造成误解:

a: on
b: off
c: null
d: 0123

它们究竟是字符串、布尔值、空值还是数字,与 YAML 版本和解析器有关。yq 会把 YAML 解析为自己的节点模型,再输出 YAML 或 JSON;转换过程中可能发生类型规范化、引号变化、注释丢失或格式重排。

因此:

  • 需要严格机器接口时,JSON 通常更容易约束;
  • 需要人工维护、注释和多文档时,YAML 更方便;
  • 不能把“YAML 语法可读”当作“所有解析器语义一致”。

二、jq 的核心模型:输入值、过滤器和输出值

2.1 jq 不是文本替换器

jq 接收 JSON 输入,并执行一个过滤器(filter)。可以把一个过滤器抽象为:

F:V输出值序列F: V \rightarrow \text{输出值序列}

其中 VV 是 JSON 值集合。一个输入值可以产生零个、一个或多个输出值。

最简单的过滤器是 .

printf '%s\n' '{"name":"api","replicas":3}' | jq .

输出:

{
  "name": "api",
  "replicas": 3
}

这里 . 表示“原样传递当前输入值”,只是默认格式化输出。

字段访问:

printf '%s\n' '{"name":"api","replicas":3}' | jq '.name'

输出:

"api"

注意:默认输出仍是 JSON 字符串,因此包含双引号。如果希望得到适合传给 Shell 的原始字符串:

printf '%s\n' '{"name":"api","replicas":3}' | jq -r '.name'

输出:

api

-r 只改变输出编码,不改变 jq 内部的数据类型。

2.2 数组访问和管道

输入:

{
  "services": [
    {"name": "api", "replicas": 3},
    {"name": "worker", "replicas": 5}
  ]
}

取得所有服务名:

jq -r '.services[].name' services.json

处理过程可以逐步理解为:

  1. .services:得到数组;
  2. .services[]:对数组中的每个元素分别产生一个输出;
  3. .services[].name:从每个元素中取 name

结果是两个独立输出值:

api
worker

jq 管道不是 Shell 管道。下面的表达式:

.services[] | .name

表示把左侧产生的每个值依次交给右侧,而不是启动操作系统进程。

2.3 条件、构造对象和更新

筛选副本数大于 3 的服务:

jq '.services[] | select(.replicas > 3)' services.json

构造新对象:

jq '.services[] | {service: .name, count: .replicas}' services.json

如果输入为:

{"services":[{"name":"api","replicas":3},{"name":"worker","replicas":5}]}

输出为两个 JSON 对象:

{"service":"api","count":3}
{"service":"worker","count":5}

修改当前对象中的字段:

jq '.replicas = .replicas + 1' service.json

这里 .replicas = ... 是结构化更新,不是字符串替换。若字段不存在,通常会创建它:

printf '%s\n' '{}' | jq '.replicas = 1'

输出:

{
  "replicas": 1
}

嵌套更新:

jq '.service.replicas = 4' config.json

数组中按条件更新:

jq '
  .services |= map(
    if .name == "api"
    then .replicas = 4
    else .
    end
  )
' services.json

处理逻辑是:

  1. .services 取出服务数组;
  2. map(...) 逐个处理数组元素;
  3. 名称为 api 的元素更新 replicas
  4. 其他元素由 else . 原样返回;
  5. |= 把新数组写回 .services

2.4 缺失、空值和错误不是一回事

以下三种状态必须区分:

{}

字段不存在;

{"replicas": null}

字段存在但值为空;

{"replicas": 0}

字段存在且值为数字零。

表达式:

jq '.replicas // 1' input.json

使用“空值合并”逻辑:当左侧结果为 nullfalse 时使用 1。它并不只判断字段是否缺失。

如果必须要求字段存在且类型为数字,可以显式验证:

jq -e '
  if (.replicas | type) == "number"
  then .
  else error("replicas must be a number")
  end
' input.json

-e 会让 jq 根据最终输出值设置更有区分度的退出状态:输出 falsenull 时也会被视为失败场景。脚本不能只检查“有没有输出”,还应检查退出码。


三、Shell 与 jq 的边界:引用、变量和注入风险

3.1 不要把 Shell 变量直接拼进过滤器

下面写法有两个问题:

name='api"; .admin = true'
jq ".services[] | select(.name == \"$name\")" config.json

Shell 会先展开变量,变量内容又可能改变 jq 过滤器的语法。即使没有恶意输入,也会因为引号、反斜杠或换行导致解析失败。

应使用 --arg

name='api"; .admin = true'

jq --arg name "$name" '
  .services[]
  | select(.name == $name)
' config.json

--arg name "$name" 将 Shell 字符串作为 JSON 字符串值绑定到 $name,不会把内容重新解释为 jq 程序。

数字参数应使用 --argjson

replicas=4

jq --argjson replicas "$replicas" '
  .replicas = $replicas
' service.json

--arg replicas "$replicas" 得到的是字符串 "4"--argjson 得到的是数字 4。二者类型不同:

printf '%s\n' '{}' | jq --arg x 4 '.x = $x'
# {"x":"4"}

printf '%s\n' '{}' | jq --argjson x 4 '.x = $x'
# {"x":4}

--argjson 的参数必须是合法 JSON。若变量为空或内容不是 JSON,命令会失败,因此外部输入需要先验证。

3.2 Shell 命令替换会丢失边界

下面代码试图读取多个服务名:

for name in $(jq -r '.services[].name' config.json); do
    echo "$name"
done

命令替换结果会经过 Shell 的词分割和路径名展开,服务名中包含空格、通配符或换行时会被错误拆分。

更安全的方式是使用 NUL 分隔:

while IFS= read -r -d '' name; do
    printf 'service=%s\n' "$name"
done < <(jq -j '.services[] | .name, "\u0000"' config.json)

若程序链路可以接受 JSON,优先让下游继续处理 JSON,而不是把结构化数据降级成空格分隔文本。


四、jq 的输入模式:单文档、JSON Lines、数组和流式解析

4.1 普通模式:一次读取一个 JSON 值

给定:

{"id":1}
{"id":2}

普通 jq 可以逐个读取这两个顶层 JSON 值:

printf '%s\n' '{"id":1}' '{"id":2}' | jq '.id'

输出:

1
2

但是每个输入值仍是独立文档。若要将它们收集成一个数组,可以使用 -s--slurp

printf '%s\n' '{"id":1}' '{"id":2}' | jq -s .

输出:

[
  {
    "id": 1
  },
  {
    "id": 2
  }
]

-s 的代价是必须把所有输入值收集起来。输入量很大时,这会增加内存消耗。

如果只是逐条转换,直接流式处理更合适:

printf '%s\n' '{"id":1}' '{"id":2}' |
  jq -c '{id, doubled: (.id * 2)}'

输出:

{"id":1,"doubled":2}
{"id":2,"doubled":4}

-c 使每个输出对象压缩为一行,适合 JSON Lines。它不是任意 JSON 的安全分隔协议;只有当上游保证“一行一个 JSON 值”时,按行读取才成立。

4.2 --stream:将嵌套 JSON 拆成路径和值

普通 jq 需要构造完整的 JSON 值。--stream 则把叶子值和空数组、空对象表示成路径事件:

printf '%s\n' '{"users":[{"name":"a"},{"name":"b"}]}' |
  jq --stream .

典型输出形态为:

[
  ["users", 0, "name"],
  "a"
]
[
  ["users", 1, "name"],
  "b"
]

每条事件通常是:

[path, value]

其中 path 是数组,表示从根到当前值的路径;value 是对应值。

筛选所有用户名称:

printf '%s\n' '{"users":[{"name":"a"},{"name":"b"}]}' |
  jq --stream '
    select(length == 2 and .[0][-1] == "name") | .[1]
  '

--stream 适合:

  • 只需扫描大量文档中的局部叶子;
  • 想在解析阶段减少完整对象的内存占用;
  • 处理极大的数组或对象。

它不适合直接替代普通更新语法。要对整个对象进行复杂的跨字段变换,通常需要完整上下文;强行使用路径事件会增加状态机复杂度。

还要注意:--stream 的输出是事件序列,不是原始 JSON 的简单“逐行版本”。数组索引、空容器和路径重建都必须按事件语义处理。

4.3 流式处理的内存条件

假设有 NN 条独立 JSON Lines 记录,每条记录平均大小为 SS

  • 逐条执行:理想情况下内存接近 O(S)O(S)
  • jq -s 收集全部记录:内存接近 O(NS)O(NS)
  • 将整个文件先读入 Shell 变量:还会受到 Shell 字符串、命令替换和空字节限制;
  • --stream 处理一个巨大 JSON 文档:解析状态和当前事件规模通常远小于完整树,但复杂聚合仍可能需要自己保存状态。

例如,下面命令并没有真正保持低内存:

jq -s 'map(select(.enabled))' huge.jsonl

它先收集所有记录,再筛选。若只需要逐条筛选:

jq -c 'select(.enabled == true)' huge.jsonl

如果要统计总数,reduce 可以逐条累加,但状态本身必须保存在内存中:

jq -s 'map(select(.enabled == true)) | length' huge.jsonl

对于 JSON Lines,若只需计数,也可以用外部工具:

jq -c 'select(.enabled == true)' huge.jsonl | wc -l

不过这要求每个输入值恰好产生一行输出,且不能使用多行格式化输出。


五、yq 的核心模型:YAML 解析、节点保留和格式转换

5.1 用 yq 处理 YAML

假设 deployment.yaml 为:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: web
spec:
  replicas: 2
  template:
    spec:
      containers:
        - name: web
          image: example/web:1.0

读取字段:

yq '.spec.replicas' deployment.yaml

输出:

2

读取镜像:

yq -r '.spec.template.spec.containers[0].image' deployment.yaml

对于 Mike Farah yq-r 可用于原始字符串输出;具体选项应以已安装版本的 yq --help 为准,因为不同实现的参数并不完全相同。

更新副本数:

yq '.spec.replicas = 3' deployment.yaml

默认输出修改后的内容,但不会自动覆盖原文件。可以显式重定向:

yq '.spec.replicas = 3' deployment.yaml > deployment.new.yaml

或者使用该实现提供的原地选项:

yq -i '.spec.replicas = 3' deployment.yaml

-i 方便,但不是事务机制。进程在写入途中崩溃、磁盘满、权限异常或被并发进程覆盖时,原文件可能处于不完整或非预期状态。因此生产更新不应把 -i 等同于安全写入。

5.2 YAML 路径与数组更新

给容器镜像统一加标签:

yq '
  .spec.template.spec.containers[] .image
' deployment.yaml

上面的空格容易造成表达式阅读困难,更明确的写法是:

yq '
  .spec.template.spec.containers[].image
' deployment.yaml

只更新名称为 web 的容器:

yq '
  .spec.template.spec.containers |= map(
    if .name == "web"
    then .image = "example/web:1.1"
    else .
    end
  )
' deployment.yaml

这与 jq 中数组 map 的思路相同:先得到数组,再对元素逐个变换,最后写回数组。

5.3 yq 与 JSON 的互操作

YAML 可以转换为 JSON:

yq -o=json '.' deployment.yaml | jq .

JSON 也可以转成 YAML:

jq '.' config.json | yq -P -o=yaml

更直接地:

yq -p=json -o=yaml '.' config.json

参数名称和组合在不同 yq 实现中可能不同,应先确认:

yq --version
yq --help

转换时需要明确一个事实:这是“解析后重新序列化”,不是保留原始字节。以下内容可能变化:

  • 缩进;
  • 引号;
  • 键顺序;
  • 注释;
  • 空行;
  • YAML 锚点和别名表示;
  • 某些标量的类型或格式。

如果代码评审依赖稳定 diff,应固定工具版本,并接受格式化输出的规范化结果。

5.4 多文档 YAML

YAML 可以在一个文件中放置多个文档:

---
kind: ConfigMap
metadata:
  name: a
---
kind: ConfigMap
metadata:
  name: b

普通 yq 操作通常会按文档处理。需要跨文档聚合时,应显式使用对应的 eval-all 或合并表达式;不要假设“文件里有多个 ---”就等价于一个数组。

跨文档处理的难点在于:每个文档都是独立根值,当前文档、文件索引和文档顺序都可能影响表达式。对于 Kubernetes 清单等场景,先明确目标是:

  1. 修改每个文档;
  2. 选择某一类文档;
  3. 把全部文档收集成数组;
  4. 跨文档合并同名对象。

这四种操作的语义不同,不能只依靠一条模糊的合并命令。


六、为什么 grepsedawk 不能代替结构化解析

传统文本工具仍然非常有用:

  • grep:筛选行或匹配文本;
  • sed:按规则替换文本;
  • awk:按记录和字段进行计算;
  • cut:截取分隔字段;
  • sortuniq:排序和去重;
  • 正则:描述局部文本模式。

但是 JSON 和 YAML 的结构边界不由固定行决定。例如:

{"description":"the string contains } and \"quotes\"","enabled":true}

正则无法可靠判断字符串中的 } 是数据还是对象结束符。嵌套数组、转义字符、Unicode、YAML 多行字符串和注释会进一步破坏“按行猜结构”的假设。

反例:

sed -i 's/replicas: 2/replicas: 3/' deployment.yaml

它只在以下条件同时成立时才可能工作:

  • 字段正好叫 replicas
  • 值正好是 2
  • 该字段独占一行;
  • 没有重复字段;
  • 没有注释或特殊缩进;
  • 不需要验证 YAML 结构;
  • 不存在同名的其他字段。

若文件改成:

spec:
  replicas: 2

仍可能替换;但若出现:

metadata:
  replicas: 2
spec:
  replicas: 2

它会修改两个位置,通常已经违背意图。结构化工具表达的是路径:

yq '.spec.replicas = 3' deployment.yaml

这才明确限定了目标。

反过来,若任务确实是修改注释、保留特殊排版、替换固定模板中的一段原文,文本工具可能比结构化工具合适。选择依据不是“哪个工具更高级”,而是目标是字节布局还是数据结构。


七、验证:语法正确不等于业务正确

结构化工具通常会完成语法解析,但不会自动知道业务约束。例如:

spec:
  replicas: -10

这是可能合法的 YAML,但未必是合法部署配置。

7.1 语法验证

验证 JSON:

jq empty config.json

若 JSON 合法,通常没有标准输出并以零退出;若语法错误,会输出诊断信息并返回非零。

验证 YAML:

yq '.' config.yaml > /dev/null

这只验证 yq 能否解析,不验证 Kubernetes、应用程序或自定义协议的约束。

7.2 类型和范围验证

验证 JSON 中的 replicas

jq -e '
  (.spec.replicas | type) == "number"
  and (.spec.replicas | floor) == .spec.replicas
  and .spec.replicas >= 0
' config.json

这个表达式要求:

  1. 字段类型为数字;
  2. 数字是整数;
  3. 数值不小于零。

若还要限制上限:

jq -e '
  (.spec.replicas | type) == "number"
  and (.spec.replicas | floor) == .spec.replicas
  and .spec.replicas >= 0
  and .spec.replicas <= 100
' config.json

验证数组非空:

jq -e '
  (.spec.template.spec.containers | type) == "array"
  and (.spec.template.spec.containers | length) > 0
' deployment.json

验证字段存在但允许值为 null,不能简单使用 //。可以用 has

jq -e '.spec | has("replicas")' config.json

7.3 先变换,再验证,再提交

安全更新的逻辑应是:

新文件={T(旧文件)若解析和业务验证均成功旧文件否则\text{新文件} = \begin{cases} T(\text{旧文件}) & \text{若解析和业务验证均成功}\\ \text{旧文件} & \text{否则} \end{cases}

其中 TT 是结构化变换。关键点是:验证对象必须是变换后的完整结果,而不是只验证一个单独字段。

例如:

set -euo pipefail

tmp=$(mktemp)
trap 'rm -f "$tmp"' EXIT

jq '
  .spec.replicas = (.spec.replicas + 1)
' config.json >"$tmp"

jq -e '
  (.spec.replicas | type) == "number"
  and (.spec.replicas | floor) == .spec.replicas
  and .spec.replicas >= 0
  and .spec.replicas <= 100
' "$tmp" >/dev/null

jq -e empty "$tmp" >/dev/null

只有所有步骤成功,才应替换原文件。set -e 不是完整错误处理机制,但配合显式检查可以避免忽略关键失败。


八、安全更新:临时文件、原子替换和权限

8.1 为什么不能直接覆盖原文件

命令:

jq '.version = "2"' config.json > config.json

存在明确错误:Shell 会先打开并截断 config.json,然后 jq 才开始读取输入。结果通常是输入变成空文件。

下面命令避免了同一个文件作为输入和输出:

jq '.version = "2"' config.json > config.new.json

但它仍不是安全提交:

  • 目标文件写到一半时进程可能退出;
  • 磁盘可能写满;
  • 新文件权限可能与旧文件不同;
  • 多个进程可能同时生成并覆盖结果;
  • 失败时可能留下误导性的半成品。

8.2 同目录临时文件和 mv

在同一个文件系统中,使用临时文件写入完整内容,再用 mv 替换:

set -euo pipefail

file=/etc/myapp/config.json
dir=$(dirname -- "$file")
tmp=$(mktemp "$dir/.config.json.XXXXXX")
trap 'rm -f -- "$tmp"' EXIT

jq '.version = "2"' "$file" >"$tmp"
jq -e empty "$tmp" >/dev/null

chmod --reference="$file" "$tmp"
chown --reference="$file" "$tmp"

mv -f -- "$tmp" "$file"
trap - EXIT

每一步的作用:

  1. mktemp "$dir/...":临时文件位于目标文件同一目录,避免跨文件系统;
  2. 重定向到临时文件:原文件在变换期间保持不变;
  3. jq -e empty:确认生成物仍是合法 JSON;
  4. chmod --referencechown --reference:尽量保留权限和所有者;
  5. mv:同一文件系统内通常是原子的目录项替换;
  6. trap:失败退出时删除临时文件。

这里的“原子”主要指观察者不会看到一个只写了一半的新目录项:要么看到旧文件,要么看到新文件。它不自动保证以下性质:

  • 新数据已经落盘;
  • 多个进程没有互相覆盖;
  • 应用程序已经重新加载新配置;
  • 更新和相关外部操作是一个事务。

8.3 fsync、目录持久化和断电边界

系统调用层面的 renamemv 原子性,不等同于断电后的持久性。若要求强持久性,通常还需要:

  1. 将临时文件内容写入;
  2. 对临时文件执行 fsync
  3. rename 替换目标;
  4. 对父目录执行 fsync

GNU/Linux 用户空间常见命令不一定直接提供完整的文件和目录同步流程。可以使用专门程序、Python/Perl 系统调用,或让应用程序自身完成持久化协议。普通配置脚本若未明确要求抗断电一致性,不应声称简单 mv 已经提供该保证。

8.4 文件权限和敏感信息

mktemp 通常创建仅所有者可读写的临时文件,但最终权限仍应显式确认。尤其是:

  • 原文件是 0600,新文件不能意外变成 0644
  • 临时文件位于受控目录;
  • 不要把带密码的 JSON 通过命令行参数传递,因为参数可能出现在进程列表;
  • 不要把秘密打印到日志;
  • Shell 日志、CI 输出和错误信息都可能泄露配置内容。

jq--arg 可以避免将变量拼进过滤器,但不会自动替你隐藏输出。如果过滤器输出整个对象,秘密仍会出现在标准输出。


九、并发更新:原子替换不等于无丢失更新

考虑两个进程同时读取:

{"replicas":2}

进程 A 计算 replicas = 3,进程 B 计算 replicas = 4。若二者分别写临时文件并依次 mv

  1. A 读取旧值 2,生成 3;
  2. B 读取旧值 2,生成 4;
  3. A 提交 3;
  4. B 提交 4。

最终结果是 4,A 的更新丢失。每次 mv 都可能是原子的,但整个“读—改—写”不是串行化操作。

需要互斥时,可使用 flock

(
  flock -x 9

  file=/var/lib/myapp/state.json
  dir=$(dirname -- "$file")
  tmp=$(mktemp "$dir/.state.json.XXXXXX")
  trap 'rm -f -- "$tmp"' EXIT

  jq '.counter += 1' "$file" >"$tmp"
  jq -e '
    (.counter | type) == "number"
    and .counter >= 0
  ' "$tmp" >/dev/null

  chmod --reference="$file" "$tmp"
  chown --reference="$file" "$tmp"
  mv -f -- "$tmp" "$file"
) 9>/var/lock/myapp-state.lock

这里的锁文件与数据文件分离。需要注意:

  • flock 是 Linux 常见实现,不是所有 Unix 都提供相同命令;
  • 锁只对遵守同一锁协议的进程有效;
  • 如果其他程序绕过锁直接写文件,锁无法阻止它;
  • NFS 等网络文件系统上的锁语义需要单独确认;
  • 锁的权限必须允许实际运行用户打开锁文件。

如果应用本身支持条件更新,比较文件版本或哈希往往更可靠:

  1. 读取旧内容和版本;
  2. 计算新内容;
  3. 提交时确认版本仍未改变;
  4. 若改变则重新读取并重试或报告冲突。

这类似乐观并发控制,适合不希望长时间持锁的场景。


十、配置更新的完整脚本

下面脚本将 JSON 配置中的服务副本数增加 1,并执行类型、范围和语法校验:

#!/usr/bin/env bash
set -Eeuo pipefail

file=${1:?usage: update-replicas FILE}
dir=$(dirname -- "$file")
base=$(basename -- "$file")
lock="$dir/.$base.lock"

(
  flock -x 9

  tmp=$(mktemp "$dir/.$base.tmp.XXXXXX")
  backup=$(mktemp "$dir/.$base.backup.XXXXXX")
  cleanup() {
    rm -f -- "$tmp" "$backup"
  }
  trap cleanup EXIT

  # 先保存旧文件,备份失败时不继续。
  cp --preserve=mode,ownership,timestamps -- "$file" "$backup"

  # 读取、变换并写入临时文件;原文件此时不变。
  jq '
    if (.spec.replicas | type) != "number"
    then error("spec.replicas is not a number")
    else .spec.replicas += 1
    end
  ' "$file" >"$tmp"

  # 对完整结果做业务验证。
  jq -e '
    (.spec.replicas | type) == "number"
    and (.spec.replicas | floor) == .spec.replicas
    and .spec.replicas >= 0
    and .spec.replicas <= 100
  ' "$tmp" >/dev/null

  # 保留旧文件的基本元数据。
  chmod --reference="$file" "$tmp"
  chown --reference="$file" "$tmp"

  # 提交更新。
  mv -f -- "$tmp" "$file"
  trap - EXIT

  printf 'updated: %s\n' "$file"
  printf 'backup:  %s\n' "$backup"
) 9>"$lock"

执行前提:

  • 调用者对配置目录有写权限;
  • 调用者有权执行 chown --reference;普通用户在某些情况下可能没有权限;
  • 目录所在文件系统支持预期的 rename 语义;
  • 所有修改者都使用同一个锁文件。

这个脚本仍有边界:

  • 备份文件名是临时随机名,恢复流程需要记录或由外层系统管理;
  • cp 成功不表示备份已抗断电;
  • 服务进程可能在替换瞬间读取旧配置,也可能在之后读取新配置;
  • 配置加载失败时,脚本无法自动保证应用回滚,除非再实现“重新加载—健康检查—恢复”的流程。

一个简单恢复动作是:

cp --preserve=all -- backup.json config.json

生产环境还应在恢复后重新执行语法验证和应用级健康检查。


十一、管道错误、pipefail 和部分输出

Shell 管道默认返回最后一个命令的退出状态:

producer | jq . | consumer

如果 producer 失败,但 consumer 仍然成功,整个管道可能返回成功。使用:

set -o pipefail

后,只要管道中的命令失败,整体通常会失败。

但流式管道还有一个故障路径:下游提前退出时,上游可能收到 SIGPIPE。例如:

jq -c '.items[]' huge.json | head -n 1

head 读到一行后退出,jq 可能因管道关闭而返回非零。这个非零不一定表示输入 JSON 损坏,而可能表示下游主动停止消费。诊断时要结合退出码、标准错误和业务意图判断。

不要将诊断信息与数据混在一起:

if ! result=$(jq -e '.required' input.json); then
    printf '%s\n' 'invalid input or missing required field' >&2
    exit 1
fi

命令替换适合小结果;大 JSON 不应完整装入 Shell 变量。若需要保存结果,应写入临时文件并检查退出状态。


十二、YAML 的生产风险:重复键、别名和类型歧义

12.1 重复键

YAML 文档可能出现:

replicas: 2
replicas: 5

不同解析器可能:

  • 取最后一个值;
  • 取第一个值;
  • 报错;
  • 保留重复键节点。

如果配置来源不可信,不能假设“最后一个覆盖前一个”是普遍规范保证。应在选定解析器的文档和测试中确认行为,或者使用能检测重复键的验证器。

JSON 也不应依赖重复键。虽然语法层面很多解析器接受:

{"a": 1, "a": 2}

但对象键唯一性和冲突处理在工具之间可能不一致。生成配置时应主动拒绝重复键,而不是利用覆盖行为。

12.2 锚点和别名

YAML 可以写:

defaults: &defaults
  timeout: 30
  retries: 3

service:
  <<: *defaults
  timeout: 60

这不是普通 JSON 的引用。解析器可能在读取时展开合并,也可能保留节点关系。经过 yq 转换和重新输出后,锚点可能被保留、展开或重排。

YAML 锚点还可能造成别名展开数量远大于原始文本,形成资源消耗问题。处理不可信 YAML 时,应限制输入大小、解析时间和别名展开行为,并避免把“不可信 YAML”直接交给具有额外对象构造能力的语言库。

12.3 不可信 YAML 与对象构造

“YAML 是数据”不表示所有 YAML 库都只进行无害数据解析。某些语言生态的 YAML 库历史上支持自定义标签和对象反序列化,可能触发构造器、文件访问或代码执行风险。

命令行 yq 的具体风险取决于实现及其底层库;不能因为命令名相同就推导出统一安全性质。处理外部输入时:

  • 固定 yq 实现和版本;
  • 使用安全加载模式;
  • 限制文件大小和权限;
  • 不把 YAML 字段直接作为 Shell 命令执行;
  • 对输出进行结构和范围验证。

例如,下面代码把配置字段直接作为命令执行,是命令注入:

cmd=$(jq -r '.command' config.json)
bash -c "$cmd"

结构化解析解决了字段定位问题,但不会自动使字段内容可信。


十三、JSON 数字、Shell 数字和精度边界

JSON 语法允许数字,但没有规定所有实现都以任意精度整数保存。jq 的数字处理受实现和版本影响,通常使用双精度浮点语义的部分特征。超过安全整数范围的大整数可能发生精度变化。

例如,JavaScript 的安全整数上限常用:

25312^{53}-1

但这不是 JSON 的通用限制,而是某些运行时数字表示的限制。跨语言传输订单号、雪花 ID、数据库主键等大整数时,通常应将其序列化为字符串:

{"id":"9007199254740993"}

而不是:

{"id":9007199254740993}

Shell 算术本身也有实现和范围边界。不要把结构化数据中的任意大数无条件交给:

$((value + 1))

如果数值精度或范围重要,应在产生数据的一侧定义类型,并在处理链中保持一致。


十四、从文件到发布的状态机

一个可靠的结构化配置更新可以表示为以下状态:

stateDiagram-v2
    [*] --> ReadOld
    ReadOld --> ParseFailed: 解析失败
    ReadOld --> Transform
    Transform --> TransformFailed: jq/yq 非零退出
    Transform --> Validate
    Validate --> Invalid: 语法或业务校验失败
    Validate --> Commit
    Commit --> CommitFailed: 权限、磁盘或并发错误
    Commit --> Reload
    Reload --> HealthCheck
    HealthCheck --> Rollback: 应用未正常加载
    HealthCheck --> Published: 健康检查通过
    Rollback --> Published: 恢复旧文件成功
    ParseFailed --> [*]
    TransformFailed --> [*]
    Invalid --> [*]
    CommitFailed --> [*]
    Published --> [*]

关键路径不是“执行一条 yq -i 命令”,而是:

  1. 读取旧状态;
  2. 解析;
  3. 计算新状态;
  4. 验证新状态;
  5. 提交文件;
  6. 让消费者重新加载;
  7. 检查消费者是否接受;
  8. 必要时恢复。

如果第 4 步成功而第 6 步失败,文件可能是合法 YAML,但应用仍无法启动。语法正确性、业务正确性和运行时可用性是三个不同层次。


十五、完整示例:读取 YAML、用 JSON 逻辑筛选并安全写回

假设 YAML 文件为:

services:
  - name: api
    enabled: true
    replicas: 2
  - name: worker
    enabled: false
    replicas: 5

目标:只将启用服务的副本数加 1,并限制结果不超过 10。

使用 yq 直接变换:

yq '
  .services |= map(
    if .enabled == true
    then .replicas = (.replicas + 1)
    else .
    end
  )
' services.yaml

输出:

services:
  - name: api
    enabled: true
    replicas: 3
  - name: worker
    enabled: false
    replicas: 5

加入验证:

tmp=$(mktemp)
trap 'rm -f "$tmp"' EXIT

yq '
  .services |= map(
    if .enabled == true
    then .replicas = (.replicas + 1)
    else .
    end
  )
' services.yaml >"$tmp"

yq -e '
  (.services | type) == "!!seq"
  and all(.services[];
    (.replicas | type) == "!!int"
    and .replicas >= 0
    and .replicas <= 10
  )
' "$tmp" >/dev/null

这里必须注意 yq 的类型表达式和版本实现差异。某些版本会使用 YAML 节点类型名称,例如 !!int;若要减少版本敏感性,也可以先转换为 JSON,再使用 jq 做严格验证:

yq -o=json '.' "$tmp" |
  jq -e '
    (.services | type) == "array"
    and all(.services[];
      (.replicas | type) == "number"
      and (.replicas | floor) == .replicas
      and .replicas >= 0
      and .replicas <= 10
    )
  ' >/dev/null

这段流程体现了工具分工:

  • yq 负责 YAML 输入和 YAML 输出;
  • jq 负责严格的 JSON 类型与数值逻辑;
  • 临时文件负责让验证针对完整结果;
  • 最终还需要安全提交,而不是直接覆盖原文件。

十六、测试:Fixture、ShellCheck、Bats 和故障注入

结构化处理脚本的错误往往不在主表达式,而在边界条件:

  • 字段缺失;
  • 字段类型错误;
  • 数组为空;
  • 文件不可读;
  • 目标目录不可写;
  • 输入包含引号、换行和 Unicode;
  • 两个进程同时更新;
  • 磁盘空间不足;
  • 下游提前退出。

可以使用 Fixture,即固定测试输入文件:

tests/fixtures/config.json
tests/fixtures/missing-replicas.json
tests/fixtures/wrong-type.json
tests/fixtures/empty-services.json

一个 Bats 测试示例:

@test "increments replicas" {
  run ./update-replicas tests/fixtures/config.json
  [ "$status" -eq 0 ]
  run jq -e '.spec.replicas == 3' tests/fixtures/config.json
  [ "$status" -eq 0 ]
}

测试脚本还应覆盖失败路径:

@test "rejects wrong type" {
  cp tests/fixtures/wrong-type.json "$BATS_TEST_TMPDIR/config.json"

  run ./update-replicas "$BATS_TEST_TMPDIR/config.json"

  [ "$status" -ne 0 ]
}

ShellCheck 可检查未引用变量、错误的命令替换、潜在的词分割等问题:

shellcheck update-replicas

故障注入的目标不是制造随机混乱,而是验证状态不变量。例如:

  1. 让临时目录不可写;
  2. 让输入文件不可读;
  3. 让校验命令返回失败;
  4. 在提交前终止进程;
  5. 同时启动两个更新进程。

每种故障都应验证:

  • 原文件是否仍可解析;
  • 原文件是否被部分覆盖;
  • 临时文件是否按预期清理;
  • 退出码是否非零;
  • 是否产生足够的诊断信息;
  • 并发更新是否丢失。

十七、常见误解和对应诊断

误解一:jq 输出为空就是成功

过滤器可能合法但产生零个输出:

jq '.items[] | select(.status == "ready")' input.json

没有 ready 项时可能没有输出,但这不一定是错误。若业务要求至少一个结果,应显式检查:

count=$(
  jq '[.items[] | select(.status == "ready")] | length' input.json
)

if (( count == 0 )); then
    printf '%s\n' 'no ready item' >&2
    exit 1
fi

误解二:-r 可以安全地产生 Shell 参数

jq -r 只去掉 JSON 字符串的引号。它不会为 Shell 转义空格、换行、通配符或命令替换语法。输出若要进入 Shell,应使用 NUL 分隔、数组传参或保持结构化格式。

误解三:yq -i 提供事务性

-i 只是原地更新选项。它不等于:

  • 先验证后提交;
  • 原子替换;
  • 自动备份;
  • 并发锁;
  • 断电持久化;
  • 应用健康检查。

生产配置修改需要自行组合这些步骤。

误解四:YAML 转 JSON 后一定能无损转回

转换会重新解析和序列化。注释、锚点、引号风格和排版可能丢失。若要求保留人工格式,必须使用专门支持注释和节点保真的工具,并用测试确认其行为;不能仅凭文件扩展名推断保真程度。

误解五:先用 grep 找字段,再用 jq 修改

这种流程把结构化定位和文本定位混合,容易产生不一致。若要确认字段是否存在,应让 jqyq 完成判断:

jq -e 'has("service") and (.service | has("name"))' config.json

grep 适合快速人工排查日志,不应承担 JSON/YAML 语义验证。


十八、如何选择处理方式

可以按数据边界选择工具:

任务 适合方式
检查 JSON 语法 jq empty
提取 JSON 字段 jq -r
修改 JSON 路径 jq 更新表达式
逐条处理 JSON Lines 不使用 -s,按记录处理
扫描巨大 JSON 的叶子值 jq --stream
读取或修改 YAML 与实现匹配的 yq
YAML 转 JSON 后做严格验证 yq -o=json 配合 jq -e
修改注释或固定排版 文本工具或支持保真的专用编辑器
配置文件安全提交 临时文件、验证、同目录 mv
多进程读改写 flock 或版本冲突检测
不可信输入 限制大小、固定版本、禁用危险加载能力、严格校验

一个实用的判断问题是:你要保持的是原始字节,还是数据语义?

  • 保持原始字节:结构化工具往往会重排内容,不一定适合;
  • 保持数据语义:结构化解析和重新序列化通常更可靠;
  • 两者都要:需要支持格式保真的 AST 工具,并接受更复杂的测试和版本管理。

结构化数据处理的关键不是记住某一条 jqyq 命令,而是明确数据模型、输入边界、输出协议和提交状态。解析负责识别结构,过滤器负责计算结果,验证负责约束语义,临时文件和锁负责控制写入风险,健康检查则负责确认消费者真正接受了新状态。只有这些环节的因果关系完整,命令行自动化才不会停留在“看起来能工作”的文本拼接。


系列导航与关联阅读

官方资料

本文依据 Linux 内核、systemd 与主流发行版官方文档重新梳理;正文与实验由 WR BLOG 编写。