シェルツールとスクリプト

この講義では、bashを一つのスクリプト言語として扱い、それとコマンドラインで常時行う幾つかの最も一般的な作業を担うシェルツールとを併用する基礎を紹介します。

シェルスクリプトの書き方

これまでは、どのようにシェルでコマンドを実行し、それらをパイプで繋げるのかを学んできました。 しかし、多くの場合では複数のコマンドを一遍に実行したり、条件分岐や繰り返しなどの制御構文を利用したりすることが必要になってきます。

そういったより複雑な操作を実現してくれるのが、次のステップであるシェルスクリプトです。 ほとんどのシェルには固有のスクリプト言語があり、変数や制御構造、そして固有の文法があります。 他のスクリプト言語と異なる点は、シェルスクリプトはシェル関連の作業に特化しているということです。 故に、コマンドのパイプラインを作ったり、実行結果をファイルに保存したり、標準入力から読み込んだりするのはシェルスクリプトの本領なので、汎用的なスクリプト言語よりも使い勝手が良いのです。 この章では、最もよく使われているbashのスクリプト言語に注目していきます。

bashで変数に代入するには、foo=barという構文を使います。変数の値にアクセスするには、$fooを用います。 ここで注意して欲しいのですが、foo = barではうまく行きません。この構文だとfooというプログラムを呼び出して引数に=barを取ると解釈されてしまうからです。 一般に、シェルスクリプトにおいてスペース記号は引数を分割する役割を果たします。この挙動は最初のうちは混乱しがちなので、常に注意を払うようにしましょう。

bashの文字列は'"の区切り文字で定義できます。しかしこの2つは同等ではありません。 'で区切った文字列はリテラルの文字列であり、"のように変数の値を代入してくれません。

foo=bar
echo "$foo"
# barと出力する
echo '$foo'
# $fooと出力する

ほとんどのプログラミング言語と同じように、bashはifcasewhileforなどの制御構造の使用が可能です。 同様に、bashにも関数があり、引数を受け取って計算することができます。ここでは関数の一例として、ディレクトリを作りcdでその中に移動するものを示します。

mcd () {
    mkdir -p "$1"
    cd "$1"
}

ここで$1はこのスクリプト/関数へ渡される一番目の引数です。 他のスクリプト言語と違って、bashは色んな変わった変数を使って引数、エラーコードや他の変数を指定します。以下はその一部を示したリストです。より詳しくまとめたリストはここにあります。

コマンドはいつも出力をSTDOUTで、エラーをSTDERRで返し、そしてエラーを報告する返り値をよりスクリプトで使いやすい形で返します。 リターンコードあるいは終了ステータスは、スクリプト/コマンドが有する実行状況をやり取りする手段です。 値0は通常何も問題がなかったことを意味し、0以外の値はエラーが起こったことを意味します。

終了ステータスはコマンドを条件付きで実行するためにも使われます。その際は&&(and演算子)と||(or演算子)の2つの短絡評価演算子を用います。また、コマンドは同じ行内で;を用いて分割することも可能です。 trueプログラムは常に0のリターンコードを返し、falseコマンドは常に1のリターンコードを返します。 いくつかの例を見てみましょう

false || echo "Oops, fail"
# Oops, fail

true || echo "Will not be printed"
#

true && echo "Things went well"
# Things went well

false && echo "Will not be printed"
#

true ; echo "This will always run"
# This will always run

false ; echo "This will always run"
# This will always run

もう一つのよくあるパターンはコマンドの出力を変数として受け取りたい場合です。これは コマンド代入 でできます。 $( CMD )と書いた箇所は、CMDが実行され、そのコマンドの出力が渡されてその箇所に代入されます。 例えば、for file in $(ls)を実行すると、シェルはまずlsを呼び出し、次にそれらの値について繰り返し処理する。 これと似たものであまり知られていない プロセス代入 という書き方があります。<( CMD )CMDを実行してその出力を一時的なファイルに保存し、<()をそのファイルの名前で置換します。これは値がSTDINではなくファイルで受け渡されるコマンドを使うときに有用です。例えば、diff <(ls foo) <(ls bar)はディレクトリfoobarの中にあるファイルの違いを示してくれます。

多くの内容を一気に説明してきたので、これらの書き方のいくつかを用いた例を見てみましょう。ここでは与えられた引数について繰り返し処理していき、文字列foobargrepで抽出し、もし見つからなかったらコメントとしてfoobarを処理したファイルに追加します。

#!/bin/bash

echo "Starting program at $(date)" # 日付が代入される

echo "Running program $0 with $# arguments with pid $$"

for file in "$@"; do
    grep foobar "$file" > /dev/null 2> /dev/null
	# パターンに一致するものがなかった場合、grepの終了ステータスは1
	# ここでSTDOUTとSTDERRはどうでもいいので、nullレジスタにリダイレクト
    if [[ $? -ne 0 ]]; then
        echo "File $file does not have any foobar, adding one"
        echo "# foobar" >> "$file"
    fi
done

比較のところでは$?が0と等しくないかどうかを調べました。 bashにはこのような比較演算が多数用意されています。詳細はマニュアルページtestを参照してください。 bashにおいて比較を用いるときは、一重の角括弧[ ]ではなく二重の角括弧[[ ]]を使うようにしましょう。shには移植できませんが、このほうが書き間違いは少ないです。より詳しい説明はここに書いてあります。

スクリプトを走らせるに際して、似たような引数を指定することが多々あります。bashにはこれを簡便に行う方法があり、式を展開するファイル名展開を行うことができます。このテクニックはよくshell globbing と呼ばれます。

convert image.{png,jpg}
# は下のように展開される
convert image.png image.jpg

cp /path/to/project/{foo,bar,baz}.sh /newpath
# は下のように展開される
cp /path/to/project/foo.sh /path/to/project/bar.sh /path/to/project/baz.sh /newpath

# globbing手法は組み合わせることもできる
mv *{.py,.sh} folder
# は全ての*.pyと*.shファイルを移動させる


mkdir foo bar
# これはfoo/a、foo/b、... foo/h、bar/a、bar/b、... bar/hのファイルを生成する
touch {foo,bar}/{a..h}
touch foo/x bar/y
# fooとbarの違いを表示する
diff <(ls foo) <(ls bar)
# 出力
# < x
# ---
# > y

bashスクリプトを書くのは手強く分かりづらいかもしれません。sh/bashスクリプトのエラーを検出するshellcheckなどのツールを利用するのも良いでしょう。

注意してほしいのは、ターミナルで実行するスクリプトは必ずしもbashである必要はありません。例えば、以下は引数を逆順に出力する簡単なPythonのスクリプトです。

#!/usr/local/bin/python
import sys
for arg in reversed(sys.argv[1:]):
    print(arg)

カーネルがこのスクリプトはshellコマンドではなくpythonインタプリタで実行するべきだと分かるのは、スクリプトの先頭にシェバンが書いてあるからです。 シェバン行をenvコマンドで書くのは良い練習になります。これはシステムの中でコマンドがどこにあるのかを探し当ててくれるので、スクリプトの移植性を高めます。位置を検索するのに、envは第一回の講義で紹介したPATH環境変数を利用しています。 今回の例ではシェバン行は#!/usr/bin/env pythonのようになるでしょう。

シェル関数とスクリプトの違いで覚えておくべきなのは:

シェルツール

コマンドの使い方

この時点で、エイリアスの章で紹介したコマンド、例えばls -lmv -imkdir -pを使うときに、どうやってフラグを見つけるのかが気になるかもしれません。 より一般的に、あるコマンドが与えられたとすると、それが何をするコマンドでどんなオプションがあるのかをどう調べればいいのでしょうか? 勿論ググってもいいのですが、UNIXはStackOverflowよりも前に作られたので、こういった情報を得るための搭載された方法があります。

シェルの講義で見てきたように、第一のアプローチとしてはコマンドを-h--helpフラグをつけて呼び出す方法です。さらに詳細に調べるのはmanコマンドを使う方法です。 manualの略で、manは指定されたコマンドのマニュアルページ(manpageという)を出してくれます。 例えば、man rmrmコマンドの動作と取りうるフラグを出力します。先に見せた-iフラグも含まれます。 実は、今まで各コマンドについて参照してきたリンクは、それらのコマンドのLinuxマニュアルページのオンラインバージョンです。 もっというと、内部コマンドではないインストールされたコマンドでさえ、開発者が書いてインストールプロセスの一部としていれば、マニュアルページの登録があります。 対話式ツール、例えばncursesを基に作られたコマンドなどのヘルプの場合は、プログラム内で:helpコマンドや?コマンドを用いてアクセスできることが多いです。

時々、マニュアルページがコマンドについて詳しく説明すぎて、普通に使うのにもどのフラグ/構文を使えばいいのか却って解読しづらいことがあります。 TLDRページはこれに対する素晴らしい補完策であり、コマンドの使用例を示すことに重きを置いているので、どのオプションを使えば良いのかが即座にわかります。 例えば、私なんかはtarとかffmpegを調べるときは、マニュアルページよりもtldrページの方を遥かに多く引いたりします。

ファイル検索

プログラマであれば誰しもが出会う一番よく行う繰り返し作業の一つが、ファイルとディレクトリの検索です。 全てのUNIX系のシステムにはfindというファイルを検索するための素晴らしいシェルツールが付属しています。findはいくつかの条件にマッチするファイルを再帰的に検索してくれます。ちょっとした例は:

# srcという名前の全てのディレクトリを検索
find . -name src -type d
# パスにtestという名前のフォルダがある全てのpythonファイルを検索
find . -path '*/test/*.py' -type f
# 一日前に変更された全てのファイルを検索
find . -mtime -1
# サイズが500kから10Mの間にある全てのzipファイルを検索
find . -size +500k -size -10M -name '*.tar.gz'

ファイルを列挙する以外に、findは検索条件に一致したファイルに対する処理もできます。 この性質は、得てして単調になりがちな作業を簡略化するのに驚くほど役に立ちます。

# .tmp拡張子のファイルを全て削除する
find . -name '*.tmp' -exec rm {} \;
# 全てのPNGファイルを検索し、JPGに変換する
find . -name '*.png' -exec convert {} {}.jpg \;

findというありきたりな単語であるにもかかわらず、その文法は時々覚えるのに苦労します。 例えば、単に何らかのパターンPATTERNに一致するファイルを探すのにも、find -name '*PATTERN*'(大文字と小文字を区別しないためには-iname)を実行しなければなりません。 こういった場合に備えてエイリアスを作るのも良いですが、シェルに関する知見の一つとしては、代替手段を探してみるのも悪くないということです。 シェル最良の性質の一つは、プログラムを呼び出しているだけだという点にあることを覚えましょう。なので、一部のプログラムに対しては、代わりのものを探す(もしくは自分で書いてしまう)ことができます。 例えば、findの代わりとして、シンプルで動作が速く使いやすいのがfdです。 このコマンドにはカラー出力、デフォルトの正規表現検索、そしてUnicode対応といった素晴らしいデフォルト設定があります。また私個人の意見として、こちらの方が比較的分かりやすい文法になっています。 例として、パターンPATTERNを検索する文法はfd PATTERNになっています。

多くの人はfindよりもfdの方が良いという意見に賛成するでしょう。しかし、ファイルを毎回探しに行くのと、何らかの索引またはデータベースを構築して素早く検索するのと、どっちの効率が良いか悩む人もいるでしょう。 そのために用意されたのがlocateです。 locateupdatedbによって更新されるデータベースを使っています。 ほとんどのシステムにおいて、updatedbcronを通して毎日アップデートされています。 そのためこの二者におけるトレードオフは実行の速さと情報の新しさです。 また、findとそれに似たようなツールは、ファイルサイズ、変更日時、またはファイルのパーミッションなどの属性を使って検索することもできます。それに対し、locateはファイル名のみ使用します。 より掘り下げた比較の議論はここを参照してください。

コードの検索

ファイルを名前で検索するのは役に立ちます。しかし、ファイルの内容を検索したい場合も結構よくあります。 よくあるのが何らかのパターンを含む全てのファイルを検索し、そのパターンが出現した箇所も一緒に出してほしいというケースです。 これを実現するために、ほとんどのUNIX系システムはgrepという入力テキストから一致パターンを探す汎用ツールを備えています。 grepはもの凄く有用なシェルツールで、データラングリングの講義で今回よりも遥かに詳しく触れる予定です。

今のところは、grepは色んなフラグがあって、多種多様な使い方があるツールとだけ覚えておいてください。 私がよく使うのは、一致した行周辺のContext(文脈)を出す-Cと、一致をinvert(反転)する、つまりパターンに一致しない行を全て出力する-vです。例えば、grep -C 5は一致した行の前の5行と後ろの5行を出力します。 多くのファイルを一気に検索するときには、-Rを使いましょう。これはRecursively(再帰的に)ディレクトリの中まで行って一致する文字列を含むファイルを探してくれます。

ただしgrep -Rは他にもたくさんの使い方に発展できます。例としては.gitフォルダを無視したり、マルチコア処理をしたり、等々。 grepの代替ツールも色々作られています。例えばackag、それからrgなどがあります。 これらは皆優秀なツールで、結構同じような機能を持っていたりします。 今回はripgrep(rg)に注目して、これがどれくらい速くて直感的なのかをお見せします。いくつかの例としては:

# requestsライブラリを使用した全てのpythonファイルを検索
rg -t py 'import requests'
# シェバン行がない全てのファイル(隠しファイルも含む)を検索
rg -u --files-without-match "^#!"
# 全てのfooを検索し、その後の5行を出力
rg foo -A 5
# 一致した集計(一致した行とファイルの数)を出力
rg --stats PATTERN

ここで注意してほしいのですが、これらのツールのどれもがfind/fdと同じようにこの手の問題を解決することができるのを知っていることの方が大事で、具体的にどのツールを使うかはさほど重要ではありません。

シェルコマンドの検索

ここまではファイルやコードの検索の仕方について見てきました。しかし、シェルをもっと使うようになると、どこかで打ったコマンドをピンポイントで検索したくなったりするでしょう。 まず最初に知っておくべきこととして、上矢印キーを押すと最後に打ったコマンドが出てきて、そのまま押し続けるとシェルの履歴を少しずつ遡ることができます。

また、historyコマンドはプログラム的にシェル履歴にアクセスできます。 これはシェルの履歴を標準出力に表示するコマンドです。 もし履歴内で検索したいのなら、パイプで出力をgrepに繋げてパターン検索をかけられます。 history | grep findは”find”という部分文字列を含むコマンドを表示してくれます。

ほとんどのシェルでは、Ctrl+Rを使うことで履歴を遡って検索できます。 Ctrl+Rを押したあと、履歴の中で検索したいコマンドの部分列を入力すれば良い訳です。 Ctrl+Rを押し続けると、履歴中で一致したものを巡回します。 これはzshでは上/下矢印キーでも有効にできます。 Ctrl+Rの更なる発展として便利なのがfzfバインディングです。 fzfは様々なコマンドで利用できる汎用的な曖昧検索ツールです。 ここでは履歴内を曖昧検索して、結果を手ごろで見やすい形に表示するのに使われています。

履歴関連で私が気に入っているもう一つの凄い技は、履歴による自動補完です。 fishシェルで初めて導入されたこの機能は、現在のシェルコマンドをそれと同じ書き出しの一番直近に打ったコマンドで動的に自動補完してくれます。 この機能はzshでも有効にできます。あなたの素敵なシェルライフの一助となるでしょう。

シェル履歴の動作を変更することもできます。例えばスペースで始まるコマンドを履歴に含まない、など。これはパスワードやその他扱いに注意が必要な情報を含むコマンドを入力する場合に便利です。 これを機能させるのには、HISTCONTROL=ignorespace.bashrcに、あるいはsetopt HIST_IGNORE_SPACE.zshrcに追加しましょう。 もし先頭のスペースを入れ忘れても、もちろん.bash_history.zhistoryを編集して手動で記録を削除できます。

ディレクトリ移動

さて、これまでの議論では、既に操作を実行する場所にいると想定していた訳ですが、それではディレクトリ間の移動を素早くこなすにはどうすればよいのでしょうか? これについては簡単な方法はたくさんあります。例えばシェルエイリアスを書いたり、ln -sを使ってシンボリックリンクを作ったり。でも実のところ、開発者によって既にとても賢くて緻密な解決策が用意されているのです。

この講義のテーマに従って、一般的なケースを最適化する方法を考えていきましょう。 頻繁に使う、かつ/または直近で使ったファイルとディレクトリの検索は、fasdautojumpなどのツールで行えます。 fasdはfrecency、つまり frequency(頻度)recency(新しさ) の両方の基準を使ってファイルとディレクトリをランク付けします。 デフォルトでは、fasdは追加としてzというコマンドを使って、 frecent なディレクトリの部分文字列をもとに素早くそこにcdできます。例として、/home/user/files/cool_projectによく移動しているなら、z coolと打つだけでそこへ飛べます。autojumpを使うなら、j coolで同じようなディレクトリ移動が可能です。

もう少し複雑なツールとして、ディレクトリ構造の概観を手っ取り早く示してくれるもの:treebrootがあります。さらに、nnnrangerなどの本格的なファイルマネージャも挙げられます。

練習問題

  1. man lsを読んでファイルを以下のように列挙するlsコマンドを書け。

    • 全てのファイルを列挙せよ。隠しファイルも含む
    • サイズは人が読める形式で表せ(例えば、454279954の代わりに454Mと表示する)
    • ファイルは新しい順で並べよ
    • 出力は色付けせよ

    出力例はこのようになる。

     -rw-r--r--   1 user group 1.1M Jan 14 09:53 baz
     drwxr-xr-x   5 user group  160 Jan 14 09:53 .
     -rw-r--r--   1 user group  514 Jan 14 06:42 bar
     -rw-r--r--   1 user group 106M Jan 13 12:12 foo
     drwx------+ 47 user group 1.5K Jan 12 18:08 ..
    
  2. 以下を行うbash関数marcoおよびpoloを書け。 marcoを実行する度にカレントディレクトリを何らかの形で記憶し、次にpoloを実行すると、どのディレクトリに居るかによらず、polomarcoを実行したディレクトリにcdして戻る。 デバッグし易いようにするために、コードをmarco.shというファイルの中に書いておき、source marco.shを実行し定義をシェルに(リ)ロードするとよい。

  3. とても稀にエラーになるコマンドがあるとする。そのデバッグは出力をキャプチャする必要があるが実行エラーを出すには時間がかかる。 以下のスクリプトをエラーが出るまで実行し、その標準出力およびエラーメッセージをファイルにキャプチャし、最後に全部表示するbashスクリプトを書け。 スクリプトがエラーになるまで何回実行されたかも示すと追加点が与えられる。

     #!/usr/bin/env bash
    
     n=$(( RANDOM % 100 ))
    
     if [[ n -eq 42 ]]; then
        echo "Something went wrong"
        >&2 echo "The error was using magic numbers"
        exit 1
     fi
    
     echo "Everything went according to plan"
    
  4. 講義で触れたように、find-execは探しているファイルを操作するのにとても強力である。 しかし、全てのファイルに対して、例えばzipファイルを作るなど何かしらの操作をしたい場合はどうすれば良いのだろう? これまで見てきてように、コマンドは引数と標準入力のどちらからも入力を受け取れる。 コマンドをパイプで繋げるときは、標準出力と標準入力を繋げているわけだが、tarのような一部のコマンドは引数から入力を受け取る。 この齟齬の橋渡しとなる、標準入力を引数にしてコマンドを実行してくれる、xargsコマンドがある。 例として、ls | xargs rmはカレントディレクトリのファイルを削除する。

    フォルダ内のHTMLファイルを再帰的に検索しzipでまとめるコマンドを書け。このコマンドは名前にスペースを含むファイルにも対応できるように注意せよ。(ヒント:xargs-dフラグをチェック)

    macOSの場合、デフォルトのBSDとしてのfindGNU coreutilsに入っているものと異なることに注意せよ。find-print0フラグおよびxargs-0フラグを利用するとよい。macOSユーザーは、macOSに搭載されたコマンドラインユーティリティがGNUの同格製品と差異があることを認識しておくべきである。もしGNU版が欲しいのなら、brewを利用すればインストールできる。

  5. (発展)あるディレクトリ内の一番新しく変更されたファイルを再帰的に検索するコマンドまたはスクリプトを書け。より一般的に、全てのファイルを変更日時の順に列挙することはできるだろうか?


このページを編集する

Licensed under CC BY-NC-SA.