jsonの戻り値とか、複雑なオブジェクトからスマートにデータを抽出[JMESPath 最強説]
「パイセン、JSONで帰ってくる複雑な構造のオブジェクトをいい感じにするやつしらないっすか?」
「ああ、複雑な構造ね。しらん、専用の関数用意するとか?」
「やっぱ、そっすよねー」
全国津々浦々でこんな会話が繰り広げられてると思います。私も2回ぐらいこのやり取りをしました。この長年の問題に終止符、JMESPath最強
じゃね?
JMESPathとは
以前、一回書いてます iga-ninja.hatenablog.com
この時私は勘違いしておりました、JMESPathはJSONだけを捌くライブラリではなく、JSONから素直にコンバート出来る各言語のオブジェクト
を捌くことが出来ます。pythonで言うなら list と dictで出来てるものなら JMESPathでクエリ出来ます。# setとかは知りません。
似たようなのに jq
コマンドがありますが、JMESPathのほうがいいです、多分。jq 詳しく知らないので、機能・性能面では評価出来ないですが、どう考えても動作環境の広さから言えばこっちが上です。能書きは最後の方に書きます。
JMESPathでこんなことができる!
前回も書きましたが
ここ見てもらえばだいたい分かります。しかも試せます。そして私も勘違いしてましたが、このXPathぽい表記が JSON文字列ではなく、各言語のオブジェクト(JSONに変換出来る)に対して通用します。READMEの抜粋ですがこれは、 pythonのdictに対して問い合わせしています。
>>> import jmespath >>> path = jmespath.search('foo.bar', {'foo': {'bar': 'baz'}}) 'baz'
チュートリアルは、ナナメ読みせず、きちんと読んだほうがいいです。
もっと難しい問い合わせ
もっと複雑な問い合わせについて、ちょっと解説します。
http://jmespath.org/examples.html#working-with-nested-data
ここ見てください。awsの ec2_describe_instances の戻りっぽいJSONですが
{ "reservations": [ { "instances": [ {"type": "small", "state": {"name": "running"}, "tags": [{"Key": "Name", "Values": ["Web"]}, {"Key": "version", "Values": ["1"]}]}, {"type": "large", "state": {"name": "stopped"}, "tags": [{"Key": "Name", "Values": ["Web"]}, {"Key": "version", "Values": ["1"]}]} ] }, { "instances": [ {"type": "medium", "state": {"name": "terminated"}, "tags": [{"Key": "Name", "Values": ["Web"]}, {"Key": "version", "Values": ["1"]}]}, {"type": "xlarge", "state": {"name": "running"}, "tags": [{"Key": "Name", "Values": ["DB"]}, {"Key": "version", "Values": ["1"]}]} ] } ] }
に
reservations[].instances[].[tags[?Key=='Name'].Values[] | [0], type, state.name]
で
[ [ "Web", "small", "running" ], [ "Web", "large", "stopped" ], [ "Web", "medium", "terminated" ], [ "DB", "xlarge", "running" ] ]
が帰ってきます。最後の [tags[?Key==`Name`].Values[] | [0], type, state.name]
が特にヤヴァイ。select name, type, state
from .. みたいに、欲しい項目を複数抜き出してます。プロジェクションってやつです。昔、情報処理とかで勉強した 射影
ですな。
ちょっと気おつけて欲しいのは このサンプルでは ?Key=='Name' と シングルクオートでNameを囲っているが、pythonとかで使うときは
?Key==`Name`
バッククオートで区切ること
pythonから使ってみる
import jmespath dt = { "reservations": [ { "instances": [ {"type": "small", "state": {"name": "running"}, "tags": [{"Key": "Name", "Values": ["Web"]}, {"Key": "version", "Values": ["1"]}]}, {"type": "large", "state": {"name": "stopped"}, "tags": [{"Key": "Name", "Values": ["Web"]}, {"Key": "version", "Values": ["1"]}]} ] }, { "instances": [ {"type": "medium", "state": {"name": "terminated"}, "tags": [{"Key": "Name", "Values": ["Web"]}, {"Key": "version", "Values": ["1"]}]}, {"type": "xlarge", "state": {"name": "running"}, "tags": [{"Key": "Name", "Values": ["DB"]}, {"Key": "version", "Values": ["1"]}]} ] } ] } jmespath.search( 'reservations[].instances[].[tags[?Key==`Name`].Values[] | [0], type, state.name]', dt ) >>[['Web', 'small', 'running'], ['Web', 'large', 'stopped'], ['Web', 'medium', 'terminated'], ['DB', 'xlarge', 'running']]
pythonでのサンプルの解説
まず、
reservations[].instances[].[tags[?Key==`Name`].Values[] | [0], type, state.name]
について解説します。(後でアレンジも試します)
最初の部分
reservations[].instances[]
は特に問題無いと思います。実行したらこんな感じ
In [33]: jmespath.search('reservations[].instances[]', dt) Out[33]: [{'state': {'name': 'running'}, 'tags': [{'Key': 'Name', 'Values': ['Web']}, {'Key': 'version', 'Values': ['1']}], 'type': 'small'}, {'state': {'name': 'stopped'}, 'tags': [{'Key': 'Name', 'Values': ['Web']}, {'Key': 'version', 'Values': ['1']}], 'type': 'large'}, {'state': {'name': 'terminated'}, 'tags': [{'Key': 'Name', 'Values': ['Web']}, {'Key': 'version', 'Values': ['1']}], 'type': 'medium'}, {'state': {'name': 'running'}, 'tags': [{'Key': 'Name', 'Values': ['DB']}, {'Key': 'version', 'Values': ['1']}], 'type': 'xlarge'}]
次の構文ですが、最初にこのクエリのやりたいことは、 instances配下にある、
- tagsのNameタグのValue(配列なので Key=Nameのもの)
- type
- stateのname(dict なので、pythonで書くと state['name']の値)
だけを抜き出したいということです。チュートリアルを終わった段階で、いきなりこのクエリを書ける人はまず、いないと思います。
問題は、複数の要素を抜き出したいが、すべての要素がフラットに並んでいない
という点です。フラットに並んでいる
とは、わかりやすい例だとこんな感じ
{ "people": [ {"first": "James", "last": "d"}, {"first": "Jacob", "last": "e"}, {"first": "Jayden", "last": "f"}, ] }
こういう例だと、そもそもJMESPath要らなくね?
という話ですが、 (people 以外にもう一個ゴミ要素があるとかなら意味ある) people[*].[first,last]
とでもすれば複数のキーの値を抜くことは出来ます。
では、まず失敗例から。tagsの下の Key=name のうちの Values (配列) に着目し、これを狙い打ちます。
In [39]: jmespath.search('reservations[].instances[].tags[?Key==`Name`].Values[]', dt) Out[39]: [['Web'], ['Web'], ['Web'], ['DB']]
これだと tags.Key='Name'のValuesは引けますが、 reservations[].instances[].tags
まで掘り下げているので、どうやっても typeやstateは出てきません。
ちなみに.Valuesのケツの[] は flattenです。無いとこうなります。
In [40]: jmespath.search('reservations[].instances[].tags[?Key==`Name`].Values', dt) Out[40]: [[['Web']], [['Web']], [['Web']], [['DB']]]
じゃあ、どうすればいいか。reservations[].instances[].tags
まで掘り下げないで reservations[].instances[...]
でマルチセレクト処理すれば良いということです!
お手本の
[tags[?Key=='Name'].Values[] | [0], type, state.name]
に着目、 これは
- tags は
tags[?Key==`Name`].Values[] | [0]
で - type は 素直に
type
で - state は
state.name
で
抜いています。更に解説を進めますと。
tags[?Key==`Name`].Values[] | [0]
のパイプよりも前はもう説明いらないとおもいますが、なんでわざわざ | [0]
としているかに注目。Valuesはlist型なので、 ['Web','01']
みたいな値が帰ってくることもありえます、その場合先頭を抜くという意味になります。(これはサンプルなので、参考程度に) あとの type, stateは解説不要ですね!
ちょっとアレンジ
In [46]: jmespath.search('reservations[].instances[].[tags[?Key==`Name`].Values[] | [0], type, state.name ]', dt) Out[46]: [['Web', 'small', 'running'], ['Web', 'large', 'stopped'], ['Web', 'medium', 'terminated'], ['DB', 'xlarge', 'running']]
これでも十分なんですが、どうせなら dict の listがかえってくるようにしたいです。どうやればよいのか、、って簡単ですよね!今までの解説を読んでたら、linstance[]の下からマルチセレクトしているだけなので、ここで{}で囲って適当にkeyをつければ終わりです。
In [48]: jmespath.search('reservations[].instances[].{_name: tags[?Key==`Name`].Values[] | [0], _type: type, _state: state.name }', dt) Out[48]: [{'_name': 'Web', '_state': 'running', '_type': 'small'}, {'_name': 'Web', '_state': 'stopped', '_type': 'large'}, {'_name': 'Web', '_state': 'terminated', '_type': 'medium'}, {'_name': 'DB', '_state': 'running', '_type': 'xlarge'}]
このサンプルの落とし穴
instances[] からの マルチセレクトであるため、 Nameタグが存在しない場合、除外する
という動きになっていません。試しにデータの一部の tag.Key Nameを NNameとかにいじってみてください。多分こうなるはずです。
In [54]: dt2 Out[54]: {'reservations': [{'instances': [{'state': {'name': 'running'}, 'tags': [{'Key': 'Name', 'Values': ['Web']}, {'Key': 'version', 'Values': ['1']}], 'type': 'small'}, {'state': {'name': 'stopped'}, 'tags': [{'Key': 'Name', 'Values': ['Web']}, {'Key': 'version', 'Values': ['1']}], 'type': 'large'}]}, {'instances': [{'state': {'name': 'terminated'}, 'tags': [{'Key': 'Name', 'Values': ['Web']}, {'Key': 'version', 'Values': ['1']}], 'type': 'medium'}, {'state': {'name': 'running'}, 'tags': [{'Key': 'NName', 'Values': ['DB']}, {'Key': 'version', 'Values': ['1']}], 'type': 'xlarge'}]}]} In [55]: jmespath.search('reservations[].instances[].[tags[?Key==`Name`].Values[] | [0], type, state.name ]', dt2)Out[55]: [['Web', 'small', 'running'], ['Web', 'large', 'stopped'], ['Web', 'medium', 'terminated'], [None, 'xlarge', 'running']]
最後がNone で抜けてきてます。これを避けるには、更にパイプを使って listの最初の要素がNoneで無いものを抽出するようにするか、これぐらいならpython側でやっちゃうかの2択かなと思います。ココらへんを選べるのがプログラム言語から使える利点ですね。
能書き
対応言語
python, ruby, php, js, go, lua LWの主要言語ほぼ全部やないか! 言語オタクもここまで来たら凄いわ!
しかも jp
というコマンドまであるし、goでね。去年の記事では rubyは対応してなかったけど、対応してる。
あとは c/c++, C#, java, swift とかの heavy系
Rust, Elixir とかの 変態
関数型言語系
ここまで来たら言語オタクから言語変態紳士へクラスチェンジ。
動作環境の優位性
AWSを使っている人なら、少なくとも pythonのJMESPathは絶対に入っているといえる。なぜなら aws-cliが依存しているから。また、Rubist曰くもAWS-SDK2が依存しているらしく、AWSに携わっている人なら、まずJMESPathは入ってる、はず。
ただ、非AWSな人にとっても、各言語でネイティブ対応できていて、バイナリ依存とかも無いので、インストール自体はさくっと簡単に出来る、はず。
なんでこんなにAWSなの?と思ったけど、作者がAWSの中の人?(オーガには入ってる)なので、AWSとは切っても切れない。けどAWS関係なしにこのライブラリはイケてる。
jqとの比較
jq のほうが歴史は古いと思いますが、JMESPathは各言語におそらく言語ネイティブ(俗にゆう pure-javaみたいな)で対応している点が優れていると思います。
あと、作者が一人であるという点も、私はプラスに考えています。なぜならこのJMESPathの仕様自体は、彼がこの世で一番知っているのだから。もちろんバグもどこかにあるでしょうが、JMESPathの仕様に対する実装レベルが(どの言語でも)一定であると想像します。
まとめ & 上達のコツ
- AWS人ならJMESPath 最高、嫌でも入ってるし!
- プログラマなら
jq
よりjp
のほうが明るい未来(と思う) - 学習は、とにかく叩いて試してみる。遅くてもチュートリアルは完全に理解するほうが、結果お得
- 正規表現と同じ、1手ですべてを解決しようとしない。ちょっとずつ書き足す
- パイプは素直にパイプ、わからなければパイプがなんとかしてくれる!
- 今回触れなかったが、UDFも作れるらしい!
個人的な感想ですが、今更 jq
を使う理由が見つからないです。過去の遺産(資産)とか関係なしのはなしですよ、もちろん。