Edited at

joコマンドさようなら、俺はパイプが好きだから。PART 1

More than 3 years have passed since last update.


joというJSON生成コマンドがあるらしいが

JSONを読み解くコマンドのjqの反対の位置づけとして、joというコマンドが存在する。が、jqと同様、UNIXの作法をわかっていないし、オマケにこちらは使い方に気を付けないとセキュリティー上の問題を誘発しかねず、つくづく呆れてしまう。こんなものを使って喜んでいる連中も嘆かわしい。

そこでPOSIX原理主義に基づき(=どのUNIX系OSにもコピーしただけで使える形で)、makrj.shというJSONジェネレーターを作った。これがあれば、シェルスクリプトからJSONデータを簡単に生成できる。JSONという形式がUNIX哲学的にはあまり好きではないが、現在主流のWebAPIに話しかけるには必要だろう。


joがなぜダメなのか

(さっさと使いたいという人は、ここは読み飛ばして次節へどうぞ)


なんでもかんでも引数からデータ与えるな!

joの最もダメな仕様。それは、データを引数から与えさせることだ。

例えば連想配列(オブジェクト)を生成したい場合は、

$ jo -p key=value

などとやる。配列を作りたい場合は

$ jo -a a,b,c

とやり、複雑なJSONを作りたければ次のようにして複数のjoコマンドを組み合わせるらしい。

$ jo -p key=value object=$(jo -a a,b,c)

なんでもかんでも引数からデータを与えるんじゃねえ!

UNIXというのは、データを標準入力から受け取り、それをどのように加工するかの指示をコマンドや引数でやるのに適した作りをしているのだ。引数領域はデータを渡す場所じゃない。

ARG_MAXというパラメーターがあるのがそのいい証拠だ。引数領域がデータを渡すための場所であるなどと考えられていないからこそこのような制約が設計上存在するのだ。(だいたいjoコマンドではARG_MAXを超えるサイズのJSONが作れないじゃないか)

それに、本来指示を与える領域にデータを流し込んだら、セキュリティーインシデントの温床になる。データ中に不正な指示を与えるコードが潜り込まされていたらどうなると思う?

というわけで、基本的な発想そのものがUNIX哲学を無視していると言わざるを得ない。


そしていつものお小言

今回JSONデータを生成するために作ったコマンドmakrj.shの行数はご覧のとおり200行程度。コメントを除けば実質120行程度だ。制作に要した日数は丸一日。使ったものはPOSIXで保証されたコマンドとシェルスクリプトだけ。

何が言いたいかというと、もっと自分で作るという発想を持とうぜ、ということだ。

今どきのプログラマーは、すぐに道具(フリーソフト)を拾ってくるという発想に流れるが、そうやってその場をしのいだ自分に待っているものは、


  • 深く、かつ、汎用的に役立つ知識・技術力がいつまでたっても身につかない自分

  • 探したソフトウェアのver.upやサポート終了を恐れながら、メンテナンスに追われる自分

だ。まったくもって不幸な話だ。

もちろんすべて自分で作れとは言わない。他人の作ったものを使うのはもちろんアリだ。だた、


  • 自分れ作れないかと検討する習慣をつけること

  • 使うのにしても、自分で作ったかのように深い理解をしながら使うこと

  • ヘンなもの(UNIX哲学をわかってないjq,joなど)に手を出さないこと

を心がけるべきだ。


makrj.shの使い方① ― JSONを自作する(デモ)

makrj.shでできることと、使い方を理解できるように1つ目のデモを示す。(2つ目は別記事でやる予定)

デモは、1人1行で表形式にまとめられている会員情報をJSON構造に変換するというものだ。


1. 元データとその仕様

今、次のような内容のデータがあったとする。

(内容はフィクションである)


member_info.txt

TR03 飯山_満   1986/04/27 man   false TR09 KS14

OM02 下神_明 1927/07/06 man true
G12 石狩_太美 1934/11/20 woman false A21 A28 N13 H07
ST06 武蔵_大和 1930/01/23 man true SI12 SW05 KO23

半角スペース区切りになっているが最終的にこの形にすればよく、元々がCSV等でもよい。(sedやAWKで自力で変換してもよいし、parsrc.sh等を使えばこの形式に容易に変換できる)

このデータの各列の意味は次のとおりである。



  • 列1 ID


    • 1:会員ID




  • 列2〜5 会員基本情報


    • 2:氏名(姓名は_で区切る)

    • 3:生年月日

    • 4:性別

    • 5:有料会員フラグ(trueまたはfalse)




  • 列6以降 友人会員一覧


    • 6:友人1会員ID

    • 7:友人2会員ID

    •   :



1列目が会員IDでありこれが主キー、2〜5列目はその会員の基本情報で今のところ列数は4種類固定、6列目以降はその会員に友人会員がいればその会員のIDを列数可変で持つというものである。


2. 求められるJSONデータ構造の仕様

さて、先ほどのデータを、とあるWebAPIに流し込むことになったとする。しかし、そのWebAPIはJSONでしか受け取ってくれれず、そのフォーマットは次のような構造を要求しているものとする。

私なりに表現してみたが、階層構造、その階層における変数のキー名などはだいたいこんなんな感じだったとする。なお、[]はその中身が連想配列ではなく配列で格納されていることを示すものとする。


とあるWebAPIが求めるJSONフォーマット

/--+-- member[]/

| |
| +-- prof/
| | |
| | +-- name 氏名(姓名は半角空白区切りで)
| | +-- birth 生年月日
| | +-- gender 性別
| | +-- paid 有料会員フラグ
| |
| +-- friends/
| |
| +-- n_friends 友人数
| |
| +-- members[]/
| |
| +-- 友人1
| +-- 友人2
| :
|
:

確かに一見すると綺麗に整理されてるけどね……。まぁ、インターフェースがこうなっていってブツクサ1言っても仕方がないので変換しよう。

データ変換時の注意点は、


  • 「氏名」の姓名の区切り文字が代っている

  • 友人の数を追加するように求められている。

である。


3.変換シェルスクリプトを書く。

先ほど把握した元の表形式データ形式と欲しいJSONデータ形式に基づくと、次のようなコードになる。


convert_memberinfo.sh

#! /bin/sh

cat /PATH/TO/DATA/member_info.txt |
awk '{n=NR-1; #
# ----- ID ----- #
print "$.member[" n "].ID",$1; #
# ----- 会員基本情報 ----- #
name=$2; gsub(/_/," ",name); # ここで姓名の区切りを変更 #
print "$.member[" n "].prof.name" ,name; #
print "$.member[" n "].prof.birth" ,$3; #
print "$.member[" n "].prof.gender",$4; #
print "$.member[" n "].prof.paid" ,$5; #
# ----- 友人会員 ----- #
n_friends=0; # 友人数を数える変数 #
for(i=6;i<=NF;i++){ #
print "$.member[" n "].friends.members[" (i-6) "]",$i; #
n_friends++; #
} #
# (友人がいない場合は空の要素を作る) #
if(n_friends==0){print "$.member[" n "].friends.members[0]";} #
# (ここで友人数を出力) #
print "$.member[" n "].friends.n_friends",n_friends; }'
|
tee member_info.tmp |
makrj.sh


最初のAWKでJSONPath-value形式という2列構成(つまりkey-value形式)のデータを生成し、それをmakrj.shコマンドに食わせるだけだ。

途中にteeコマンドを挿んであるが、これはここでのチュートリアルのためにJSONPath-value形式の中間データがどういう内容であるかを見てもらう目的で入れたものであり、本番では必要ない。

書いたらさっそく実行だ。


4.できあがったデータの内容


中間データの内容

実行したらJSON形式に変換された会員データがめでたく出てくるのだが、その前に中間データの中身を見てみよう。


中間データ(member_info.tmp)

$.member[0].ID TR03

$.member[0].prof.name 飯山 満
$.member[0].prof.birth 1986/04/27
$.member[0].prof.gender man
$.member[0].prof.paid false
$.member[0].friends.members[0] TR09
$.member[0].friends.members[1] KS14
$.member[0].friends.n_friends 2
$.member[1].ID OM02
$.member[1].prof.name 下神 明
$.member[1].prof.birth 1927/07/06
$.member[1].prof.gender man
$.member[1].prof.paid true
$.member[1].friends.members[0]
$.member[1].friends.n_friends 0
$.member[2].ID G12
$.member[2].prof.name 石狩 太美
$.member[2].prof.birth 1934/11/20
$.member[2].prof.gender woman
$.member[2].prof.paid false
$.member[2].friends.members[0] A21
$.member[2].friends.members[1] A28
$.member[2].friends.members[2] N13
$.member[2].friends.members[3] H07
$.member[2].friends.n_friends 4
$.member[3].ID ST06
$.member[3].prof.name 武蔵 大和
$.member[3].prof.birth 1930/01/23
$.member[3].prof.gender man
$.member[3].prof.paid true
$.member[3].friends.members[0] SI12
$.member[3].friends.members[1] SW05
$.member[3].friends.members[2] KO23
$.member[3].friends.n_friends 3

$をルート階層とし、.を階層区切り文字として、キー名(連想配列名)を書き連ね、各データの位置が示された文字列が行頭にあるがこれがJSONPathである。連想配列ではなく配列にしたい場合はキー名の後に[n]で番号をつける。ここらへんの詳しい仕様はもちろんJSONPathの公式ページを見ればわかるが、ここまで説明すればだいたい感覚的につかめることだろう。


JSONデータ化された会員情報データの内容

さて、先ほどのプログラムを実行したら画面上には既にJSONが表示されているはずだ。人に読ませることは重要視しておらず適当に改行が挟まれているだけ2で、インデントなどは一切行っていない。そのため、そのままここに記しても見づらいので、適当に整形して示すことにする。飽くまで実際に出力されたものを見て確認したいのであれば、JSONLintというサイトにコピー&ペーストするのがよいだろう。


出力された会員情報JSONデータ

{ "member": [{"ID"     : "TR03"                       ,

"prof" : {"name" : "飯山 満" ,
"birth" : "1986/04/27",
"gender" : "man" ,
"paid" : false },
"friends": {"members" : ["TR09",
"KS14" ] ,
"n_friends": 2 }
},
{"ID" : "OM02" ,
"prof" : {"name" : "下神 明" ,
"birth" : "1927/07/06",
"gender" : "man" ,
"paid" : true },
"friends": {"members" : [] ,
"n_friends": 0 }
},
{"ID" : "G12" ,
"prof" : {"name" : "石狩 太美" ,
"birth" : "1934/11/20",
"gender" : "woman" ,
"paid" : false },
"friends": {"members" : ["A21" ,
"A28" ,
"N13" ,
"H07" ] ,
"n_friends": 4 }
},
{"ID" : "ST06" ,
"prof" : {"name" : "武蔵 大和" ,
"birth" : "1930/01/23",
"gender" : "man" ,
"paid" : true },
"friends": {"members" : ["SI12",
"SW05",
"KO23" ] ,
"n_friends": 3 }
} ]
}

ちゃんと生成できてるでしょ?こういうデータを出力するプログラムを毎回一から書くのは大変だ。(特にカッコの開きや閉じの条件判定とかで)


注意


キー名に半角空白やピリオド、角カッコは使わない

そんなJSONを設計してるやつは死ねと言いたい。技術的にできないこともないが、そんな偏屈なJSONに対応して処理速度を落としたくなどない。それは「90%の解を目指せ」といっているUNIX哲学にも反する。

どうしても必要ならば、半角空白は\u0020、ピリオドは\u0020、角カッコは\u005b\u005dでAPIに渡せばいい。


JSONPath列と値列の間は半角空白1つ

中間データ(JSONPath-value形式)において、JSONPathの列と値の列の区切りは必ず半角空白1つにすること。


中間データ(JSONPath-value形式)の良い例

$.member[0].ID TR03

$.member[0].prof.name 飯山 満
$.member[0].prof.birth 1986/04/27
$.member[0].prof.gender man
$.member[0].prof.paid false
:


中間データ(JSONPath-value形式)の悪い例

$.member[0].ID          TR03

$.member[0].prof.name 飯山 満
$.member[0].prof.birth 1986/04/27
$.member[0].prof.gender man
$.member[0].prof.paid false
:

後者(悪い例)の場合は、IDは“_________TR03”(_は半角空白の意味)と見なされる。

makrj.shコマンドは、実数、true/false、nullなどのデータ型はある程度臨機応変に認識するが、前後に余分なスペースが含まれていたら容赦なく文字列型とみなすようにしている。よって、後者の例だとpaidもfalseではなく手前に半角空白が2つ入っている文字列“__false”(_は半角空白の意味)として扱われる。


行の順番を守る

makrj.shコマンドは、JSONを生成するにあたってJSONPathの変化を見ながらやっている。なので、本来隣り合って配置されるべきデータは中間データでも行としても隣り合っていなければならない。もし隣り合っていなかった場合は、無駄に個々のデータが別々のカッコで独立した、とてもヘンなデータが生成されるので注意。


PART2に続く

PART2では、既存のJSONを受け取り、parsrj.shコマンドでJSONPath-value形式に変換して、加工して、元に戻す話を書く予定。





  1. べつに行列形式でだっていいのにね。最近のシステムはこういうところに無駄なエネルギーを使い過ぎだと思う。 



  2. 適宜改行を挿れておかないと、UNIXの処理バッファーが満杯になるまでなかなかデータが出てこないし、後続のコマンドの処理が重たくなる。