
jq 处理接口返回的 JSON,最麻烦的是语法正确、退出码也正常,结果却不对的那类写法。它们不会报错,只会让日志里出现一个看起来合理的值。下面五个都在本机 jq-1.7.1-apple 上跑过,脚本存档在 tools/verify-jq-pitfalls.sh,输出原样贴出。
反例一:把 // 当空值兜底
echo '{"a":false}' | jq '.a // "default"'
echo '{"a":0}' | jq '.a // "default"'
echo '{"a":""}' | jq '.a // "default"'
echo '{"a":[]}' | jq '.a // "default"'
"default"
0
""
[]
// 的语义是取左边所有既不是 false 也不是 null 的结果,一个都没有才用右边。所以 0、空字符串、空数组都能活下来,唯独 false 被替换掉。字段恰好是布尔开关时,这个写法会把 false 显示成默认值,读日志的人只会以为字段没配。
要区分「字段不存在」和「字段是 false」,用 has 判断:
jq 'if has("a") then .a else "default" end'
反例二:--arg 进来的一定是字符串
jq -n --arg n 3 '$n + 1'
jq -n --arg n 3 'if $n > 2 then "big" else "small" end'
jq -n --argjson n 10 -c '[$n, 9] | sort'
jq -n -c '["9","10"] | sort'
jq: error (at <unknown>): string ("3") and number (1) cannot be added
exit=5
"big"
[9,"10"]
["10","9"]
加法报错还算幸运,至少炸在明处。换成比较和排序就静默出错了:$n 是字符串 "10",跟数字 9 放在一个数组里排序,结果是 [9,"10"],因为 jq 的类型序里数字整体排在字符串前面,跟数值大小无关。两个字符串比大小则是字典序,["9","10"] | sort 得到 ["10","9"],9 排在 10 后面。
传数字进去用 --argjson,或者拿到之后显式 tonumber。
反例三:以为大整数过一趟 jq 就丢精度
echo '{"orderId":12345678901234567890}' | jq -c '.orderId'
echo '{"orderId":12345678901234567890}' | jq -c '.orderId | tostring | length'
echo '{"orderId":12345678901234567890}' | jq -c '.orderId + 0'
echo '{"orderId":9007199254740993}' | jq -c '.orderId + 0'
12345678901234567890
20
12345678901234567000
9007199254740992
这组结果分两种情形,混在一起就容易判断错。只做透传时,20 位订单号原样输出,tostring 出来的字符串长度也还是 20,一个字符都没丢。一旦参与算术,.orderId + 0 就把它交给了 IEEE 754 双精度,末四位被抹成 0;9007199254740993 减一变成 ...992,也就是 2^53 往上不再精确。
所以「用 jq 取订单号」是安全的,「用 jq 比订单号大小」不安全。后一种把两边都当字符串比较,或者交给数据库去做。这一条在本机 jq 1.7.1 上成立,1.7 之前的版本是否保留数字字面量没实测。
反例四:.[] 遇到 null 直接中断
echo '{"items":null}' | jq -c '.items[]'
echo '{"items":null}' | jq -c '.items[]?'
echo '[1,2]' | jq -c '.items[]'
jq: error (at <stdin>:1): Cannot iterate over null (null)
exit=5
exit=0
jq: error (at <stdin>:1): Cannot index array with string "items"
exit=5
上游字段是 null 时整条管道直接失败,退出码 5,在 set -e 的脚本里就是整段中断。加上 ? 之后两个场景都安静退出,包括那个把数组当对象用的错误。? 的作用范围是整条表达式,它不区分「值本身是 null」和「结构不符合预期」,后者被吞掉之后,问题会推迟到别处才暴露。
折中的写法是先看一眼类型再遍历:
jq -c '.items | if type == "array" then .[] else empty end'
反例五:-e 的退出码把脚本打断
jq -e '.a == true' < <(echo '{}') ; echo "exit=$?"
jq -e '.a == true' < <(echo '{"a":true}') ; echo "exit=$?"
jq -e '.a == true' < <(echo '{"a":false}') ; echo "exit=$?"
jq -e 'map(select(. > 5))' < <(echo '[1,2]'); echo "exit=$?"
jq -e '.a == true' <- {} exit=1
jq -e '.missing' <- {} exit=1
jq -e '.a == true' <- {"a":true} exit=0
jq -e '.a == true' <- {"a":false} exit=1
jq -e 'map(select(. > 5))' <- [1,2](结果为空数组) exit=0
jq -e '.' <- null exit=1
-e 的规则是:最后一个输出是 false 或 null 就返回 1。字段缺失时 .a 是 null,null == true 是 false,退出码 1。在 set -e 的部署脚本里,一行 jq -e '.enabled == true' config.json 就能让脚本静默结束,日志里什么都没有。
还有一处和直觉相反:map(select(. > 5)) 在 [1,2] 上的结果是空数组,空数组既不是 false 也不是 null,退出码 0。也就是说「筛完没有一条命中」和「成功了」在退出码上无法区分。脚本里要拿 jq 做条件判断,别依赖 -e,把结果读出来自己比:
enabled=$(jq -r 'if has("enabled") then (.enabled | tostring) else "false" end' config.json)
if [ "$enabled" = "true" ]; then
echo "开关是开着的"
fi
这些反例背后的同一件事
五个反例落到同一个动作上:先确认类型和存在性,再做取值。// 只管 false 和 null,--arg 永远给字符串,数值一旦参与运算就按双精度走,[] 碰上非数组就断,-e 只认最后一个输出。这五条各写一行保护代码就能绕开。
什么时候不值得这么写:一次性看接口返回、结果只在屏幕上过一眼,随手 jq . 就够了。要进脚本、要被别人按时执行的过滤器,值得把类型判断补齐,因为这类错误的表现形式都是「跑完了,没报错,值不对」。我倾向于把超过十行的 jq 过滤器挪回脚本语言里,jq 擅长的是抽取和改形,不是流程控制。