2013/04/28

jqコマンドが実は高性能すぎてビビッた話

GWが始まりましたが、鎌倉のGWは観光客多すぎて逆に住民はげんなりして外に出なくなる感じです。とはいえ路地まで観光客が攻め込んでくることは少ないので、路地を散歩する分には天気がよくていい感じですね。ちなみに人力車のおにーさんはそういう味のある路地を知り尽くしているので人力車で移動するのはそこそこオススメです(ぼくは乗ったことないけど「こんなところも通るんだ!」ってところで見かけたりします)。

さて、jqというコマンドをご存じでしょうか。

jq is a lightweight and flexible command-line JSON processor. と書いてあるとおり、コマンドラインでJSONを扱うことができるコマンドです。で、今まさに仕事で巨大JSONと戦うことが多く、このコマンドが大活躍です。

とはいえ、ぼくの使い方としては「巨大JSONをキレイに整形して表示する」というのがメインでした。jqコマンドを知らなかった頃はcurl使ってRESTなAPIを叩いてJSONをローカルに保存し、それをJSONViewという拡張機能を組み込んだChromeに食わせて「あー見やすい見やすい、しかもページ内検索もできる〜」という感じでJSONを扱っていました。

そこで知ったのがjqコマンドです。最初使い方がよく分からなくて、フィルタの指定が必須というエラーが出て仕方なくマニュアルをみると先頭に「全部表示したいなら . というフィルタ指定だよ」と書いてあったのでとりあえずそれで使っていました。

これだけでも十分便利なんですよねー。具体的にどう便利かをみてみましょう。

curlで帰ってきたJSONをjqに食わせて可読性を上げる

curlでFacebook APIを叩くと普通に標準出力に出力が出てきますが、あんまり読めません。以下の例は1行でJSONがきてpreで貼り付けると大変なことになるので80桁で改行を手動でいれています。。

[548](*'-')< facebook$ curl -s https://graph.facebook.com/244659772307485
{"id":"244659772307485","name":"Paberish","description":"Paberish\u306f\u305d\u3
06e\u4eba\u3060\u3051\u304c\u6709\u3059\u308b\u77e5\u8b58\u3084\u79d8\u5bc6\u300
1\u6280\u8853\u3092\u300c\u30b9\u30af\u30ed\u30fc\u30eb\u30d6\u30c3\u30af\u300d\
u3068\u3057\u3066\u30a2\u30d7\u30ea\u3067\u51fa\u7248\u3067\u304d\u308b\u30b5\u3
0fc\u30d3\u30b9\u3067\u3059\u3002","category":"Entertainment","subcategory":"Boo
ks","link":"http:\/\/www.facebook.com\/apps\/application.php?id=244659772307485"
,"namespace":"paberish","icon_url":"http:\/\/photos-a.ak.fbcdn.net\/photos-ak-sn
c7\/v85006\/73\/244659772307485\/app_2_244659772307485_408215649.gif","logo_url"
:"http:\/\/photos-b.ak.fbcdn.net\/photos-ak-snc7\/v85006\/73\/244659772307485\/a
pp_1_244659772307485_1802087060.gif","company":"KAYAC inc.","weekly_active_users
":"12","monthly_active_users":"51","daily_active_users_rank":262596,"monthly_act
ive_users_rank":288137}

とりあえず適当な例をーと思って自分のかかわってるFacebookアプリのGraph APIを叩いてみたんだけど、そもそも日本語がエスケープされてて読めなかった。しらんかった。

さて、ここで出力結果をjqに渡すだけでこれが劇的に見やすくなります。

[549](*'-')< facebook$ curl -s https://graph.facebook.com/244659772307485 | jq "."
{
  "monthly_active_users_rank": 288137,
  "daily_active_users_rank": 262596,
  "monthly_active_users": "51",
  "weekly_active_users": "12",
  "company": "KAYAC inc.",
  "logo_url": "http://photos-b.ak.fbcdn.net/photos-ak-snc7/v85006/73/244659772307485/app_1_244659772307485_1802087060.gif",
  "id": "244659772307485",
  "name": "Paberish",
  "description": "Paberishはその人だけが有する知識や秘密、技術を「スクロールブック」としてアプリで出版できるサービスです。",
  "category": "Entertainment",
  "subcategory": "Books",
  "link": "http://www.facebook.com/apps/application.php?id=244659772307485",
  "namespace": "paberish",
  "icon_url": "http://photos-a.ak.fbcdn.net/photos-ak-snc7/v85006/73/244659772307485/app_2_244659772307485_408215649.gif"
}

すっげー読みやすい!

これだけでもかなりイケてるコマンドだということが分かります。ていうか使い始めて3日でcurlでJSONが帰ってくるAPIを叩くときはこれ無しで生きて行けない身体になりました。そのぐらい便利です。

jqの強力なフィルタ機能を実用的に使う

で、これだけでも便利だったんですが、ちょっと所用でTwitterのユーザのつぶやきからからハッシュタグを含む抽出する必要に迫られました。最終的にはツールを書いて処理する必要があったんですが、その書いたツールが真っ当に動いているか目視で確認する必要があり、それの比較対象としてjqがすごく強力だったというのが今日のメインディッシュです。

ということで、それをどうやってjqでやるかを順を追ってチュートリアルっぽく説明していきます。

まずサンプルとしてツイートをバコッと取ってくる必要があるんですが、ぼくのツイートにはほとんどハッシュタグが含まれていなくてあんまり面白くないので、同僚でインターネットコンテンツ力が非常に高い@kenjiskywalkerさんのツイートを拝借しましょう。GET statues/user_timelineのAPIを使って先週くらいの200件をとってきたやつをサンプルとして使います。

まずぼくどんなJSON帰ってきてるのかよく分かっていないのでJSONを眺めたところ、200件のツイートが入った配列になってることが分かったので、まずその配列の中の最初の1件でデータ構造をチェックします。JSONのルートが.で、その直下の配列の0件目をとるには[0]を指定します。

[556](*'-')< twitter$ jq ".[0]" kenji2.json 
{
  "lang": "ja",
  "retweeted": false,
  "favorited": false,
  "entities": {
    "user_mentions": [],
    "urls": [],
    "symbols": [],
    "hashtags": []
  },
  "favorite_count": 1,
  "retweet_count": 0,
  "in_reply_to_status_id_str": null,
  "in_reply_to_status_id": null,
  "truncated": false,
  "source": "<a href=\"http://sites.google.com/site/yorufukurou/\" rel=\"nofollow\">YoruFukurou</a>",
  "text": "新卒氏、意識高いし技術力高いし、本当にそのうち草むしり担当になりそう",
  "id_str": "327435930354974720",
  "id": 327435930354974700,
  "created_at": "Thu Apr 25 14:56:23 +0000 2013",
  "in_reply_to_user_id": null,
  "in_reply_to_user_id_str": null,
  "in_reply_to_screen_name": null,
  "user": {
    "id_str": "343195273",
    "id": 343195273
  },
  "geo": null,
  "coordinates": null,
  "place": null,
  "contributors": null
}

いきなりコンテンツ力高いツイートだ…。

さてこれでどこに何が入ってるか大体わかりました。hashtagsの中が配列になっているので、ハッシュタグの一覧を出すにはルート直下の配列を全部ぶん回し、hashtagsの配列をさらにぶん回せば一覧はでてきますね。配列全部を回すには [] という記法を使います。とりあえず ".[].entities.hashtags[]" というフィルタで回してみましょう。

[562](*'-')< twitter$ jq ".[].entities.hashtags[]" kenji2.json
{
  "indices": [
    0,
    4
  ],
  "text": "ザビオ"
}
{
  "indices": [
    5,
    8
  ],
  "text": "焼肉"
}
{
  "indices": [
    9,
    22
  ],
  "text": "オーディションしましょう"
}
{
  "indices": [
    0,
    4
  ],
  "text": "ザビオ"
}
{
  "indices": [
    5,
    27
  ],
  "text": "最悪口座にお金振り込んでくれれば大丈夫です"
}
{
  "indices": [
    28,
    32
  ],
  "text": "焼肉代"
}
{
  "indices": [
    39,
    44
  ],
  "text": "マッツォ"
}
{
  "indices": [
    35,
    40
  ],
  "text": "マッツォ"
}
{
  "indices": [
    29,
    40
  ],
  "text": "本日の帷子川情報です"
}
{
  "indices": [
    22,
    33
  ],
  "text": "本日の帷子川情報です"
}

hashtagsの中はindicesっていうオフセット情報と実際のハッシュタグのテキストが入ってたんですね。欲しいのはtextだけだからフィルタに.textを追加します。

[563](*'-')< twitter$ jq ".[].entities.hashtags[].text" kenji2.json
"ザビオ"
"焼肉"
"オーディションしましょう"
"ザビオ"
"最悪口座にお金振り込んでくれれば大丈夫です"
"焼肉代"
"マッツォ"
"マッツォ"
"本日の帷子川情報です"
"本日の帷子川情報です"

はい、これでハッシュタグ一覧が出てきました。ただこれだけだとあんまり有用じゃないので、ツイートのIDとハッシュタグのセットにしたJSONを構成したいと思います。

ここからは説明がちょっと飛躍気味になっていくので何言っているのかよくわからんという人はjqのマニュアルも読んでください。

まず、jqにはオブジェクトを作る機能があり、 {(.key): .value}という記法でキーと値の組み合わせを生成できます。なので、ツイートごとに {(.id): .entities.hashtags[].text}というのを生成すればお目当てのデータが取得できそうです。

これを実現するにはルート直下のツイートをforeachで回して取り出して1個ずつ処理するイメージで記述する必要があるのですが、マニュアルには特にforeachとかないし…と困ってしまいます。答えは|を使っって.[] | {(.id): .entities.hashtags[].text}感じで記述する感じになります。.[]で取得した各要素を|を使って取り出し、その取り出した要素をルートとして .idでツイートのIDを、.entities.hashtags[].textでハッシュタグのテキストを取得しています。

さて実行してみましょう!

[571](*'-')< twitter$ jq ".[] | {(.id): .entities.hashtags[].text}" kenji2.json
Assertion failed: (jv_get_kind(k) == JV_KIND_STRING), function jq_next, file execute.c, line 250.
Abort trap: 6

ウッ、ダメだった。これよくハマりやすいエラーなので、あえてエラーになるパターンを紹介しています。このエラーの原因はメッセージを見るだけだとなかなか分からないのですが、JSONのオブジェクトを生成するときにキーに指定した(.id)が文字列型ではなく数値型なのでキーに出来ないところにあります。これを修正するには、値を文字列型に変更する必要があります。それは簡単に変更可能です。tostringを使います。キー指定を(.id | tostring):に変更して実行してみましょう。

[572](*'-')< twitter$ jq ".[] | {(.id | tostring): .entities.hashtags[].text}" kenji2.json
{
  "327380853917376500": "ザビオ"
}
{
  "327380853917376500": "焼肉"
}
{
  "327380853917376500": "オーディションしましょう"
}
{
  "327380576141197300": "ザビオ"
}
{
  "327380576141197300": "最悪口座にお金振り込んでくれれば大丈夫です"
}
{
  "327380576141197300": "焼肉代"
}
{
  "327292703756914700": "マッツォ"
}
{
  "327291797028081660": "マッツォ"
}
{
  "327215257019879400": "本日の帷子川情報です"
}
{
  "326943518876639200": "本日の帷子川情報です"
}

できたできた。キーがツイートのID、値がハッシュタグのテキストになっています。ハッシュタグが3つ含まれているツイートはそれぞれ別のオブジェクトとして出来ていますが、キーは同じになっています。ちなみにツイートURLに使われているidはツイートオブジェクトのidではなくてid_strに入ってるみたいですね(さっき気付いた)。

ここまでで大体目的を達成していますが、出力してるのがオブジェクトの羅列になっていて気持ち悪い! という方もいるかもしれません。そういう人はフィルタ自体を配列生成を指定する[]で囲んで "[.[] | {(.id|tostring): .entities.hashtags[].text }]"という感じにすれば配列の中に出力される感じになります。オブジェクトのルートが配列なのは気持ち悪い! というような人は"{(\"hashtags\"): [.[] | {(.id|tostring): .entities.hashtags[].text }]}"とすれば、hashtagsというキーの下にIDとハッシュタグテキストのオブジェクトが配列で入るJSONになります。

はぁはぁ、ここまでjqが便利だねーという話からフィルタが強力すぎてびびったという話まで一気に書き上げましたがいかがだったでしょうか? jqコマンドはすごい高速に動きます。今回サンプルに使った150KB程度のJSONを解析するのにMacBook Air(Middle 2011)で0.2秒程度でした。この程度であればよっぽどデカいJSONを扱わない限り実用的な速度ですので、ちょろっとJSONを変形したいみたいな用途だとこのフィルタ機能を使いこなすことでサクサクと作業できそうですね。

今回書いたフィルタよりももっと複雑なフィルタがもし必要そうであればさすがにjqでやる枠を越えそうなので、フィルタの可読性的な面でも実用上はこの辺が限界かなーと思います。ただFluentdとかMongoDBとかからデータとってきてワンライナー叩くみたいな場面には非常に強力なので、多少複雑なフィルタを書くのに挑戦してYak Shavingなゴールデンウィークを過ごしてみるのもよいかもしれません。

あと、今回 .[] を連発したときの挙動については触れなかったんですが、hashtags[] ではなく hashtags[0] に変えると出力されてくる結果が全然変わります(ハッシュタグが含まれないツイートもオブジェクトが生成されるようになり、値にnullが入る)。その辺も把握しておくと使いこなすときにハマることが少なくなりますので是非挙動を見てみてください。

うされもん @acidlemonについて

|'-')/ acidlemonです。鎌倉市在住で、鎌倉で働く普通のITエンジニアです。

30年弱住んだ北海道を離れ、鎌倉でまったりぽわぽわしています。

外部サイト情報

  • twitter
  • github
  • facebook
  • instagram
  • work on kayac