概要
シェルスクリプトでjq
コマンドなど使わず、sed
などの基本コマンドのみでJSONを破綻なく解釈したい。
破綻なくというのはこの記事では以下を指す。
- 文字列に記号が入っていても問題ない
- 配列、オブジェクトのネスト構造を、ちゃんとネスト構造として解釈する
何ができるようになるか
最初に約30行の処理でJSONを変形することで、以降の処理で、主にsed
を使いJSONを破綻なく解釈できるようになる。
例として、以下のJSONを扱う。
{
"startAt": 0,
"total": 8,
"issues": [
{
"id": "100",
"fields": {
"summary": "test100",
"description": "test test test test",
"iconUrl": "http://localhost:8000/images/icons/test.gif"
}
},
{
"id": "101",
"fields": {
"summary": "test101",
"description": "test \":[]{} test",
"iconUrl": "http://localhost:8000/images/icons/test.gif"
}
}
]
}
issues[1].fields.description
の値を取得したい場合、
root_val=$(cat | normalize_and_encode)
root=$(printf %b "$root_val" | tr -d '{}' | sed 's/,/\n/g')
issues_val=$(printf %s "$root" | sed -n 's/^"issues"://p')
issues=$(printf %b "$issues_val" | tr -d '[]' | sed 's/,/\n/g')
issue_val=$(printf %s "$issues" | sed -n 2p)
issue=$(printf %b "$issue_val" | tr -d '{}' | sed 's/,/\n/g')
fields_val=$(printf %s "$issue" | sed -n 's/^"fields"://p')
fields=$(printf %b "$fields_val" | tr -d '{}' | sed 's/,/\n/g')
description_val=$(printf %s "$fields" | sed -n 's/^"description"://p')
description=$(printf %b "$(printf %s "$description_val" | sed 's/"//g')")
printf 'description: `%s`\n' "$description"
で、
description: `test ":[]{} test`
と期待する結果が得られるようになる。
上記のように記号が文字列に含まれていても破綻なく扱える。
アイデア
- 最もネストが深い配列・オブジェクトをエンコードし、それらの表現に
[]{},
の記号が含まれないようにする - 上記を全体で
[]{},
が含まれなくなるまで繰り返す - この文字列にデコードを1回行うと、最も浅い配列・オブジェクトのみが
[]{},
で解釈できるようになる - 上記により、最も浅い配列・オブジェクトを簡単にUNIXが扱いやすい1要素1行の形式に整形できるようになる
- 子の配列・オブジェクトを扱いたい場合は、その部分のみ取り出すことで、上記の処理を再帰的に適用できる
- エンコードとデコードには、エスケープとエスケープ解釈を活用する
必要な実装
必要な実装は、最初のJSON変換のみで、その全実装は以下。
normalize_and_encode() (
json=$(cat \
| sed 's/\\"/\\042/g' \
| sed 's/"[^"]*"/\n&\n/g' \
| sed '/^"/!s/ //g' \
| sed '/^"/{
s/,/\\054/g
s/\[/\\133/g
s/\]/\\135/g
s/{/\\173/g
s/}/\\175/g
}' \
| tr -d '\n'
)
while printf %s "$json" | grep '[[{]' > /dev/null; do
json=$(printf %s "$json" \
| sed 's/[[{][^][}{]*[]}]/\n&\n/g' \
| sed '/^[[{].*[]}]$/{
s/\\/\\\\/g
s/,/\\054/g
s/\[/\\133/g
s/\]/\\135/g
s/{/\\173/g
s/}/\\175/g
}' \
| tr -d '\n'
)
done
printf %s "$json"
)
処理の説明
前半:JSONの正規化
json=$(cat \
| sed 's/\\"/\\042/g' \
| sed 's/"[^"]*"/\n&\n/g' \
| sed '/^"/!s/ //g' \
| sed '/^"/{
s/,/\\054/g
s/\[/\\133/g
s/\]/\\135/g
s/{/\\173/g
s/}/\\175/g
}' \
| tr -d '\n'
)
JSONを今回の処理に都合良いように正規化する。
主に文字列にJSONの配列・オブジェクト構造を表す文字が入らないよう、",[]{}
を\NNN
のエスケープ表現に変更している。
\NNN
のエスケープ表現はJSON仕様には含まれないので、実際には正規化の範囲を超えている。
\uHHHH
であれば、JSON仕様に合致するのだが、
POSIXのprintf
のエスケープ解釈の仕様には\NNN
は含まれているが、\uHHHH
は含まれない。
また、GNU coreutilsのprintf
の\uHHHH
は、今回必要な",[]{}
に対応する表現はinvalidになってしまう。
Re: /usr/bin/printf: invalid universal character name
上記のように、\uHHHH
では、エスケープ解釈に支障あるため、仕方なく\NNN
のエスケープ表現にしている。
以下、順番に説明していく。
json=$(cat \
| sed 's/\\"/\\042/g' \
↑文字列中の\"
を\042
に置換していく。
これで、"
はJSON全体で文字列を囲うことにしか使われなくなることが保証される。
| sed 's/"[^"]*"/\n&\n/g' \
↑文字列が文字列のみで1行となるように改行を入れる。
| sed '/^"/!s/ //g' \
↑"
で始まっていない行、つまり文字列でない行では、スペースを削除する。
| sed '/^"/{
s/,/\\054/g
s/\[/\\133/g
s/\]/\\135/g
s/{/\\173/g
s/}/\\175/g
}' \
↑"
で始まる行、つまり文字列の行では、JSON構造に使われる文字,[]{}
、を\NNN
でのエスケープ表現にする。
これで、文字,[]{}
は、JSON全体でJSON構造の表現にしか使われないことが保証できる。
後述の処理で:
を単独で判定することはないため、:
を対象にする必要はない。
| tr -d '\n'
↑処理都合で追加していた改行と、元からあった不要な改行を削除する。
(JSONでは文字列中には制御文字は使えないため、文字列中に改行はないはず)
この変換で例のJSONは、
{"startAt":0,"total":8,"issues":[{"id":"100","fields":{"summary":"test100","description":"test test test test","iconUrl":"http://localhost:8000/images/icons/test.gif"}},{"id":"101","fields":{"summary":"test101","description":"test \042:\133\135\173\175 test","iconUrl":"http://localhost:8000/images/icons/test.gif"}}]}
となる。
文字列中の記号が、
"description":"test \042:\133\135\173\175 test"
と\NNN
のエスケープ表現に変換されている点が注目すべき点だ。
後半:再帰的にエンコード
ネストが最も深い配列・オブジェクトをエスケープし、適用対象がなくなるまで繰り返す。
while printf %s "$json" | grep '[[{]' > /dev/null; do
json=$(printf %s "$json" \
| sed 's/[[{][^][}{]*[]}]/\n&\n/g' \
| sed '/^[[{].*[]}]$/{
s/\\/\\\\/g
s/,/\\054/g
s/\[/\\133/g
s/\]/\\135/g
s/{/\\173/g
s/}/\\175/g
}' \
| tr -d '\n'
)
done
printf %s "$json"
順番に解説していく。
while printf %s "$json" | grep '[[{]' > /dev/null; do
↑$json
に[
か{
が含まれなくなるまで、後続のエスケープ処理を繰り返す。
以降もそうだが、POSIXではecho
はエスケープ解釈するかどうかが明確ではないので、
エスケープ解釈しないことを保証したい文字列出力ではprintf %s
を使っていく。
json=$(printf %s "$json" \
| sed 's/[[{][^][}{]*[]}]/\n&\n/g' \
↑ネストが最も深い配列・オブジェクト、つまりネストしていない配列・オブジェクトが単体の行となるよう分割する。
正規表現が少し分かりづらいが、
[
か{
で始まり、][}{
を含まない部分を読み進め、]
か}
で終わる、と判定することで、上記を実現している。
| sed '/^[[{].*[]}]$/{
s/\\/\\\\/g
s/,/\\054/g
s/\[/\\133/g
s/\]/\\135/g
s/{/\\173/g
s/}/\\175/g
}' \
↑上記で単体の行になった、ネストしていない配列・オブジェクトに対して、,[]{}
を\NNN
でのエスケープ表現に変更する。
直前の加工後なため、[
か{
で始まり、]
か}
で終わるという判定に簡略化できている。
これによって、これらには,[]{}
が含まれなくなる。
また、\
自体をエスケープすることにより、多重にエスケープできるようにしている。
| tr -d '\n'
)
done
↑処理都合で挿入していた改行を削除する。
そして、ここまでの結果が$json
に再代入される。
そして最初の判定に戻り、再帰的にエスケープ処理が繰り返される。
printf %s "$json"
↑ループが終わったら結果を出力する。
この処理で、例のJSONは、
\173"startAt":0\054"total":8\054"issues":\\133\\\\173"id":"100"\\\\054"fields":\\\\\\\\173"summary":"test100"\\\\\\\\054"description":"test test test test"\\\\\\\\054"iconUrl":"http://localhost:8000/images/icons/test.gif"\\\\\\\\175\\\\175\\054\\\\173"id":"101"\\\\054"fields":\\\\\\\\173"summary":"test101"\\\\\\\\054"description":"test \\\\\\\\\\\\\\\\042:\\\\\\\\\\\\\\\\133\\\\\\\\\\\\\\\\135\\\\\\\\\\\\\\\\173\\\\\\\\\\\\\\\\175 test"\\\\\\\\054"iconUrl":"http://localhost:8000/images/icons/test.gif"\\\\\\\\175\\\\175\\135\175
のようになる。
ネストが深い配列・オブジェクトはネストの数だけ多重にエスケープされている。
\
は、エスケープの度に2倍に増えていく。
変形したJSONの扱い方
normalize_and_encode
で変形したJSONの扱い方を説明していく
値をオブジェクトとして解釈し1要素1行の文字列にする
object=$(printf %b "$value" | tr -d '{}' | sed 's/,/\n/g')
順番に説明していく。
printf %b "$value"
↑エスケープを解釈している。Bashではecho -e
でもよいが、POSIXに準拠すると上記になる。
これで、最も浅いオブジェクトのみ{,,,}
の構造が表れる。
| tr -d '{}'
↑エスケープ解釈で文字になった最も浅いオブジェクトの{
と}
を削除する。
最初と最後の文字の削除になるはず。
| sed -z 's/,/\n/g'
↑エスケープ解釈で文字になった最も浅いオブジェクトの,
を改行に変換する。
これで、最も浅いオブジェクトが1要素1行となりUNIXコマンドで扱いやすい形式になる。
例のJSONに1回適用すると、
"startAt":0
"total":8
"issues":\133\\173"id":"100"\\054"fields":\\\\173"summary":"test100"\\\\054"description":"test test test test"\\\\054"iconUrl":"http://localhost:8000/images/icons/test.gif"\\\\175\\175\054\\173"id":"101"\\054"fields":\\\\173"summary":"test101"\\\\054"description":"test \\\\\\\\042:\\\\\\\\133\\\\\\\\135\\\\\\\\173\\\\\\\\175 test"\\\\054"iconUrl":"http://localhost:8000/images/icons/test.gif"\\\\175\\175\135
となる。
1階層目のオブジェクトが1要素1行になっているのが分かる。3要素のオブジェクトなので3行になっている。
また、2階層以降の配列・オブジェクトはエスケープされていて、[]{},
を含まないことが分かる。
オブジェクトのキーに対応する値を取り出す
キー名がname
の値を取り出す場合、以下になる。
value=$(printf %s "$object" | sed -n 's/^"name"://p')
"name":
で始まる行で"name":
を削除しつつ、その行を出力している。
これで値部分のみを取り出すことができる。
この値がオブジェクトの場合は、「値をオブジェクトとして解釈し1要素1行の文字列にする」を再度適用することができる。
配列の場合は、「値を配列として解釈し1要素1行の文字列にする」を適用することができる。
値を配列として解釈し1要素1行の文字列にする
array=$(printf %b "$value" | tr -d '[]' | sed 's/,/\n/g')
「値をオブジェクトとして解釈し1要素1行の文字列にする」との違いは、
tr
での{}
が[]
になっているのみ。
例のJSONで、ルートオブジェクトのissues
の値に適用すると、
\173"id":"100"\054"fields":\\173"summary":"test100"\\054"description":"test test test test"\\054"iconUrl":"http://localhost:8000/images/icons/test.gif"\\175\175
\173"id":"101"\054"fields":\\173"summary":"test101"\\054"description":"test \\\\042:\\\\133\\\\135\\\\173\\\\175 test"\\054"iconUrl":"http://localhost:8000/images/icons/test.gif"\\175\175
となる。
2要素の配列なので2行になっている。
配列の指定インデックスに対応する値を取り出す
配列の2番目のインデックスの値を取り出す場合、以下になる。
value=$(printf %s "$array" | sed -n 2p)
sed
の基本的な使い方で、指定行を取り出している。
オブジェクトで値を取り出した場合と同様に、取り出した値が、オブジェクト・配列の場合、
「値をオブジェクトとして解釈し1要素1行の文字列にする」、「値を配列として解釈し1要素1行の文字列にする」を適用することができる。
値を文字列として解釈する
string=$(printf %b "$(printf %s "$value" | sed 's/"//g')")
文字列を囲う"
を削除し、その中身をエスケープ解釈している。
前述の処理により、文字列中に"
は含まれていないことが保証されている。
printf %b
が解釈できるエスケープは、JSON文字列のエスケープの仕様と一致していない部分もあるがおおよそうまく動作する。
例えば、\uHHHH
はPOSIX仕様には含まれないが、Bashのビルトインprintf
では解釈できる。
まとめ
シンプルなアイデアにより、シェルスクリプトでsed
などの基本コマンドのみでJSONを破綻なく解釈できる方法が確立した。