我用 jq 的频率几乎比任何其他工具都高。每一个 API 响应、每一个 Kubernetes
对象、每一行 journalctl -o json,最后都会经过它。可每隔几周,我总会在同样几个
地方卡住:怎么让它是 slurp 而不是流式?为什么明明有值,// 默认值却生效了?为
什么 jq 什么都不打印?
这就是我想要的那份 jq 教程:一本我真正会用的 jq 命令手边书,每条命令都在小样本 文件上跑过,输出是真实的、直接贴进来的。不用翻 man page,不用猜。下面所有内容都 在 Linux 上用 jq 1.8.2 生成。
我假设你已经能敲 jq 了——用包管理器装上(apt install jq、brew install jq),再跑 jq --version 确认一下。这篇文章的重点不是安装,而是那百分之二十的
语法,它能覆盖百分之九十五的实际工作:从 API 响应里取字段、重塑结构、再喂给管道
里的下一个命令。
全文只用两个小文件。app.json:
{"name":"billing","version":"2.3.1","replicas":3,"env":{"REGION":"eu-west-1","DEBUG":false},"owners":["ana","bo"],"limits":{"cpu":"500m"}}
以及 users.json,一个记录数组:
[
{"id":1,"name":"ana","role":"admin","active":true,"logins":42},
{"id":2,"name":"bo","role":"dev","active":false,"logins":7},
{"id":3,"name":"cy","role":"dev","active":true,"logins":19},
{"id":4,"name":"dee","role":"ops","active":true,"logins":7},
{"id":5,"name":"eve","role":"dev","active":true}
]
美化输出与紧凑输出
这两个模式你会一直用到。默认的 . 会按两空格缩进重新排版,适合给人看、或者拿来
diff 两份文档。-c 则每个值输出一行,适合继续往 grep、wc 或别的程序里灌。
早点养成用 -c 的习惯能省掉很多困惑,因为下面大多数小节都输出紧凑格式,正是为了
让结果在终端里保持可读。
jq '.' app.json
{
"name": "billing",
"version": "2.3.1",
"replicas": 3,
"env": {
"REGION": "eu-west-1",
"DEBUG": false
},
"owners": [
"ana",
"bo"
],
"limits": {
"cpu": "500m"
}
}
jq -c '.' app.json
{"name":"billing","version":"2.3.1","replicas":3,"env":{"REGION":"eu-west-1","DEBUG":false},"owners":["ana","bo"],"limits":{"cpu":"500m"}}
选取:.、.a.b,以及用 .[] 遍历
.field 走一层,.a.b 可以链式深入;开头的 .[] 会把数组或对象展开成一个流,
每个值一行。
jq '.env' app.json
{
"REGION": "eu-west-1",
"DEBUG": false
}
jq '.owners[]' app.json
"ana"
"bo"
加上 -r 引号就消失了——这是这个工具里最有用的一个开关:
jq -r '.env.REGION' app.json
eu-west-1
键不存在不会报错,而是返回 null。这算优点,直到你不小心依赖了它:
jq '.limits.memory' app.json
null
用 select 过滤
select(expr) 只保留 expr 为真的输入,其余全部变成空(也就是完全不输出)。它
最好接在 .[] 或 map 后面,并且能和 and、or、比较运算组合。正是这个构造让
jq 从格式化工具变成查询语言:与其用子串去 grep,不如对结构化值断言一个条件,
拿回你正好想要的那些对象。因为 select 本身就是个滤镜,你可以链好几个,或者接在
map 之后,每一级看到的是上一级产出的流。
jq -r '.[] | select(.role=="dev") | .name' users.json
bo
cy
eve
jq -r '.[] | select(.active and .logins > 10) | .name' users.json
ana
cy
用 map、to_entries、with_entries 做变换
map(f) 对每个元素跑一遍 f 再把结果收成数组——它是 .[] 的数组形态版本。
to_entries 把对象变成 [{key,value}];with_entries(f) 等价于
to_entries | map(f) | from_entries,于是重命名或丢掉键都成了一行,而不用手写循环。
这三者合起来几乎覆盖你需要的所有重塑,因为几乎所有重塑要么是「改每个元素」,要么
是「改每个键」。
jq -c 'map(.name)' users.json
["ana","bo","cy","dee","eve"]
jq -c '.env | to_entries' app.json
[{"key":"REGION","value":"eu-west-1"},{"key":"DEBUG","value":false}]
jq -c '.env | with_entries(.key |= ascii_downcase)' app.json
{"region":"eu-west-1","debug":false}
只留下打开着的开关:
jq -c '.env | with_entries(select(.value == true))' app.json
{}
默认值与可选访问:// 和 ?
a // b 在 a 为 null 或 false 时给出 b。第二种情况最容易坑人,记住:
// 是「假值」默认,不是「非 null」判断。结尾的 ? 会在你试图遍历不可遍历的东西
时把报错吞掉;它把「这条路径不存在」从硬失败变成空流,这对一个必须容忍缺字段的
管道来说正是想要的。
jq '.limits.memory // "unset"' app.json
"unset"
jq -c '.env.DEBUG // "was false"' app.json
"was false"
这里 .users 不存在,于是对 null 做 .[] 会报错——但 ? 能把错误变成空输出:
jq '.users[]' app.json
jq: error (at app.json:1): Cannot iterate over null (null)
jq '.users[]?' app.json
构造对象与数组
构造输出的方式和你写 JSON 一样,只是字段可以是任意表达式。用 [...] 把流包起来
就收成数组,{n: .name, l: .logins} 则按你选的键构造对象。这就是从冗长的 API 载
荷变成下游脚本真正需要的那两三个字段的办法,通常比在你熟悉的语言里做同样的重塑还
要短。
jq -c '[.[] | {n: .name, l: .logins}]' users.json
[{"n":"ana","l":42},{"n":"bo","l":7},{"n":"cy","l":19},{"n":"dee","l":7},{"n":"eve","l":null}]
值得记住的 jq 命令开关:-r、-c、-s、—arg、-e
有些管道活儿是滤镜语法干不了的:原始输出、紧凑输出、把流 slurp 成数组、传入参数, 以及把结果变成进程退出码。
-s(slurp)把整个输入当成单个数组读进来,而不是流。这就是把换行分隔的 JSON 变
成一个数组的办法。看 events.ndjson:
{"ts":"10:01","level":"info","svc":"api","msg":"start"}
{"ts":"10:02","level":"error","svc":"api","msg":"db timeout"}
{"ts":"10:03","level":"warn","svc":"web","msg":"slow"}
{"ts":"10:04","level":"error","svc":"web","msg":"500"}
jq -c -s 'length' events.ndjson
4
jq -r -s 'group_by(.level)[] | "\(.[0].level): \(length)"' events.ndjson
error: 2
info: 1
warn: 1
--arg 传字符串,--argjson 把值当 JSON 解析。两者都能让你远离 shell 引号地狱:
jq -c --arg region eu-west-1 '.env.REGION == $region' app.json
true
jq -c --argjson n 2 '.replicas * $n' app.json
6
-e 让结果决定退出码——false/null 为 1,无输出为 4,其余为 0。这才让
jq -e 成为脚本里真正的条件:if jq -e '.ready' status.json > /dev/null; then ...
直接读 JSON 并据此分支,一行解析代码都不用写。4 那种情况最常被忽略,因为「滤镜
什么都没匹配到」通常是个值得捕获的失败。
jq -e '.replicas > 99' app.json; echo "exit=$?"
false
exit=1
jq -e '.nope' app.json; echo "exit=$?"
null
exit=1
分组、排序、unique 与数学运算
group_by 和 sort_by 都会先排序,所以它们能干净地组合,通常不需要再来第二遍。
unique 不需要键,对任何值类型都管用。add、length、max、min 负责算术;
而且因为 add 把 null 当作单位元,对一个有时缺失的字段求和也能直接跑通。
jq -c 'group_by(.role) | map({role: .[0].role, n: length})' users.json
[{"role":"admin","n":1},{"role":"dev","n":3},{"role":"ops","n":1}]
jq -c 'map(.role) | unique' users.json
["admin","dev","ops"]
jq -c 'sort_by(.logins // 0) | reverse | map(.name)' users.json
["ana","cy","dee","bo","eve"]
对一个有时缺失的字段求和,null 会自动被忽略,因为 null 是加法的单位元:
jq -c 'map(.logins) | add' users.json
75
用 —stream 流式处理大文件
对一个大到装不进内存的文件,--stream 会在解析的同时吐出 [path, value] 对。常
用技巧是只保留叶子节点(length==2),这样整个文档永远不会被完整装进内存:
jq -c --stream 'select(length==2)' app.json | head -4
[["name"],"billing"]
[["version"],"2.3.1"]
[["replicas"],3]
[["env","REGION"],"eu-west-1"]
这些路径数组正是 getpath 和 setpath 认识的形式,所以一旦你用顺了 --stream,
就能在不把文档周边部分实体化出来的前提下,选中、丢弃或改写某个叶子。对于那个装不
进 RAM 的 10 GB 日志文件,这就是能跑通的命令和被 OOM 杀掉之间的区别。
把载荷变成一张表
快速扫一眼 API 响应最快的办法,就是把它拍平成一张表。@tsv 会替你转义制表符和换
行,所以结果可以安全地交给 awk 或 cut;@csv 同理,只是带正确的引号处理:
jq -r '.[] | [.name, .role] | @tsv' users.json
ana admin
bo dev
cy dev
dee ops
eve dev
「我总是记不住」清单
- 没有
-n就没有输出。 不依赖输入的滤镜照样会去读 stdin。jq -r '"hi"' </dev/null什么都不打印;jq -nr '"hi"'打印hi。 -S对键排序,让 diff 稳定;-a只输出 ASCII;--indent 4和--tab改变缩进。keys_unsorted保留原始顺序;普通的keys会排序。has("k")在不遍历的情况下判断键是否存在。@csv、@tsv、@sh把一个值格式化给别的工具用:jq -r '.[] | [.name, .role] | @tsv' users.json。getpath(["a","b"])读取一条你以数据形式构造出来的路径。splits返回的是流,所以要把它括起来:jq -r -n '["a-b-c" | splits("-")][1]'打印b。try ... catch收敛错误:jq -c 'try (.replicas / 0) catch "boom"' app.json。-s会给输入再套一层数组——把一个本来就是数组的文件喂给-s会得到[[...]],从而让map(.field)失效。max/min会把null算进去,而null排在数字前面,所以只要有记录缺了 该字段,map(.logins) | min就会返回null。
这就是我日常真正在跑的那套循环:select、变换、默认、slurp、分组。想了解更宏观的 「为什么要用结构化数据」,我的结构化日志那篇是 配套读物;而管道本身出问题时,strace 就是我查明原因的方式。