読者です 読者をやめる 読者になる 読者になる

マナーの悪い JSON データを jq で整形する

Drill

JSONデータの問題

色々なJSONデータを扱っていると、たまにマナーの悪いデータを目にします。例えば、政府統計情報e-Statでダウンロードできる次のようなJSONデータ(一部を切り出しています)。

{"CLASS_OBJ":[
  {"@id":"tab","@name":"表章項目","CLASS":[{...},{...},...]}, ←このオブジェクト内の"CLASS"の値は配列
  {"@id":"cat01","@name":"全域・人口集中地区2010","CLASS":[{...},{...},...]}, ←同じく配列
  {"@id":"area","@name":"地域(2010)","CLASS":[{...},{...},...]}, ←同じく配列
  {"@id":"time","@name":"時間軸(年次)","CLASS":{...}} ←このオブジェクト内の"CLASS"の値は配列ではないオブジェクト!
]}

おそらく、このAPIサービスを提供しているプログラムの内部では、「CLASS」のデータはどれも配列として値を持っているはずです。ところが、これをJSONデータとして出力する際に、要素が2つ以上あればJSONデータとしても配列([a, b, ...])で表されますが、要素が1つしかない場合はなぜかオブジェクト({...})として出てきてしまっている感じです。こちらとしては要素が一つでも配列として出てきてほしいのですが。

これの影響はデータをパースするところで出てきます。私が最近使っているApache Drillの例では、このデータに対してSQLクエリを実行しようとすると次のようなエラーが出ます。

0: jdbc:drill:zk=local> SELECT * FROM dfs.`/tmp/stats.json`;
Error: DATA_READ ERROR: You tried to write a VarChar type when you are using a ValueWriter of type SingleListWriter.

File  /tmp/stats.json
Record  1
Line  1
Column  430917
Field  @code
Fragment 0:0

[Error Id: 647d9c6f-8d53-4996-8367-8ba8a03edd9e on 192.168.0.10:31010] (state=,code=0)

これは本来リスト型が期待されるところに可変長文字列型が使われたので、データ読み込みエラーになったというメッセージです。

少し調べてみると、このような情報もありました。

JAX-RSのリファレンス実装であるJerseyの古いバージョンでは、JSONバインディングの実装に起因して冒頭のような問題が起きるようです。想像ですが、e-StatのAPIを提供しているプログラムではこのような実装が使われているのでしょう。ちなみに、JSONバインディングにJacksonを使ったり、比較的新しいJersey 2.xを使用すると問題ないとのこと。

データの問題のある部分を特定する

とはいえ、すでにこのように出力されてしまったデータは仕方ないので、個別に整形して使いやすくしなければなりません。そこで、「JSONsed」とも言われる、コマンドラインJSONプロセッサ「jq」で整形することにします。

jq - A lightweight and flexible command-line JSON processor
http://stedolan.github.io/jq/

f:id:nagixx:20150603122438p:plain

jqは軽量かつとても柔軟にJSONデータを加工できるので、JSONデータを扱う人であれば知っていて損はないと思います。導入は、コマンドのバイナリファイルを上記のサイトからダウンロードしてくるのが最も簡単です。

それでは実際にやってみましょう。まず、上記のDrillのエラーの例では、「Column 430917」という表示が出ていることから、先頭から430,917文字目の直前あたりに手がかりがありそうです。

$ cut -c 430800-430917 /tmp/stats.json
A","@level":"4","@parentCode":"47000"}]},{"@id":"time","@name":"\u6642\u9593\u8EF8(\u5E74\u6B21)","CLASS":{"@code":"20

上のようにデータを見てみると、直前にCLASSというキーがあるため、CLASSの値の型が問題ではないかと推測できます。ここでjqを使って、この推測が正しいことを確認します。

$ jq '.. | objects | .CLASS | select(type != "null") | type' /tmp/stats.json
"array"
"array"
"array"
"object"

jqは引数に様々なフィルタをパイプ(|)でつなげた形式で処理を指定します。

jq [オプション] 'フィルタ1 | フィルタ2 | フィルタ3 | ...' [入力ファイル]

上のコマンドで指定している各フィルタは次のような処理を行うことを意味しています。

フィルタ説明
.. 入力JSONデータのすべてのレベルを再帰的にたどり、処理の対象とする
objects object型のデータのみを選択する
.CLASS CLASSをキーとする値を出力する。ない場合はnullを出力する
select(type != "null") データ型がnullではないデータを選択する
type データ型を文字列で出力する。null、boolean、number、string、array、objectのいずれか

つまり、ここで行っている処理は入力データのすべての階層をたどってキーが「CLASS」である値のデータ型を出力するというものです。すると、先頭から3つはarray型であるにも関わらず、最後の値はobject型になっていることが分かります。これがエラーの原因ということが裏付けられました。

データを整形する

では、これをエラーにならないように整形してみましょう。jqを使うとかなり簡潔に処理を書くことができます。

$ jq '(.. | objects | .CLASS | objects) |= [.]' /tmp/stats.json > /tmp/stats-out.json

一見分かりにくいのですが、前半の括弧の中は先ほどのフィルタの処理とほぼ同じで、キーが「CLASS」で値がobject型となるフィールドを選択しています。後半の表記は、次のような意味を持っています。

表記説明
|= 左辺の値を右辺の値で更新する。右辺の入力は左辺のフィルタの出力結果であることがポイント
[.] .は入力データそのものを表すが、[ ]で囲むことで入力データを要素とするJSON配列を生成する

つまり、演算子「|=」の右辺には左辺の結果のオブジェクトが入力されるのですが、これを[ ]で囲んで1つのオブジェクトを要素として持つ配列にして、左辺の値を更新するという処理になります。

ちゃんと置換されたかどうかdiffで確認してみます。

$ diff stats.json stats-out.json
27148,27152c27148,27154
<             "CLASS": {
<               "@code": "2010000000",
<               "@name": "2010年",
<               "@level": "1"
<             }
---
>             "CLASS": [
>               {
>                 "@code": "2010000000",
>                 "@name": "2010年",
>                 "@level": "1"
>               }
>             ]

大丈夫そうですね。Drillでもエラーにならないことを確認しました。

0: jdbc:drill:zk=local> SELECT * FROM dfs.`/tmp/stats-out.json`;
+------------------------------------------------------------------------------+
|                                                                              |
+------------------------------------------------------------------------------+
| {"RESULT":{"STATUS":0,"ERROR_MSG":"正常に終了しました。","DATE":"2015-06-02T20:19:08.1 |
+------------------------------------------------------------------------------+
1 row selected (6.797 seconds)

jqのコマンドの詳細はこちらにありますので、色々試してマスターしてみましょう。

jq Manual
http://stedolan.github.io/jq/manual/