開眼シェルスクリプト2012年6月号

Pocket
LINEで送る

出典: 技術評論社SoftwareDesign

6. 開眼シェルスクリプト 第6回

6.1. はじめに

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

 DBを使うと、排他制御やユーザの管理など様々な便利機能も利用できるのですが、
関係演算だけならテキストファイルでもできます。
テキストファイルで関係演算ができると、
端末だけで作業が簡潔することが非常に多くなります。
今回は端末でファイル操作する方法を覚えて、
次回でシェルスクリプトで簡単なシステムを作ります。

 USP研究所ではシェルスクリプトでNoSQLなシステムを作って実績もあげていますので、
これから紹介する方法の延長でデータストアを作ることも可能です。
これについても面白い事例が多いので紹介したいのですが、
これは別の機会に譲ることとします。

6.1.1. 自由を保つ

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

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

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

$ hogesql
SQL>

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

 逆に、テキストファイルだけでデータを管理する場合、
「公式マニュアル」が存在していないので、
それは欠点までとは言わなくとも不利な点でしょう。
でも、テキストですからいつでも表計算ソフトにコピペできます。

6.2. コマンドの使い方

 今回はTukubaiコマンドの join0, join1, join2 を使います。
LinuxやFreeBSDにはjoinという標準のコマンドがあるのですが、
オプションがややこしいのでこれは使いません。
また、今回の端末上でのコードは、
Ubuntu Linux 11.10のbash 4.2上で試しながら書きました。

6.2.1. join1

 まず、join0より先にjoin1を説明します。join1はファイル同士をキーでくっつけるコマンドです。
リスト1、2が典型的な使い方です。魚屋の例です。

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

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
[ueda@sakura 201206]$ cat file1
01 たらこ
02 いくら
03 キャビア
04 カラスミ
[ueda@sakura 201206]$ cat file2
20120104 01 10
20120104 02 321
20120104 03 13
20120105 02 211
20120105 05 12

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

↓リスト2: join1の使い方

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

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

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

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

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

リスト3: join2の使用

1
2
3
4
5
6
$ 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

6.2.3. 論理演算するjoin0

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

リスト3: join0の使用

1
2
3
4
5
6
#トランザクション
$ 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を使ってマスタにないものを抽出

1
2
3
4
5
6
7
#標準出力からはマスタとマッチしたものが出力される。
#この場合は捨てる。
$ 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 はありません。

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

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

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

見れば分かるように、第一フィールドが通し番号(スタッフ番号)、
第二フィールドが名前(例なのでfamily nameだけ)、第三フィールドが電子メールアドレスです。念のため、メールアドレスは架空のものとお断りしておきます。

 会員も、スタッフと同じフォーマットのリストで管理しています。
第一フィールドは会員番号です。
本当はUPS友の会には会員が100万人いるのですが、
人数は10人にして、会員番号は3桁にしておきます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
$ 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月にあり、現在、勉強会への参加者を募集しています。
現在の参加者リストは次のようになってます。

1
2
3
4
5
6
7
8
9
$ 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個のファイルに対して、リレーショナルな演算をしてみましょう。

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

 まず最初の例です。この会の会長は、
面白そうな人に声をかけてUPS友の会のスタッフにしているのですが、
こういうスタッフの集め方をしていると
「スタッフなのに会員になっていない人」が出る可能性があります。
会費を取りたいので、しばらく泳がせてから会費を請求して会員にしています。
そのようなスタッフのあぶり出しです。(注意:あくまで架空の話)

 これくらいなら、わざわざシェルスクリプトを書くよりも、
出力を見ながら手作業でやったほうがよさそうです。
端末で、まずキー項目(メールアドレス)をファイルの左側に寄せて、
キーでソートします。

#端末をいじるときは作業ディレクトリを作って、
#必要なファイルをコピーしてくること
$ self 3 1 2 MEMBER | sort > member
$ self 3 1 2 STAFF | sort > staff
$ head -n 3 member staff
==> member <==
hamada@nullnull.com M002 濱田
kamata@x-japan.com M006 鎌田
takeda@takenaka.com M003 武田

==> staff <==
hamada@nullnull.com S002 濱田
kamata@x-japan.com S003 鎌田
matura@superstrongmachine.com S004 松浦

 トランザクションにあって、マスタにあるもの/ないものの抽出は、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.3.2. 勉強会の会費計算

 次に、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で金額を出す。
$ sort tmp | count 1 2 | awk '/@ @/{print $3*500}/@ M/{print $3*300}'
1000
1200
#sm2(Tukubaiコマンド)で合計
$ sort tmp | count 1 2 | awk '/@ @/{print $3*500}/@ M/{print $3*300}' | sm2 0 0 1 1
2200

 この処理では、少し面白いawkの使い方をしています。
awkは、

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

という書き方ができます。
awkはパターンがあると、行を読み込んだときに各パターンと照合して、
合致したら、そのパターンに対応する処理を行います。
二つ以上のパターンに一致するときは、それぞれの処理が同じ行に適用されます。

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

 もう一点。 sm2 0 0 1 1 は、
入力の第一フィールドを合計するために使われています。
sm2はTukubaiコマンドで、以下のように使います。
4個オプションがありますが、前二つでキーの範囲、後ろ二つで値の範囲を指定します。

#こういう情報を処理します。
$ 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(第1フィールド)をキーに$4を合計
$ cat BASS | sm2 1 1 4 4
#キーを無視して$4を合計
バース 211
$ 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

6.3.3. 会員を追加する

 勉強会はおおいに盛り上がり、非会員だった人が全員その場で入会を希望しました。
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

次のように一気に書くこともできますので一応示しておきますが、
無理に一気に書くことはあまりしないほうがよいと思います。
手作業なので、少しずつファイルにリダイレクトして中身を確認して進めましょう。
<() は、括弧内の処理をファイルのようにコマンドに入力するための記号ですが、
処理の流れが一方通行でなくなるので筆者の場合は滅多に使いません。

$ self 3 MEMBER | sort | join0 +ng key=1 - <(sort STUDY.201206) 2>&1 > /dev/null | self 2 1
篠塚 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

 ところで、このような端末操作は常に間違いがつきまといます。
ちゃんとチェックしましょう。
少なくとも、diffには必ず通します。

$ diff MEMBER MEMBER.new
10a11,12
> M011 篠塚 sinozuka@zzz.com
> M012 山倉 yamakura@hogehogeho.jp

もっとレコード数が大きくて目で確認するのが大変なときは、
次のような方法もあります。
gyoは、ファイルの行数を出力するコマンドです。

#既存のレコードに変更がないことを確認
$ diff MEMBER MEMBER.new | grep '^<' | gyo
0
#新規レコード数を確認
$ diff MEMBER MEMBER.new | grep '^>' | gyo
2

これで納得したらファイルを更新します。

$ mv MEMBER MEMBER.20120601
$ mv MEMBER.new MEMBER

6.4. 終わりに

 今回はTukubaiコマンドのjoin0,1,2を使ってファイルの関係演算をしました。
コマンドがたった3個増えるだけで、
できることがずいぶん広がったと思っていただければ今回は成功だと思います。
これは、「インタフェースを束縛しない」効果だと言えます。

 次回は、UPS友の会の会員情報を、
もうちょっとシステマチックに管理するシェルスクリプトを扱います。
特に最後のファイル更新前のチェックは、
シェルスクリプトにして機械的にした方がよさそうです。
エラーチェックには例外処理などの仕組みが必要なので、
シェルスクリプトでどうそれを実装するかを扱いたいと思います。

Pocket
LINEで送る