FrontPage

2008/08/12からのアクセス回数 21087

はじめに

Amazonの協調フィルタリングで話題になった「集合知(collective intelligence)」ですが、 そこにはこれが正解というものはなく、仮説と検証のサイクルを何度も繰り返しながら新たな方式が 日々研究開発されています。

仮説と検証のサイクルでは、生のデータから分析可能なデータに加工する作業が幾度となく繰り返され その処理内容は様々です。このため、データ加工では短期間に適切なデータを作成するというハードな 業務です。

そんな時に役にたつのが、これからご紹介します

  • UNIXのコマンドをパイプでつないで、データを加工する方法(パイプライン処理)
  • シェルスクリプトを使って、パイプライン処理によりきめ細かな処理を追加する方法

です。

シェルには、sh, ksh, bash, csh, tcsh等いろいろな種類がありますが、ここではshを使って説明します。 *1

パイプでつなぐ

1968年、UNIXの産みの親であるKen ThompsonとDennis RitchieがAT&Tのベル研究所でMultics と呼ばれる大型のオペレーティングシステムを開発していた頃、 「インタラクティブで便利なコンピュータサービス」が欲しいと言って作ったのがUNIXです。

彼らは、「ベル研の文書処理システムを作る」と言って予算を引き出し、PDP-11 (システムメモリ16Kバイト、ユーザメモリ8Kバイト、ハードディスク512Kバイト) という現在のマイコンチップ以下のハードウェアを購入し、 その上に現在のUNIXシステムのコマンド群とroffと呼ばれる文書処理システムを構築したのでした。

今では、Linuxの普及により誰でもUNIXの環境を持つことができるようになりました。 この機会に、UNIXの「パイプでつなぐ」を試してみてください。

UNIXの3大発明

ここでは、「パイプでつなく」をテーマにUNIXの偉大な発明の中から、

  • fork : 子プロセスの生成
  • pipe : パイプ
  • dup : リダイレクト

について、その仕組みを例題を使いながら、わかりやすく説明していきます。

パイプを使った処理の例

  • 単語処理の例

パイプを使った処理の例として、「tr」コマンドのmanページに出てくる単語の種類をカウントします。

$ man tr | tr -cs 'A-Za-z' '\n' | sort -u | wc -l
     492

このようにUNIXのコマンドをパイプでつなぐことによって簡単に必要な処理をこなすことができます。

  • リアルタイムの例

「単語処理の例」は、別にパイプを使わなくてもファイルを使って逐次的に処理できます。 パイプがファイルと決定的に異なる点は、そのリアルタイム性です。

図は、マウスイベントを別Windowにプロットする例です。

demo_pipeline.jpg

このデモでは、xevコマンドとEvent2Plot, Graphのシェルスクリプトをパイプでつないで、xplotの画面にイベントをリアルタイムでプロットしています。

$ xev | Event2Plot | Graph

パイプのつなぎ方

pipeシステムコールは、本当に不思議な関数です。自分が出力したものを自分へ送るストリームの輪(パイプ)がpipeシステムによって生成されます。 マニュアルの説明を読んだだけでpipeシステムコールの使い方を理解できる人は少ないでしょう。

  • 最初にパイプを生成します。

pipe.jpg

  • 次にforkシステムコールを使って、子プロセスを生成します

fork.jpg

  • 親のin、子のoutをクローズします

close.jpg

  • 親の1番のファイル記述子(stdout)をクローズし、dupします、子の0番のファイル記述子(stdin)をクローズしてdupします

dup.jpg

パイプを実現する3つのシステムコール

パイプをつなぐときに必要なシステムコール

  • fork
  • pipe
  • dup

について、おさらいも含めて説明します。

fork システムコールのおさらい

forkシステムコールの仕様を簡単に書くと、

呼び出し形式
     #include <unistd.h>

     pid_t
     fork(void);
機能
 呼び出し元のプロセスをコピーして、新しいプロセスを生成します。
戻り値
 成功すると、子プロセスには0が返され、
親プロセスには、子プロセスのプロセスIDが返されます。forkに失敗したら、-1を返します。

です。

forkでは、

  • 子プロセスは、変数、ファイルなどのリソースを親プロセスと共有し
  • 親と子プロセスは、forkのリターン値で別の処理に切り分けられ

ます。

簡単なプログラムで、上記の仕様を確認してみましょう。

#include <stdio.h>
#include <unistd.h>

main()
{
	int	pid;

	if ((pid = fork()) == -1) {
		fprintf(stderr, "can't fork\n");
	}
	else if (pid == 0) {
		// child
		fprintf(stdout, "this is a child process\n");
		fprintf(stderr, "pid(child)=%d\n", pid);
	}
	else {
		// parent
		fprintf(stdout, "this is a parent process\n");
		fprintf(stderr, "pid(parent)=%d\n", pid);
	}
}

以下のようにコンパイルして、実行すると

$ cc -o ex1 ex1.c
$ ex1
this is a parent process
pid(parent)=6556
this is a child process
pid(child)=0

と出力され、親と子プロセスの分岐とpidの値が正しくセットされていることが分かります。

次に、標準出力(1)とエラー出力(2)をリダイレクトでファイルに保存します。

shでは n> のようにリダイレクトするファイル記述番号を指定することができます。

  • 1>1.out の指定により、標準出力をファイル(1.out)にリダイレクト
  • 2>2.out の指定により、標準エラー出力をファイル(2.out)にリダイレクト

しています。

$ ex1 1>1.out 2>2.out
$ more *.out
<< 1.outの内容 >>
this is a parent process
this is a child process
<< 2.outの内容 >>
pid(parent)=6570
pid(child)=0

と、親と子プロセスが同じファイルに書き込み、ファイルの共有が行われていることが確認できます。

pipe システムコールの使い方

pipeシステムコールの仕様を簡単に書くと、

呼び出し形式
     #include <unistd.h>

     int
     pipe(int fildes[2]);
機能
 パイプを生成し、ペアのファイル記述子を割り当てます。1番目のファイル記述子(fildes[0])が読み込み、
2番目(fildes[1])が書き込み用となります。
 fildes[1]に書き込まれたデータは、fildes[0]から読み込まれまる。
これにより、あるプログラムの出力を他のプログラムの入力にすることができます。
 読み込みまたは書き込みのファイル記述子のいずれかがクローズするとwidowed(未亡人)となります。
widowedとなったパイプに書き込むと書き込みプロセスはSIGPIPEシグナルを受け取ります。
widowedにすることで、読み込みプロセスにEnd-Of-Fileを送ることができます。
読み手がパイプのデータをすべて読み込んだ後やwidowedになった後のパイプから読み込もうとすると
0が返されます。
戻り値
 成功すると0を返し、そうでない場合には-1を返します。

パイプは、

  • 単一方向のデータフローを持つバッファです
  • バッファが満杯になった場合には、書き込みプロセスはスリープ状態になり、十分な空きができるまで待ちます
  • 読み込みプロセスは、バッファに書き込まれるまでスリープ状態で待ちます
  • 書き込み側がクローズするとパイプからの読み込みでは0(End-Of-File)が返されます
  • 読み手側でクローズされたパイプに書き込もうとするとSIGPIPEシグナルが発生します

のように機能します。

簡単なプログラムで、上記の仕様を確認します。

// ex2.c : pipe example
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

#define	BUFSIZE	(128)

main()
{
	int	pid;
	int	fd[2];
	// create a pipe.
	if (pipe(fd) == -1) {
		fprintf(stderr, "Can't create pipe\n");
		exit(1);
	}
	printf("fd[0]=%d, fd[1]=%d\n", fd[0], fd[1]);

	if ((pid = fork()) == 0) {
		// child
		char buf[BUFSIZE];
		int	len;
		// close pipe out.
		close(fd[1]);
		// read message.
		len = read(fd[0], buf, BUFSIZE);
		printf("len=%d, buf=%s", len, buf);
		len = read(fd[0], buf, BUFSIZE);
		close(fd[0]);
		fprintf(stderr, "child finishied. len=%d\n", len);
	}
	else {
		// parent
		char buf[] = "hello world\n";
		// close pipe in.
		close(fd[0]);
		// write message.
		write(fd[1], buf, sizeof(buf));
		close(fd[1]);
		fprintf(stderr, "parent finishied\n");
	}
}

プログラムを実行すると

$ ex2
fd[0]=3, fd[1]=4
parent finishied
len=13, buf=hello world
child finishied. len=0

と出力され、

  • pipeシステムコールによってfdに3, 4のファイル記述子が割り当てられ
  • 親プロセスがhello world\nを書き込み、書き込み用ファイル記述子をクローズする
  • 子プロセスが、hello world\nを読み込み、次の読み込みをすると0が返され

ます。*2

dup システムコールの使い方

同様にdupシステムコールの仕様を簡単に書くと、

呼び出し形式
     #include <unistd.h>

     int
     dup(int fildes);
機能
 既存のファイル記述子を複製し、新しく生成されたファイル記述子を返します。
プロセスには、getdtablesize()で返される大きさのファイル記述子テーブルを持ち、
新しいファイル記述子には、未使用の内もっとも小さい値が返されます。
戻り値
 正常終了の場合、0以上の値が返され、そうでない場合には-1を返します。

となります。

UNIXのファイル記述子の割り当てルールは、

  • 新しいファイル記述子には、未使用の内もっとも小さい値が返される

ので、特定の値のファイル記述子を割り当てたい時には、

  • dupの直前にそのファイル記述子をクローズする

ことで実現できます。 リダイレクトやパイプは、この単純なルールを使って実現されています。

dupの動作を確認するために、以下のサンプルプログラムを作成します。

// ex3.c : dup example
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

#define	BUFSIZE	(128)

main()
{
	int	pid;
	int	fd[2];
	// create a pipe.
	if (pipe(fd) == -1) {
		fprintf(stderr, "can't create pipe\n");
		exit(1);
	}

	if ((pid = fork()) == 0) {
		// child
		int	in;
		// close pipe out.
		close(fd[1]);
		// close stdin.
		close(0);
		in = dup(fd[0]);
		close(fd[0]);
		fprintf(stderr, "in=%d\n", in);
		fprintf(stderr, "child : exec wc\n");
		execl("/usr/bin/wc", "wc", 0);
	}
	else {
		// parent
		int	out;
		// close pipe in.
		close(fd[0]);
		// close stdout.
		close(1);
		out = dup(fd[1]);
		close(fd[1]);
		fprintf(stderr, "out=%d\n", out);
		fprintf(stderr, "parent: exec echo 'hello world'\n");
		execl("/bin/echo", "echo", "hello world", 0);
	}
}

プログラムを実行すると、

$ ex3
out=1
parent: exec echo 'hello world'
in=0
child : exec wc
       1       2      12

と出力されます。 *3

  • 親プロセスのパイプの出力用ファイル記述子は、1(標準出力)に割り当てられ
  • echo 'hello world'を実行します
  • 子プロセスのパイプの入力用ファイル記述子は、0(標準入力)に割り当てられ
  • echoの結果'hello world'を標準入力から読み込みwcを実行します

wcの出力結果は、

$ echo 'hello world' | wc
       1       2      12

と同じであり、親プロセスと子プロセスの間でパイプが正常に結ばれたことが確認できました。

パイプをつなぐサンプル

パイプのつなぎ方が分かったところで、最初に紹介したパイプのサンプルを使ってパイプライン処理のメリットについて説明します。

パイプライン処理の特徴として、以下のことが挙げられます。

  • 確認しながら、パイプをつなぐ
  • 複雑な処理は、単純な処理をパイプでつなぐ
  • 途中経過を確認しながら、パイプでつなぐ

確認しながら、パイプをつなぐ

いくつかの処理を確認しながら、パイプを増やしていくのがパイプを使ったプログラムの特徴です。 最初のサンプルで、man trの結果から単語を切り出す部分をみてみましょう。

trの処理は、

  • -cオプションは、指定されたパターン以外の文字列を置換の対象とし、
  • -sオプションは、同じ文字に置換された場合、それを1個にまとめます

この結果、英字以外の文字列は、改行(\n)1個に置き換わり、1行に1個の単語が抽出されます。

実際にその処理をみてみましょう。

$ man tr | tr -cs 'A-Za-z' '\n'
TR
BSD
General
Commands
Manual
TR
途中省略
Std
POSIX
standard
BSD
July
BSD

と出力されます。

単語が抽出できたことを確認し、同じ単語を1つにまとめます。

sortコマンドの

  • -uオプションは、入力をソートするとき重複する行を1つにまとめます

先ほどのパイプラインに、sort -uを追加して重複を取り除きます。

$ man tr | tr -cs 'A-Za-z' '\n' | sort -u
A
ALL
AM
AN
AR
ASCII
AT
Additionally
Any
As
B
BI
BSD
以下省略

と出力され、英単語とは思えない1または2文字の単語が含まれています。

これは、manページが、

man.jpg

のように文書整形コマンドによって、強調文字、アンダーラインが付加されており、 これをターミナルに出力するために、制御コードが付加されているためです。

$ man tr | od -c | more
0000000    T   R   (   1   )                                            
0000020                                            B   S   D       G   e
0000040    n   e   r   a   l       C   o   m   m   a   n   d   s       M
0000060    a   n   u   a   l                                            
0000100                                        T   R   (   1   )  \n  \n
0000120    N  \b   N   A  \b   A   M  \b   M   E  \b   E  \n            
0000140            t  \b   t   r  \b   r       -   -       t   r   a   n
0000160    s   l   a   t   e       c   h   a   r   a   c   t   e   r   s
0000200   \n  \n   S  \b   S   Y  \b   Y   N  \b   N   O  \b   O   P  \b
0000220    P   S  \b   S   I  \b   I   S  \b   S  \n                    
0000240    t  \b   t   r  \b   r       [   -  \b   -   C  \b   C   c  \b
0000260    c   s  \b   s   u  \b   u   ]       _  \b   s   _  \b   t   _
0000300   \b   r   _  \b   i   _  \b   n   _  \b   g   _  \b   1       _
0000320   \b   s   _  \b   t   _  \b   r   _  \b   i   _  \b   n   _  \b
0000340    g   _  \b   2  \n                       t  \b   t   r  \b   r
以下省略                

のように強調文字では、

  • N \b Nのように文字を表示し、バックスペースで戻し、再度文字を出力

アンダラインでは、

  • _ \b s のように先に_を表示し、バックスペースで戻し、文字を出力

しているのです。

正しい結果がでるようにするには、バックスペースとその前の文字を削除しなければなりません。

そこで、sedコマンドを追加します。(以下のコマンドで^Hは、Ctrl-vの後にCtrl-Hを入力してください)

$ man tr | sed -e 's/.^H//g' | tr -cs 'A-Za-z' '\n' | sort -u | more
A
ALL
ASCII
Additionally
Any
As
BSD
C
COLLATE
COMPATIBILITY
CTYPE

と正しく単語が抽出できました。

確認が終わった後で、moreをwc -lに変えると

$ man tr | sed -e 's/.^H//g' | tr -cs 'A-Za-z' '\n' | sort -u | wc -l
     436

正しい単語の数を得ることができました。

このように一つずつ確認しながらパイプをつなぐことによって短時間に正しい結果を得ることができます。

複雑な処理は、単純な処理をパイプでつなぐ

例えば、WebのアクセスログからGoogleの検索でブログにアクセスしたログを抽出したいとします。

  • Googleの検索からアクセスされたログには、http://www.google.co.jp/searchという文字が含まれ
  • PukiWikiを使ったブログには、index.phpという文字が含まれます

この条件を使って

$ grep http://www.google.co.jp/search /var/log/apache2/access.log | grep index.php 
218.228.8.55 - - [09/Aug/2009:06:43:41 +0900] "GET /~take/TakeWiki/index.php?avr
%2F%E6%9C%80%E5%88%9D%E3%81%AE%E4%B8%80%E6%AD%A9 HTTP/1.1" 200 33207 "http://www
.google.co.jp/search?q=avr+isp&hl=ja&lr=lang_ja&client=firefox-a&rls=org.mozilla
:ja:official&start=50&sa=N" "Mozilla/5.0 (Windows; U; Windows NT 5.1; ja; rv:1.9
.1.2) Gecko/20090729 Firefox/3.5.2"
以下省略

のように簡単に目的のログだけを抽出することができます。

もちろん、grepの正規表現を使って1つのgrepで検索することもできますが、少しの間違いでうまく検索できなくなります。

AかつBのような時、

$ grep A | grep B

のようにパイプでつなぐのが簡単かつ確実です。特に急いでいるときには難しい表現はさけ、簡単な処理をパイプでつないでいく方が堅実です。

途中経過を確認しながら、パイプでつなぐ

2つ目のサンプル、

 xev | Event2Plot | Graph

では、Event2PlotとGraphというシェルスクリプトを使いました。

引数やオプションが多い場合には、それをシェルスクリプトに書いて一つにまとめると便利です。 Graphは、graphコマンド*4のオプションを以下のようにまとめたものです。

#! /bin/sh
graph -T X -x 0 100 -y 0 100 -m 0 -S 5
  • 1行目の#! は、使用するシェル(コマンド・インタープリター)を指定するおまじないです。ここでは/bin/shを使うことを指定します
  • graphのコマンドとオプションをセットしています

エディタでGraphを入力した後に、シェルで実行できるように実行権限をセットします。

$ chmod +x Graph

Graphを起動して、X,Yの座標をブランク区切りで入力すると、リアルタイムに座標が画面にプロットされます。

$ Graph
10 30
50 50
70 20

これでGraphの完成です。*5

次にxevコマンドを実行し、太い線の正方形の中で、マウスを動かしてみてください。

MotionNotify event, serial 23, synthetic NO, window 0xa00001,
    root 0x1fd, subw 0x0, time 236035801, (68,77), root:(298,252),
    state 0x0, is_hint 0, same_screen YES

MotionNotify event, serial 23, synthetic NO, window 0xa00001,
    root 0x1fd, subw 0x0, time 236035818, (66,106), root:(296,281),
    state 0x0, is_hint 0, same_screen YES

のようにMotionNotify eventの次の行に(68,77)のようにマウスの矩形内の座標(左上が原点)が含まれています。

これをawkを使ってX Y座標に変換しているのが、Event2Plotです。 先ほどと同様に

#! /bin/sh
awk -F, '
/MotionNotify/ { 
        getline; 
        gsub(/\(/, "", $4);
        gsub(/\)/, "", $5);
        printf("%d %d\n", $4, 100 - $5); 
}'

をEvent2Plotに入力して、

chmod +x Event2Plot

を実行してください。

xevとEvent2Plotをパイプでつないで、

$ xev | Event2Plot
6 59
27 85
37 82
42 81
43 80
42 80
41 79
40 79
39 79
38 79
以下省略

最初、ある程度マウスを動かすと画面に座標が表示されます。*6

ここまで、確認できたらGraphと連結してみましょう。

$ xev | Event2Plot | Graph

無事、最初の図と同じように出力されます。

グラフにプロットされるデータの途中経過がどのようになっているか知りたいことはありませんか。 このような場合には、teeコマンドで途中のデータをファイルに出力し、tailコマンドで表示します。 *7

$ xev | Event2Plot | tee /tmp/1 | Graph &
$ tail -f /tmp/1
58 50
65 54
73 65
72 71
62 80
57 81
途中省略
終了するには、Ctrl-Cを入力してください

リアルタイムとまではいきませんが、途中経過が表示されます。

その他のパイプ(pipe)の使われ方

グルーピング

初期のUNIXでは、ファイルシステムを跨ぐファイルのコピーには、cpコマンドが使えませんでした。 そこで、tarコマンドとパイプを使って以下のようにコピーしました。

$ tar cf - . | (cd /mnt/bak; tar -xf -)

これまでは、()で括ってパイプを使ったことはありませんでしたが、これはグルーピングと呼ばれる 処理で、()で括ったコマンドは入出力が共通になります。 *8

バッククォート(`)

バッククォートで括られたコマンドの出力をパイプでつないで、その結果を別のコマンドの引数にします。

例えば、変数NOWに今の時刻をセットしたい場合に、

$ NOW=`date`
$ echo $NOW
Wed Aug 12 20:15:11 JST 2009

とすると、変数NOWにdateコマンドの実行結果がセットされ、echo $NOWでセットした時刻が表示されます。

また、あるコマンドの実行結果を別のコマンドの引数にしたい場合にも便利です。 例)変数iの値に1を足した値を表示する場合(シェル内の四則演算等にはexprを使います)

$ i=1
$ echo `expr $i + 1`
2

これを使えば、変数の値を変える場合には、

$ i=`expr $i + 1`
$ echo $i
2

とすればよいことが分かります。

ヒアドキュメント(<<EOF)

シェルスクリプト内に記述したドキュメントにシェル変数の置換を行い、その結果を別のコマンドの入力とします。

ヒアドキュメントは、テンプレート(ひな形)として使用されることが多く、 sh, awk等のプログラムをシェルスクリプト内で生成し、実行する例が多く見られます。

簡単な例を使ってヒアドキュメントの変数置換を確かめてみましょう。

$ NOW=`date`
$ cat <<EOF
> What time is it now?
> It's $NOW.
> EOF
What time is it now?
It's Thu Aug 13 17:56:49 JST 2009.
  • 変数NOWに現在時刻がセットされ、
  • <<EOFがヒアドキュメントの開始です。<<の後には任意の文字列を指定します。ここではEOFとしました
  • スクリプトではなく、端末などで実行すると第2プロンプトの> が出ます
  • 出力は、期待通りNOWが現在時刻に置換されています

awkではshと同様に変数を$を使って参照するため、ヒアドキュメントの変数置換がかえって邪魔になることがあります。

そのような場合には、<<\EOFのように<<の後に\を付けます。

$ cat <<\EOF
> What time is it now?
> It's $NOW.
> EOF
What time is it now?
It's $NOW.

簡単シェルスクリプト

パイプをつないでデータを加工する方法がわかったところで、それをシェルスクリプトにして、いつでも使えるようにしましょう。

まずは、シェルのおさらいからはじめましょう。ここではsh, bashの系列のシェルをベースに説明します。

おさらい

ここでは、シェルのおさらいも兼ねて、

  • リダイレクト
  • ファイル名展開
  • シェル変数と環境変数
  • 特殊変数
  • クォーティング

について、例題を交えながら説明します。

リダイレクト

UNIXのCプログラムでは、main関数がプログラムの開始部分(エントリポイント)であり、プログラムmainを呼び出す前にシェルが標準入力(stdin)、標準出力(stdout)、標準エラー出力(stderr)にそれぞれ、0, 1, 2のファイル記述子がオープンされた状態でmainが呼び出されます。

シェルがプログラムを起動する前に、標準入力、標準出力、標準エラー出力を付け替えることから入出力の動的変更のことをリダイレクトと呼びます。

リダイレクトの仕組みは、dupシステムコールの例題でも示したとおり、

  • 新しい接続先のオープン
  • 切り替える入出力のクローズ
  • dupシステムコールを使った入出力の付け替え

によって実現されています。

主なリダイレクト指定方法は、

  • < を使った標準入力の指定
  • > を使った標準出力の指定
  • >> を使ったファイルへの追加出力指定

があります。

では、実際にリダイレクトを使って簡単なファイルを作成します。

$ echo test > /tmp/test.out
$ cat /tmp/test.out
test
$ echo more test >> /tmp/test.out
$ cat /tmp/test.out
test
more test
$ wc </tmp/test.out
       2       3      15

存在しないファイルに>>を使った場合、新たにファイルを作成し、そのファイルに出力します。 また、すでにファイルが存在する場合、>を使ったリダイレクトはそのファイルに上書きします。 *9

ファイル展開

シェルでは、メタ記号を使って複数のファイル名に展開することができます。

メタ記号の一覧を以下に示します。

メタ記号説明
?任意の一文字にマッチする
[abef][]で囲まれた文字のいずれかの一文字にマッチする
[a-z]a-zの間に含まれる一文字にマッチする
[^abc][の直後に^が指定されるとそれ以降に指定した文字列以外の文字にマッチする
*任意の文字列にマッチする

ファイル展開はディレクトリ内のファイルに対して行われます。

例えば、ファイルが.bakで終わるすべてのファイルを削除する場合には、

$ rm *.bak

のように指定します。

サンプルソースのディレクトリには、以下のファイルがあります。

$ ls -a
.		2.out		Graph		ex2		images
..		Event2Plot	Makefile	ex2.c		range
.cproject	Ex5.sh		Pipeline.txt	ex3		range.c
.project	Ex6.sh		Tgrep.sh	ex3.c		tgrep
.settings	Ex7.sh		ex1		ex4		tgrep.c
1.out		Ex8.sh		ex1.c		ex4.c

しかし、echo *では

$ echo *
1.out 2.out Event2Plot Ex5.sh Ex6.sh Ex7.sh Ex8.sh Graph Makefile Pipeline.txt 
Tgrep.sh ex1 ex1.c ex2 ex2.c ex3 ex3.c ex4 ex4.c images range range.c tgrep 
tgrep.c

となり、.で始まるファイルが含まれていません。 UNIXでは、.で始まるファイル、ディレクトリはメタ記号の展開にマッチしないのです。

それでは、.*とするとどうなるでしょう。

$ echo .*
. .. .cproject .project .settings

.で始まるファイルの他に、.(カレントディレクトリ)と..(親ディレクトリ)も展開されます。

メタ記号の展開で間違ってカレントディレクトリや親ディレクトリが指定されないように、 ファイル展開では.で始まるファイルはメタ記号では展開されません。 この性質を利用して.profile等各種コマンドのユーザ設定ファイルは.で始まるファイル名を使います。

それでは、.ではじまるファイル名を指定するには、以下のように指定します。

$ echo .[^.]*
.cproject .project .settings

シェル変数

シェルには、同一シェル内だけで有効なシェル変数と子プロセスにも引き継がれる環境変数があります。

シェル変数に値をセットするには、変数名と値の間に空白を入れずに=を入れます。 代入する値の中に空白が含まれる場合シングルクォート(')またはダブルクォート(")で括ります。 シェル変数名にはアルファベット、数字、アンダースコア(_)が使用できます。 慣例としてループの添え字を除いて、変数名には大文字が多く使われています。

簡単なシェル変数の例を以下に示します。

$ HOST_NAME=IES00
$ IN_FILE=""
$ PS="ps -ef"

順番に見ていきましょう。

  • シェル変数HOST_NAMEに"IES00"をセットします
  • シェル変数IN_FILEに空文字をセットします
  • シェル変数PSに"ps -ef"をセットします

変数を参照するには、変数名の前に$を付けます。 変数は、空白や.などの変数名に使えない文字を区切りとしますが、英字の途中で変数を置換させたいときには、 ダブルクォート"や${}で括って参照します。

$ $PS
  UID   PID  PPID   C     STIME TTY           TIME CMD
    0     1     0   0   0:17.04 ??         0:24.09 /sbin/launchd
    0    15     1   0   0:01.72 ??         0:01.83 /usr/libexec/kextd
    0    16     1   0   0:26.06 ??         0:41.26 /usr/sbin/DirectoryService
以下省略
$ VAR=hello
$ echo $VAR
hello
$ echo ${VAR}_again
hello_again
$ echo using"$VAR"
usinghello

また、シェル変数には配列指定も可能であり、変数名[添え字]の形式で指定することができます。 すべての配列要素を参照する場合には、${配列名[@]}とし、要素の個数を参照する場合には、${#配列名[@]}とします。

$ LINE[0]=foo
$ LINE[1]=bar
$ echo ${LINE[@]}
foo bar
$ echo ${#LINE[@]}
2

変数をクリアするには、unset 変数名を使います

$ unset LINE
$ echo ${LINE[@]}

環境変数

つぎに環境変数について説明します。環境変数を定義するには、以下の2通りの方法があります。

すでに定義されているシェル変数を環境変数に切り替えるには、exportコマンドを使用します。

$ export VAR

新規に環境変数を定義する場合には、exportの後に変数名=値を指定します。

$ export VAR=hello

どうやってシェル変数と環境変数が使い分けられているのか、環境変数の仕組みを見ながら説明します。

Cのmain関数の宣言形式に、

#include <stdio.h>
int main(int argc, char * argv[], char * env[])

というのがありますが、最後のenvが環境変数の配列をシェルから引き継ぐための、ポインターです。

簡単なCのサンプルを使って環境変数がシェルから引き継がれることを確かめてみましょう。

// ex4.c : env example
#include <stdio.h>

int main(int argc, char* argv[], char *env[])
{
	int i;
	for (i=0 ; env[i] != NULL ; ++i) {
		printf("%s ", env[i]);
	}
	printf("\n");
}

コンパイルし、実行します。*10

$ cc -o ex4 ex4.c
$ ex4
MANPATH=/opt/local/share/man:/usr/share/man:/usr/local/share/man:/usr/X11/man 
TERM_PROGRAM=Apple_Terminal TERM=xterm-color SHELL=/bin/bash 
CATALINA_HOME=/Users/take/local/tomcat 
TMPDIR=/var/folders/Xo/XoxQsiGT2RWIck+BYuxhKU+++TI/-Tmp-/ 
Apple_PubSub_Socket_Render=/tmp/launch-wSems0/Render TERM_PROGRAM_VERSION=240.2 
以下省略

この結果をenvコマンドと比べると

$ echo `env`
MANPATH=/opt/local/share/man:/usr/share/man:/usr/local/share/man:/usr/X11/man 
TERM_PROGRAM=Apple_Terminal TERM=xterm-color SHELL=/bin/bash 
CATALINA_HOME=/Users/take/local/tomcat 
TMPDIR=/var/folders/Xo/XoxQsiGT2RWIck+BYuxhKU+++TI/-Tmp-/ 
Apple_PubSub_Socket_Render=/tmp/launch-wSems0/Render TERM_PROGRAM_VERSION=240.2 
以下省略

同じ結果となり、環境変数がサンプルプログラムに引き渡されていることが分かります。

特殊変数

シェルには、シェル変数、環境変数の他に、

  • 位置変数
  • 特殊変数
  • 特殊置換

の変数操作があります。

位置変数
シェルやシェル関数への引数を$0, $1, $2, ...$nのように引数の位置を数字で参照する方法です。
特殊変数
プロセスID、引数の個数などの特殊な情報を参照するときに使用します。以下の特殊変数がよく使われます。
  • $* : $1から始まるすべての位置変数を展開します
  • $0 : 実行コマンドの名前を返します
  • $# : 位置変数の数を返します
  • $$ : 実行中のプロセスIDを返します
  • $? : 直前のコマンドの終了ステータスを返します
特殊置換
${変数:=省略値}の形式で指定し、変数が定義されていない場合には省略値を参照します。

クォーティング

シェルで、空白で区切られた文字列を1つの引数にしたり、置換文字の$, *, ?等を引数にそのまま渡したりしたいことがあります。

このようなときには、バックスラッシュ\やシングルクォート'を使って文字をクォーティングします。

$ echo \$
$
# echo '* ?'
* ?

シェルのクォーティングルールをきちんと説明した書物は少なく、参考文献のThe UNIX Systemの図4-4から引用します。

メタ文字
'"`\$*
'tnnnnn
"ntyyyn
`nntynn

ここで、

  • t : 終端文字
  • y : シェルによって解釈される
  • n : シェルによって解釈されず、そのまま渡される

ことを意味します。

ただし、`で囲まれた$は、シェル変数が置換されるので、yの間違いではないかと思われます。

$ HELLO="echo hello"
$ echo `$HELLO`
hello

以上のことから、シェルのクォーティングルールを簡単に説明すると

  • ' は、すべての特殊文字を無効にします
  • "は、変数置換、\を有効にし、`によって返された結果を1つの引数にまとめる時に使用します
  • `は、変数置換、\を有効にします

となります。

おきまりのパターン

シェルは、コマンドを組み合わせるだけではなく、その使い方を拡張するためにも利用できます。

ここでは、単純な検索するプログラムtgrepを複数ファイルの検索に拡張する方法を以下のような手順で説明します。

  • 引数のチェックの追加
  • 各ファイルに対する繰り返し処理の追加

tgrepは、

  • 標準入力から1行文の文字列を読み込み
  • 第一引数に指定されたパターンを含んでいたら、その行を標準出力に書き出す

をファイルの終わりまで繰り返す、簡単なプログラムです。

#include <stdio.h>
#include <string.h>

#define BUFSIZE (512)

main(int argc, char *argv[])
{
        char*   pattern = *(++argv);
        char    line[BUFSIZE];

        while(fgets(line, BUFSIZE, stdin) != NULL) {
                if (strstr(line, pattern) != NULL)
                        printf(line);
        }
}

これから作成するシェルスクリプトの名称をTgrep.shとします。 主な機能は、

  • 引数が1個の場合には、標準入力から検索し引数で指定された文字列を検索します
  • 引数が2個以上の場合には、第2引数以降の各ファイルに対して第1引数で指定された文字列を検索します

引数チェック

引数のチェックでよく使用されるシェルの文法がcase文です。 シェルのcase文には正規表現を使うことができるので、C言語のcase文よりも使い易くなっています。

まず、引数の数を調べるところは、特殊シェル変数の$#で引数の個数を取り出し、 case文で処理を振り分けます。各条件で実行する処理は、2個のセミコロン(;;)で区切ります。

#! /bin/sh
case $# in
  1)
     echo pattern: $1 ;;
  [2-9])  echo pattern: $1; shift; echo files: $* ;;
esac

ここで、shiftコマンドは、位置引数を1個左にシフトコマンドです。shiftコマンドで2個目以降の引数を$*で表せることになります。

まずは、このようにシェルスクリプトが正しい動作をするかechoを使って引数をチェックします。

$ Tgrep.sh 1
pattern: 1
$ Tgrep.sh 1 2 3 4
pattern: 1
files: 2 3 4

うまく動作しているように見えますが、これでは10個以上の引数が与えられた正常に動作しません。 そこで、引数のパターンを0個、1個、それ以外に変えてみましょう。

#! /bin/sh
case $# in
  0) echo invalid argument.; exit 1;;
  1) echo pattern: $1 ;;
  *) echo pattern: $1; shift; echo files: $* ;;
esac

引数が10個以上でも正常に動作するか、引数の個数を変えて確かめてみましょう。

$ Tgrep.sh 1 2 3 4 5 6 7 8 9 10 11
pattern: 1
files: 2 3 4 5 6 7 8 9 10 11
$ Tgrep.sh 1 
pattern: 1
$ Tgrep.sh 
invalid argument.

すべてうまくいきました。これで、引数チェックは完成です。

各ファイルに対する繰り返し

次は、検索するファイル数が複数になったときの処理を追加します。 同じ処理を何回か繰り返す場合には、for文を使用します。

引数のテストではechoを使っていましたが、これからはtgrepに替え、 2個以上のファイルに対する処理にfor文を入れてみましょう。

#! /bin/sh
case $# in
  0) echo invalid argument.; exit 1;;
  1) tgrep $1 ;;
  *) PATTERN=$1; shift;
        for i in $*
        do
                tgrep $PATTERN < $i;
        done ;;
esac

できたスクリプトを動かしてみます。

$ Tgrep.sh BUFSIZE *.c
#define	BUFSIZE	(128)
		char buf[BUFSIZE];
		len = read(fd[0], buf, BUFSIZE);
		len = read(fd[0], buf, BUFSIZE);
#define	BUFSIZE	(128)
#define	BUFSIZE	(512)
	char	line[BUFSIZE];
	while(fgets(line, BUFSIZE, stdin) != NULL) {

うまく検索しているみたいですが、これではどのファイルにパターンがマッチしたのか分かりません。

そこで、Tgrep.shの出力行の先頭にファイル名を追加することにします。 出力行の処理には、while文とread文を使用します。出力行をread文でLINE変数にセットして、 それをファイル名と一緒にechoコマンドで出力します。

#! /bin/sh
case $# in
  0) echo invalid argument.; exit 1;;
  1) tgrep $1 ;;
  *) PATTERN=$1; shift;
        for i in $*
        do
                tgrep $PATTERN < $i | while read LINE
                                    do
                                        echo $i: $LINE
                                    done
        done ;;
esac

出力結果は、以下のようになります。

$ Tgrep.sh BUFSIZE *.c
ex2.c: #define BUFSIZE (128)
ex2.c: char buf[BUFSIZE];
ex2.c: len = read(fd[0], buf, BUFSIZE);
ex2.c: len = read(fd[0], buf, BUFSIZE);
ex3.c: #define BUFSIZE (128)
tgrep.c: #define BUFSIZE (512)
tgrep.c: char line[BUFSIZE];
tgrep.c: while(fgets(line, BUFSIZE, stdin) != NULL) {

少しずつ動作を確認しながら、機能を拡張することで難なく目標とするシェルスクリプトが完成しました。 このようにわずか13行のシェルスクリプトで単純な検索プログラムtgrepが実際に活用できるコマンドとなることがシェルスクリプトの魅了です。

制御コマンド

詳しい説明もせずにfor文、case文、while文を使ってしまったので、ここで詳しくシェルの制御コマンドについて説明します。

シェル制御コマンドのおもしろいところは、ifならfi、caseはesacのようにコマンドの終わりが、制御文字を逆順にした単語となっているものが多い点です。

for文

for 文は、次の形式で使用します。

for name in word_list
do
  block_stmt;
done
  • word_listには空白で区切った単語の列を指定し、指定された順で変数nameにセットされ
  • doとdoneの間のblock_stmtが実行されます
  • in word_listが省略された場合には、すべての引数($*と同等)が展開されます

簡単な例で動作をみてましょう。

# 10回ループを回す場合の簡単な例
for i in 1 2 3 4 5 6 7 8 9 10
do
  echo $i; # ループカウンタの値を出力する
done

端末からこれを実行すると、

$ for i in 1 2 3 4 5 6 7 8 9 10
> do
>   echo $i;
> done
1
2
3
4
5
6
7
8
9
10

doneが入力されるまでは、第2プロンプトの> が出力され、その後1から10までが出力されます。

シェルスクリプトでは、Cのfor文のような指定回数だけループを回すような処理をfor文では書けません。 そこで、pythonのrangeと同様のプログラムを作成し、for文で指定回数ループが書けるようにしてみましょう。

rangeの仕様は、以下の通りとします

  • 引数が1個の場合、0以上、指定された値より小さい整数を出力します
  • 引数が2個の場合、第1引数を初期値とし、初期から第2引数より小さい整数を出力します
  • 引数が3個の場合、第1引数を初期値、第2引数を最大値、第3引数をステップ量と、初期値から最大値未満の整数をステップ単位で出力します

range.cは、以下のようになります。

// range.c : range program.
#include <stdio.h>

main(int argc, char* argv[])
{
        int init = 0;
        int max = 0;
        int step = 1;
        int i;

        if (argc == 2) {
                max = atoi(argv[1]);
        }
        else if (argc == 3) {
                init = atoi(argv[1]);
                max = atoi(argv[2]);
        }
        else if (argc == 4) {
                init = atoi(argv[1]);
                max = atoi(argv[2]);
                step = atoi(argv[3]);
        }
        if (step > 0)
                for (i = init; i < max; i += step)
                        printf("%d ", i);
        else
                for (i = init; i > max; i += step)
                        printf("%d ", i);
        printf("\n");
}

動作を確認してみましょう。

$ range 1 10
1 2 3 4 5 6 7 8 9 
$ range -1 1
-1 0 
$ range 10 2 -1
10 9 8 7 6 5 4 3 
$ range 3
0 1 2 

となります。

それでは、rangeを使って与えられた数をカウントダウンするスクリプトEx5.shを作成してみましょう。 *11

# /bin/sh
for i in `range $1 0 -1`
do
  echo $i ^G; sleep 1;
done

では、実行権限を与えて、5からカウントダウンしてみましょう。

$ chmod +x Ex5.sh
$ Ex5.sh 5
5 
4 
3 
2 
1 

1秒ごとにブザーが鳴って、5から1までカウントダウンしました。

case文

case文は、パターンに合致した実行文を処理します。

case word in
  pattern1) block_stmt ;; # pattern1に合致した場合、
  ....
  pattern2) block_stmt ;; # pattern2に合致した場合、
  *)        block_stmt ;; # 上記のいずれでもない場合、
esac

処理の終わりには、;ではなく;;を指定します。また、*)とすることで上記以外という指定になります。 pattern1, pattern2のパターン指定には、shの正規表現を使うことができます。

通常、case文は引数のチェックに使用され、その場合には位置引数をシフトするshiftコマンドが一緒に使用されます。

UNIXには、オプションを処理する便利なコマンドgetoptがあり、これを使うとオプションは

$ getopt cf: -cf 1 2 3
 -c -f 1 -- 2 3

となり、

  • -c -f 1のようにオプションまたオプションとその値のペアに変換
  • -- でオプションの終わりを表し
  • それ以降がコマンドへの引数

に変換してくれるので、シェルスクリプトの引数処理がとても簡単になります。

getoptを使った例として、 コマンドEx6.shのオプションがvfのいずれかで、fオプションにはファイル名が後に続くような場合、

#! /bin/sh
set -- `getopt cf: $*`; # オプションのチェックと展開して、位置引数に再セットする
C_FLAG=false
FILE=""
for i in $*
do
  case $i in
    -c) C_FLAG=true ;;
    -f) FILE=$2; shift ;;
    --) break ;;
    -?) echo invalid option; exit 1;;
  esac
  shift;
done
echo C_FLAG=$C_FLAG FILE=$FILE ARGS=$*

となります。

これを実行すると

$ chmod +x Ex6.sh
$ Ex6.sh -cf 1 2 3
C_FLAG=true FILE=1 ARGS=2 3

となります。

if文

次にif文です。if文は、testコマンドと一緒に使うことが多いです。

if文の形式は、

if expr_list then ; block_stmt [else block_stmt] fi

の形式を取ります。

expr_listの最後にセミコロンを付けない場合には、

if expr_list
then
  block_stmt
else
  block_stmt
fi

のように改行してthenを記述します。*12

expr_listで最後に実行したコマンドの終了コードが0の場合に条件が真となります。 if分がネストする場合には、elif文を使用します。

if expr_list
then
  block_stmt
elif expr_list
then
  block_stmt
else
  block_stmt
fi

if文の例を以下に示します。

if [ $? -ne 0 ] # 直前のコマンドが正常に終了しなかった場合
then
  if [ -e $TMP_FILE ] # 一時ファイルが存在すれば、
  then
    rm -f $TMP_FILE;  # 一時ファイルを削除し、処理を終了する
    exit 1;
  fi
fi

testコマンド

testは、引数で指定された条件を確認するコマンドです。 shでは、慣例として"["*13も利用することができます。この場合には終わりを"]"で囲みます。*14

よく使われるtestコマンドオプションを以下に示します。

=
文字列が等しい場合に、真を返します
!=
文字列が等しくない場合、真を返します
-eq
数値が等しい場合に、真を返します
-ne
数値が等しくない場合に、真を返します
-d
ディレクトリの場合に、真を返します
-e
ファイルが存在する場合に、真を返します
-z
文字列の長さが0の場合に、真を返します。シェル変数に値がセットされているか否かを調べるときに使用します
-n
文字列の長さが0でない場合に、真を返します

while文

while文は、条件が真の間、ループを回します。

while文の仕様は、

while expr_list 
do
  block_stmt;
done

でexpr_listで最後に実行したコマンドの終了条件が0(真)の場合に、ループ内のblock_stmtを実行します。

以下のEx7.shコマンドではread文と組み合わせて使用します。

#! /bin/sh
i=0
while read line
do
  IN_LINE[$i]=$line;
  i=`expr $i + 1`;
done
echo IN_LINE=${IN_LINE[@]};

Ex7.shを実行すると

$ Ex7.sh <<EOF
> abc efg
> 12345
> EOF
IN_LINE=abc efg 12345

のようにシェル変数IN_LINEの値が出力されます。

シェルスクリプトTIP集

縦をよこにする

ディレクトリの特定のファイルのみを削除する場合、削除するファイルのリストをファイルに保存し、このファイルの情報を使ってrmコマンドを実行すると便利です。

  1. 削除するファイルの候補をlsコマンドで/tmp/rm.lstに出力する
  2. /tmp/rm.lsの内容をエディタで編集する
  3. rmコマンドを起動する

では実際にやってみましょう。 これまでのCのサンプルファイルがあるディレクトリでオブジェクトファイルを作成し、それを削除してみます。

$ cc -c *.c
$ ls *.o >/tmp/rm.lst
$ cat /tmp/rm.lst
ex1.o
ex2.o
ex3.o
ex4.o
range.o
tgrep.o
$ rm `cat /tmp/rm.lst`
$ ls *.o
ls: *.o: No such file or directory

rm `cat /tmp/rm.lst`のように一覧ファイルをバッククォート` で実行すると縦に並んでいたファイルのリストが横一覧のコマンド引数に変換することができます。

一意なファイル名の生成

シェルスクリプト中で一時ファイルを作成する場合、一意なファイル名を一時ファイル名としたいことがあります。 このような場合特殊変数$$を使ってシェルのプロセスIDを取得し、ファイル名の一部に付けることで一意なファイル名を作成します。 特にヒアドキュメントのテンプレート機能を使ってawk等のスクリプトをシェルスクリプト中に生成する場合に使用します。

以下のシェルスクリプト(Ex8.sh)のようなパターンになります。

#! /bin/sh
TMP_FILE=/tmp/temp.$$
NAME='Hiroshi TAKEMOTO'

cat << EOF > $TMP_FILE
My name is $NAME.
This is a simple shell script example.
EOF
echo `cat $TMP_FILE`
rm $TMP_FILE

Ex8.shを実行すると

$ Ex8.sh
My name is Hiroshi TAKEMOTO. This is a simple shell script example.
$ ls /tmp/temp.*
ls: /tmp/temp.*: No such file or directory

一時ファイルは、実行後に削除されています。

出力の結合

shでは、標準エラー出力や標準出力の指定を2, 1のように記述番号を使って指定することができます。 標準出力と標準エラー出力を一緒にファイルに出力する場合には、2>&1を使って出力をまとめることができます。

例)test.shの標準出力と標準エラー出力を/tmp/stdou_and_stderr.outする場合、

$ test.sh 2>&1 >/tmp/stdou_and_stderr.out

特殊ファイル

出力内容を捨てたい時やファイルを空にしたい時に特殊ファイル/dev/nullを使用します。 標準出力をリダイレクトで/dev/nullに送れば、そのメッセージが捨てられます。 大量のメッセージを出力するプログラムでそのメッセージが必要ない場合にこの方式を使用することによって処理スピードが格段に速くなます。

また、ファイルを空にするときには、

$ cp /dev/null file_name

のようにするとfile_nameで指定されたファイルのサイズが0になります。

まとめ

パイプでつなぐ(パイプライン処理)ことによって、短時間にデータを加工する方法をご紹介しました。 更にシェルスクリプトを使うことによって

  • データを加工するプログラムの一部をシェルスクリプト内部で自動生成したり
  • 共通な処理は、シェルスクリプトにまとめ、さらにそれを使ってパイプをつなぐ

といったことを組み合わせることができます。 ここで紹介したデータ加工のパイプライン処理とシェルスクリプトの活用が、 日常の業務で皆様のお役に立てば幸いです。

謝辞

竹中章子さん、戸張一夫さんには原稿に対し貴重なコメントを頂きました。感謝申し上げます。

参考文献

皆様のご意見、ご希望をお待ちしております。
  • この記事は、「コマンドを組み合わせるのりとしてのシェルスクリプト」を再編成したものです。 -- 竹本 浩? 2009-08-15 (土) 16:00:35

(Input image string)


*1 bashでも以下に紹介する機能は使用できます
*2 最初の読み込みで、len=13となっているのは、文字列の終わりの0が含まれているためです
*3 execlは、引数で指定されたコマンドを実行します。実行プロセスには、ファイル記述0, 1, 2ファイルも引き継がれます
*4 GNU plotutils パッケージに含まれています
*5 Macの場合MacPortのgraphはXウィンドウをサポートしていないので、xtermから-T tekオプションで起動してください
*6 ある程度バッファに溜まるまで出力されないです
*7 tailコマンドの-fオプションによって、ファイルに追加されたデータがリアルタイムに表示されます
*8 実装上は別のシェルプロセスを起動し、コマンドを実行するので、このようなことが可能になります
*9 ファイルのrw属性は変更されません
*10 makeコマンドですべてのサンプルをコンパイルすることができます
*11 ^Gは、Ctrl-vのあとにCtrl-gを入力してください
*12 私はこちらの形式を使用しています
*13 testのシンボリックリンク
*14 [および]はコマンドであるため、expr_listとの間に空白を入れる必要があります
*15 私は、この本でUNIXを勉強しました。これ1冊でshの使い方、システムコールを使ったC言語プログラミング、UNIXコマンドの使い方が習得できます。さすがshを作った人が書いた本です!
*16 初期のUNIXのソースコードを使ってUNIXカーネルの仕組みを解説した名著です。

添付ファイル: fileman.jpg 847件 [詳細] fileclose.jpg 772件 [詳細] filedup.jpg 906件 [詳細] filepipe.jpg 844件 [詳細] filefork.jpg 933件 [詳細] filedemo_pipeline.jpg 856件 [詳細]

トップ   編集 凍結解除 差分 バックアップ 添付 複製 名前変更 リロード   新規 一覧 単語検索 最終更新   ヘルプ   最終更新のRSS
Last-modified: 2012-03-07 (水) 08:43:23 (1814d)
SmartDoc