Shell Script で苦労したところを書いてみる

最近はシェルプログラムの勉強と実践が多いけど、今日はとても簡単なシェルスクリプトを書いたけど、結構ハマったので、忘れないように記録しておきたい。

# プログラムの仕様

とても簡単なもので、複数のエンドポイントの、死活情報をCUIの画面として表示するといった内容。出来たのはこんな感じ。

<img width="787" alt="Screen Shot 2019-03-20 at 10.54.49 PM.png" src="https://qiita-image-store.s3.amazonaws.com/0/3470/efc3a4ba-4859-4cb2-6eb4-690ca3e322f0.png">

何故か最初の一行だけズレているのは理由がわかりませんが、継続調査。まずは、動くものを提供するのが重要と考えました。

こんな簡単なモノなのに、色々ハマりました。シェルプログラムおそるべし。まぁ、ラーニングコースで学び終わった程度なので最初はそんなモノかも。わからなかった部分を整理していきたいと思います。

# ハマりポイント

ハマったポイントを解説付きで整理していきます。

## read コマンド

最初はパラメータだったのですが、複数のエンドポイントのデータを表示するので、stdin から入力するようなスタイルにしました。

```bash
$ dashboard < endpoints.txt
```
のような形で。内容は次の通り。

_endpoint.txt_

```
https://qiita.com/TsuyoshiUshio@github/items/33f4236fdb0717a783c1
https://github.com/
https://www.tldp.org/LDP/abs/html/varsubn.html
```

ところが、`read endpoints` などで読み込もうとしたら、何故か１件しか読み込まれません。`IFS='\n'`とやって、区切り文字を変えても改行が認識されない感じです。なんでやねんと思いましがが、こういう時は、仕様に戻るのが正解です。改めて、manual を見てみると、read は改行までの１行を読むので、１回しか読まないと。最初の１行しか取得出来ないのは当然です。

ですので、終わりまで読み込みましょう。ちなみに下記のコードになっている理由は、あまり意識せずにかくと、`read -r ep` の結果で判断してしまいそうですが、そうすると、最終行でEOFが来る時に、read は、false を返します。だから、最後の１行まで読むためには、EOFファイルが来ても、そこに行があったらフラッシュしておきます。

```bash
DONE=false
declare -a endpoints 
declare -i index=0
until $DONE ; do 
  read -r ep || DONE=true
  endpoints[$index]=$ep
  (( index++ )) 
done 
```
こうすれば、配列にエンドポイント情報が代入されます。

## Arrayへの代入

Array を定義して、Array に値を代入する方法も書き方が最初わかりませんでした。結論としてはこんな感じ。Arrayの定義は、`declare -a` フラグで。その変数への個別の代入は、`endpoint[$index] で。さらにややこしいことに、Arrayのループを回したい時は、`for endpoint in ${endpoints}; do ` ではダメで、`for endpoint in ${endpoints[@]}` にしないと、ループになってくれません。
また、個別の値を参照したい場合の書き方は、`${endpoints[index]}` 中のindex には$が不要なことがポイントです。 

_readfile.sh_

```bash
#!/bin/bash
DONE=false
declare -a endpoints 
declare -i index=0
until $DONE ; do 
  read -r ep || DONE=true
  endpoints[$index]=$ep
  echo "${index}:$ep :${endpoints[index]}"
  (( index++ )) 
done 

echo ${#endpoints[@]}
declare -i i=0
for endpoint in ${endpoints[@]} ; do
  i="${i}+1"
  echo "${i}: $endpoint"
done
```

# 複数の空白を出力する

これが最大限にハマりました。空白を出力するコードを書いてあげて、普通に表示すると、下記の感じになって、思いっきりスペースが無視されます。これは、Bashでは、スペースがデリミタであるので、普通に出力するとスペースを全く表示してくれません。

```bash

*** DevOps OpeanHack health report ***
------------------------------------------------------------------------------------------------
|    Timestamp    | Status | Endpoint |
```

例えば、次のプログラムはうまく動きません。

_whitespace.sh_

```bash
#!/bin/bash

lines() {
    declare -i number=$1
    declare delimiter=$2
    result=""
    for (( i=0; i<$number; ++i )); do
      result="$delimiter$result"
    done
    printf '%b\n' $result
}

echo $(lines 10 ",")
echo "start:$(lines 10 ' '):end"
```

### 結果

`,` はちゃんと出力されていますが、` ` は無視されています。

```bash
$ ./whitespace.sh
,,,,,,,,,,
start::end
```

いろんな方法を試して、やっといい感じでかける書き方がわかりました。

```bash
  declare endpoint=$2
  declare max_endpoint_length=$3
  declare -i space_length=0
  (( space_length=$max_endpoint_length-${#endpoint} ))
  spaces=$(printf "%-${space_length}s" " ")
```

ポイントとしては、printf を使っていることと、`%-${..}s` というテンプレートを使っています。これは、特定の１文字を数字だけ表示するというテンプレートです。例えば

```
$ printf "%-20s:finish" " "
                    :finish
```
という感じで変数にホワイトスペースを格納することが出来ます。

## `-` を表示する

他に困ったのが、`-` の表示です。Printf の方がフォーマットができるので、便利なのですが、シェルはメソッドサブスティチューションなので、そのまま printf にすると、次のようなエラーが発生して、オプションと勘違いされます。そうしないためには、先頭に`---` をつけて、パラメータの意味をキャンセルするか、echoで表示するか、フォーマットしてしまうかになります。

```
$ b="-----"
$ echo "$b"
-----
$ printf "$b"
-bash: printf: --: invalid option
printf: usage: printf [-v var] format [arguments]
$ printf "%s\n" $b
-----
$ printf -- "$b"
-----
```

## 日付の表示

直接フォーマットをかけることが出来ます。

```bash
timestamp=$(date "+%Y%m%d-%H%M%S")
```

## 画面のクリアとエスケープシーケンス

エスケープシーケンスを使うと画面クリアとか出来たり、色をつけたりすることが出来ます。エンドポイントを定期ポーリングアプリなので、Top的な表示がいいと思いました。残念ながら、Mac では、自分で新しいバージョンの、bashを入れないといけないので、今回は単純に`clear`コマンドを使って、決して、再描画する方法にしました。ちなみに新しいbashのためには、`brew install bash` とかで入れる必要があります。

このブログとかいい感じです。

* [ANSIエスケープシーケンス チートシート](https://qiita.com/PruneMazui/items/8a023347772620025ad6)

## 標準入力で、リダイレクトの入力があるか判断する

基本的な使い方は、標準入力からのリダイレクトを想定しているのですが、リダイレクトがされていない時は、ヘルプのメッセージを表示したいと思いました。こんな感じの実装です。`-s` は、サイズが0より上かを判断してくれます。ちなみに、パイプだと、`-p` が使えます。詳しくは[7.1.1.1. Expressions used with if](https://www.tldp.org/LDP/Bash-Beginners-Guide/html/sect_07_01.html)

```bash
if [ -s /dev/stdin ]; then
  echo ""
else 
  usage
  exit 1
fi 
```

# コード全体

_dashboard_

```bash
#!/bin/bash

usage() {
    cat <<END

Usage: dashboard 
    
Report the health status of the endpoints. Read data from standard input.
The data should be separated by '\n'.

e.g.
dashboard < endpoints.txt

END
}

healthcheck() {
    declare url=$1
    result=$(curl -I $url 2>/dev/null | grep HTTP/1.1)
    echo $result
}

lines() {
    declare -i number=$1
    declare delimiter=$2
    result=""
    for (( i=0; i<$number; ++i )); do
      result="$delimiter$result"
    done
    printf '%b\n' $result
}


header() {
  declare -i endpoint_length=$1
  declare -i line_length
  declare -i space_length=0
  line_length="$endpoint_length+31"

  title="*** DevOps OpeanHack health report ***"
  line=$(lines $line_length '-')
  (( space_length=$endpoint_length-8 ))
  spaces=$(printf "%-${space_length}s" " ")
  header="|    Timestamp    | Status | Endpoint ${spaces}|"
  printf "%b\n" "$title\n$line\n${header}\n$line\n"    
}

footer() {
  declare -i endpoint_length=$1
  declare -i line_length
  (( line_length=$endpoint_length+31 ))
  line=$(lines "${line_length}" "-")
  printf "%s\n" "$line\n"
}

display() {
  declare status=$1
  declare endpoint=$2
  declare endpoint_length=$3
  declare -i space_length=0
  timestamp=$(date "+%Y%m%d-%H%M%S")
  (( space_length=$endpoint_length-${#endpoint} ))
  spaces=$(printf "%-${space_length}s" " ")
  
  echo "| $timestamp | $status  | $endpoint ${spaces}|"      
}

trimStatus() {
    read response
    if [ -z "$response" ]; then
      echo "  Dead  "
    else
      if [[ $response =~ 'HTTP/1.1 200' ]]; then
         echo "  Alive "
      else 
         echo "  Dead  "
      fi
    fi 
}

if [ -s /dev/stdin ]; then
  echo ""
else 
  usage
  exit 1
fi 

declare -i max_endpoint_length=0

DONE=false
declare -a endpoints 
declare -i index=0
until $DONE ; do 
  read -r ep || DONE=true
  endpoints[$index]=$ep
  (( index++ )) 
done 

for endpoint in ${endpoints[@]} ; do 
  if [[ ${#endpoint} > $max_endpoint_length ]]; then
    max_endpoint_length=${#endpoint}
  fi
done

while [[ true ]]; do
  declare -a messages
  declare -i i=0
  for endpoint in ${endpoints[@]} ; do 
    statusBuffer=$(healthcheck $endpoint)
    status=$(echo $statusBuffer | trimStatus)  
    messages[$i]="$(display $status $endpoint $max_endpoint_length)"
    (( i++ ))
  done 
  clear 
  printf "$(header $max_endpoint_length)\n"
  for message in "${messages[@]}" ; do 
    echo "$message"
  done
  printf -- "$(footer $max_endpoint_length)\n"
  sleep 3
done
```

### 実行

```bash

リダイレクトの標準入力がなければ、usage を表示します。

```
$ ./dashboard

Usage: dashboard

Report the health status of the endpoints. Read data from standard input.
The data should be separated by '\n'.

e.g.
dashboard < endpoints.txt
```

実行すると画面がクリアされてポーリングされます。

```bash
$ dashboard < endpoints.txt
```

```bash
*** DevOps OpeanHack health report ***
------------------------------------------------------------------------------------------------
|    Timestamp    | Status | Endpoint                                                          |
------------------------------------------------------------------------------------------------
| 20190320-225245 | Alive  | https://qiita.com/TsuyoshiUshio@github/items/33f4236fdb0717a783c1  |
| 20190320-225245 | Alive  | https://github.com/                                               |
| 20190320-225246 | Alive  | https://www.tldp.org/LDP/abs/html/varsubn.html                    |
------------------------------------------------------------------------------------------------
```

## 残課題

なぜか、最初の１行だけ最後の`|` がズレています。２行目以降と全く同じアルゴリズムなので、おそらく、データが影響しているのと思いますが、特定は出来ていません。

# まとめ

なんとか、シェルスクリプトを作ることが出来ました。生からリファレンスなしでゴリゴリ書いたいのははじめてですが、なんかダサさも感じます。もっと良い書き方があれば是非コメントいただければと思います。