シェルスクリプト

最終更新日:2024-11-22 | ページの編集

概要

質問

  • コマンドを保存して再利用するにはどうすればよいですか?

目的

  • 固定されたファイルセットに対してコマンドまたはコマンドの一連の処理を実行するシェルスクリプトを書く。
  • コマンドラインからシェルスクリプトを実行する。
  • コマンドラインで指定されたファイルセットを操作するシェルスクリプトを書く。
  • 自分や他の人が作成したシェルスクリプトを含むパイプラインを作成する。

ついに、シェルがいかに強力なプログラミング環境であるかを確認する準備が整いました。
繰り返し実行するコマンドをファイルに保存し、それらの操作を後で再実行するために、
1つのコマンドを入力するだけで済むようにします。
歴史的な理由から、ファイルに保存された一連のコマンドは通常 シェルスクリプト と呼ばれますが、
実際にはこれらは小さなプログラムです。

シェルスクリプトを書くことで作業が速くなるだけでなく、同じコマンドを何度も再入力する必要がなくなります。
また、タイポの可能性を減らし、再現性を向上させます。後で作業を見返したり、他の誰かが作業を見つけてそれを基にしたい場合でも、
スクリプトを実行するだけで同じ結果を再現できます。長いコマンドを思い出したり再入力したりする必要はありません。

まず alkanes/ に戻り、新しいファイル middle.sh を作成してシェルスクリプトにします:

BASH

$ cd alkanes
$ nano middle.sh

nano middle.sh コマンドは、テキストエディタ「nano」で middle.sh ファイルを開きます。
ファイルが存在しない場合は、新規に作成されます。次の行を挿入してファイルを編集します:

head -n 15 octane.pdb | tail -n 5

これは以前構築したパイプのバリエーションで、
octane.pdb ファイルの11行目から15行目を選択します。
ここではまだコマンドを実行していないことに注意してください。
コマンドをファイルに組み込んでいるだけです。

次にファイルを保存します(nanoでは Ctrl-O)、
その後、テキストエディタを終了します(nanoでは Ctrl-X)。
alkanes ディレクトリに middle.sh というファイルが作成されていることを確認します。

ファイルを保存したら、その中に含まれるコマンドをシェルに実行させることができます。
シェルは bash と呼ばれるため、次のコマンドを実行します:

BASH

$ bash middle.sh

出力

ATOM      9  H           1      -4.502   0.681   0.785  1.00  0.00
ATOM     10  H           1      -5.254  -0.243  -0.537  1.00  0.00
ATOM     11  H           1      -4.357   1.252  -0.895  1.00  0.00
ATOM     12  H           1      -3.009  -0.741  -1.467  1.00  0.00
ATOM     13  H           1      -3.172  -1.337   0.206  1.00  0.00

確かに、スクリプトの出力は、パイプラインを直接実行した場合とまったく同じ結果になります。

テキストとその他の形式

Microsoft WordやLibreOffice Writerのようなプログラムは通常「テキストエディタ」と呼ばれますが、
プログラミングに関してはもう少し慎重になる必要があります。
Microsoft Wordはデフォルトで .docx ファイルを使用して、テキストだけでなく、フォントや見出しなどの書式情報も保存します。
これらの追加情報は文字として保存されておらず、head のようなツールには意味を持ちません。
head は標準のキーボード上の文字、数字、句読点のみを含む入力ファイルを想定しています。
したがって、プログラムを編集する際にはプレーンテキストエディタを使用するか、ファイルをプレーンテキスト形式で保存するよう注意する必要があります。

次に、任意のファイルの行を選択したい場合はどうでしょうか?
その都度 middle.sh を編集してファイル名を変更するのは、シェルでコマンドを入力して実行するより時間がかかるかもしれません。
代わりに、middle.sh を編集してより汎用的にします:

BASH

$ nano middle.sh

次に、「nano」で octane.pdb のテキストを特別な変数 $1 に置き換えます:

head -n 15 "$1" | tail -n 5

シェルスクリプト内では、$1 は「コマンドライン上の最初のファイル名(または他の引数)」を意味します。
これでスクリプトを以下のように実行できます:

BASH

$ bash middle.sh octane.pdb

出力

ATOM      9  H           1      -4.502   0.681   0.785  1.00  0.00
ATOM     10  H           1      -5.254  -0.243  -0.537  1.00  0.00
ATOM     11  H           1      -4.357   1.252  -0.895  1.00  0.00
ATOM     12  H           1      -3.009  -0.741  -1.467  1.00  0.00
ATOM     13  H           1      -3.172  -1.337   0.206  1.00  0.00

また、別のファイルでも以下のように実行できます:

BASH

$ bash middle.sh pentane.pdb

出力

ATOM      9  H           1       1.324   0.350  -1.332  1.00  0.00
ATOM     10  H           1       1.271   1.378   0.122  1.00  0.00
ATOM     11  H           1      -0.074  -0.384   1.288  1.00  0.00
ATOM     12  H           1      -0.048  -1.362  -0.205  1.00  0.00
ATOM     13  H           1      -1.183   0.500  -1.412  1.00  0.00

引数の周囲のダブルクオート

ループ変数をダブルクオートで囲む理由と同じく、ファイル名にスペースが含まれている場合に備えて、$1 をダブルクオートで囲みます。

現在、返される行の範囲を調整するたびに middle.sh を編集する必要があります。
これを修正するために、スクリプトを3つのコマンドライン引数を使用するように構成します。
最初のコマンドライン引数($1)の後に提供される追加の引数は、それぞれ $2, $3 という特別な変数を介してアクセスできます。

この仕組みを利用して、行範囲を headtail に渡すようスクリプトを編集します:

BASH

$ nano middle.sh
head -n "$2" "$1" | tail -n "$3"

以下のように実行できます:

BASH

$ bash middle.sh pentane.pdb 15 5

出力

ATOM      9  H           1       1.324   0.350  -1.332  1.00  0.00
ATOM     10  H           1       1.271   1.378   0.122  1.00  0.00
ATOM     11  H           1      -0.074  -0.384   1.288  1.00  0.00
ATOM     12  H           1      -0.048  -1.362  -0.205  1.00  0.00
ATOM     13  H           1      -1.183   0.500  -1.412  1.00  0.00

引数を変更することでスクリプトの動作を変えることができます:

BASH

$ bash middle.sh pentane.pdb 20 5

出力

ATOM     14  H           1      -1.259   1.420   0.112  1.00  0.00
ATOM     15  H           1      -2.608  -0.407   1.130  1.00  0.00
ATOM     16  H           1      -2.540  -1.303  -0.404  1.00  0.00
ATOM     17  H           1      -3.393   0.254  -0.321  1.00  0.00
TER      18              1

これでも動作しますが、次に middle.sh を読む人が何をするスクリプトなのかを理解するのに少し時間がかかるかもしれません。
スクリプトの先頭に コメント を追加することで改善できます:

BASH

$ nano middle.sh
# ファイルの中間部分の行を選択する。
# 使用法: bash middle.sh ファイル名 終了行 行数
head -n "$2" "$1" | tail -n "$3"

コメントは # 文字で始まり、その行の終わりまで続きます。
コンピュータはコメントを無視しますが、人々(特に将来の自分)がスクリプトを理解しやすくするために不可欠です。
唯一の注意点は、スクリプトを変更するたびにコメントが正確であることを確認する必要があることです。
間違った方向に読者を導く説明は、コメントがないよりも悪い場合があります。

複数のファイルを1つのパイプラインで処理したい場合はどうすればよいでしょうか?
例えば、.pdb ファイルを長さ順に並べ替えたい場合、次のコマンドを入力します:

BASH

$ wc -l *.pdb | sort -n

wc -l はファイル内の行数をリストします
wc は “word count” を意味し、-l オプションを追加すると “行を数える” を意味します)。
sort -n は数値順に並べ替えます。
これをファイルに入れることもできますが、その場合、現在のディレクトリ内の .pdb ファイルのリストを並べ替えるだけになります。
他の種類のファイルのリストを取得できるようにするには、ファイル名をスクリプトに渡す方法が必要です。
$1, $2 のような変数は使えません。なぜなら、ファイル数がわからないからです。
代わりに、特別な変数 $@ を使用します。これは「シェルスクリプトへのすべてのコマンドライン引数」を意味します。
また、引数にスペースが含まれている場合に備えて $@ をダブルクオートで囲む必要があります("$@" は特別な構文で、"$1", "$2" … と同等です)。

例を示します:

BASH

$ nano sorted.sh
# ファイルをその長さで並べ替える。
# 使用法: bash sorted.sh 1つ以上のファイル名
wc -l "$@" | sort -n

BASH

$ bash sorted.sh *.pdb ../creatures/*.dat

出力

9 methane.pdb
12 ethane.pdb
15 propane.pdb
20 cubane.pdb
21 pentane.pdb
30 octane.pdb
163 ../creatures/basilisk.dat
163 ../creatures/minotaur.dat
163 ../creatures/unicorn.dat
596 total

ユニークな種のリストを作成する

リアは数百のデータファイルを持っています。それぞれのファイルは以下のような形式です:

2013-11-05,deer,5
2013-11-05,rabbit,22
2013-11-05,raccoon,7
2013-11-06,rabbit,19
2013-11-06,deer,2
2013-11-06,fox,1
2013-11-07,rabbit,18
2013-11-07,bear,1

このようなファイルの例は、shell-lesson-data/exercise-data/animal-counts/animals.csv にあります。

コマンド cut -d , -f 2 animals.csv | sort | uniq を使用すると、
animals.csv 内のユニークな種を抽出することができます。
この一連のコマンドを毎回入力するのを避けるために、科学者はシェルスクリプトを書くことを選ぶかもしれません。

コマンドライン引数として任意の数のファイル名を受け取り、それぞれのファイルに含まれるユニークな種のリストを出力する
species.sh という名前のシェルスクリプトを書いてください。

BASH

# CSVファイル内のユニークな種を検索するスクリプト
# このスクリプトはコマンドライン引数として任意のファイル名を受け取ります

# すべてのファイルに対してループを実行
for file in $@
do
    echo "$file に含まれるユニークな種:"
    # 種の名前を抽出
    cut -d , -f 2 $file | sort | uniq
done

例えば、論文に使用するグラフを作成するために便利な一連のコマンドを実行したとします。
後で再度グラフを作成できるように、コマンドをファイルに保存したいとします。
再度コマンドを入力する代わりに(そして間違える可能性を減らすために)、次のようにします:

BASH

$ history | tail -n 5 > redo-figure-3.sh

このコマンドで、ファイル redo-figure-3.sh に以下の内容が保存されます:

297 bash goostats.sh NENE01729B.txt stats-NENE01729B.txt
298 bash goodiff.sh stats-NENE01729B.txt /data/validated/01729.txt > 01729-differences.txt
299 cut -d ',' -f 2-3 01729-differences.txt > 01729-time-series.txt
300 ygraph --format scatter --color bw --borders none 01729-time-series.txt figure-3.png
301 history | tail -n 5 > redo-figure-3.sh

エディタで一部編集し、行番号と最後の history コマンドを削除することで、
正確にグラフを作成する手順を記録したスクリプトを得ることができます。

コマンドを実行する前に履歴に記録する理由

以下のコマンドを実行すると:

BASH

$ history | tail -n 5 > recent.sh

ファイルの最後のコマンドは history コマンド自体になります。
つまり、シェルは実際にコマンドを実行する前に history をコマンドログに追加します。
実際、シェルは 常に コマンドを実行する前にログに追加します。
なぜこのような仕様になっていると思いますか?

コマンドが原因でクラッシュやフリーズが発生した場合、そのコマンドが何であったかを知ることで問題の調査が可能になります。
コマンドが実行された後にのみ記録された場合、クラッシュが起きた時に最後に実行されたコマンドの記録を失う可能性があります。

実際には、多くの人がシェルスクリプトを開発する際、
まずシェルプロンプトでコマンドを何度か実行して正しいことを確認し、
その後、それらを再利用のためにファイルに保存します。
この方法では、データやワークフローに関する発見をリサイクルすることが可能です。
history を一度実行し、出力を少し編集してシェルスクリプトとして保存するだけで済みます。

ネルのパイプライン: スクリプトの作成


ネルの指導教官は、すべての分析が再現可能でなければならないと主張しました。
そのための最も簡単な方法は、すべてのステップをスクリプトに記録することです。

まず、ネルのプロジェクトディレクトリに戻ります:

BASH

$ cd ../../north-pacific-gyre/

nano を使用してファイルを作成します…

BASH

$ nano do-stats.sh

以下の内容を含むスクリプトを作成します:

BASH

# データファイルの統計を計算する。
for datafile in "$@"
do
    echo $datafile
    bash goostats.sh $datafile stats-$datafile
done

このスクリプトを do-stats.sh という名前で保存することで、
以下のコマンドを入力するだけで分析の最初の段階を再実行できるようになります:

BASH

$ bash do-stats.sh NENE*A.txt NENE*B.txt

また、以下のようにも実行できます:

BASH

$ bash do-stats.sh NENE*A.txt NENE*B.txt | wc -l

これにより、処理されたファイル数のみが出力され、処理されたファイル名のリストは表示されません。

ネルのスクリプトの特徴の1つは、処理するファイルを実行者が決定できることです。
以下のようにも書けます:

BASH

# Site A と Site B のデータファイルの統計を計算する。
for datafile in NENE*A.txt NENE*B.txt
do
    echo $datafile
    bash goostats.sh $datafile stats-$datafile
done

この方法の利点は、常に正しいファイルを選択できることです。
「Z」ファイルを除外することを忘れる心配がありません。
しかし、欠点は、常にそのファイルだけを選択する点です。
すべてのファイル(「Z」ファイルを含む)や、南極の同僚が作成した「G」や「H」ファイルに対して実行するには、スクリプトを編集する必要があります。
さらに柔軟にするには、コマンドライン引数をチェックし、指定がなければ NENE*A.txt NENE*B.txt を使用するようスクリプトを変更することもできます。
もちろん、これは柔軟性と複雑さのトレードオフをもたらします。

シェルスクリプトの変数

alkanes ディレクトリで、次のコマンドを含む script.sh という名前のシェルスクリプトがあるとします:

BASH

head -n $2 $1
tail -n $3 $1

alkanes ディレクトリで以下のコマンドを入力します:

BASH

$ bash script.sh '*.pdb' 1 1

次のうち、どの出力が期待されますか?

  1. alkanes ディレクトリ内の .pdb ファイルの各ファイルの最初と最後の行の間にあるすべての行
  2. alkanes ディレクトリ内の .pdb ファイルの各ファイルの最初と最後の行
  3. alkanes ディレクトリ内のすべてのファイルの最初と最後の行
  4. *.pdb の引用符が原因でエラー

正しい答えは 2 です。

特別な変数 $1, $2, $3 はスクリプトに与え

られたコマンドライン引数を表します。
したがって、実行されるコマンドは次のようになります:

BASH

$ head -n 1 cubane.pdb ethane.pdb octane.pdb pentane.pdb propane.pdb
$ tail -n 1 cubane.pdb ethane.pdb octane.pdb pentane.pdb propane.pdb

引用符で囲まれているため、シェルは *.pdb を展開しません。
そのため、スクリプトの最初の引数は '*.pdb' となり、headtail によってスクリプト内で展開されます。

特定の拡張子を持つ最長のファイルを見つける

longest.sh というシェルスクリプトを作成してください。このスクリプトは、引数としてディレクトリ名とファイル拡張子を受け取り、
そのディレクトリ内で指定された拡張子を持つファイルのうち、最も多くの行を含むファイルの名前を出力します。
例えば:

BASH

$ bash longest.sh shell-lesson-data/exercise-data/alkanes pdb

は、shell-lesson-data/exercise-data/alkanes ディレクトリ内の .pdb ファイルの中で最も多くの行を持つファイルの名前を出力します。

別のディレクトリでスクリプトをテストしてみてください。例:

BASH

$ bash longest.sh shell-lesson-data/exercise-data/writing txt

BASH

# シェルスクリプト: 
# 1. ディレクトリ名
# 2. ファイル拡張子
# を引数に受け取り、そのディレクトリ内で
# 指定された拡張子を持つ最も多くの行を含むファイル名を出力します。

wc -l $1/*.$2 | sort -n | tail -n 2 | head -n 1

このパイプラインの最初の部分、wc -l $1/*.$2 | sort -n は、各ファイルの行数を数えて数値順にソートします(最大値が最後に来る)。
複数のファイルがある場合、wc はすべてのファイルの合計行数を示す最終的なサマリー行も出力します。
tail -n 2 | head -n 1 を使用して、このサマリー行を削除しています。

もし wc -l $1/*.$2 | sort -n | tail -n 1 を使用した場合は、最終的なサマリー行が表示されます。
出力を理解するために、パイプラインを段階的に構築して確認することができます。

スクリプト読解問題

再び shell-lesson-data/exercise-data/alkanes ディレクトリを考えてみます。
このディレクトリには .pdb ファイルがいくつか含まれており、その他のファイルが作成されている場合もあります。
以下の3つのスクリプトが、それぞれ bash script1.sh *.pdbbash script2.sh *.pdbbash script3.sh *.pdb として実行された場合の挙動を説明してください。

BASH

# Script 1
echo *.*

BASH

# Script 2
for filename in $1 $2 $3
do
    cat $filename
done

BASH

# Script 3
echo $@.pdb

各ケースで、シェルはスクリプトに渡す前に *.pdb のワイルドカードを展開し、
結果のファイル名リストをスクリプトに引数として渡します。

Script 1:

このスクリプトは、名前にドット . を含むすべてのファイルのリストを出力します。
スクリプトに渡された引数はスクリプト内では使用されていません。

Script 2:

このスクリプトは、.pdb 拡張子を持つ最初の3つのファイルの内容を出力します。
$1, $2, $3 はそれぞれ最初、2番目、3番目の引数を指します。

Script 3:

このスクリプトは、スクリプトに渡されたすべての引数(すなわちすべての .pdb ファイル)を出力し、
その後に .pdb を追加します。$@ はシェルスクリプトに渡された すべて の引数を指します。

出力

cubane.pdb ethane.pdb methane.pdb octane.pdb pentane.pdb propane.pdb.pdb

スクリプトのデバッグ

以下のスクリプトが north-pacific-gyre ディレクトリ内の do-errors.sh に保存されているとします:

BASH

# データファイルの統計を計算する。
for datafile in "$@"
do
    echo $datfile
    bash goostats.sh $datafile stats-$datafile
done

このスクリプトを north-pacific-gyre ディレクトリから次のように実行すると:

BASH

$ bash do-errors.sh NENE*A.txt NENE*B.txt

出力は空になります。
原因を特定するために、-x オプションを使用してスクリプトを再実行します:

BASH

$ bash -x do-errors.sh NENE*A.txt NENE*B.txt

この出力には何が表示されますか?どの行がエラーの原因ですか?

-x オプションは bash をデバッグモードで実行します。
これにより、実行される各コマンドが出力され、エラーの特定に役立ちます。

この例では、echo が何も出力していないことがわかります。
ループ変数名にタイポ(datfile)があり、変数 datfile が存在しないため、空の文字列が返されています。

まとめ

  • コマンドを再利用するためにファイル(通常はシェルスクリプト)に保存する。
  • bash [ファイル名] を使用してファイル内のコマンドを実行する。
  • $@ はシェルスクリプトのすべてのコマンドライン引数を参照する。
  • $1, $2 などは、それぞれ最初のコマンドライン引数、2番目のコマンドライン引数を参照する。
  • 値にスペースが含まれる場合に備えて、変数を引用符で囲む。
  • 処理するファイルをユーザーが選択できるようにすることは、柔軟性が高く、Unixの組み込みコマンドとの一貫性が高い。