LoginSignup
6
8

More than 1 year has passed since last update.

シェルスクリプトでjqを使わずsedなど基本コマンドのみでJSONを破綻なく解釈

Last updated at Posted at 2021-08-05

概要

シェルスクリプトで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を破綻なく解釈できる方法が確立した。

6
8
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
8