見出し画像

ChatGPT で保守性の高いシェルスクリプトを作る方法


はじめに

こんにちは、くるふとです。
ナビタイムジャパンでは、時刻表 API や地図描画 API の 開発・運用業務を主に担当しています。

生成AIの台頭によりプロンプトエンジニアリングが注目されてきました。
プログラミングの領域でも生成AIにコードを書いてもらうケースが多くなっています。
特にシェルスクリプトなど、特定の作業の自動化のために生成AIにコードを書いてもらう使い方は非常に有効です。

個人で使う分には便利ですが、業務で利用するようなスクリプトを生成AIで用意する場合は注意が必要です。というのも、シェルは書き方の自由度が高く生成AIにそのまま書かせると可読性や修正性を担保することが難しいです。
そこで今回は、 ChatGPT を例に保守性の高いシェルスクリプトを作る方法を解説します。

プロンプト例

実際に読者が試すことができるよう、最初にプロンプト例を紹介します。
以下のテキストをコピーしていただいて、 ChatGPT に入力してみてください。(ChatGPT のモデルは GPT-4o を想定しています)

以下の内容にしたがって複数のシェルスクリプトを実装してください

- 作成するスクリプト
    - ディスク容量をチェックするスクリプト
        - ローカル内のディスク容量をチェックし、数値と割合を出力する
        - ディスク使用率80%未満: 終了コード 0 を返却
        - ディスク使用率80%以上: 終了コード 1 を返却
            - ディスク容量がひっ迫している旨を伝えるエラー文も出力する
    - 古いファイルを削除するスクリプト
        - 指定したディレクトリ内の作成から30日経過したファイルをすべて削除する
        - 削除時は削除するファイルを標準出力に表示してから削除する
        - デバッグモードが有効化されている場合は削除対象のファイルの一覧出力のみにとどめ、削除は行わない
        - スクリプトの第1引数で対象のディレクトリを指定する
        - スクリプトの第2引数でデバッグモードを有効化するか否かを指定する
        - ファイルの削除が完了した場合: 終了コード 0 を返却
        - 指定したディレクトリが存在しない or ディレクトリではなかった場合: 終了コード 1 を返却
    - 指定したディレクトリに存在するファイルの内訳を出力するスクリプト
        - 指定したディレクトリに存在するファイルの数と合計サイズを出力する
        - スクリプトの引数で対象のディレクトリを指定する
            - 複数の引数を受け取ることができる
        - 出力が完了した場合: 終了コード 0 を返却
        - 指定したディレクトリが存在しない or ディレクトリではなかった場合: 終了コード 1 を返却
- 実装時のルール
    - スクリプトの拡張子は .sh で統一する
    - シバン #!/bin/bash を定義する
    - シバンの一行下に set -euo pipefail を定義する
    - インデントはスペース2つ分にする
    - 関数の宣言は function xxx(){ ... } で統一する
    - main 関数をエントリーポイントする
    - 関数内の変数は local で宣言する
    - グローバルな定数は readonly で宣言する
    - 変数展開は ${ ... } で統一する
    - if 文は [[ ... ]] で統一する
    - コマンド置換は $( ... ) で統一する
    - Linter として shellcheck を利用する
    - Formatter として mvdan/sh を利用する
    - その他の構文については、基本 Google の Shell Style Guide に従う

プロンプトの構成について簡単に解説します。

「作成するスクリプト」と「実装時のルール」の2つの項目に分けてプロンプトを準備します。
「作成するスクリプト」にはスクリプトの仕様について記載します。今回は3つのスクリプトの仕様を記載しています。このまま利用いただいても良いですが、出力したいスクリプトに合わせて内容を変えていただいて構いません。詳細は「作成するスクリプト詳細」で解説します。

「実装時のルール」ではスクリプトの構文等を指示しています。ここの内容は固定で大丈夫です。こちらも詳細は「実装時のルール詳細」で解説します。

作成するスクリプト詳細

スクリプトの仕様を箇条書きで記述しています。
仕様を記述する際は、終了コードについて指示するようにします。スクリプトを実行した際、どのケースでどの終了コードを返却するかを指示します。

シェルにおける終了コードについて

シェルでは特定のコマンドやスクリプトを実行した際、終了コードを返却します。例えば、以下の Hello World! を返却するスクリプトは終了コード 0 を返却します。

$ cat ./hello.sh 
#!/bin/bash
set -euo pipefail

function main() {
    echo "Hello World!"
}

main "$@"
$ ./hello.sh # スクリプトの実行
Hello World!
$ echo $? # 終了コードの表示
0

終了コード 0 は正常終了を表し、0 以外は異常終了(エラー)を表現します。
スクリプトの仕様も、正常終了は終了コード 0 を返却し、異常終了は 0 以外を返却するよう指示すると良いでしょう。

実装時のルール詳細

シェルの書き方についてルールをいくつか設ける旨をプロンプト上で指示します。ルールについては Google の Shell Style Guide を参考に設定していきます。

スクリプトの拡張子は .sh で統一する

スクリプトのファイル名は xxxxxx.sh で統一します。

シバン #!/bin/bash を定義する

シバンとはスクリプトの1行目に #! 始まりで記述するもののことを指します。どのインタプリタでスクリプトを実行するかを定義できます。シェルスクリプトの場合は先頭行に #!/bin/bash という形でシバンを定義します。

シバンの一行下に set -euo pipefail を定義する

set はシェルの設定を変更するためのコマンドです。

-e は errexit オプションです。実行したコマンドがエラーになった場合、後続の処理を実行せず直ちにシェルを終了させます。

-u は nounset オプションです。未定義の変数を利用しようとした場合、エラーを返します。

-o pipefail はパイプライン内のコマンドが失敗した際、パイプライン全体の終了ステータスを失敗として返却するオプションです。例として -o pipefail を有効化した場合と無効化した場合の終了ステータスの違いを見てみます。

$ # pipefail が無効の場合 -> 終了ステータス 0 が返却される
$ echo "def def def" | grep -io "abc" | wc -l &>/dev/null; echo $?
0
$ # pipefail が有効の場合 -> 終了ステータス 1 が返却される
$ set -o pipefail; echo "def def def" | grep -io "abc" | wc -l &>/dev/null; echo $?
1

echo "def def def" | grep -io "abc" | wc -l では abc という文字が何個含まれているかをカウントするコマンドですが、渡された文字列に abc という文字列がないため grep コマンドが失敗します。
-o pipefail が有効化されているとこのコマンドを失敗として扱うことができます。

インデントはスペース2つ分にする

可読性担保のため、スクリプト内のインデントはスペース2つ分に統一します。

関数の宣言は function xxx(){ ... } で統一する

関数であることが明確になるよう、関数の定義は function xxx(){ ... } で統一します。

main 関数をエントリーポイントとする

スクリプト上では必ず function main() { ... } を定義し、 main "$@" で main 関数を実行します。エントリーポイントを設けることにより、スクリプト内の処理の流れを追いやすくします。

#!/bin/bash
set -euo pipefail

.
.
.
<省略>

function main() {
  echo "This is main function."
}

main "$@"

関数内の変数は local で宣言する

関数内の変数は先頭に local を付与します。local を付与することにより、関数内でのみ有効な変数を定義することができます。

function hoge() {
  local arg="$1"
  echo "arg: ${arg}"
}

グローバルな定数は readonly で宣言する

スクリプト内で共通となる定数は readonly で宣言します。これにより、変数への再代入を防ぐことができます。

#!/bin/bash
set -euo pipefail

readonly SRC_DIR="/path/to/src"
readonly DST_DIR="/path/to/dst"

function hoge() {
  echo "SRC_DIR: ${SRC_DIR}"
  echo "DST_DIR: ${DST_DIR}"
}

main "$@"

変数展開は ${ ... } で統一する

変数展開は ${ … } で統一します。
$… の形式でも展開できますが、可読性担保のため ${ … } で統一します。

function hoge() {
  local msg="Hello World!"
  
  # OK
  echo "message: ${msg}"
  
  # NG
  echo "message: $msg"
}

if 文は [[ ... ]] で統一する

if 文は [[ ... ]] で統一します。
[ ... ] の形式でも展開できますが、可読性担保のため [[ ... ]] で統一します。
また、 [[ ... ]] 形式の方が機能が充実しており、 [[ ... ]] を利用する方が良いケースが多いです。

function hoge() {
  local arg="$1"

  # OK
  if [[ "${arg}" == "hoge" ]]; then
    echo "This is [[ ... ]]"
  fi

  # NG
  if [ "${arg}" == "hoge" ]; then
    echo "This is [ ... ]"
  fi

  # 補足: [[ ... ]] であれば && || の利用や =~ (正規表現)の利用ができる
  if [[ "${arg}" == "hoge" || "${arg}" != "fuga" && "${arg}" != "piyo" ]]; then
    echo "[[ ... ]] can use && and ||"
  fi

  if [[ "${arg}" =~ ^ho.*$ ]]; then
    echo "[[ ... ]] can use =~"
  fi
}

コマンド置換は $( ... ) で統一する

コマンド置換は $( ... ) で統一します。
` … ` でもコマンド置換はできるが、 $( … ) は入れ子にできるためこちらに統一する。

function main() {
  local arg="$1"

  # OK
  echo "date: $(date "+%Y/%m/%d")"

  # NG
  echo "date: `date "+%Y/%m/%d"`"
    
  # $( ... ) は入れ子にすることができる
  echo "Num of lines: $(grep -c "$(date "+%Y%m%d")" hoge.txt)"
}

Linter として shellcheck を利用する

Linter として koalaman/shellcheck を利用します。 shellcheck を利用することで、バグが混入しやすい書き方を回避できます。

Formatter として mvdan/sh を利用する

Fomatter として mvdan/sh を利用します。shfmt を利用することで、フォーマットがかかったスクリプトが作成できます。

その他の構文については、基本 Google の Shell Style Guide に従う

最後におまじないとして Google Style Guides に従うことを記載しておきます。

出力結果

結果として以下のスクリプトが生成できました。

ディスク容量をチェックするスクリプト

check_disk.sh

#!/bin/bash
set -euo pipefail

function check_disk_usage(){
  local usage
  usage=$(df / | grep / | awk '{print $5}' | sed 's/%//')

  echo "Current disk usage: ${usage}%"
  if [[ ${usage} -lt 80 ]]; then
    exit 0
  else
    echo "Error: Disk usage is critically high at ${usage}%." >&2
    exit 1
  fi
}

function main(){
  check_disk_usage
}

main

古いファイルを削除するスクリプト

ファイル名: delete_old_files.sh

#!/bin/bash
set -euo pipefail

function delete_old_files(){
  local target_dir=${1}
  local debug_mode=${2:-false}

  if [[ ! -d ${target_dir} ]]; then
    echo "Error: ${target_dir} is not a directory or does not exist." >&2
    exit 1
  fi

  local old_files
  old_files=$(find "${target_dir}" -type f -mtime +30)

  if [[ -z ${old_files} ]]; then
    echo "No files older than 30 days found in ${target_dir}."
    exit 0
  fi

  echo "Files to be deleted:"
  echo "${old_files}"

  if [[ ${debug_mode} == "true" ]]; then
    echo "Debug mode enabled. No files will be deleted."
  else
    echo "${old_files}" | xargs rm -f
    echo "Old files deleted successfully."
  fi
}

function main(){
  if [[ $# -lt 1 ]]; then
    echo "Usage: $0 <directory> [debug_mode]"
    exit 1
  fi
  delete_old_files "$@"
}

main "$@"

指定したディレクトリに存在するファイルの内訳を出力するスクリプト

ファイル名: summarize_directory.sh

#!/bin/bash
set -euo pipefail

function summarize_directory(){
  local target_dir=${1}

  if [[ ! -d ${target_dir} ]]; then
    echo "Error: ${target_dir} is not a directory or does not exist." >&2
    exit 1
  fi

  local file_count
  local total_size

  file_count=$(find "${target_dir}" -type f | wc -l)
  total_size=$(du -sh "${target_dir}" | awk '{print $1}')

  echo "Directory: ${target_dir}"
  echo "Number of files: ${file_count}"
  echo "Total size: ${total_size}"
}

function main(){
  if [[ $# -lt 1 ]]; then
    echo "Usage: $0 <directory>..."
    exit 1
  fi

  for dir in "$@"; do
    summarize_directory "${dir}"
  done
}

main "$@"

回答の詳細は以下のリンクで確認できます。

生成AIでコードを作る場合の注意点

今回のような方法でスクリプトを生成し業務で利用する場合、以下のようなケースに注意してください。

  • スクリプトの仕様が指示した内容と差異がある

  • 出力されたコードにセキュリティリスクが存在する(脆弱性を含んだバージョンのライブラリや CLI を利用している、等)

  • 出力されたコードに機密情報が含まれている

  • 出力されたコードが特定のライセンスに基づいている

上記のようなケースを検知できるよう、生成したコードはチームメンバーのレビューを通すのを心がけるのがよいでしょう。

まとめ

いかがでしたでしょうか?
生成AIの精度は年々増しており、業務で利用できるレベルまで到達しています。リスクを理解しつつ、効果的に使えるようになるのが重要になってくると思います。
当記事の内容が皆様の業務効率化につながれば幸いです。