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

Pocket
LINEで送る

出典: 技術評論社SoftwareDesign

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

1.1. はじめに

1.1.1. GancarzのUNIX哲学

突然ですが、これがなんだかご存知でしょうか?

  • 小さいものは美しい。
  • 各プログラムが一つのことをうまくやるようにせよ。
  • できる限り原型(プロトタイプ)を作れ。
  • 効率よりも移植しやすさを選べ。
  • 単純なテキストファイルにデータを格納せよ。
  • ソフトウェアの効率をきみの優位さとして利用せよ。
  • 効率と移植性を高めるためにシェルスクリプトを利用せよ。
  • 束縛するインターフェースは作るな。
  • 全てのプログラムはフィルタとして振る舞うようにせよ。

これはGancarzのUNIX哲学 [Gancarz2001]というものです。
UNIX(系OS)の使い方、さらには世界観をまとめたもので、
極めて乱暴にまとめると、

「端末やシェルスクリプトでコマンドを使い倒して早く仕事しようぜ」

ということを言っています。(繰り返しますが乱暴です。)

ここで言っている仕事というのは、
エンジニアがコンピュータのために仕事をすることよりも、
顧客データ管理、表計算、原稿書きなど利用することだと考えてください。
UNIXの使い方がCLI(コマンドラインユーザインタフェース)中心だった時代は、
テキストファイルに字を書いて保存したり、検索したり、
表計算したりすることが普通のことだったのです。
(私自身はそんな時代を知らない一人ですが。)

1.1.2. テキストファイルは自由だ

現在は、便利なGUIソフトがいろいろできたおかげで、
何でもCLIという窮屈なことは無くなりましたが、
テキストファイルが必要なくなることはありません。
どんなワープロソフトでもテキストのペーストは受け付けますし、
インターネットのプロトコルの多くはテキストベースです。
文字しかないということは、ソフトウェア同士、
あるいはコンピュータと人間の間で余計な決めごとが無いという利点があるのです。

また、テキストでデータを持っておくと、何にでも化けるという利点があります。
上記のUNIX哲学で「単純なテキストファイルにデータを格納せよ」
と言っているのはまさにこのことです。
筆者の例で恐縮ですが、今、
この原稿をreST(restructuredText)という形式のテキストで書いています。
reSTはそのまま編集の方に渡しても読んでもらえるほど分かりやすい形式です。
そして、Sphinxというツールに通すとhtmlに変換できるので、
体裁をブラウザで確認しながら書いています。
次回の執筆はもっと便利なように、

  • さらに編集者フレンドリーなテキストに変換するスクリプト
  • 掲載時とそっくりな体裁になるLaTeX形式への変換スクリプト

を書いてから開始しようと考えています。
自分の書くreSTだけ相手にすればよいので、そんなに難しくはないでしょう。

このようにテキストで済ませていると、特定のソフトの仕様に束縛されることがありません。
我々の場合なら、ちょっとプログラムでも書いてみようかという気持ちになるでしょう。
[Raymond2007]にも、UNIXはテキスト文化なので、
気軽にプログラムに入門できる効用があると述べられています。

1.1.3. 短いシェルスクリプトで様々な処理を

UNIX系OSのコマンドの多くは、テキスト処理のためにあります。
ただ、一つ一つのコマンドを端末で使う機会は多くの人にあるようですが、
組み合わせてシェルスクリプトにすると複雑な処理ができることは、
あまり知られていません。知られていないと言うより、
忘れ去られていると言った方が適切かもしれません。

この連載の目的は、UNIX系OSでコマンドを触ったことのある人に、
シェルスクリプトの便利な利用例を紹介し、
もっとコマンドの用途を広げてもらうことにあります。
コマンドをシェルスクリプトで組み合わせると、
役に立つプログラムが短く書けてしまう例を紹介していきます。
コマンドが多くの仕事をするので、シェルスクリプトを上手く書けば、
とてもあっさりとしたものが出来上がります。

ただし、「短いシェルスクリプト」に開眼するには、
ほとんどの人の場合、ある一定の時間がかかります。
特になにかしらの言語を知っている人が書くと、インデントとfor文だらけになりがちです。
そのようなシェルスクリプトはもっと短くできるのですが、
重要な割にニッチすぎて話題になりません・・・。

そこで本連載では、「シェルスクリプトの便利な利用例」と同じくらいの重要度で、
「短いシェルスクリプトを書く手練手管」も紹介していきます。
シェルスクリプトを使うことにピンと来ない人でも、
「深いインデント回避のためのロジック」を楽しんでいただけたらと考えています。

1.1.4. 筆者について

これを書いているのは、USP研究所という、
シェルスクリプトで業務システムを構築する会社に勤務し、
毎日シェルスクリプトばかり書いている男です。
短く書いて早く仕事を終わらせることにのみ、心血を注いでいます。

夜は(?)USP友の会というシェルスクリプトの会の会長として、
主に技術以外のところで微妙な働きをしています。

前職では某大学でロボットのプログラムばかり書き、
学生にもそれを強要していました。この時代は映像処理、カメラの制御、
その他人工知能的なものをひたすらプログラムしており、主にC++を使っていました。
現在もロボカップ(ロボットサッカーの大会)の日本大会で手伝いをしています。

もしシェルスクリプトに興味をそそられるようでしたら、
USP友の会が出没するイベントを訪ねていただければと存じます。

1.2. 今回のお題: ディレクトリのバックアップ処理+α

第一回はベタで、かつあまりテキスト処理とは縁がなさそうな、バックアップ処理を扱います。
お題はベタですが、連載で目指すコマンドの使い方を見せることができると考え、
取り上げました。また、処理の途中でちょっとしたテキスト処理が出てきます。

下のような状況を想定します。

  • /var/wwwの下が、毎日少しずつ書き換わる。
  • /var/wwwの下にはそんなにファイルがないので、毎日丸ごとバックアップを取る。
  • しかし、バックアップファイルが増えすぎるのはいやなので、昔のものは適切に間引きたい。

バックアップファイルには日付を入れて管理することになります。
間引く際には日付の計算をすることになるので、バックアップ自体よりも、
日付の計算をどのようにシェルスクリプトで書くかということが鍵になりそうです。

1.2.1. 環境等

筆者が本連載のために使っているシェル、OS(ディストリビューション)、
マシンは以下の通りです。

  • シェル:bash 4.1.2
  • OS:Linux (CentOS 6)
  • マシン:ThinkPad x41

テキストの文字コードは、UTF-8です。

スクリプトは平易な文法で書きますので、
bashのバージョンが違って困ることはないと考えています。
OSやディストリビューションの違いについては、
Macも含めてUNIX系ならば、bashが動けばなんとかなります。
文中のコードがそのままで動くという保証はありませんが、
コマンドのオプションを変えたり、
コマンドをインストールすることでご自身で試すことはできます。
どこにソースが転がっているか分からないコマンドは使いませんので、
適宜インストールするか、オプションを調整して乗り切っていただけたらと考えております。

1.2.2. 肩慣らし

まずは/var/wwwをバックアップするシェルスクリプトを書いてみましょう。
tarコマンドで/var/wwwディレクトリのファイルを固め、
ホームディレクトリ下の./WWW.BACKUPというディレクトリに置くことにします。
シェルスクリプトを動かすアカウントで、
/var/wwwが読めるようにパーミッション設定されていることが前提です。

シェルスクリプトを書いてみたのがリスト1のコードです。
シェルスクリプト名は~/SYS/WWW.BACKUPとしました。
バックアップファイルの置き場所は上記のように~/WWW.BACKUP、
ファイル名は、www.<日付>.tar.gzとしました。
ディレクトリ名やファイル名の命名規則は、
ある「お作法」にしたがっていますが、ここではあまり気にしないでください。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
#!/bin/bash -vx

dest=/home/ueda/WWW.BACKUP
tmp=/tmp/$$
today=$(date +%Y%m%d)

#/tmpに/var/www/の内容を固めて圧縮
tar zcvf $tmp.tar.gz /var/www/
#バックアップファイルの置き場所に移動
mv $tmp.tar.gz ${dest}/www.${today}.tar.gz

【リスト1: 最初のWWW.BACKUP】

たった10行なので、このコードを使っておさらいをしましょう。
1行目の#から始まる行ですが、
これはスクリプトを読み込むインタプリタを指定するための行です。
#!のことを「シバン」(shebang)と言います。
インタプリタは、ここではbashなので、bashの置いてある/bin/bashを指定します。

もし/bin/bashにbashが無い場合は、以下のようにwhichコマンドを使って調べましょう。

[ueda@cent GIHYO]$ which bash
/bin/bash

#!/bin/bash の後ろの-xvは、
シェルスクリプト実行時にログが表示されるようにするオプションです。
3~5行目は、変数を指定しています。
変数と言っても、bashの変数は単に文字列を格納するためにあります。
書き方は、3行目のように、

変数名=値となる文字列

です。3行目で、destという変数に「/home/ueda/WWW.BACKUP」という文字列が格納されます。
=の両側に空白を入れてはいけません。空白を入れてしまうと、
bashが、変数のつもりで書いた文字列をコマンドだと解釈します。
変数destは、シェルスクリプト中で$destや${dest}と書くと値に置き換わります。

4, 5行目は、ちょっと難しいことをしています。
4行目は、tmpという変数に、「/tmp/」と「$$という変数の値」をくっつけた文字列を格納しています。
これでよく分からなければ、以下のように実際に打ってみましょう。

[ueda@cent GIHYO]$ tmp=/tmp/$$
[ueda@cent GIHYO]$ echo $tmp
/tmp/8389

「$$」は予約変数で、このシェルスクリプトのプロセス番号が格納されています。
tmpはファイル名に使いますが、プロセス番号を入れることでファイル名の衝突を防ぎます。

変数todayには、dateコマンドから出力される文字列が格納されます。
これは言葉で説明するより、端末を叩いた方がよいでしょう。

#dateコマンドで8桁日付を出力
[ueda@cent GIHYO]$ date +%Y%m%d
20111022
#$()でコマンドを囲うと、
#コマンドから出力された文字列を変数に代入できる。
[ueda@cent GIHYO]$ today=$(date +%Y%m%d)
[ueda@cent GIHYO]$ echo $today
20111022

変数を定義したら、あとは単にバックアップするコマンドを書くだけです。
tarコマンドの使い方についてはご自身で調べていただきたいのですが、
9行目で$tmp.tar.gzというファイルに/var/www/の内容が圧縮保存されます。
このスクリプトでは、
一度/tmpで作ったバックアップが10行目のmvで/home/ueda/WWW.BACKUPに移されています。
これは、途中でスクリプトが止まったとき、
中途半端なバックアップがWWW.BACKUPにできないようにする配慮です。

書いたら早速動かしてみましょう。図1のように、
実行したときのログが画面に吐き出されるはずです。
+印の行に、実行されたコマンドが表示されます。

[ueda@cent SYS]$ ./WWW.BACKUP
#!/bin/bash -vx

dest=/home/ueda/WWW.BACKUP
+ dest=/home/ueda/WWW.BACKUP
tmp=/tmp/$$
+ tmp=/tmp/9174
today=$(date +%Y%m%d)
date +%Y%m%d)
date +%Y%m%d
++ date +%Y%m%d
+ today=20111022

#/tmpに/var/www/の内容を固めて圧縮
tar zcvf $tmp.tar.gz /var/www/
+ tar zcvf /tmp/9174.tar.gz /var/www/
tar: Removing leading `/' from member names
/var/www/
/var/www/html/
(中略。だらだらと保存したファイルが表示される。)
#バックアップファイルの置き場所に移動
mv $tmp.tar.gz ${dest}/www.${today}.tar.gz
+ mv /tmp/9227.tar.gz /home/ueda/WWW.BACKUP/www.20111022.tar.gz

【図1: WWW.BACKUPの実行ログ】

WWW.BACKUPディレクトリにファイルがあったら成功です。解凍できるか試してください。
(tar zxvf <ファイル名>で解凍できます。)

1.2.3. 日付の演算をコマンドだけで行う

さあ今回はここからが本番です。
WWW.BACKUPを(crontabなどを使って)毎日実行すると、日々のファイルができます。
これらのファイルを適切に間引くという処理をWWW.BACKUPに追加します。
具体的には、「直近一週間のバックアップファイルを残し、
あとは毎週日曜のバックアップファイルだけを残す。」という処理を記述します。

このような処理は、プログラミングに慣れた人なら、
「for文を作り、for文の中で一つずつバックアップファイルの日付を調べ、
if文で処理を場合分けし・・・」というコードを書いていくことが通常です。
しかしシェルスクリプトでそれをやってしまうと、読みやすいコードになりません。
シェルが得意なのはファイル入出力とパイプライン処理なので、
これらを駆使して入れ子の少ない平坦なコードを書きます。

まず、上で書いたシェルスクリプトについて、
tarを使う前の部分をリスト2のように書き加えます。
初めて見た方のために補足すると、
パイプ「|」は、コマンドの出力を次のコマンドに渡すための記号、
リダイレクト「>」は、コマンドの出力をファイルに保存するための記号です。

14行目から18行目はデバッグのためのコードで、
「昔のバックアップ」のダミーファイルを作っています。
この部分は最後に消します。もしwhileがうまく動かなければ、
端末で手打ちでダミーファイルを作っても構いません。
17行目のdateコマンドの使い方はあまりなじみが無いかもしれませんが、
-dというオプションを使うと日付の演算ができます。
20行目以降は古いファイルを間引くパートです。
記述はまだ途中で、この段階ではファイルの日付を取得して表示しているだけです。

 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
#!/bin/bash -vx

#ログをlogというファイルに保存する
exec 2> ./log

dest=/home/ueda/WWW.BACKUP
tmp=/tmp/$$
today=$(date +%Y%m%d)

###############################################################
#デバッグのため、ダミーファイルを作る
#稼動時には消す。

d=20100101
while [ $d -lt $today ] ; do
        touch $dest/www.$d.tar.gz
        d=$(date -d "${d} 1 day" +%Y%m%d)
done

###############################################################
#古いファイルの削除

#移動
cd $dest
#ファイル列挙
ls                    |
#ドットを区切り文字にして第二フィールド(=日付)を取り出す。
cut -d. -f2          |
#日付ではないものを除去
egrep "[0-9]{8}"        |
#念のためソート
sort                > $tmp-days

#デバッグのために出力
cat $tmp-days

rm -f $tmp-*
exit 0
(以下略。tarの処理が書いてある。)

【リスト2: 日付の処理を途中まで加えたWWW.BACKUP】

$tmp-daysに日付の一覧ができたので、この中からファイルを消すべき日付を抽出します。
シェルスクリプトを書いたことのある人は、
読み進む前にぜひコードを考えてみてください。
コードには、「if」が一個も現れません。

筆者の書いたコードをリスト3に示します。$tmp-daysを求めた後の部分です。
2行目から13行目で、直近7日分の日付を書いたファイルと、
日曜日を書いたファイルを作成します。
その後、17, 18行目で「直近7日分でも日曜日でもない日付」を抽出しています。

6~9行目のwhileの部分が汚いですが、ここでは日付のデータに曜日を付加しています。
6行目で$tmp-daysの内容がパイプから一行ずつ読み込まれて変数dにセットされています。
7行目のdateで、変数dの日付に曜日が付けられます。
dateコマンドの出力は、doneの後のパイプからgrepに渡っています。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#直近7日分の日付
tail -n 7 $tmp-days     > $tmp-lastdays

#日曜日
cat $tmp-days   |
while read d ; do
        date -d "${d}" +"%Y%m%d %w"
        #1:日付 2:曜日(ゼロが日曜)
done        |
#第二フィールドが0のものだけ残す
grep "0$"       |
#曜日を消す
cut -d" " -f1   > $tmp-sundays

#days,lastdays,sundaysをマージして、
#一つしかない日付が削除対象
sort -m $tmp-{days,lastdays,sundays}    |
uniq -u                          > $tmp-remove

#デバッグのため出力
cat $tmp-remove

rm -f $tmp-*
exit 0

【リスト3: ファイルを消す日付を求めるためのロジック】

18行目のuniq -uは、一個だけしかない日付だけ出力するという動きをします。
これで、$tmp-daysにあって、$tmp-lastdaysや$tmp-sundaysに無い日付だけが出力されるので、
「直近7日でも日曜でもない日付=ファイルを消す日付」が得られます。
sortとuniqだけでこのような演算ができるということに気づくにはちょっと経験が要りますが、
二行で済んでしまう破壊力は抜群です。

1.2.4. 完成

では、肝心の「消去する」を実装しましょう。
これもxargsというコマンドを知っていれば、一行で実装できます。
リスト4のように、uniq -uの後に次のようにパイプでつなぎます。

1
2
3
4
5
6
#days,lastdays,sundaysをマージして、
#一つしかない日付が削除対象
sort -m $tmp-{days,lastdays,sundays}    |
uniq -u                          |
#消去対象日付がパイプを通って来る。
xargs -i rm www.'{}'.tar.gz

【リスト4: xargsとrmで指定日付のファイルを消去】

xargsは、パイプから受けた文字をオプションに変換するコマンドです。
この例では、www.と.tar.gzの間に日付を一つずつ入れてrmのオプションにしていきます。
この連載ではあまり難しいコマンドを使うことは避けていきますが、
while文をなくすためなら、このような高度なコマンドも扱っていきます。

最後に、体裁を整えたシェルスクリプトをリスト5に示します。
本文で触れていない小細工も盛り込んでいますので、解析してみてください。

 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
47
48
49
50
51
52
53
54
55
56
57
#!/bin/bash -vx
#
#       /var/wwwのバックアップ
#
#       written by R. UEDA (USP研究所) Oct. 10, 2011
#

exec 2> /home/ueda/LOG/LOG.$(basename $0).$(date +%Y%m%d)

dest=/home/ueda/WWW.BACKUP
tmp=/tmp/$$
today=$(date +%Y%m%d)

###############################################################
#古いファイルの削除

#移動
cd $dest
#ファイル列挙
ls                    |
#ドットを区切り文字にして第二フィールド(=日付)を取り出す。
cut -d. -f2          |
#日付ではないものを除去
egrep "[0-9]{8}"        |
#念のためソート
sort                > $tmp-days

#直近7日分の日付のリスト
tail -n 7 $tmp-days     > $tmp-lastdays

#日曜日のリスト
cat $tmp-days   |
while read d ; do
        date -d "${d}" +"%Y%m%d %w"
        #1:日付 2:曜日(ゼロが日曜)
done        |
#第二フィールドが0のものだけ残す
grep "0$"       |
#曜日を消す
cut -d" " -f1   > $tmp-sundays

#days,lastdays,sundaysをマージして、
#レコードが一つしかない日付が削除対象
sort -m $tmp-{days,lastdays,sundays}    |
uniq -u                          |
xargs --verbose -i rm www.'{}'.tar.gz

###############################################################
#バックアップ

#/tmpに/var/www/の内容を固めて圧縮
tar zcvf $tmp.tar.gz /var/www/ >&2
#バックアップファイルの置き場所に移動
mv $tmp.tar.gz ${dest}/www.${today}.tar.gz

rm -f $tmp-*
exit 0

【リスト5: 完成したWWW.BACKUP】

シェルスクリプトを書いたら、コメントは豊富に書きましょう。
コマンド自体は汎用品なので、使った意図を書いておかないと後から意味不明になります。
逆に言えば、意図と処理がはっきり分かれるということが、シェルスクリプトの特徴とも言えます。

今回の例で気づいた人もいると思いますが、短いシェルスクリプトを書けるようになる第一歩は、
ファイルを配列の代わりに使う癖を付けることです。
grepやuniqなどのコマンドの多くも、実はそういうことを前提に作られているのです。

1.3. おわりに

今回は、シェルスクリプトを書く動機について説明し、
バックアップというお題に対するシェルスクリプトWWW.BACKUPを作りました。
WWW.BACKUPは57行のスクリプトで、そのうちコードが23行、コメントと空白が34行でした。
制御構文は、while文1個で、if文はゼロでした。

以下が今回の重要な点です。

  • テキストファイルはソフトに束縛されず、自由
  • ファイルを配列代わりに使うと短いシェルスクリプトを記述可能

次回以降もUNIX哲学の道を邁進しますので、ご贔屓に。

1.4. 出典

[Gancarz2011] Mike Gancarz (著), 芳尾 桂 (翻訳):
UNIXという考え方 –その設計思想と哲学, オーム社, 2001.

[Raymond2007] Eric S.Raymond (著), 長尾 高弘 (翻訳):
The Art of UNIX Programming, アスキー, 2007.

Pocket
LINEで送る