こんにちは、エンジニアリングGの中村です。

以前にこのブログにてエムスリーでの社内研修について紹介しました。今回は、この中でのbashスクリプティング講座の資料を公開します。

www.m3tech.blog

弊社の中でもいろいろな用途でbashが使われていますが、bashは簡単に利用できるもののプログラミング言語としてはバグを生みやすい、辛い言語だと思います。 ここで紹介しているのはいわゆるコーディング規則というよりも、バグ防止と可読性向上のためのルールをTips集的にまとめたものです。 bashにおいてまだまだ注意するところはありそうですが、多少なりともわかりにくいスクリプトの削減になればと期待しています。

[追記: 2018-08-22]

はてブにて以下のコメントをいただきました。

これは全くそのとおりだと思います。一番大事な主張が、研修の中で口頭では伝えていたのですが資料に書き忘れているのに気づきました。

複雑な処理はbashではなく他の言語（pythonなど）を使った方がよいです。 この資料は、bashという言語がいろいろと注意点が多く、本気で対処するには辛い言語であるということを伝るためのものでもあります。 bashで無理にテクニックを駆使してやろうとするのではなく、使いやすい言語を選ぶことを検討した方がよいと思います。

bashスクリプティング講座

はじめに

bashスクリプトは誰でもとりあえずは作成できるという反面、記述が雑になりやすい傾向があるように思います。 ですが、一度作成されたものはその後も使い続けられ、後々になって思わぬバグがおきたり、メンテナンスがしづらくて苦労したりということになります。

そのため，bashといえども他の言語での開発と同様に読みやすく・わかりやすく書くように心がけてください。 言語面での制約が少ない分、わかりにくいコードになりやすい（そういうのを書けてしまう）ので、他よりも一層良いコーディングを意識する必要があります。

以下では、bashでバグになりやすい点や、読みやすくなるような書き方の指針、bashのテクニック的なことを上げています。

そもそも

また、そもそもbashを使うべきなのどうかを考えてください。 この資料を見て、実はbashがいろいろと辛い・大変な言語であることを感じてほしいと思っています。 ある程度以上の規模や複雑なことをするのであれば、bashではなく他の言語を使ってください。 もしbashを使うのであれば、ここから示す注意点や記法を守りつつ作成してください。

スクリプトの定型テンプレート

最初に、シェルスクリプトのおおよその書き方のテンプレートを以下に記載します。

main.sh

set -euC readonly DEBUG = false readonly WORKDIR =/tmp/mydir INPUT_FILE = OUTPUT_FILE = FLAG = 0 function usage () { cat <<EOS >&2 Usage: $0 [-o OUTPUT_FILE] [-a] INPUT_FILE INPUT_FILE 入力ファイル -o OUTPUT_FILE 出力ファイル -a オプション。〜〜を〜〜として動作します EOS exit 1 } function parse_args () { while getopts " o:a " OPT; do case $OPT in o ) OUTPUT_FILE = $OPTARG ;; a ) FLAG = 1 ;; ? ) usage ;; esac done shift $((OPTIND - 1 )) INPUT_FILE = ${1 :- } if [[ " $INPUT_FILE " == "" ]] ; then usage fi } function hoge () { local input_file = " $1 " cat <<EOS cat " $input_file " EOS } function fuga () { local output_file = " $1 " cat <<EOS echo "Hello" > " $output_file " EOS } function main () { local input_file = " $1 " local output_file = " $2 " local flab = " $3 " hoge " $input_file " fuga " $output_file " } if [[ " ${BASH_SOURCE[ 0 ]} " == "${0}" ]] ; then parse_args " $@ " main " $OUTPUT_FILE " " $INPUT_FILE " " $FLAG " fi

test.sh

source ./main.sh function test_fuga { fuga log_file.txt cat log_file.txt } function test_args { parse_args -o " output " input echo $INPUT_FILE echo $OUTPUT_FILE } function test_main { main item1 item2 0 } test_args

bashの書き方でよくある問題

bashは便利な反面、値の解釈などで分かりにくい動きが多くあります。 例えば、以下の記事にていろいろな落とし穴が紹介されています。

Bashのよくある間違い | Yakst https://yakst.com/ja/posts/2929



本記事は上記の記事を参考にさせていただきつつ、社内で特にありがちなところを補足したものです。

[必須] 明確な意図が無いならシバン(#!〜〜) には /bin/sh ではなく /bin/bash を明示する

スクリプトの１行目のシバン(shbang)には、 sh を使うことを明確にしていないなら bash を明示してください。

sh と bash は正確には別物です。 sh はPOSIX仕様にそった機能のみが使えるものですが、 bash はそれよりも機能が豊富です。

OSによっては /bin/sh が別のシェルのシンボリックリンクである場合があります( dash だったり)。 また、 /bin/bash のシンボリックリンクの場合もあったりしますが、実体はbashでも sh 実行モードで動いていて実質POSIXしか使えないときもあります。

同じスクリプトをいくつものUnix,Linux系OSをまたいで流用するのであれば sh を意識して実装する必要があります。

ですが、社内のスクリプト（特に分析用・調査用のスクリプト）は、特定の環境でのみ動けばいいようなことがほとんどなのでbashを使っておいてください。

[必須] 変数を展開するときは必ず「"」で囲う

bashでの変数は普通のプログラミング言語とはかなり違うため、色々と注意する必要があります。 その中で大きなポイントとしては以下の２点です。

空白文字を変数に入れて扱う時 半角スペースやタブを含む値を扱う時

これらの直感的にはわかりにくい問題を回避するためにも、変数を利用するときは必ず「""」で囲うようにしてください。

変数が空白文字を保持しているときに起きる問題: その１

VAL1 = some_func $VAL1 foo bar some_func foo bar

上記の２つのコマンド実行は同じ意味になります。つまり、some_funcの第１引数は $VAL1 ではなく foo となります。 fooを第２引数として指定したいのであれば、 $VAL1 をダブルクオートで囲んで第１引数が空文字となるようにする必要があります。

VAL1 = some_func " $VAL1 " foo bar some_func "" foo bar

変数が空白文字を保持しているときに起きる問題: その２

以下は、if文の構文エラーになってしまいます。

VAL1 = if [ $VAL1 -eq 1 ] ; then echo " OK " ; fi if [ -eq 1 ] ; then echo " OK " ; fi → 実行エラー発生。 [ ] 内の構文が不正となる（第１引数が 「-eq」になるため左辺がない )

変数をダブルクオートで囲うと回避できます。

VAL1 = if [ " $VAL1 " -eq 1 ] ; then echo " OK " ; fi if [ "" -eq 1 ] ; then echo " OK " ; fi → [ ] 内の第一引数が 「 "" 」となるので成立する

値に半角スペースを含む場合に起きる問題

変数の値に半角スペースがあると、コマンド引数やループなどのときにスペース区切りで値が分解されてしまいます。

問題パターン１

FILE_NAME = " some text file.txt " cp $FILE_NAME ./mydir/ cp some text file.txt ./mydir/ → ３ファイルをコピーするような動作になり、そんなファイルはなくてエラー

問題パターン２

touch " some text file.txt " for file in $( ls *.txt ) ; do echo $file ; done echo some ; echo text ; echo file.txt → ls の結果の文字列「some text file.txt」を空白で分解してループしてしまう for file in *.txt; do if [ ! -e " $file " ] ; then continue ; fi echo $file done

[必須] if文は [ 〜 ] ではなく [[ 〜 ]] を使う

bashでは、条件判定部分を [ とは別に [[ を使えます。

[[ の方が安全で機能が豊富なのでこちらを使うことを推奨します。

[[ では前述の変数が空の場合のエラーが発生しません（ちゃんと条件判定として実行される)

VAL1 = if [[ $VAL1 == '' ]] ; then echo " OK " ; fi => でもエラーにならず、 " OK " と表示される

詳しくは以下参照してください。

[Bash] testと[と[[ - Qiita https://qiita.com/Riliumph/items/f07272fa9b0834032a9d

bashの似てて紛らわしいもの [[ / [ / test はどこが違うの？ - それマグで！ http://takuya-1st.hatenablog.jp/entry/2017/01/02/163036

Google Shell Style Guide ... is preferred over [, test and /usr/bin/[. https://google.github.io/styleguide/shell.xml#Test,%5B_and%5B%5B



[必須] 配列変数を展開するときは「"」で必ず囲う

コマンドやfunctionに渡された引数の全てを保持する「$@」という特殊変数を使う時は、必ず「"」で囲ってください。 値に半角スペースを含む場合に囲うかどうかで挙動が変わってしまいます。

for i in $@ ; do echo $i ; done for i in " $@ " ; do echo $i ; done

「"」で囲わない場合は、値に半角スペースを含む場合にそこで分解されてしまいます。 例えば、スペースを含むファイル名を受け取るような場合に正しいファイル名を利用できないといったことが起こります。

この「"$@"」はこの４文字で固有の動作をする表記になっています。 func "hoge $@" といった表記では成立しません。

また、引数の一覧を取得する別の方法に「$*」というものがありますが、こちらでは「"$*"」と書いても必ず半角スペースが分割されてしまいます。 なので、基本的に「$*」は使わず「"$@"」を使うようにしてください。

bashスクリプトのいい感じの書き方

スクリプトを書くときの全体的な記述の方針を以下にまとめます。

[必須] functionの記述方法

functionを定義するときは以下のフォーマットで書いてください。

"function"を明記

名前のあとの「()」は任意

function内の最初で引数をローカルに保持

例としては以下のような書き方になります。

function func1 () { local arg1 = " $1 " echo hello " $arg1 " }

bashでは"function"を明記しなくても定義できますが、 grepなどでfunctionの定義を探しやすくするために明記することをお願いします。

[必須] functionなどのブロック内はインデントする

bashも普通のプログラミング言語と同様にコードブロックの中ではインデントして記述してください。

例外としてヒアドキュメントの場合などで行頭に空白を入れられない場合は除きます。

function func1 () { echo " hoge " if [[ -e " file.txt " ]] ; then echo " file " fi while true; do echo " loop " break done } function func2 () { cat <<EOS | somework select * from doctor EOS # ヒアドキュメントのマーカは例外 # ヒアドキュメント内部が行頭に空白が入れられなければ例外 cat <<EOS > some.yml setting: text: Hello world! EOS cat <<EOS | sed -E 's/^ + \| //' > some.yml |setting: | text: Hello world! EOS } ( echo " in subprocess " do_something )

[必須] インデントはスペースのみ

ファイル中のインデントはスペースで2文字、もしくは4文字で記述してください。

タブ文字は不可。スペース・タブ混在は不可です。

タブ文字はgitlabなどでの表示の文字幅などが指定できないため、見た目を統一するためにスペースとしてください。

[必須] １ファイル中の記述の配置

シェルスクリプトに記述することになる要素は以下のようなものがあります。

外部スクリプトのsource

グローバル定数

グローバル変数

引数parse処理

関数定義

スクリプトのメイン処理のエントリー部分

これらの要素は、要素ごとに一塊になるように記述してください。 定数や関数の定義がファイル中に散在すると、見落としの原因になってスクリプトの全体を読みづらくなります。

各要素の記述順序は基本的に前述のテンプレートのような順序で記述するようにしてください。

[必須] main関数を作成してエントリーポイントとする

引数のパース処理やグローバルの定数・変数の定義を除いて何らかの処理を記述するときは、必ず関数として定義してください。 スクリプトのエントリーポイントとなるメイン処理も、main関数を定義してそれをスクリプトの最後で呼び出すように記述してください。

また、main関数の実行を if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then でブロックすることで、スクリプトを直接実行したときのみ処理を行い、sourceで読み込まれたときは処理を行わないようにできます。

これには以下の利点があります。

全てを関数化することでコードのインデントが揃って読みやすくなる

事前準備処理と、メイン処理とを関数として分けて定義することで処理の意味を読み取りやすくなる

デバッグ作業やテストにてメイン処理を実行せず、個々の関数のみを動作させることができるようになる

[必須] 変数名の命名

関数、変数の命名については以下の表記としてください。

snake caseで命名する (例: use_this_format、OUTPUT_DIR)

グローバルな変数・定数は全て大文字(例: OUTPUT_DIR)

ローカル変数は小文字 (例: use_this_format)

日付は値の書式が分かる様に foo_bar_date よりは foo_bar_yyyymmdd のようにしてもよい

時間を表す数値などは、変数名で単位がわかるようにする。foo_sec (秒単位)、bar_min(分単位)など

関数は処理の内容に沿ったスクリプト全体で意味が通る名前にする NG: create_sql、OK: create_message_list_sql



[必須] 変数名は長くなってもわかりやすく付ける

bashはグローバル変数の利用が多くなりがちで、その性質から定義場所と利用場所が離れるなどして読解が難しくなることがあります。 そのため、グローバル変数については変に短くするよりも長いほうがいいと考えます。

変数名の例

ある程度省略がわかるものはOKとしますが、基本的には省略の必要はないと思います OK: TBL (= table), LBL (= label ) NG: L (一文字だけの変数), 〜〜_L (接尾語などでも１文字はだめ）



[必須] コマンド、functionを作成するときはしっかりとコメントを記述する

コマンドやfunctionの使い方や作成された意図などについてコメントを残すようにしてください。 sourceして使う前提のファイルでも、そのファイル自体にも用途やそのファイルの意図を記載してください。

以下の点を明確になるように記載することを心がけてください。

そのスクリプト・コマンドの用途・使い方・利用上の注意点

前提として定義しておく環境変数やコマンド、ファイルなどの依存関係

引数やオプションの定義

アウトプットとなるものの定義（ファイル、DBのテーブルなど）

functionのコメント例

function csv_escape () { local value = " $1 " echo " \" " $( echo " $value " | sed ' s/"/""/g ' ) " \" " }

[必須] コマンド実行結果の標準出力を変数で取得するときは「$(cmd)」表記を使う

コマンド実行の標準出力を取得するには、「`command`」と「$(command)」の２通り表記がありますが、必ず「$(command)」で記述してください。

「`」の表記は記号が小さく視認しづらいのと、「$()」であれば入れ子で記述することができるためです。

VAL = $( echo " hoge " )

[必須] localやreadonlyを付けて変数を定義する

bashではfunction内で定義した変数もグローバル変数になります。 それを避けるためにfunction内で変数を定義するときは local を付けてください。 もし、それを付けずに意図的にグローバル変数を定義しようとしてるときは、何か無茶をしようとしていると思って再考してください。

ただし、 local の変数のスコープは普通のプログラミング言語とは異なるので過信しすぎないように注意してください。

localはそれが宣言されたブロック内の呼び出し全てに影響します。

function hoge () { local val1 = 100 fuga } function fuga () { echo $val1 } hoge echo " val1=[ ${val1} ] "

[必須] 値を変更しない定数を定義するときはreadonlyを付けて定義する

値を変更しない定数を定義するときは、 readonly を付けてください。 これをつけた定数は上書きで代入しようとするとエラーになります。

readonly HOGE = 10 HOGE = 100

[推奨] グローバル変数をできるかぎり使わない

bashは変数のスコープを制限しづらくグローバル変数を多用しがちです。

ですが、グローバル変数を使うと実装を読み解きづらくなるため基本的にはグローバル変数を使わないように実装してください。

functionを読み解くときに、そのfunctionだけを見れば処理が理解できるようになることを心がけてください。 グローバル変数を直接参照できるとしてもその変数を直接つかうのではなく、引数から渡すようにしてください。

ただし、何もかもの変数を渡して回ると逆に読みにくくなる場合もありますので、グローバルな定数やほぼ定数的に使うようなものは直接使ってもよいです。

NG

VAL1 = $1 function func1 () { echo " $VAL1 " } func1

OK

readonly WORKDIR =/tmp/workspace readonly LOGFILE = $WORKDIR /app.log INPUT_MESSAGE = $1 function func1 () { local message = $1 echo " $message " > $LOGFILE } func1 " $INPUT_MESSAGE "

[推奨] 複雑な処理が必要になったらbash以外を使うことを考える

bashは簡易なことを行うなら便利ですが、複雑なデータ構造のものを扱う場合などには向きません。

そのため、bashでやるのが難しいことがでてきたらbashを使うのを諦めて別の言語やコマンドを使うことを考えてください。

bash以外のコマンド・言語

sed / tr コマンド

awk

perl / python / ruby

[必須] bashスクリプトには set -euC を付ける

bashの実行時のオプションにスクリプトを作成する上で有用なものがあります。 オプションはbashコマンドの引数としてしてするか、スクリプト中に set コマンドで指定できます。

スクリプトを作成するときは、このオプションの中で基本的に set -euC を付けてください。

set -euC echo " myscript "

シバン( #!/bin/bash )のところに記述することもできますが、それだとデバッグ時などでスクリプトを bash コマンドで直接実行するようなときにオプションが機能しなくなるので set で指定する方がよいと考えます。

bash -x myscript.sh

オプションの意味は以下のとおりです:

-e オプション

スクリプト中のコマンド実行がエラー終了(終了コード 0以外での終了)した場合にスクリプトを停止します。 逆に言えば、このオプションなしではコマンドがエラー終了してもそのまま処理が継続してしまいます。 そのため、想定外のバグや突発的なエラー発生時に、誤って後続の処理をしてしまうようなことが無いように、このオプションは必ずつけるべきです。

注意点として、 grep や ls などのコマンドは結果が0件になるような場合に終了コードが0以外になるので、 そういう場合は一時的に -e オプションを解除するようにする必要があります。

set -e echo " エラー終了する範囲 " set +e # 一時的にエラーで停止しないようにする myvalue = $( grep " hoge " myfile.txt ) RET = $? set -e # 再度、有功化 grep " hoge " mfile.txt || true

-u オプション

-u オプションを指定すると未定義変数を使おうとするとエラー終了するようになります。

これにより、変数名のタイプミスや初期化わすれでの変数の利用などを防ぐことができます。

set -u echo " $VAL1 "

回避方法

set -u echo " ${VAL1 :- } "

-C オプション

-C オプションを付けるとリダイレクトでのファイル上書き時にエラー終了するようになります。 リダイレクトでファイルを生成するときに想定外に上書きしてしまうことを防ぐことができます。

set -C touch file1.txt echo " Hello " > file1.txt echo " Hello " > | file1.txt

[推奨] スクリプト終了時に必ず処理をさせたいときは trap を利用する

trap コマンドを使うことで、スクリプトが終了したときに必ず実行する処理を指定することができます。

それをつかって、スクリプトの最後に掃除処理などを行うことができます。

TEMPFILE = "" function main () { echo " start main " TEMPFILE = $( mktemp ) echo " hoge fuga " > $TEMPFILE } function cleanup () { if [[ -n " $TEMPFILE " && -e " $TEMPFILE " ]] ; then mv -n " $TEMPFILE " ~/.trash/ fi } trap cleanup EXIT main

bashスクリプトの実装テクニック

よくあるコマンドのTips

mkdir -p 指定したパスを子階層も含めてすべて生成する 既にディレクトリがある場合はエラーにもならず無視される 既存ディレクトリの有無を確認してからmkdirする必要がない

mv -v, cp -v, rm -v 多くのコマンドは -v をつけるとコマンドの実行ログ出力される mv, cp, rmはそれぞれのファイル操作の元・先のファイルを表示するので動作結果を確認できる

mv -n, cp -n ファイル操作系のコマンドに -n オプションを付けると上書き防止になる 移動先、コピー先に既に同名のファイルがあった場合にエラー終了する



関数から複数の値を返す方法

関数から複数の値を返したい場合は以下のようにprintコマンドを使うと対応できます。 あまり、おすすめするわけではないですが、使う方法のテクニックとして紹介します。

function some_func () { local retval_output_file = $1 local retval_temp_table = $2 print -v $retval_output_file " %s " output.csv print -v $retval_temp_table " %s " temp_table_name } function main () { local output_file temp_table && some_func output_file temp_table echo " $output_file " echo " $temp_table " }

ログの出し方

ログを出力するときに、処理中のファイルや関数、実行行数などを出力することができます。

function log () { local fname = ${BASH_SOURCE[ 1 ] ## */ } echo -e " $( date ' +%Y-%m-%dT%H:%M:%S ' ) ${fname} : ${BASH_LINENO[ 0 ]} : ${FUNCNAME[ 1 ]} $@ " } log " message "

途中終了でも安全なファイル生成方法

ファイルを生成するときは、一旦別名のファイルに出力した上で最後にリネームするようにするとよいです。 ファイルの生成途中でエラーになったり、手動停止したりした場合に、作成途中のファイルを間違って最終データとして使ってしまわないようにすることができます。 また、再実行時に既にファイルがあるときはスキップするような処理にするときにも有功です。

if [[ ! -e target_file.csv ]] ; then find ./hoge/ -name \* .txt >> target_file.csv.tmp echo " special.txt " >> target_file.csv.tmp mv target_file.csv.tmp target_file.csv fi

一時的に環境変数を設定・上書きした状態でコマンドを実行する

シェルでコマンドを実行するときに、一時的に環境変数を上書きしてコマンド実行することができます。

export LANG= ja_JP.UTF -8 date LANG =C date echo $LANG

また、以下のように「()」で囲うと、その範囲内のみサブプロセスで実行することになるため、 その内部での変数への変更は「()」の外側に影響しません。

これは、 cd でのフォルダ移動も同じ効果があります。

VAL1 = 10 echo $VAL1 pwd ( VAL1 = 100 echo $VAL1 # => 100 cd /usr/ local pwd # => /usr/ local ) echo $VAL1 pwd

コマンドやfunctionに長い文字列を渡すときは標準入力を使う

表示用の文言やSQL文などの長い文字列を渡すような場合は、引数で渡すよりもパイプを使って標準入力をつかった方が良いです。

function func1 () { local val = $1 if [[ -z " $val " || " $val " == "-" ]] ; then val = $( cat - ) fi echo $val } echo " foo " | func1 echo " bar " | func1 " - " func1 " hoge "

bashのデバッグ技法

read -p "pause" で一時停止

readコマンドをつかえばEnterキーを入力するまで停止できます。

処理の途中状態を確認するときや、trapなどで掃除処理を仕込んだ場合でそれが実行されるまえの状態を確認するようなときなどに便利です。

trap DEBUG, trap ERR

trap でシグナルとしてDEBUGを指定するとスクリプト中の全行の実行時に処理を割り込めます。

trap ' read -p "pause" ' DEBUG

trap でシグナルとしてERRを指定するとコマンドのエラー終了時に処理を割り込めます。

trap caller ERR

スクリプトの途中でコマンドの引数を設定する

set -- に続けて文字列を記述すると、それらを実行中のスクリプトの引数として上書きできます。 スクリプトのテストを記述するようなときに有用です。

echo ORIGINAL ARGS: " $@ " set -- word1 " some value " echo CHANGED ARGS: " $@ "

上記を実行すると以下のようになります。

$ ./sample.sh hoge fuga ORIGINAL ARGS: hoge fuga CHANGED ARGS: word1 some value

set -x オプション

bashの -x オプションをはデバッグをするときに有用です。 これを指定すると、スクリプトが処理したコマンドが表示されます。 変数も実際の値に展開されて表示されるので、実際に処理された内容を把握することができます。

set -x VAL1 = 100 echo " Output: ${VAL1 :- hoge } "

実行すると以下のように表示されます

+ VAL1 = 100 + echo ' Output: 100 ' Output: 100

注意

パスワードを変数に保持して使うような処理で、 -x 付きのログをログファイルに保存したりしないこと。

-x は変数の内容(つまり、パスワード)を展開した状態で出力されるので、ログにパスワードが残ってしまいます。

参考資料

本資料の作成にあたって以下の記事などを参考にさせていただきました。

シェルスクリプトを書くときに気をつける9箇条 - Qiita https://qiita.com/b4b4r07/items/9ea50f9ff94973c99ebe

bashデバッグTips - Qiita https://qiita.com/mashumashu/items/ee436b770806e8b8176f

What is the bash equivalent to Python's if __name__ == '__main__' ? - Stack Overflow https://stackoverflow.com/questions/29966449/what-is-the-bash-equivalent-to-pythons-if-name-main

シェルスクリプトのロギングを楽にするtips - Qiita https://qiita.com/Ets/items/cd3baa5cecbf553f822d

Bashスクリプトのチートシートと便利なスニペットまとめ - オープンソースこねこね http://kohkimakimoto.hatenablog.com/entry/2016/03/14/044924



エンジニア募集

エムスリーでは自身で手を動かし、技術で医療の課題を解決するエンジニアを募集しています。 この記事（or 他の記事も）を読んで興味を持った方はぜひ下記リンクよりご応募ください！