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

Pocket
LINEで送る

出典: 技術評論社SoftwareDesign

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

7.1. はじめに

 今回の開眼シェルスクリプトは、なにかしらの重要なファイルを上書き更新するシェルスクリプトを扱います。
ファイルを更新するということは「覆水盆に返らず」ですので、それなりに気を使うべきです。
今回はシェルスクリプトを使って安全に更新する方法を扱います。

7.1.1. コマンドはファイルを上書きしない

 たまにtwitterなどで「ファイルを直接更新するコマンドはないのか?」
という発言を捕捉することがあります。
つまり、下のような例で、ファイル file が変更できないのかということでしょう。

$ command file

 UNIX系OSにおいては、このようなコマンドは少数派です。
sedやnkfコマンドは上書きができますが、オプションを指定しないと上書きモードになりません。

 ファイル上書きをむやみに許してしまうと、次のようになにかと不都合です。

  • パイプ接続できるコマンドとできないコマンドの識別する労力が増大
  • なにか失敗すると後戻り不可能。あるいは面倒

 ファイルを上書きしたいときは、面倒でも次のような手続きを踏みます。

$ command file > file.new
$ mv file.new file

もし必要なら、mvの前にdiffをとって確認したり、
もとのファイルのバックアップをとったりします。
逆に言えば、そのような機会が必ず用意されるという点で、
一度別のファイルに結果を出力してからmvする方法は合理的と言えます。
シェルスクリプトでも、同様の手続きを踏みます。

 もういい加減ネタ切れですが、今回も格言(?)を。

  • 覆水盆に返らず —呂尚
  • いきなり盆をひっくり返すから盆に返らないだけ —筆者

7.2. お題:会員管理を自動化する

 前回に引き続き、架空の団体「UPS友の会」の会員管理を扱います。
前回は手動で会員リストを操作しましたが、
今回は会員リストへの新会員の追加処理をシェルスクリプト化します。
シェルスクリプトでは、会員リストを上書きするときに会員リストを壊さないように、
様々な仕掛けをします。これまでの連載では、
ほとんど一直線のパイプライン処理ばかり扱っていましたが、
今回は細かい文法をいくつか知っておく必要があります。
細かくなると、文法がシェルごとに違うことがありますが、
今回はbashの文法を使います。

7.2.1. 準備

 今回は、リスト1の環境でプログラムを組んで動作させます。

↓リスト1: 環境

ueda@uedaubuntu:~$ bash --version | head -n 1
GNU bash, バージョン 4.2.10(1)-release (i686-pc-linux-gnu)
ueda@uedaubuntu:~$ echo $LANG
ja_JP.UTF-8
ueda@uedaubuntu:~$ lsb_release -a | grep Description
Description:    Ubuntu 11.10

 まず、ディレクトリを準備をします。
適当な場所に、リスト2のようにディレクトリを掘ります。

  • SCR: シェルスクリプト(ADDMEMBERファイル)置き場
  • DATA:会員リスト(MEMBERファイル)の置き場所

↓リスト2: ディレクトリ

1
2
3
4
5
6
UPSTOMO/
├── DATA
│   └── MEMBER
├── SCR
│   └── ADDMEMBER
└── newmember

newmemberは、新しく追加する会員のリストで、一時的なのものです(リスト3)。

↓リスト3: 新規会員を書いたファイル

1
2
3
$ cat newmember
門田 kadota@paa-league.net
香川 kagawa@dokaben.com

MEMBERファイルには、次のような既存会員データが記録されています(リスト4)。
このファイルが原本です。

↓リスト4: 会員リスト

1
2
3
4
5
6
7
8
#1:会員番号 2:氏名(簡略化のため姓のみ) 3:e-mailアドレス
#4:入会処理日 5:退会処理日
$ head -n 5 ./DATA/MEMBER
10000001 上田 ueda@hogehoge.com 19720103 -
10000002 濱田 hamada@nullnull.com 19831102 -
10000003 武田 takeda@takenaka.com 19930815 20120104
10000004 竹中 takenaka@takeda.com 19980423 -
10000005 田中 tanaka@kakuei.jp 20000111 -

newmemberファイルのデータに会員番号と入会処理日をつけて、
MEMBERファイルに追記するのが、ADDMEMBER の役目です。
MEMBERファイルを壊してはいけませんので、
入力をチェックしてから追記処理をしなくてはなりません。

7.2.2. 準備

 まず、シェルスクリプトADDMEMBERに、
リスト5のようにエラーを検知する仕組みを書きます。

↓リスト5: エラー検知処理を書く

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
#!/bin/bash

tmp=/home/ueda/tmp/$$

CHECK(){
        [ -z "$(echo ${PIPESTATUS[@]} | tr -d '0 ')" ] && return

        echo "エラー: $1" >&2
        echo 処理できませんでした。>&2
        rm -f $tmp-*
        exit 1
}

#テスト
true | true
CHECK これは成功する。

true | false
CHECK falseで失敗

rm -f $tmp-*
exit 0

 5~12行目はbashの関数です。
書き方はリスト1のように、 名前(){処理} となります。
呼び出し方はコマンドと一緒で、名前を行頭に書きます。
引数は () 内で定義せず、関数内で $1, $2, ... と呼び出します。
例えば、19行目で CHECK falseで失敗 と記述されていますが、
「falseで失敗」は、CHECK関数の第一引数で、関数内で$1として呼び出せます。
リスト5では、8行目で$1を使っています。

 エラーメッセージは、標準エラー出力に出すのが行儀良いでしょう。
8,9行目のように、 >&2 と書くことで、
echoの出力先を標準エラー出力にリダイレクトできます。
基本的に、標準出力はコマンド(コンピュータ)のため、
標準エラー出力は人間が読むために使います。

 6行目の呪文を一つずつ紐解いていきましょう。
まず ${PIPESTATUS[@]}
は、パイプでつながったコマンドの終了ステータスを記録した文字列に置き換わります。
終了ステータスは、コマンドが成功したかどうかを示す値で、
コマンドが終わると変数 $? にセットされる値です。
ただ、 $? には一つの終了ステータスしか記録できないので、
bashではPIPESTATUSという配列に、
パイプでつながったコマンドの終了ステータスを記録できるようになっています。
リスト6に例を示します。trueコマンドとfalseコマンドは、
ただ単に成功(終了ステータス0)、
失敗(終了ステータス1)を返すコマンドです。

↓リスト6: PIPESTATUS

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
#単独動作
#終了ステータスは$?で参照できる
$ true
$ echo $?
0
$ false
$ echo $?
1
#PIPESTATUSには終了ステータスが順に入る
$ true | true | true | true
$ echo ${PIPESTATUS[@]}
0 0 0 0
$ true | true | false | true
$ echo ${PIPESTATUS[@]}
0 0 1 0
#コマンドが一個だけでもOK
$ true
$ echo ${PIPESTATUS[@]}
0

 PIPESTATUSが分かったところで再びリスト5の6行目に戻ります。
"$(echo ${PIPESTATUS[@]} | tr -d '0 ')"
は、「文字列 ${PIPESTATUS[@]} をtrに送って、0と半角空白を取り除いた文字列」
となります。 $() は、
括弧中のコマンドの標準出力を文字列として置き換えるための表記方法です。
${PIPESTATUS[@]} から0と空白を除去すれば、
コマンドの終了ステータスがすべて0ならば空文字列になります。
[ -z "文字列" ] && return で、
「空文字であったら関数を出る」という意味になるので、
コマンドにエラーがなければCHECK関数をすぐに出て処理に戻ります。

  [ ]&& についても解説が必要でしょう。
[ はコマンドです。 man [ と打ってみると、
マニュアルが表示されるはずです。
このコマンドはテストコマンドと呼ばれ、
コマンド本体 [ とオプション ] で囲まれた部分に
条件式をオプションで書いて、
条件式が満たされれば終了ステータス0を返すコマンドです。
[ -z "文字列" ] と書くと、
「文字列が空であること」をテストすることになり、
空文字ならば終了ステータス0を返します。
リスト7で動きを示します。

↓リスト7: 空文字かどうかの判定

1
2
3
4
5
6
$ [ -z "" ]
$ echo $?
0
$ [ -z "12" ]
$ echo $?
1

そして、 && をコマンドをはさむと、
左側のコマンドの終了ステータスが0の場合に右側のコマンドが実行されます。
リスト5の7行目の場合、PIPESTATUSに0以外のものがなければ [ が0を返すので、
returnが実行されて処理が関数から出ます。
もし0でない数字が含まれていたら、処理は8行目以降に進み、
エラー情報が表示され、中間ファイルが消されて、
終了ステータス1でスクリプトが終わります。

 ところで、テストコマンドを使うときは、
必ず変数や文字列に置き換わる部分を”“で囲んでください。
リスト8のように、違った結果が返ってきます。
“”で囲っていない変数が空だと、
[ コマンドがオプションとして認識できないので、
このように挙動が変わってしまいます。

↓リスト8: 変数を”“で囲まないと挙動が変わる

1
2
3
4
5
6
7
8
#空の変数aをセット
$ a=
$ [ -n "$a" ]
$ echo $?
1
$ [ -n $a ]
$ echo $?
0

 最後に、書いたスクリプトを実行してみましょう。
これまでのことが理解できていたら、
リスト9のような出力になることも理解できると思います。

↓リスト9: 実行結果

1
2
3
$ ./ADDMEMBER
エラー: falseで失敗
処理できませんでした。

7.2.3. チェックを実装する

 では、ADDMEMBERに次のチェック項目を実装してみましょう。

  • 入力のデータがちゃんと二列になっているか
  • メールアドレスについて、文字列と文字列の間に@がついているか

ある文字列がメールアドレスかどうかという判断は大変です。
厳密にチェックしたい場合は、コマンドを準備して、
そこに通して判断させるということを考えないといけません。
ここでは簡素に済ますことにします。

 リスト10が上記2点を実装したものです。

19,20行目でフィールド数を確認します。
gyoとretuはTukubaiコマンド(https://uec.usp-lab.com)で、
gyoはレコードの数、retuはフィールド数を出力するものです。
リスト11に使用例を示します。
あるファイルのフィールド数が揃っていると、
retu file | gyo と書くと1が出力されます。
リスト10のチェックでは、
19行目でそれを利用してフィールドが揃っていることを確認して、
20行目でフィールド数が2であることを調べています。
ちなみに、gyoは awk 'END{print NR}'
retuは awk '{print NF}' | uniq と等価です。

 23,24行目では、入力から電子メールのフィールドをself(Tukubaiコマンド)
で切り出して、grepで条件に合うものを抽出しています。
25行目で、もとのレコード数と抽出された電子メールのレコード数を比較しています。

↓リスト10: チェックのコード

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#!/bin/bash

tmp=/home/ueda/tmp/$$

CHECK(){
        (略)
}

####################################
#標準入力をファイルに書き出す
cat < /dev/stdin > $tmp-file
#1:名前 2:emailアドレス
CHECK 読み込めません

####################################
#入力チェック

###入力ファイルが2列か調べる
[ "$(retu $tmp-file | gyo)" -eq 1 ] ; CHECK 列数
[ "$(retu $tmp-file)" -eq 2 ] ; CHECK 列数

###@が文字列と文字列の間に挟まっていること
self 2 $tmp-file        |
grep '^..*@..*$'        > $tmp-ok-email
[ "$(gyo $tmp-file)" -eq "$(gyo $tmp-ok-email)" ]
CHECK email

rm -f $tmp-*
exit 0

↓リスト11: retuの使用例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
$ cat fuge
1 2 3
1 2 3
1 2 3
1 2 3
$ gyo fuge
4
$ cat fuge | retu
3
$ cat hoge
a
a
a
a a a
a a
$ cat hoge | retu
1
3
2

7.2.4. 動作の確認

 スクリプトを書いたら、挙動を確認してみましょう。
リスト12のように、エラーメッセージと終了ステータスが
適切に出力されることを確認してみます。

↓リスト12: 挙動の確認

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
#正しい入力
$ echo 山田 email@email | ./ADDMEMBER
$ echo $?
0
#emailがない
$ echo 山田  | ./ADDMEMBER.CHECK
エラー: 列数
処理できませんでした。
$ echo $?
1
#間違えてtwitterアカウントを入力
$ echo 山田 @usptomo | ./ADDMEMBER.CHECK
エラー: email
処理できませんでした。
$ echo $?
1

7.2.5. メンバー追加処理を書く

 入力のチェック部分は完成したので、
本来やりたいことである新規会員の追加処理を書きましょう。
こちらにもエラーチェックは必要です。
特に、ファイルを更新するときは神経を使わなければなりません。

↓リスト13: MEMBERファイル更新スクリプト

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#!/bin/bash

dir="$(dirname $0)/../DATA"
tmp=/home/ueda/tmp/$$

(リスト10の5~26行目。関数と入力チェック)

####################################
#追記処理
DATE=$(date +%Y%m%d)

#1:名前 2:email
cat $tmp-file                           |
#MEMBERと形式を合わせる
awk -v d="${DATE}" '{print 0,$0,d,"-"}' |
#1:会員番号(仮) 2:名前 3:email 4:登録日 5:"-"
#MEMBERとマージ
cat $dir/MEMBER -                       |
#1:会員番号 2:名前 3:email 4:登録日 5:退会日
awk '{if($1==0){$1=n};print;n=$1+1}' > $tmp-new
CHECK 追加処理失敗

#新しいリストをチェック
[ "$(retu $tmp-new | gyo)" -eq 1 ]
CHECK フィールド数が不正
[ "$(retu $tmp-new)" -eq 5 ]
CHECK フィールド数が不正
#emailの重複チェック
DUP=$(self 3 $tmp-new | sort | uniq -d | gyo)
[ "${DUP}" -eq 0 ]
CHECK email重複

######################################
#更新
cat $dir/MEMBER > $dir/MEMBER.${DATE}.$$
CHECK 旧リストのバックアップ
cat $tmp-new > $dir/MEMBER
CHECK 新リストの書き出し

######################################
#diffで確認
echo 変更しました >&2
diff $dir/MEMBER.${DATE}.$$ $dir/MEMBER >&2

rm -f $tmp-*
exit 0

 リスト9にスクリプト全体を示します。
13行目から20行目で、新たなメンバーをMEMBERファイルに追加して、
$tmp-new に新しいリストを作成しています。

 目新しいところとしては、3行目のdirnameコマンドの使い方と、
15行目のawkの使い方でしょう。
dirnameコマンドは、このスクリプトのあるディレクトリを出力します。
このスクリプトでは、MEMBERファイルの場所を特定するために使っています。
15行目では、bashの変数をawkに渡すために、
-vというオプションを使用しています
(脚注: http://d.hatena.ne.jp/Rocco/20071031/p2)。

 23行目から31行目までで、しつこくチェックをします。
29行目の uniq -d
は第一回でも使いましたが重複するレコードを抽出するために使っています。

 35~38行目での更新では、
更新前のファイルのバックアップをとっています。
こうしておけば、何かあっても安心です。
本連載で扱っているシェルスクリプトはファイル操作のためのものが中心なので、
もとのファイルさえ残しておけば多少ルーズに書いても、
致命的なことになりにくいという性質があります。
また、パイプを使うとファイルを直接上書きすることはないので、
スクリプトが途中で止まれば重要なファイルは守られるという性質があります。

 一方で rm -Rf ~/ などと書いてしまうとなにもかも消えてしまうので、
ホームのバックアップは必須ですが・・・。

 最後に、スクリプトを動作させて、今回は終わりにします。

↓リスト14: 会員の追加の実行

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
###更新前
$ tail -n 2 ./DATA/MEMBER
10000009 山本 yamamoto@bash.co.jp 20101010 -
10000010 山口 yamaguchi@daioujyou.com 20120401 -
###更新実行
$ cat newmember | ./SCR/ADDMEMBER
変更しました
10a11,12
> 10000011 門田 kadota@paa-league.net 20120429 -
> 10000012 香川 kagawa@dokaben.com 20120429 -
###不正な値を入力してみる
$ echo 上田 ueda@hogehoge.com | ./SCR/ADDMEMBER
エラー: email重複
処理できませんでした。
###更新後
$ tail -n 4 ./DATA/MEMBER
10000009 山本 yamamoto@bash.co.jp 20101010 -
10000010 山口 yamaguchi@daioujyou.com 20120401 -
10000011 門田 kadota@paa-league.net 20120429 -
10000012 香川 kagawa@dokaben.com 20120429 -
###バックアップが作成されている
$ ls ./DATA/MEMBER*
./DATA/MEMBER  ./DATA/MEMBER.20120429.8648

7.2.6. おわりに

 今回は、ファイルの追記を自動化するためのスクリプトを書きました。
関数、テストコマンド、 && 記号など、ややこしいものが出てきました。
これらの記号は一般的なプログラミング言語に比べると洗練されたものとは言えません。
しかしシェルスクリプトの場合、ほとんどファイルと標準出力を相手にプログラムするので、
配列やメモリなど可視化しにくいものを相手するよりは、
かなり楽に処理を書くことができると考えています。

Pocket
LINEで送る