UECジャーナル

開眼☆シェルスクリプト 【7】安全にファイルを更新する ― エラーチェックの実装

はじめに

重要なファイルを上書き更新するシェルスクリプトを扱う。ファイルを更新するということは「覆水盆に返らず」なので、それなりに気を使うべきだ。

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

UNIX系OSにおいては、コマンドを実行することで既存の元ファイルの内容を変更するようなコマンドは少数派だ。sed(1)やnkf(1)コマンドは上書きができるが、オプションを指定しないと上書きモードにならない。基本的には新しいファイルに、変更後の内容を書き出す。

$ command file

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

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

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

リスト1: ファイル内容の更新手順

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

もし必要なら、mv(1)の前にdiff(1)をとって確認したり、もとのファイルのバックアップをとったりする。一度別のファイルに結果を出力してからmv(1)する方法は合理的だ。

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

前回に引き続き、架空の団体UPS友の会の会員管理を扱う。前回は手動で会員リストを操作したが、今回は会員リストへの新会員の追加処理をシェルスクリプト化する。シェルスクリプトでは、会員リストを上書きするときに会員リストを壊さないように、さまざまな仕掛けをする。

準備

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

リスト2: ディレクトリ

UPSTOMO/
├── DATA
│   └── MEMBER
├── SCR
│   └── ADDMEMBER
└── newmember
SCR: シェルスクリプト (ADDMEMBERファイル) 置き場DATA:会員リスト (MEMBERファイル) の置き場所

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

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

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

MEMBERファイルには、次のような既存会員データが記録されている。このファイルが原本となる。

リスト4: 会員リスト

$ 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 -
$ 
1:会員番号 2:氏名 (簡略化のため姓のみ) 3:e-mailアドレス 4:入会処理日 5:退会処理日

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

準備

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

リスト5: エラー検知処理を書く - bashを使用

#!/usr/bin/env bash

tmp=/home/ueda/tmp/$$

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

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

#テスト
true | true
CHECK "trueで成功"

true | false
CHECK "falseで失敗"

rm -f $tmp-*
exit 0

5~12行目はbashの関数だ。書き方はリスト5のように、名前(){処理}となる。呼び出し方はコマンドと一緒で、名前を行頭に書く。引数は()内で定義せず、関数内で$1、$2、...と呼び出す。

エラーメッセージは、標準エラー出力させる。8、9行目のように、1>&2と書くことで、echoの出力先を標準エラー出力にリダイレクトできる。

${PIPESTATUS[@]}は、パイプでつながったコマンドの終了ステータスを記録した文字列に置き換わる。終了ステータスは、コマンドが成功したかどうかを示す値で、コマンドが終わると変数$?にセットされる。ただし、$?には1つの終了ステータスしか記録できない。これに対しbashでは、PIPESTATUSという配列に、パイプでつながったコマンドの終了ステータスを記録できるようになっている。

リスト6: PIPESTATUS

$ true 
$ echo $?
0
$ false
$ echo $?
1
$ true | true | true | true
$ echo ${PIPESTATUS[@]}
0 0 0 0
$ true | true | false | true
$ echo ${PIPESTATUS[@]}
0 0 1 0
$ true 
$ echo ${PIPESTATUS[@]}
0
$ 

$(echo ${PIPESTATUS[@]} | tr -d '0 ')は、「文字列${PIPESTATUS[@]}をtr(1)に送って、0と半角空白を取り除いた文字列」となる。$()は、括弧中のコマンドの標準出力を文字列として置き換えるための表記方法だ。${PIPESTATUS[@]}から0と空白を除去すれば、コマンドの終了ステータスがすべて0ならば空文字列になる。

[ -z "文字列" ] && returnで、「空文字であったら関数を出る」という意味になるので、コマンドにエラーがなければCHECK関数をすぐに出て処理に戻る。

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

$ [ -z "" ] 
$ echo $?
0
$ [ -z "12" ] 
$ echo $?
1
$ 

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

[コマンドを使うときは、必ず変数や文字列に置き換わる部分を"で囲むようにする。"で囲っていない変数が空だと、[コマンドが空状態を認識できないため、次のように挙動が変わってしまう。

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

$ a=
$ [ -n "$a" ] 
$ echo $?
1
$ [ -n $a ] 
$ echo $?
0
$ 

[コマンドの使い方については、シェルプログラミングTipsの[と]の間には空白を入れるにも説明があるので参考にしてほしい。

書いたスクリプトを実行する。これまでのことが理解できていたら、リスト9のような出力になることがわかるはずだ。

リスト9: 実行結果

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

チェックを実装する

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

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

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

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

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

#!/usr/bin/env 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

19、20行目でフィールド数を確認する。gyoretuTukubaiコマンドで、gyoはレコードの数、retuはフィールド数を出力する。リスト11に使用例を示す。あるファイルのフィールド数が揃っていると、retu file | gyoと書くと1が出力される。リスト10のチェックでは、19行目でそれを利用してフィールドが揃っていることを確認して、20行目でフィールド数が2であることを調べている。

ちなみに、gyoはawk 'END{print NR}'、retuはawk '{print NF}' | uniqと等価だ。

リスト11: retuの使用例

$ 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
$ 

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

動作の確認

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

リスト12: 挙動の確認

↓ 正しい入力
$ echo 山田 email@email | ./ADDMEMBER
$ echo $?
0
$

↓ emailがない
$ echo 山田  | ./ADDMEMBER.CHECK 
エラー: 列数
処理できませんでした
$ echo $?
1
$ 

↓ 間違えてtwitterアカウントを入力
$ echo 山田 @usptomo | ./ADDMEMBER.CHECK 
エラー: email
処理できませんでした
$ echo $?
1
$ 

メンバー追加処理を書く

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

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

#!/usr/bin/env 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 変更しました 1>&2
diff $dir/MEMBER.${DATE}.$$ $dir/MEMBER 1>&2

rm -f $tmp-*
exit 0

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

目新しいところとしては、3行目のdirname(1)コマンドの使い方と、15行目のawk(1)の使い方だろう。dirname(1)コマンドは、このスクリプトのあるディレクトリを出力する。このスクリプトでは、MEMBERファイルの場所を特定するために使っている。15行目では、変数をawk(1)に渡すために-vというオプションを使用している。

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

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

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

↓ 更新前
$ 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
$ 

おわりに

ファイルの追記を自動化するためのスクリプトを書いた。シェルスクリプトではファイルと標準出力を相手にプログラムする。これはデータの可視化という面で、配列やメモリなど可視化しにくいものを相手するよりも、直感的に作業できるといえる。

Software Design 2012年7月号 上田隆一著、「テキストデータならお手のもの 開眼シェルスクリプト 【7】安全にファイルを更新する ― エラーチェックの実装」より加筆修正後転載

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