シェルツールとスクリプト
この講義では、bashを一つのスクリプト言語として扱い、それとコマンドラインで常時行う幾つかの最も一般的な作業を担うシェルツールとを併用する基礎を紹介します。
シェルスクリプトの書き方
これまでは、どのようにシェルでコマンドを実行し、それらをパイプで繋げるのかを学んできました。 しかし、多くの場合では複数のコマンドを一遍に実行したり、条件分岐や繰り返しなどの制御構文を利用したりすることが必要になってきます。
そういったより複雑な操作を実現してくれるのが、次のステップであるシェルスクリプトです。 ほとんどのシェルには固有のスクリプト言語があり、変数や制御構造、そして固有の文法があります。 他のスクリプト言語と異なる点は、シェルスクリプトはシェル関連の作業に特化しているということです。 故に、コマンドのパイプラインを作ったり、実行結果をファイルに保存したり、標準入力から読み込んだりするのはシェルスクリプトの本領なので、汎用的なスクリプト言語よりも使い勝手が良いのです。 この章では、最もよく使われているbashのスクリプト言語に注目していきます。
bashで変数に代入するには、foo=bar
という構文を使います。変数の値にアクセスするには、$foo
を用います。
ここで注意して欲しいのですが、foo = bar
ではうまく行きません。この構文だとfoo
というプログラムを呼び出して引数に=
とbar
を取ると解釈されてしまうからです。
一般に、シェルスクリプトにおいてスペース記号は引数を分割する役割を果たします。この挙動は最初のうちは混乱しがちなので、常に注意を払うようにしましょう。
bashの文字列は'
と"
の区切り文字で定義できます。しかしこの2つは同等ではありません。
'
で区切った文字列はリテラルの文字列であり、"
のように変数の値を代入してくれません。
foo=bar
echo "$foo"
# barと出力する
echo '$foo'
# $fooと出力する
ほとんどのプログラミング言語と同じように、bashはif
、case
、while
やfor
などの制御構造の使用が可能です。
同様に、bash
にも関数があり、引数を受け取って計算することができます。ここでは関数の一例として、ディレクトリを作りcd
でその中に移動するものを示します。
mcd () {
mkdir -p "$1"
cd "$1"
}
ここで$1
はこのスクリプト/関数へ渡される一番目の引数です。
他のスクリプト言語と違って、bashは色んな変わった変数を使って引数、エラーコードや他の変数を指定します。以下はその一部を示したリストです。より詳しくまとめたリストはここにあります。
$0
- スクリプト名$1
から$9
- スクリプトに渡される引数。$1
は一番目の引数で、以下同様。$@
- 全ての引数$#
- 引数の総数$?
- 直前のコマンドのリターンコード$$
- 現在のスクリプトのプロセス識別子(PID)!!
- 引数を含む直前のコマンド全体。よくあるパターンはパーミッションがなかっただけでコマンドを実行できなかったときで、sudo !!
と打てばそのコマンドをsudoで速やかに再実行できます。$_
- 直前のコマンドの最後の引数。もしインタラクティブシェルを使っているなら、Esc
からの.
と打てば即座にこの値を取れます。
コマンドはいつも出力を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)
はディレクトリfoo
とbar
の中にあるファイルの違いを示してくれます。
多くの内容を一気に説明してきたので、これらの書き方のいくつかを用いた例を見てみましょう。ここでは与えられた引数について繰り返し処理していき、文字列foobar
をgrep
で抽出し、もし見つからなかったらコメントとして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 と呼ばれます。
- ワイルドカード - 何らかのワイルドカード検索をかけたい場合、
?
と*
はそれぞれ1文字および任意の文字数にマッチします。例として、ファイルfoo
、foo1
、foo2
、foo10
およびbar
があったとすると、コマンドrm foo?
はfoo1
、foo2
を削除しますが、それに対しrm foo*
はbar
以外の全てのファイルを削除します。 - 波括弧
{}
- 複数のコマンドにおいて共通の部分文字列があった場合、波括弧を使ってbashに自動的に展開させることができます。これはファイルの移動や変換において非常に便利です。
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
のようになるでしょう。
シェル関数とスクリプトの違いで覚えておくべきなのは:
- 関数はシェルと同じ言語でなければならない。一方スクリプトはどの言語でも良い。これがスクリプトにおいてシェバンを書くのが重要である理由です。
- 関数は一度定義が読み込まれればロードされる。スクリプトは実行するたびに毎回ロードされる。そのため関数はロードが僅かに速いが、変更されるたびに定義を再読込しなければいけない。
- 関数は現在のシェル環境で実行されるのに対し、スクリプトは自身のプロセスの中で実行される。故に、関数は環境変数を変えることができる。例えば関数はカレントディレクトリを変えられるが、スクリプトはそれができない。スクリプトには
export
でエクスポートされた環境変数の値が渡される。 - どのプログラミング言語とも同様に、関数はシェルコードのモジュール性・再利用性・可読性を高めるのに強力な構造です。シェルスクリプトに固有の関数定義があることも珍しくありません。
シェルツール
コマンドの使い方
この時点で、エイリアスの章で紹介したコマンド、例えばls -l
、mv -i
やmkdir -p
を使うときに、どうやってフラグを見つけるのかが気になるかもしれません。
より一般的に、あるコマンドが与えられたとすると、それが何をするコマンドでどんなオプションがあるのかをどう調べればいいのでしょうか?
勿論ググってもいいのですが、UNIXはStackOverflowよりも前に作られたので、こういった情報を得るための搭載された方法があります。
シェルの講義で見てきたように、第一のアプローチとしてはコマンドを-h
や--help
フラグをつけて呼び出す方法です。さらに詳細に調べるのはman
コマンドを使う方法です。
manualの略で、man
は指定されたコマンドのマニュアルページ(manpageという)を出してくれます。
例えば、man rm
はrm
コマンドの動作と取りうるフラグを出力します。先に見せた-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
です。
locate
はupdatedb
によって更新されるデータベースを使っています。
ほとんどのシステムにおいて、updatedb
はcron
を通して毎日アップデートされています。
そのためこの二者におけるトレードオフは実行の速さと情報の新しさです。
また、find
とそれに似たようなツールは、ファイルサイズ、変更日時、またはファイルのパーミッションなどの属性を使って検索することもできます。それに対し、locate
はファイル名のみ使用します。
より掘り下げた比較の議論はここを参照してください。
コードの検索
ファイルを名前で検索するのは役に立ちます。しかし、ファイルの内容を検索したい場合も結構よくあります。
よくあるのが何らかのパターンを含む全てのファイルを検索し、そのパターンが出現した箇所も一緒に出してほしいというケースです。
これを実現するために、ほとんどのUNIX系システムはgrep
という入力テキストから一致パターンを探す汎用ツールを備えています。
grep
はもの凄く有用なシェルツールで、データラングリングの講義で今回よりも遥かに詳しく触れる予定です。
今のところは、grep
は色んなフラグがあって、多種多様な使い方があるツールとだけ覚えておいてください。
私がよく使うのは、一致した行周辺のContext(文脈)を出す-C
と、一致をinvert(反転)する、つまりパターンに一致しない行を全て出力する-v
です。例えば、grep -C 5
は一致した行の前の5行と後ろの5行を出力します。
多くのファイルを一気に検索するときには、-R
を使いましょう。これはRecursively(再帰的に)ディレクトリの中まで行って一致する文字列を含むファイルを探してくれます。
ただしgrep -R
は他にもたくさんの使い方に発展できます。例としては.git
フォルダを無視したり、マルチコア処理をしたり、等々。
grep
の代替ツールも色々作られています。例えばack、ag、それから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を使ってシンボリックリンクを作ったり。でも実のところ、開発者によって既にとても賢くて緻密な解決策が用意されているのです。
この講義のテーマに従って、一般的なケースを最適化する方法を考えていきましょう。
頻繁に使う、かつ/または直近で使ったファイルとディレクトリの検索は、fasd
やautojump
などのツールで行えます。
fasdはfrecency、つまり frequency(頻度) と recency(新しさ) の両方の基準を使ってファイルとディレクトリをランク付けします。
デフォルトでは、fasd
は追加としてz
というコマンドを使って、 frecent なディレクトリの部分文字列をもとに素早くそこにcd
できます。例として、/home/user/files/cool_project
によく移動しているなら、z cool
と打つだけでそこへ飛べます。autojumpを使うなら、j cool
で同じようなディレクトリ移動が可能です。
もう少し複雑なツールとして、ディレクトリ構造の概観を手っ取り早く示してくれるもの:tree
、broot
があります。さらに、nnn
やranger
などの本格的なファイルマネージャも挙げられます。
練習問題
-
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 ..
-
以下を行うbash関数
marco
およびpolo
を書け。marco
を実行する度にカレントディレクトリを何らかの形で記憶し、次にpolo
を実行すると、どのディレクトリに居るかによらず、polo
はmarco
を実行したディレクトリにcd
して戻る。 デバッグし易いようにするために、コードをmarco.sh
というファイルの中に書いておき、source marco.sh
を実行し定義をシェルに(リ)ロードするとよい。 -
とても稀にエラーになるコマンドがあるとする。そのデバッグは出力をキャプチャする必要があるが実行エラーを出すには時間がかかる。 以下のスクリプトをエラーが出るまで実行し、その標準出力およびエラーメッセージをファイルにキャプチャし、最後に全部表示する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"
-
講義で触れたように、
find
の-exec
は探しているファイルを操作するのにとても強力である。 しかし、全てのファイルに対して、例えばzipファイルを作るなど何かしらの操作をしたい場合はどうすれば良いのだろう? これまで見てきてように、コマンドは引数と標準入力のどちらからも入力を受け取れる。 コマンドをパイプで繋げるときは、標準出力と標準入力を繋げているわけだが、tar
のような一部のコマンドは引数から入力を受け取る。 この齟齬の橋渡しとなる、標準入力を引数にしてコマンドを実行してくれる、xargs
コマンドがある。 例として、ls | xargs rm
はカレントディレクトリのファイルを削除する。フォルダ内のHTMLファイルを再帰的に検索しzipでまとめるコマンドを書け。このコマンドは名前にスペースを含むファイルにも対応できるように注意せよ。(ヒント:
xargs
の-d
フラグをチェック)macOSの場合、デフォルトのBSDとしての
find
はGNU coreutilsに入っているものと異なることに注意せよ。find
の-print0
フラグおよびxargs
の-0
フラグを利用するとよい。macOSユーザーは、macOSに搭載されたコマンドラインユーティリティがGNUの同格製品と差異があることを認識しておくべきである。もしGNU版が欲しいのなら、brewを利用すればインストールできる。 -
(発展)あるディレクトリ内の一番新しく変更されたファイルを再帰的に検索するコマンドまたはスクリプトを書け。より一般的に、全てのファイルを変更日時の順に列挙することはできるだろうか?
Licensed under CC BY-NC-SA.