LoginSignup
56
55

More than 1 year has passed since last update.

移植性・可搬性の高いシェルスクリプトを書くための技術まとめ

Last updated at Posted at 2020-04-09

はじめに

この記事は私がシェルスクリプト用のBDDテスティングフレームワーク ShellSpec の開発を通して得た移植性・可搬性の高いシェルスクリプトを書くための技術のまとめ、および関連する私の記事へのリンク集です。関連する新しい記事を書いたらここからリンクしますので、このページをストックするなりブックマークしておくと良いと思います。

この記事を変更通知目的でストックしている方へ
記事が多くなりリンク集のメンテナンスが大変になったため、この記事の定期的な更新はやめることにします。もし更新通知が必要な方は代わりに私をフォローしてください。ほとんどシェルスクリプト関係の記事しか書いていないためそれで十分目的を果たせると思います。

Q & A

なぜシェルスクリプトで書くのか?

シェルスクリプトには他のスクリプト言語にはない特徴が二つあります。一つ目はシェルスクリプトはコマンドを連携させるのに適した言語であるということです。シェルスクリプトはコマンドを関数にように使える言語です。シェル関数ですらコマンドと互換性があるように設計されています。他の言語は関数は関数であってコマンドではありません。コマンドと関数の違いはコマンドは標準出力にデータを出力し、戻り値は(原則として)終了ステータスです。一方他の言語の関数は標準出力にデータを出力するのではなく値として戻り値を返します。コマンドが標準出力にデータを出力する理由は、人間もそれを読むことが出来るようにするためです。コマンドは道具として人間がシェルから実行します。人間とコンピュータの両方にとって使いやすいのがコマンドであり、このコマンドを使ってプログラムできるのがシェルスクリプトです。この違いがあるため他の言語ではシェルスクリプトを置き換えることが出来ません。ただしすべてのことをシェルスクリプトだけでやるべきだという話ではありません。シェルスクリプトが得意なのはコマンドを連携させる自動化です。ウェブシステム開発などそれ以外の多くの用途にシェルスクリプトは適していません。シェルスクリプトは他の言語と組み合わせて使うものであって、他の言語で作られたプログラムがあってこそシェルスクリプトは役に立つ言語です。

二つ目は多くの環境でスクリプト言語の実行環境、つまりシェルがインストールされているということです。スクリプト言語であるためターゲットOS向けにコンパイルする必要もありません。シェルさえ入っていれば、Linux、macOS、BSD, Unix, Windows (WSL, Cygwin, MSYS) いずれの環境でも動きます。(その証拠に ShellSpec は並列実行、ランダム実行、プロファイラなどの機能を持ちながら、Docker の busybox イメージといった最小構成でも Debian 2.2 の bash 2.03 といった古い環境でも動作します。)

どこでも動くようにするのは大変では?

はっきり言って大変です。本当の意味でどこでも動くようにするには POSIX に準拠するだけでは足りません。まず POSIX コマンドはどの環境でも使えるコマンドというわけではありません。POSIX コマンドであってもインストールされていないものは多数あります(詳細はPOSIXコマンドは「どの環境にもあるコマンド」ではないよという話参照)。たとえインストールされていたとしても、それぞれのコマンドには細かい動きに違いがあります。POSIX で規定されているコマンドやオプションですら違いがあります。またシェルにも実装の違いやバグもあります。bash などで使える便利な配列も使えませんし POSIX 準拠のコマンドも最低限の機能しかありません。なによりそれらを吸収するライブラリが不足しています。まるで往年の JavaScript の世界のようです。なんの手がかりもなしに進んでしまえば、最初の段階で諦めてしまうことでしょう。そこでこの記事というわけです。

シェルスクリプトは移植性が低いのでは?

はい、シェルスクリプトの移植性は低いです。他の言語は移植性の問題をほとんど意識することなく、単一のコードでどこでも動くようになりますが、POSIX に準拠するだけでは不十分で、シェルスクリプトは環境の違いに対応して書かなければどこでも動くようにはなりません。どこでも動くようにならない理由の一つは、一般的なシェルスクリプトが利用している標準的なコマンド ≒ UNIX (POSIX) コマンドに完全な互換性が無いからです。これらのコマンドは同じコマンド名でありながら各 UNIX 系 OS 毎にバラバラに開発または拡張されてきたという歴史的事情によって動作に細かい違いがあります。それは POSIX で標準化されている範囲であっても同じです。POSIX の標準化とは動作の違いを完全に統一するものではなく、違う部分は違うと明記してアプリケーション開発者に注意喚起し対応することを求めるものです。したがって UNIX (POSIX) コマンドに依存しながら移植性を高めることは難しく、環境ごとの違いを把握してそれぞれの違いに対応するという面倒な作業をしないかぎりどこでも動くシェルスクリプトにはなりません。OS が持っている標準的なコマンドは移植性が低く開発効率も低い、これを十分に理解する必要があります。シェルスクリプトに移植性を持たせて開発するのは時間がかかる大変な作業なのです。移植性問題を解決する鍵は、依存する外部コマンドを減らすことと、移植性問題を解決するラッパーを使うことです。ShellSpec の開発では移植性の問題を解決するために多くのラッパー関数を作成し、UNIX (POSIX) コマンドを含む外部コマンドには頼らずにシェルで実装できるものは可能な限りシェルで実装するという方針を取っています。例えば sed や awk ですら使っていません(注意 ShellSpec の方針を真似る必要はありません)。

注意 シェルスクリプトは移植性が高い主張する考え方もありますが、それはガンカーズが提唱した UNIX 哲学「UNIXという考え方」に書かれた教義「梃子の効果と移植性を高めるためにシェルスクリプトを利用せよ」の間違った理解から来ています。一見シェルスクリプトの移植性が高いと読めるような教義ですが、「UNIX という考え方」はソフトウェア(シェルスクリプト)を異なる OS に移植するという話ではなく、ある一つの UNIX を別のアーキテクチャのコンピュータに移植するという話が書かれている本です。したがってほとんどの人が考える「シェルスクリプトを別の OS (UNIX) に移植する」という話をしていません。そもそもこの本は「効率がよい C 言語」と「(他のコンピュータへの)移植性が高いシェルスクリプト」の二つの言語の比較しかしておらず、他の言語は登場していません。その理由は、この本の舞台である 1980 年代の UNIX の開発の時代ではそれぐらいしか言語がなく、つまりこの本は古すぎる歴史書だからです。いちいち環境の違いに自分で対処してコードを書かなければいけないシェルスクリプトと、単一のコードでどこでも動く他の言語のどちらが移植性が高いかは言うまでもないでしょう。詳細は「名著「UNIXという考え方 - UNIX哲学」は本当に名著なのか? 〜 著者のガンカーズは何者なのかとことん調べてみた」を参照してください。

なぜ POSIX で標準化された機能のみを使うのか?

POSIX は目的ではなく道具です。より多くの環境でシェルスクリプトを動くようにするのが本当の目的です。POSIX は移植性の問題を完全に解決する標準規格ではありませんが、それでも可能な限り POSIX で標準化された機能のみを使用するように心がけていれば、未知の環境でも別のコマンドをインストールすることなく、そのままの環境で動くことが期待できます。ただしこの方針では生産性は低くなります。重要なことですが、特に理由がないのであれば別のコマンドをインストールせずに動くことにこだわらないようにしてください。POSIX コマンドにこだわらずに、どの環境にもでも移植されているコマンドを使ったシェルスクリプトの方が移植性は高くなります。

ShellSpec は特殊なソフトウェアです。テストフレームワークとして可能な限りその他のコンポーネントに依存しないことを目指しています。一般的には各環境にコマンドを追加インストールすることはできるはずです。多くの環境に移植されているコマンド(それらの多くは POSIX に準拠して作られておりどこでもインストールして使えます)を利用すれば、開発効率を大幅に上げることができます。通常は生産性が高い方が優れています。ShellSpec は生産性を落としてでも、その他のコンポーネントに依存しないようにしており、その制約の中で移植性・可搬性をあげるために技術をまとめたのがこの記事です。

本当にどこでも動きますか?テストはしていますか?

これらの技術は原則として 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 ではなく手動で時々やってるだけなので少し不安です。)

リンク集

移植性・可搬性の高いシェルスクリプトを書くための技術

それぞれ個別の記事にしています。

関連技術

その他のシェルスクリプト関連の記事です

互換性の違いを吸収する技術

この記事をただのリンク集にするのはもったいないので、他の記事では説明しないであろう、互換性の違いを吸収する方法を紹介します。

私が可搬性のあるシェルスクリプトを書く場合、以下のような順番で使う道具を決めています。

  1. シェルの機能だけで実現できるならシェルだけで実装する
  2. 機能、パフォーマンスの理由でシェルで実装するのが難しい場合は POSIX で規定されたコマンド(及びそのオプション)を使用する
  3. それでも難しい場合は環境固有のシェル機能やコマンドをを使うが、このとき互換性の違いを吸収する関数を作成する

私が書く記事は基本的にシェルだけで実装するか POSIX 準拠のコマンドだけを使用するのが大半なので 3.の互換性の違いを吸収する方法について説明する場所は少ないと思うのです。なのでここで紹介したいと思います。

例 stat

stat は POSIX で規定されているコマンドではありませんが、Linux でも macOS (BSD) でも使用できます。しかしその仕様(オプション)は大きく異なります。例えばファイルサイズを取得する方法です。

# ファイルサイズの取得
stat -c %s /etc/hosts # Linux
stat -f %z /etc/hosts # macOS (BSD)

これを例に互換性を吸収する方法を紹介します。まずそれぞれで関数を定義します。

filesize() { # Linux
  stat -c %s "$1"
}

filesize() { # macOS (BSD)
  stat -f %z "$1"
}

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"; } # Linux
fi

注意点としては、どちらでもすでに動作が定義されていて実行しても無害なオプションを使用するということです。「このオプションは Linux でしか使えない。」ということを利用して判別すると、のちに macOS で実装されて動かなくなる可能性があります。(無害なオプションを使う理由は言うまでもないですね。)調べれば大抵使えるオプションが見つかると思うのですが、なければロングオプションの --version--help であれば比較的安心して使えると思います。さすがに --version--help を他の目的に使うことはないでしょう。

2. 定義を遅延させる

この関数を「最初に使われた時」に定義する方法です。

filesize() {
  if [ "$(stat -f /)" = / ]; then
    filesize() { stat -f %z "$1"; } # macOS (BSD)
  else
    filesize() { stat -c %s "$1"; } # Linux
  fi

  # 初回はここで定義した関数を呼び出す。次回からは直接定義した関数が使用される。
  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"; } # Linux
  fi

  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\")"; } # Linux
  fi

  filesize "$@" 
}

引数で指定した変数に戻り値を返すというテクニックは応用の幅が広いので覚えておくと良いです。サブシェルを使用しないのでパフォーマンスにも優れています。ただし eval を使用するので変数名等に意図しない文字が入らないように注意して下さい。脆弱性につながる可能性があります。

3. 関数内での再定義をさける

「2. 定義を遅延させる」は通常は全てのシェルで動くのですが、まれに関数内で同じ関数名で再定義を行うとシェルが誤動作(バグ?)する場合があります。その状態になるとシェルが落ちたり定義したはずの関数が消えてしまったりとおなしな状況になります。ksh や posh でまれに発生するのですが、いくつかの条件が組み合わさって発生しているようではっきりとした条件は分かっていません。

その場合は、遅延させるのを諦め「1. 実装」を使えばいいのですが、判定条件が複雑な場合、変数を使用したくなります。ただしグローバル変数は使いたくないという場合の実装方法です。

filesize() { # 1 段目
  set -- "$(stat -f /)"
  [ "$1" = / ] && return 0
  return 1
}
if filesize; then # 2 段目
  filesize() { eval "$1=\$(stat -f %z \"\$2\")"; } # macOS (BSD)
else
  filesize() { eval "$1=\$(stat -c %s \"\$2\")"; } # Linux
fi

# 参考 環境が複数ある場合
filesize &&:
case $? in # 2 段目
  0) filesize() { eval "$1=\$(stat -f %z \"\$2\")"; } ;; # macOS (BSD)
  1) filesize() { eval "$1=\$(stat -c %s \"\$2\")"; } ;; # Linux
esac

同じ関数名で 2 つの役割をもたせています。1 段目は環境判別処理を行うために filesize 関数を使用しています。そして 2 段目で目的の関数を定義しています。一般的には同じ関数名を使い回すのはどうかと思いますが 1 段目はグローバル変数を使用しないために使うだけで本来は不要な一時的な関数なのでこれぐらいは良しとしています。

さらに注意ですが、これだけでは誤動作が解決しない場合があります。ksh でまれに発生するのですが、「1. トップレベルに関数を定義している。」「2. トップレベルでその関数を呼び出している。」「3. その関数を再定義している。」のすべての条件を満たすと意味不明な不具合が発生することがあります。(必ず発生するわけではありません。)その場合のワークアラウンドとして eval を使用して2.の条件を満たさないようにします。具体的には次のように書き換えます。

# filesize &&:
# ↓
eval "filesize &&:" &&:

# トップレベルで関数を定義・呼び出しをしなければ良いので
# すべての処理を以下のように関数内に入れても良い
initialize() {
  ...
  filesize &&:
  ...
}
initialize

&&: が 2 つあるのは set -e が有効な時に mksh が eval の中で終了してしまう問題のワークアラウンドです。

さいごに

POSIX で標準化された機能だけを使ってシェルスクリプトを作成すると多くの場所で簡単に動かすことが出来ます。しかしそれはかなり大変な作業です。一番の理由は互換性を吸収するライブラリがないことではないでしょうか? ブラウザ・JavaScript の世界でも今でこそ脱 jQuery などと言われていますが最初は Prototype.js や jQuery という互換性を吸収するライブラリの登場をきっかけに大きく変わっていったと記憶しています。開発者各々がシェルやコマンドの互換性や罠やバグに振り回されている状態ではシェルスクリプトで快適な開発はできません。目指すはバッドノウハウの共有ではなくバッドノウハウが不要な世界です。ライブラリを . で読み込んで提供されている関数を使えば細かいことを考えずに移植性・可搬性が実現できる。シェルスクリプトの世界にもそういう流れが訪れれば良いなと私は考えます。

私見ですがこれまではシェルスクリプトの"ライブラリ"のテストは大変だったと思います。テスティングフレームワークはありますが、それらはシェルスクリプトを使って(外部コマンドの)テストを記述・実行するためのものであり、シェルスクリプト自身をテストするものになっていない(不可能ではないがやりづらい)からです。しかし今は ShellSpec があります。これはシェルスクリプト自身とライブラリに最適化されたテスティングフレームワークなのです。ここから多くのライブラリが生まれ、シェルスクリプトの有用性が再認識され、ゆくゆくはシェルそのものが強化され(bash 等の拡張はほんと標準化されてほしいです。)各コマンドも互換性向上と機能強化されればシェルスクリプトでの開発はもっと簡単になるでしょう。他の言語で実装すればいいと思うかもしれませんが、標準でどこでも使える今のシェルがなくなる未来なんて誰も想像できないですよね?どうせ使うなら快適な方が良いに越したことはありません。

56
55
4

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
56
55