シェルスクリプト(bash)：基本的な5つのTips

#前書き
私は入社2年目になったら、「(シェル)スクリプト書いて」と依頼される事が増えました。
あれから半年、[シェル芸人日記](http://qiita.com/t_nakayama0714/items/bfe4852e0535858ee662)と**恐ろしい工数**の力を借り、ようやくスクリプト初心者になりました。
本記事では、スクリプト作成中に四苦八苦しながら覚えた内容を備忘録として紹介します。
具体的には、どのような箇所でハマったか、およびその問題の解決方法を記載します。

#紹介内容
1. 環境変数をシェルスクリプト内で設定する方法
2. ヒアドキュメントによって、文字列をファイルに転記する方法
3. デリミタ(区切り文字)のないファイルから文字列を抽出する方法
4. crontabによって定時刻に動作させるスクリプトの置き場所
5. windowsで記述したスクリプトをLinuxで実行可能な状態にする方法

## 環境変数をシェルスクリプト内で設定する方法
問題：シェルスクリプト内で設定した筈の環境変数が反映されていない。不思議だ。
原因：シェルスクリプトはサブシェルで実行され、サブシェルの中で環境変数が定義されていたため、
　　　サブシェル(スクリプト)終了後に設定した変数の情報が消えてしまう。
解決策：
この問題は、`bash script.sh`という形でコマンドを実行した場合に、
script.shに記載したコマンド(およびスクリプト自身)が子(孫)プロセスで実行される事により発生します。
残念ながら、子プロセスの環境変数を親に引き継ぐ事はできないので、期待通りの結果を得られません。
そこで、この問題を解決するために、現在のシェル上でスクリプトを実行する[source](http://x68000.q-e-d.net/~68user/unix/pickup?source)コマンドを使用します。
例：`source script.sh`

これだけで、シェルスクリプト内に記載した環境変数を現在のシェルに反映することができます。
しかし、「第三者がスクリプトを実行する場合、bash/sourceのどちらを用いるか分からない事」を考えると、
「[bash](http://kazmax.zpp.jp/cmd/b/bash.1.html)コマンドを実行した場合、sourceコマンドを使うように警告を出す」のが好ましいと思われます。

そこで、以下に特定の条件下で、警告を出すスクリプトを例示します。

```warning.sh
#!/bin/bash

if [ -n "$BASH_SOURCE" ]; then
    THIS_SCRIPT=${BASH_SOURCE}
else
    THIS_SCRIPT="$(pwd)/warning.sh"
fi

# check variables
echo $BASH_SOURCE
echo $0

if [ "$0" = "${THIS_SCRIPT}" ]; then
    echo "Please run as 'source ${THIS_SCRIPT}'"
    exit 1
fi    
```

実行結果を以下に示します。
変数BASH_SOURCEには実行したスクリプトへのパスが格納されますが、
変数$0に格納される値は実行時に使用したコマンドによって異なります。
bashコマンドで実行した場合はスクリプトへのパス、sourceコマンドの場合は使用中のシェルが格納されます。

```
$ bash warning.sh 
warning.sh
warning.sh
Please run as 'source warning.sh'

$ source warning.sh 
warning.sh
bash
You used source command

```

##ヒアドキュメントによって、文字列をファイルに転記する方法
問題：[echo](http://eng-entrance.com/linux-command-echo)コマンドとリダイレクトを駆使した転記は手間。
原因：特になし。
解決策：
 [ヒアドキュメント](https://ja.wikipedia.org/wiki/%E3%83%92%E3%82%A2%E3%83%89%E3%82%AD%E3%83%A5%E3%83%A1%E3%83%B3%E3%83%88)を使用すれば、何十行という文字列を一度にファイルに書き出す事ができます。
この方式では、スクリプト内で定義した変数をヒアドキュメント内で使用。
ユーザとの対話形式で変数値を決定し、スケルトンファイルを大量に作成する際に役立ちます。

具体的なヒアドキュメントの記法例(heredoc.sh)、
およびその実行結果(output.txt)を以下に示します。

```heredoc.sh
#!/bin/bash

VAR=check

cat << EOS > output.txt
①

ヒアドキュメントの範囲は、"<< EOS"をコマンドに渡した行の次の行(①)から、
次に単独で"EOS"が記載された行の一つ上の行(②)までです。
EOS部分は、自由な文字列で問題ありませんが、
ヒアドキュメントの開始と終了で、同じ文字列を用いなければなりません。

$VAR   # ヒアドキュメント内で変数を使用する場合。
\$VAR  # 変数として出力しない場合。
`ls`   # ヒアドキュメント内でコマンドを使う場合、シングルクォートで囲む。       

②
EOS
```

```output.txt
①                                                                               

ヒアドキュメントの範囲は、"<< EOS"をコマンドに渡した行の次の行(①)から、
次に単独で"EOS"が記載された行の一つ上の行(②)までです。
EOS部分は、自由な文字列で問題ありませんが、
ヒアドキュメントの開始と終了で、同じ文字列を用いなければなりません。

check   # ヒアドキュメント内で変数を使用する場合。
$VAR    # 変数として出力しない場合。
heredoc.sh
dir.txt # ヒアドキュメント内でコマンドを使う場合、シングルクォートで囲む。

②
```

##デリミタ(区切り文字)のないファイルから文字列を抽出する方法
問題：ファイル内にカンマ/空白/コロンが多数存在し、[「ネ申Excel」](http://koduki.hatenablog.com/entry/2014/12/13/224152)に抽出した文字列を転記できない。
原因：~~bashで文字列操作をするのが悪い。~~ ファイルに変更を加えずに、[cut](http://itpro.nikkeibp.co.jp/article/COLUMN/20060227/230738/)コマンドによる抽出を試みた事。
解決策：
テキストファイルに独自のデリミタを加えれば、cutコマンドで文字列を抽出できます。
例えば、以下のeditor.txtから、PACKAGE/VERSION/DESCRIPTION列をそれぞれ抽出する場合を考えます。
人間の目ならば「どの範囲がPACKAGE列なのか」と一瞬で判断できますが、
コマンド抽出(例：`cut -f 1 editor.txt`)では適切なデリミタがないため、期待する結果が返ってきません。

```editor.txt
PACKAGE        VERSION      DESCRIPTION                                      
-----------------------------------------------------------------
vim            2:7.4.488-7  compatible ver of the UNIX editor Vi. 
emacs          46.1         extensible self-documenting  editor.
Sublime Text   3126         text editor for code, markup & prose.
Atom           1.10.2       A hackable text editor.
```

この問題を解決するために、テキスト中に含まれていない記号(例："@"や"#")をデリミタとして、
以下のように挿入してから`cut -f 1　-d @ editor.txt`を実行します。

```editor_modify.txt
vim@            2:7.4.488-7@  compatible ver of the UNIX editor Vi. 
emacs@          46.1@         extensible self-documenting  editor.
Sublime Text@   3126@         text editor for code, markup & prose.
Atom@           1.10.2@       A hackable text editor.
```

デリミタを挿入する方法としては、[sed](http://itpro.nikkeibp.co.jp/article/COLUMN/20060227/230879/)コマンドの後方参照を利用します。
後方参照では、後方参照したい文字列を"\("と"\)"で囲み、参照は"\1"で行います。
イメージとしては、"\(example\)"の場合、変数\1にexampleが代入されたと考えられます。

今回の場合では、デリミタの挿入を2回に分けて行います。
つまり、PACKAGE列の末尾に"@"を挿入した後に、VERSION列の末尾に"@"を挿入します。
デリミタの挿入では、PACKAGE列は必ず行頭から始まり、末尾にスペースが2個存在する事を利用し、
VERSION列は文字列の開始が数字かつ末尾にスペースが2個存在することを利用します。

具体的なスクリプトを以下に例示します。

```cut.sh
#!/bin/bash

# 不要な1~2行目を削除。
sed -e "1,2d" ./editor.txt > tmp.txt

# PACKAGE列にデリミタを挿入。
sed -i -e "s/\(^[a-zA-Z\ ]*[a-zA-Z]\)\ \ /\1@/g" ./tmp.txt

# VERSION列にデリミタを挿入。
sed -i -e "s/\([0-9\.\:]*[0-9]\)\ \ /\1@/g" ./tmp.txt

# 各列を抽出後、不要な文頭の空白を消去                                       
cut -f1 -d @ tmp.txt
cut -f2 -d @ tmp.txt | sed -e "s/^[ ]*//g"
cut -f3 -d @ tmp.txt | sed -e "s/^[ ]*//g"
```

```実行結果.
$ bash cut.sh 　　　# Excelに転記する方法は省略します。
vim
emacs
Sublime Text
Atom
2:7.4.488-7
46.1
3126
1.10.2
compatible ver of the UNIX editor Vi.
extensible self-documenting  editor.
text editor for code, markup & prose.
A hackable text editor.
```

##crontabによって定時刻に動作させるスクリプトの置き場所
問題：[crontab](https://ja.wikipedia.org/wiki/Crontab)で指定した日時以外で、スクリプトが実行される。
原因：/etc/cron.hourly内にスクリプトを格納したため。
解決策：
定時刻に実行したいスクリプトは、以下のディレクトリに格納してはいけません。

```/etc/.
/etc
　├── cron.daily
　├── cron.hourly
　├── cron.monthly
　├── cron.weekly
```

その理由は、/etc/crontab内で上記4ディレクトリ内スクリプトを実行する時間が設定されているからです。
そのため、気を利かしつもりでcron.daily内に「土曜日に一度だけ実行予定スクリプト」を格納した場合、
意図しない曜日で実行されてしまいます。

```/etc/crontab.
# ヘッダは省略
SHELL=/bin/sh
PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin

# m h dom mon dow user  command
17 *    * * *   root    cd / && run-parts --report /etc/cron.hourly
25 6    * * *   root    test -x /usr/sbin/anacron || ( cd / && run-parts --report /etc/cron.daily )
47 6    * * 7   root    test -x /usr/sbin/anacron || ( cd / && run-parts --report /etc/cron.weekly )
52 6    1 * *   root    test -x /usr/sbin/anacron || ( cd / && run-parts --report /etc/cron.monthly )
```

上記の仕様および[「ありがちなスクリプトが動かない問題」](http://tamulapin.hatenablog.com/entry/2015/01/16/181207)を把握していれば、問題はありません。
ちなみに、一般的には以下のような手順で定期的に実行したいスクリプト(もしくはコマンド)を登録します。

```
$ crontab -u <username> -e  # <username>はスクリプトやコマンドを実行するユーザ
no crontab for <username> - using an empty one

Select an editor.  To change later, run 'select-editor'.
  1. /bin/nano        <---- easiest
  2. /usr/bin/emacs24
  3. /usr/bin/vim.basic
  4. /usr/bin/vim.tiny

Choose 1-4 [1]: 3

# cron設定ファイル内
# ヘッダ部分省略
# 書式：分 時 日 月 曜日 <実行コマンド>
# 毎日、10時15分にscript.shを実行する場合
15 10 * * * bash /home/<username>/sample.sh
```

##windowsで記述したスクリプトをLinuxで実行可能な状態にする方法
問題：windowsで記述したスクリプトは実行時にエラーが出る事。
原因：OSによって改行コードが異なるため。
解決策：
根本的な解決方法は、Windowsを購入しない事、Windows上でシェルスクリプト(bash)を書かない事です。
書いてしまった場合は仕方がないので、改行コードを変換します。
下表の通り、Win -> Linuxへの変換では"CR(carriage return)"を除外すれば良い事が分かります。

| OS | 改行コード |
|:-:|:-:|
| Mac  | LF  |
| Linux  | LF  |
| Windows  | CR+LF  |

改行コードの変換方法に使えるコマンドは、1)sed、2)[nkf](http://itpro.nikkeibp.co.jp/article/COLUMN/20060227/230849/)、3)[dos2unix](http://www.unix-power.net/centos7/dos2unix.html)、の3つです。
dos2unixはデフォルト環境では導入されていないことが多いので、試す場合は、
`sudo apt-get install dos2unix`(もしくはその他のパッケージマネージャの流儀)で導入してください。

```1)sedコマンドを使用.
$ sed "s/^M//g"  dos.sh  > dos2unix.sh   # ^MはCtrl+vの後にCtrl+mを押下。
```

```2)nkfコマンドを使用.
$ nkf -Lu dos_win.sh > unix.sh
```

```3)dos2unixを使用.
$ dos2unix dos2unix.sh
```


#所感
シェルスクリプトを組む上でハマる機会が多かったのは、sedコマンドとif文の条件式でした。
sedコマンドは"**s**tream **ed**itor"と呼ばれるだけあって、強力な機能を有します。
しかし、sedコマンドと正規表現になれないと、期待に反した文字列を抽出する事が非常に多い。今でも多い。
if文の条件式は知識として蓄えるのが大変ですし、条件式が期待と異なる評価を下す事がありました。
こちらに関しては、`man test`で条件式の一覧(チートシート)が表示できる事、それと経験でカバーしてます。
~~たまに、thenやfiを書き忘れるけど。~~

あとは、bashよりPythonの方が書いていて楽しい。
シェルスクリプトを作成し始めた時期とPython3.x系を学び始めて時期が被った関係で、
「大規模なスクリプトを組むならば、Pythonを使った方が良い」という意見が生まれました。
この意見は、可能な限りC言語の採用は控える、という考え方に近いです。
bash、C言語の利点は分かりますが、自分の業務を楽にするだけなら自分の得意な言語を使ったほうが良い。


#参考書籍
[シェルプログラミング実用テクニック](http://gihyo.jp/book/2015/978-4-7741-7344-3)
[月間シェルスクリプトマガジン](http://shell-mag.com/)

シェルスクリプトに関しては、人によって定番書籍が違う印象があります。
そもそも、bashの書籍を読んでいる(持っている)人を見かけない。