はじめに
この記事は私がシェルスクリプト用のBDDテスティングフレームワーク ShellSpecの開発を通して得た移植性・可搬性の高いPOSIX準拠のシェルスクリプトを書くための技術のまとめ、および関連する私の記事へのリンク集です。関連する新しい記事を書いたらここからリンクしますので、このページをストックするなりブックマークしておくと良いと思います。
Q & A
なぜシェルスクリプトで書くのか?
シェルスクリプトには他の言語にはない特有のメリットがあるからです。それは多くの環境でスクリプトがそのまま動くということです。ターゲットOS向けにコンパイルする必要もないですし、各言語用の実行環境やライブラリをインストールする必要もありません。シェルさえ入っていれば、Linux、macOS、BSD, Unix, Windows (WSL, Cygwin, MSYS) いずれの環境でも動きます。(その証拠に ShellSpec は並列実行、ランダム実行、プロファイラなどの機能を持ちながら、Docker の busybox イメージといった最小構成でも Debian 2.2 の bash 2.03 といった古い環境でも動作します。)
なぜ POSIX 準拠なのか?
「POSIX 準拠」は目的ではなく道具です。より多くの環境でシェルスクリプトを動くようにするのが本当の目的です。POSIX で標準化された機能のみを使用していれば、未知の環境でもそのまま動くことが期待できます。
どこでも動くようにするのは大変では?
はっきり言って大変です。実は本当の意味でどこでも動くようにするには POSIX 準拠だけでは足りません。シェルには実装の違いやバグもあります。bash などで使える便利な配列も使えませんし POSIX 準拠のコマンドも最低限の機能しかありません。なによりそれらを吸収するライブラリが不足しています。まるで往年の JavaScript の世界のようです。なんの手がかりもなしに進んでしまえば、最初の段階で諦めてしまうことでしょう。そこでこの記事というわけです。
本当にどこでも動きますか?テストはしていますか?
これらの技術は原則として ShellSpec の実装技術を元にしており、一部を除き ShellSpec と同様の環境でテストしています。(時々、記事にする時にミスしてたりバグが発覚したりしてますが・・・。一応全部修正してるつもりです。) ShellSpec のテスト環境は現時点で「bash≧2.03」「bosh≧2018/10/17」「busybox≧1.10.2」「dash≧0.5.2」「ksh≧93s」「mksh≧28」「posh≧0.3.14」「yash≧2.30」「zsh≧3.1.9」、OS は Linux (Debian 2.2 以降)、Windows (WSL, Cygwin, MSYS 基本的に最新のみ)、macOS (10.10以降)、FreeBSD (10.x以降) です。残念ながら UNIX は扱える環境がないのでテストできていません。(Solaris だけはテストしていますが CI ではなく手動で時々やってるだけなので少し不安です。)
移植性・可搬性の高いシェルスクリプトを書くための技術
それぞれ個別の記事にしています。
- シェルスクリプトのechoで”問題なく”色をつける
- シェルスクリプトのechoの移植性の問題に本気で対応する
- シェルスクリプト オプション解析 徹底解説 (getopt / getopts)
- 高機能で短いシェルスクリプト用のオプション解析コード(POSIXシェル準拠・独自実装)
- POSIX準拠でもreadlink -f (完全互換版)
- POSIX準拠のシェルスクリプトで乱数を計算する(Xorshift32実装)
- POSIX準拠のシェルスクリプトでバイナリデータを扱う
- POSIX準拠のシェルスクリプトでハッシュ値を計算する(FNV-1 / FNV-1a実装)
- シェルスクリプトでUNIXTIMEの取得と日時との相互変換(POSIX準拠dateコマンドのみ使用)
互換性の違いを吸収する技術
この記事をただのリンク集にするのはもったいないので、他の記事では説明しないであろう、互換性の違いを吸収する方法を紹介します。
私が可搬性のあるシェルスクリプトを書く場合、以下のような順番で使う道具を決めています。
- シェルの機能だけで実現できるならシェルだけで実装する
- 機能、パフォーマンスの理由でシェルで実装するのが難しい場合は POSIX で規定されたコマンド(及びそのオプション)を使用する
- それでも難しい場合は環境固有のシェル機能やコマンドをを使うが、このとき互換性の違いを吸収する関数を作成する
私が書く記事は基本的にシェルだけで実装するか POSIX 準拠のコマンドだけを使用するのが大半なので 3.の互換性の違いを吸収する方法について説明する場所は少ないと思うのです。なのでここで紹介したいと思います。
例 stat
stat
は POSIX で規定されているコマンドではありませんが、Linux でも macOS (BSD) でも使用できます。しかしその仕様(オプション)は大きく異なります。例えばファイルサイズを取得する方法です。
# ファイルサイズの取得stat-c %s /etc/hosts # Linuxstat-f %z /etc/hosts # macOS (BSD)
これを例に互換性を吸収する方法を紹介します。まずそれぞれで関数を定義します。
filesize(){# Linuxstat-c %s "$1"}
filesize(){# macOS (BSD)stat-f %z "$1"}
実装
互換性を吸収するには現在の環境を判別し、この2つの関数のうち適切な方の関数を定義します。環境の判別は uname
を使用して Linux
という文字が入っているか? Darwin
(macOS) が入っているか?で分ける方法を思いつくかもしれませんが、これはおすすめしません。なぜなら Darwin
であっても Linux版 (GNU 版) の stat
が使われる場合があるからです。(Homebrew では実際にそのようなことが出来ます。)
ベストな方法は実際にコマンドを実行してその挙動で判別することです。具体的には stat
の場合 stat -f /
を使って判別することが出来ます。このオプションは Linux では --file-system
オプションと同じ意味で、ルートファイルシステムのステータスを返します。macOS (BSD) では -f
はフォーマット文字列なのでそのままの文字列 /
を返します。
# Linux$ stat-f /
File: "/"
ID: f148a0b23b752507 Namelen: 255 Type: ext2/ext3
Block size: 4096 Fundamental block size: 4096
Blocks: Total: 122661614 Free: 116726932 Available: 110478625
Inodes: Total: 31227904 Free: 30595983
# macOS (BSD)$ stat-f /
/
このように挙動の違いを利用して定義する関数を分けることができます。(長いので関数は一行にします。)
if["$(stat-f /)"= / ];then
filesize(){stat-f %z "$1";}# macOS (BSD)else
filesize(){stat-c %s "$1";}# Linuxfi
注意点としては、どちらでもすでに動作が定義されていて実行しても無害なオプションを使用するということです。「このオプションは Linux でしか使えない。」ということを利用して判別すると、のちに macOS で実装されて動かなくなる可能性があります。(無害なオプションを使う理由は言うまでもないですね。)調べれば大抵使えるオプションが見つかると思うのですが、なければロングオプションの --version
や --help
であれば比較的安心して使えると思います。さすがに --version
や --help
を他の目的に使うことはないでしょう。
発展
この関数を「最初に使われた時」に定義する方法です。
filesize(){if["$(stat-f /)"= / ];then
filesize(){stat-f %z "$1";}# macOS (BSD)else
filesize(){stat-c %s "$1";}# Linuxfi# 初回はここで定義した関数を呼び出す。次回からは直接定義した関数が使用される。
filesize "$@"}
この方法のメリットは、コマンド判別のタイミングを実際に使用するタイミングまで遅らせることが出来るので、必要がない場合に判別のコストを減らしたり、また複雑な判別処理を(グローバル)変数を使わずに位置パラメータを使って行うことができるということです。あまり意味がない例ですが、グローバル変数を使わないというのはこういうものです。
filesize(){# 位置パラメータを変数の代わりとして使うことで# ret=$(stat -f /) のようなグローバル変数(ret)の使用を避けることが出来るset--"$(stat-f /)""$@"if["$1"= / ];then
filesize(){stat-f %z "$1";}# macOS (BSD)else
filesize(){stat-c %s "$1";}# Linuxfi
shift
filesize "$@"}
ただし、この方法はサブシェルが使われると毎回定義することになってしまうので注意してください。
size1=$(filesize "file1")# サブシェルが使われてるので定義しても破棄されるsize2=$(filesize "file2")# よってここでもまた定義される
filesize "file3"# サブシェルを使っていないので以降は定義された状態が続く
これを避けるには read -r line
ように引数に指定した名前の変数に戻り値が返るように filesize
関数のインターフェースを変更します。
size1=''# 参考 shellcheck の未定義変数の参照という警告を騙す方法
filesize size1 "file1"# サブシェルではないので定義されたままecho"$size1"# ファイルサイズは size1 変数に代入されている
実装です。
filesize(){if["$(stat-f /)"= / ];then
filesize(){eval"$1=\$(stat -f %z \"\$2\")";}# macOS (BSD)else
filesize(){eval"$1=\$(stat -c %s \"\$2\")";}# Linuxfi
filesize "$@"}
引数で指定した変数に戻り値を返すというテクニックは応用の幅が広いので覚えておくと良いです。サブシェルを使用しないのでパフォーマンスにも優れています。ただし eval
を使用するので変数名等に意図しない文字が入らないように注意して下さい。脆弱性につながる可能性があります。
さいごに
シェルスクリプトを POSIX 準拠で作成すると多くの場所で簡単に動かすことが出来ます。しかしそれはかなり大変な作業です。一番の理由は互換性を吸収するライブラリがないことではないでしょうか? ブラウザ・JavaScript の世界でも今でこそ脱 jQuery などと言われていますが最初は Prototype.js や jQuery という互換性を吸収するライブラリの登場をきっかけに大きく変わっていったと記憶しています。開発者各々がシェルやコマンドの互換性や罠やバグに振り回されている状態ではシェルスクリプトで快適な開発はできません。目指すはバッドノウハウの共有ではなくバッドノウハウが不要な世界です。ライブラリを .
で読み込んで提供されている関数を使えば細かいことを考えずに移植性・可搬性が実現できる。シェルスクリプトの世界にもそういう流れが訪れれば良いなと私は考えます。
私見ですがこれまではシェルスクリプトの"ライブラリ"のテストは大変だったと思います。テスティングフレームワークはありますが、それらはシェルスクリプトを使って(外部コマンドの)テストを記述・実行するためのものであり、シェルスクリプト自身をテストするものになっていない(不可能ではないがやりづらい)からです。しかし今は ShellSpec があります。これはシェルスクリプト自身とライブラリに最適化されたテスティングフレームワークなのです。ここから多くのライブラリが生まれ、シェルスクリプトの有用性が再認識され、ゆくゆくはシェルそのものが強化され(bash
等の拡張はほんと標準化されてほしいです。)各コマンドも互換性向上と機能強化されればシェルスクリプトでの開発はもっと簡単になるでしょう。他の言語で実装すればいいと思うかもしれませんが、標準でどこでも使える今のシェルがなくなる未来なんて誰も想像できないですよね?どうせ使うなら快適な方が良いに越したことはありません。