UECジャーナル

開眼☆シェルスクリプト 【6】テキストで台帳管理を行う ― SQLライクなファイル結合

はじめに

シェルやシェルスクリプトを使った関係演算について紹介する。関係演算というのは、リレーショナルデータベース管理システム (RDBMS、以下単にDBと表記) に、SQLでJOINやSELECTなどと命令を書いて行わせる処理のことをここでは指している。

DBを使うと、排他制御やユーザの管理など様々な便利機能も利用できるのだが、関係演算だけならテキストファイルでもできる。テキストファイルで関係演算ができると、端末だけで作業が完結することが非常に多くなる。

ユニバーサル・シェル・プログラミング研究所ではシェルスクリプトでNoSQLなシステムを開発して実績もあげているので、これから紹介する方法の延長でデータストアを作ることも可能だ。これについても面白い事例が多いので紹介したいのだが、これは別の機会に譲ることとする。

自由を保つ

データをフラットテキストで保存すると話が早いことは、本連載の第1回にも述べた。わざわざ表計算ソフトやDBがあるのにテキストを使うのは大変そうだが、実は自由が効く方法だ。おなじみGancarzのUNIX哲学に次の格言がある。

Avoid captive user interfaces (束縛するインタフェースは作るな)

これは、コマンドは対話的に作ってはいけないということを言っている。たとえば、あるDBソフトのCUIクライアントを起動すると、

$ hogesql
SQL> 

のように、コマンドの入力プロンプトからSQLの入力プロンプトに変わってしまうのだが、一旦こうなってしまうとquitするまでgrep(1)もcat(1)もリダイレクトも使えなくなる。全部SQLで書かなくてはならない。GUIを持つアプリケーションでも同じで、アプリケーションの中で全部操作を行うことになる。そうなってしまうとソフトウェアの方は全部の操作を引き受ける必要が生じて巨大化する。巨大化の途中には頻繁にバージョンアップも起こるだろう。互換性の問題も発生する。

コマンドの使い方

今回はTukubaiコマンドjoin0join1join2を使う。LinuxやFreeBSDにはjoinという標準のコマンドがあるのだが、オプションがややこしいのでこれは使わない。

join1

まず、join0より先にjoin1を説明する。join1はファイル同士をキーでくっつけるコマンドだ。リスト1、2が典型的な使い方だ。まず、次のようなデータがあるものとする。

リスト1: マスタファイルトランザクションファイル

$ cat file1
01 たらこ
02 いくら
03 キャビア
04 カラスミ
$ cat file2
20120104 01 10
20120104 02 321
20120104 03 13
20120105 02 211
20120105 05 12
$ 

リスト1のfile1は、魚卵に2桁のコードをつけて管理しているマスタファイルだ。同じくリスト2のfile2は、ある日、何がいくつ売れたかを記録したファイルだ。こちらのファイルはトランザクションファイルと呼ばれる。file1とfile2を突き合わせると、マスタファイルにある項目が、いつ、どれだけ売れたかを知ることができる。

join1は、まさにこのような用途に作られたコマンドで、リスト2のように使う。

リスト2: join1の使い方

$ LANG=C sort -k2,2 file2 | join1 key=2 file1 -
20120104 01 たらこ 10
20120104 02 いくら 321
20120105 02 いくら 211
20120104 03 キャビア 13
$ 

key=2は、トランザクションファイルの第2フィールドにキーがあるという意味だ。キーの次にマスタファイル、その次にトランザクションファイルを指定する。-は、ファイルの代わりに標準入力を指定するためのオプションで、cat(1)などほかのコマンドでも使える一般的な記法だ。マスタファイルは、必ず左側にキーがあってソートされていなければならない。key=2が指定されているので、トランザクションファイルの第2フィールドと、マスタの第1フィールドが突き合わせられる。

この例では、join1の前にトランザクションをソートしているが、join1に入力するデータは、キーでソートしなければならない。ソートしていないと、レコードが抜け落ちる。sort(1)にLANG=Cと打つのは、sortは(1)LANG環境によってソート順が違ってしまい混乱する場合があるので、それを避けるように書いている。

トランザクションのレコードを残すjoin2

join2は、join1と同じ記法で使えるが、挙動が違う。リスト3とリスト2を比べると分かるのだが、join2はマスタに記録のないトランザクションのレコードも残す。マスタに無いものを急遽売ったときに、売上の計算でそれを抜いて計算することはないので、そのようなときにjoin2を使う。

リスト3: join2の使用

$ LANG=C sort -k2,2 file2 | join2 key=2 file1 -
20120104 01 たらこ 10
20120104 02 いくら 321
20120105 02 いくら 211
20120104 03 キャビア 13
20120105 05 ****** 12
$ 

論理演算するjoin0

join1,2はマスタファイルの項目をトランザクションにくっつけるが、join0はマスタにある項目をトランザクションから抽出する。

リスト4: join0の使用

$ LANG=C sort -k2,2 file2 | join0 key=2 file1 -
20120104 01 10
20120104 02 321
20120105 02 211
20120104 03 13
$ 

逆にマスタにない項目を抽出することもできる。+ngというオプションをつけると、標準エラー出力からマスタにないトランザクション項目が出力される。

リスト4: join0を使ってマスタにないものを抽出

↓ 標準出力からはマスタとマッチしたものが出力される
$ LANG=C sort -k2,2 file2 | join0 +ng key=2 file1 - > /dev/null
20120105 05 12
$

↓ 標準エラー出力を標準出力に振り向けて、もとの標準出力の結果を捨てる
$ LANG=C sort -k2,2 file2 | join0 +ng key=2 file1 - 2>&1 > /dev/null
20120105 05 12
$ 

+ngはjoin1でも使える。join2の場合はトランザクションが全部残るので、join2には+ngはない。

お題:シェルスクリプトで会員管理

架空の団体UPS友の会の会員管理業務を行う。UPS友の会には、会を取り仕切るスタッフがいる。事務局には、次のようなリストがある。

$ cat STAFF
S001 上田 ueda@hogehoge.com
S002 濱田 hamada@nullnull.com
S003 鎌田 kamata@x-japan.com
S004 松浦 matura@superstrongmachine.com
$ 

第1フィールドが通し番号 (スタッフ番号) 、第2フィールドが名前 (例なのでfamily nameだけ) 、第3フィールドが電子メールアドレスだ。念のため、メールアドレスは架空のものとお断りしておく。

会員も、スタッフと同じフォーマットのリストで管理している。第1フィールドは会員番号だ。人数は10人とし、会員番号は3桁にしておく。

$ cat MEMBER
M001 上田 ueda@hogehoge.com
M002 濱田 hamada@nullnull.com
M003 武田 takeda@takenaka.com
M004 竹中 takenaka@takeda.com
M005 田中 tanaka@hogehogeho.jp
M006 鎌田 kamata@x-japan.com
M007 田上 tanoue@tanoue.co.jp
M008 武山 takeyama@zzz.com
M009 山本 yamamoto@bash.co.jp
M010 山口 yamaguchi@daioujyou.com
$ 

会員にもスタッフにも住所は聞いていないので、個人の識別はメールアドレスで行っている。

UPS友の会の主な活動は、電源に関する勉強会だ。次の勉強会は6月にあり、現在、勉強会への参加者を募集している。現在の参加者リストは次のようになってる。

$ cat STUDY.201206
takeda@takenaka.com 武田
yamakura@hogehogeho.jp 山倉
hamada@nullnull.com 濱田
tanoue@tanoue.co.jp 田上
ueda@hogehoge.com 上田
sinozuka@zzz.com 篠塚
yamaguchi@daioujyou.com 山口
yamamoto@bash.co.jp 山本
$ 

では、この3個のファイルに対して、リレーショナルな演算をしてみよう。

スタッフなのに、会員になってない人のあぶり出し

まず最初の例だ。この会の会長は、面白そうな人に声をかけてUPS友の会のスタッフにしているのだが、こういうスタッフの集め方をしているとスタッフなのに会員になっていない人が出る可能性がある。

トランザクションにあって、マスタにあるもの/ないものの抽出は、join0で行う。ここでは会員リストをマスタ扱いにして、会員のスタッフ、非会員のスタッフを分別する。

$ join0 +ng key=1 member staff > staff_member 2> staff_nonmember
# 会員かつスタッフ
$ cat staff_member 
hamada@nullnull.com S002 濱田
kamata@x-japan.com S003 鎌田
ueda@hogehoge.com S001 上田
# 会員でないスタッフ
$ cat staff_nonmember 
matura@superstrongmachine.com S004 松浦
$ 

この場合、松浦さんが登録漏れしていることがわかる。

勉強会の会費計算

次に、6月の勉強会の収入を確認する。UPS友の会の勉強会では、飲み物やお菓子代程度の会費を集めている。会費は次のように設定している。

スタッフ:無料 (当日の労働が参加費) 会員:300円非会員:500円

この計算は、勉強会参加リスト (STUDY.201206) をトランザクションにして、マスタの情報をくっつけていき、最後に各レコードに金額を付与して計算する。

まず、ソートを実施する。

$ sort STUDY.201206 > study
$ head -n 3 study 
hamada@nullnull.com 濱田
sinozuka@zzz.com 篠塚
takeda@takenaka.com 武田
$ 

次に、順にマスタ情報をくっつけていく。レコードが落ちてはいけないから、join2を使う。

$ cat study | join2 key=1 member - | join2 key=1 staff - | head -n 3
hamada@nullnull.com S002 濱田 M002 濱田 濱田
sinozuka@zzz.com **** **** **** **** 篠塚
takeda@takenaka.com **** **** M003 武田 武田
$ 

必要なフィールドだけ取り出して、数を数える。

#必要なフィールド:スタッフ番号、会員番号の頭のアルファベット
$ cat study | join2 key=1 member | join2 key=1 staff | self 2.1.1 4.1.1 | tr '*' '@'
$ cat tmp
S M
@ @
@ M
@ M
S M
@ M
@ @
@ M
#どの区分の人が何人いるか?
$ sort tmp | count 1 2 
@ @ 2
@ M 4
S M 2
$ 

これくらい簡単な話であればあとは手で計算すれば十分だが、次のように最後まで計算を進めることもできる。

↓ awk(1)で計算する場合
$ sort tmp | count 1 2 | awk '/@ @/{print $3*500}/@ M/{print $3*300}' 
1000
1200
$

↓ sm2で計算する場合
$ sort tmp | count 1 2 | awk '/@ @/{print $3*500}/@ M/{print $3*300}' | sm2 0 0 1 1
2200
$ 

awk(1)は、

awk 'パターン1 { 処理1 } パターン2 { 処理2 } パターン3 { 処理3 } ...'

という書き方をする。パターンに一致する行に対し、そのパターンに対応する処理を行う。2つ以上のパターンに一致するときは、それぞれの処理が同じ行に適用される。

また、この処理のパターン/@ @/や/@ M/は、$0~/@ @/や$0~/@ M/と同じ意味で、行全体に対して正規表現を当てはめる処理だ。

sm2 0 0 1 1は、入力の第1フィールドを合計するために使われている。sm2Tukubaiコマンドで、次のように使う。4個オプションがあるが、前2つでキーの範囲、後ろ2つで値の範囲を指定する。sm2の使用は、たとえば次のようなものになる。

$ cat BASS 
バース SD 1980 3
バース SD 1981 4
バース SD 1982 1
バース TEX 1982 1
バース 阪神 1983 35
バース 阪神 1984 27
バース 阪神 1985 54
バース 阪神 1986 47
バース 阪神 1987 37
バース 阪神 1988 2
$

↓ 第1フィールドをキーに第4フィールドを合計
$ cat BASS | sm2 1 1 4 4
バース 211
$

↓ キーを無視して第4フィールドを合計
$ cat BASS | sm2 0 0 4 4
211
$ 

↓ 第1フィールドと第2フィールドをキーに第4フィールドを合計
$ cat BASS | sm2 1 2 4 4
バース SD 8
バース TEX 1
バース 阪神 202
$ 

↓ BASSファイルから第2フィールドを削除の後、年毎に集計
$ cat BASS | delf 2 | sm2 1 2 3 3
バース 1980 3
バース 1981 4
バース 1982 2
バース 1983 35
バース 1984 27
バース 1985 54
バース 1986 47
バース 1987 37
バース 1988 2
$ 

会員を追加する

STUDY.201206ファイルに新しい会員データを入れ、これをMEMBERファイルに統合してみよう。まずは、非会員の勉強会参加者を抽出する。キーをソートしてからjoin0の+ngオプションで非会員を抽出する。

$ sort STUDY.201206 > study 
$ head -n 3 study 
hamada@nullnull.com 濱田
sinozuka@zzz.com 篠塚
takeda@takenaka.com 武田
$ self 3 MEMBER | sort | join0 +ng key=1 - study > /dev/null 2> tmp
$ self 2 1 tmp > newmember 
$ cat newmember 
篠塚 sinozuka@zzz.com
山倉 yamakura@hogehogeho.jp
$ 

あとはファイルをくっつけて番号を打ち直せば新しいリストができる。

$ sed 's/^M0*//' MEMBER | cat - newmember | awk '{if(NF==3){n=$1;print}else{print ++n,$0}}' | awk '{print sprintf("M%03d",$1),$2,$3}' > MEMBER.new
$ cat MEMBER.new
M001 上田 ueda@hogehoge.com
M002 濱田 hamada@nullnull.com
M003 武田 takeda@takenaka.com
M004 竹中 takenaka@takeda.com
M005 田中 tanaka@hogehogeho.jp
M006 鎌田 kamata@x-japan.com
M007 田上 tanoue@tanoue.co.jp
M008 武山 takeyama@zzz.com
M009 山本 yamamoto@bash.co.jp
M010 山口 yamaguchi@daioujyou.com
M011 篠塚 sinozuka@zzz.com
M012 山倉 yamakura@hogehogeho.jp
$ 

終わりに

Tukubaiコマンドjoin0join1join2を使ってファイルの関係演算をした。コマンドがたった3個増えるだけで、できることがずいぶん広がったことがわかるだろう。これはインタフェースを束縛しないことによる効果だ。

Software Design 2012年6月号 上田隆一著、テキストデータならお手のもの 開眼シェルスクリプト 【6】テキストで台帳管理を行う ― SQLライクなファイル結合より加筆修正後転載

Last modified: 2014-01-13 00:00:00