開眼シェルスクリプト2012年6月号
Tue Dec 9 22:03:35 JST 2014 (modified: Sun Oct 1 10:50:27 JST 2017)
views: 3747, keywords: この記事は最終更新日が7年以上前のものです。
出典: 技術評論社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 $3500}/@ M/{print $3300}' 1000 1200 #sm2(Tukubaiコマンド)で合計 $ sort tmp | count 1 2 | awk '/@ @/{print $3500}/@ M/{print $3300}' | 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友の会の会員情報を、 もうちょっとシステマチックに管理するシェルスクリプトを扱います。 特に最後のファイル更新前のチェックは、 シェルスクリプトにして機械的にした方がよさそうです。 エラーチェックには例外処理などの仕組みが必要なので、 シェルスクリプトでどうそれを実装するかを扱いたいと思います。