2
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

実践!ポータブルシェルスクリプト 〜 シェル関数によるLinuxとmacOSの移植性・互換性問題の攻略方法

Last updated at Posted at 2021-10-21

はじめに

シェルスクリプトは移植性が低い言語です。それは各コマンドの実装が複数あり挙動がそれぞれ異なるからです。POSIX で標準化されている範囲であれば最低限の移植性は期待できますが、それも完璧なものではなく機能的に不十分であることも多いです。この記事では「標準出力のバッファリング問題」を例として、Linux (GNU) と macOS (BSD) のコマンドにおける互換性問題をスマートに解決するためのシェルスクリプトプログラミングテクニックを紹介したいと思います。

標準出力のバッファリング問題とは?

「標準出力のバッファリング問題」とはなにかを説明しないと話が進まないので、まずはこれを説明します。以下のコードを実行してみてください。

#!/bin/sh
dummy=$(printf "%1000s" | tr ' ' '\r')
while :; do
  printf "$dummy" # 画面上は何も表示されない文字を出力している
  date +"%Y-%m-%d %H:%M:%S"
  sleep 1
done | sed "s|-|/|g" # | cat

以下のように 1 秒間隔で日付が出力されたと思います。

2021/10/18 22:46:28
2021/10/18 22:46:29
2021/10/18 22:46:30
2021/10/18 22:46:31
2021/10/18 22:46:32

では、次に最後の # | cat のコメントアウトを外して以下のように書き換えて実行してみてください。

done | sed "s|-|/|g" | cat

今度も同じように出力されたと思いますが、1 秒間隔ではなく数秒間隔で出力されたと思います。(おそらく Linux だと 5 秒、macOS だと 15 秒、間隔を変えたい場合は %1000s の数値を変更してください。)

この現象はもっと簡単な例で再現することができます(ここでは sed コマンドの代わりに tr コマンドを使用しています)。

# 1 秒間隔で出力する
while :; do date; sleep 1; done | tr - /

# 数分経過しないとファイルに出力されない
while :; do date; sleep 1; done | tr - / > log.txt

なぜこのようなことが起きるのかと言うと sed コマンドや tr コマンドは出力先が画面 (tty) でない場合、すぐに出力するのではなく一定量バッファに貯めてから出力する実装になっているからです。これがバッファリングです。なぜバッファリングを行うかと言うとパフォーマンス向上のためです。バッファリングを行わない場合、小さいデータを出力するたびにシステムコールが呼び出されてしまいます。システムコールの呼び出しは相対的に遅い処理であるため、一定量メモリに蓄えておいてから一気に出力しているわけです。

補足 上記のコードで 1000 バイトの dummy 変数を出力している理由は date コマンドの出力だけではバッファに貯まるまで時間がかかりすぎるためです。

なおバッファリングを行うかどうかは実装次第です。例えば BusyBox 1.30.1 の tr コマンドはバッファリングを行いません。この動作は POSIX で指定されたものではなく各実装の拡張機能にあたります。

一般的にはパフォーマンスが向上するためバッファリングを行ったほうが良いのですが、これが問題になる場合もあります。例えば何か異常があったときにログファイルにエラーを記録し、別のプロセスがログファイルに記録されたエラーを検知して直ちにユーザーに通知するようなシステムです。異常が発生するのはまれなのでバッファはなかなか貯まりません。そのためユーザーに通知されるのはずっとあと、何度も異常が発生した後に全件まとめて通知されてしまうでしょう。それでは手遅れになってしまいます。

そこでこのバッファリング処理を無効にしたい。ということになります。

バッファリング処理を無効にする方法

バッファリング処理を無効にする方法は簡単です。(POSIX で標準化はされていませんが)sed はそのためのオプション -u (unbuffered) を Linux(GNU 版)でも macOS(BSD 版)でも備えています。

#!/bin/sh
dummy=$(printf "%1000s" | tr ' ' '\r')
while :; do
  printf "$dummy"
  date +"%Y-%m-%d %H:%M:%S"
  sleep 1
done | sed -u "s|-|/|g" | cat

しかし tr コマンドの場合には GNU 版 にはバッファリング処理を無効にするオプションがありません(BSD 版の tr コマンドにはあります)。でも諦める必要はありません。GNU ではこの標準入出力のバッファ処理の動作を変更する stdbuf というコマンドがあるからです。

Linux (GNU) 版

#!/bin/sh
dummy=$(printf "%1000s" | tr ' ' '\r')
while :; do
  printf "$dummy"
  date +"%Y-%m-%d %H:%M:%S"
  sleep 1
done | stdbuf -oL tr - / | cat

macOS (BSD) 版

#!/bin/sh
dummy=$(printf "%1000s" | tr ' ' '\r')
while :; do
  printf "$dummy"
  date +"%Y-%m-%d %H:%M:%S"
  sleep 1
done | tr -u - / | cat

これで GNU 版と BSD 版の両方に対応する方法はわかりましたが、それぞれで異なるコードが必要でます。ではこの 2 つを合わせて両方の環境で動くスクリプトにしてみましょう。

補足 stdbuf は GNU coreutils のコマンドであるため、macOS で Homebrew でインストールした stdbuf を使っても BSD 版の tr には効果はありません。代わりに GNU 版の gtr を使う必要があります(どうやって実装しているんだろうと思ったら Linux では環境変数 _STDBUF_OLD_PRELOAD を使用していました。しかし macOS では LD_PRELOAD ではなく別の方法のようです)。その方法によっても解決することはできますが、この記事は GNU 版と BSD 版の互換性問題を解決するプログラミングテクニックを紹介するものであるため、今回は GNU coreutils を使うという解決策は使用しません。

良くないコード

まずは例としてあまり良くない書き方で実装します。

#!/bin/sh
case $(uname) in
  Linux) TR=gnu ;;
  Darwin) TR=bsd ;;
  *) exit 1 ;;
esac

dummy=$(printf "%1000s" | tr ' ' '\r')
while :; do
  printf "$dummy"
  date +"%Y-%m-%d %H:%M:%S"
  sleep 1
done | {
  case $TR in
    gnu) stdbuf -oL tr - / ;;
    bsd) tr -u - / ;;
  esac
} | cat

この例には 2 つの「良くないこと」が含まれています。一つは「tr コマンドの実装を識別するコード」、もう一つは「tr コマンドを使うときに処理を分岐させているコード」です。

uname でコマンドの実装を区別してはいけない

まず簡単な「tr コマンドの実装を識別するコード」から

case $(uname) in
  Linux) TR=gnu ;;
  Darwin) TR=bsd ;;
  *) exit 1 ;;
esac

このコードでは uname コマンドを使って現在の OS を取得しています。uname の出力が Linux であれば、GNU 版 trDarwin であれば BSD 版の tr だろうという理屈です。しかし果たしてそれは本当でしょうか?

Homebrew で GNU 版の tr コマンドをインストールすることができることからも分かるように、その環境のデフォルトのコマンドとは、同じ名前の異なるコマンドをインストールすることは可能です。Homebrew でインストールされた tr コマンドは gtr という名前で使用しますが、設定をすれば tr という名前で使うことができます。

そのため uname コマンドの出力で GNU 版のコマンドか BSD 版のコマンドを判断するのはよくありません。そこで実際にコマンドを実行してその挙動で区別します。区別する方法はコマンドごとに考える必要がありますが、一番手っ取り早いのは --version オプションに対応していれば GNU 版だろうという判定方法です。

if tr --version >/dev/null 2>&1; then
  TR=gnu
else
  TR=bsd
fi

# 一行で書いても良い
tr --version >/dev/null 2>&1 && TR=gnu || TR=bsd

他にも -u オプションが使えれば BSD 版だろうという方法もありますが、現時点では GNU 版には -u オプションがなく将来追加されるかもしれません。そのときに同じ意味でオプションが追加されれば問題ないのですが -u オプションに別の意味が割り当てられると困ることになります。また --version は比較的安全に GNU 版を区別できる方法ですが、将来 --version に対応した別の実装が登場するかもしれません。そう考えるとこちらも完璧な方法はありませんが、どちらにしろ未知の実装であればどういう挙動をするかは不明なので、だいたい動くだろうというレベルで十分だと思います。

uname を使ってもだいたい動くだろうということになるかもしれませんが、別の tr コマンドの実装をインストールすることで互換性問題を解決するという、ユーザー自身の手で解決できる手段を潰してしまうことになるのでよくありません。uname コマンドを使って環境を区別する方法は、移植性を重視するようなシェルスクリプトではできる限り避けた方が良いでしょう(もちろん移植性を気にしないシェルスクリプトであればそこまで考える必要はありません)。また uname コマンドを使わなくしたことで、Darwin 以外の BSD にも対応することができ移植性が向上しています。uname コマンドを使う場合、知らないカーネル名に対応することができません。

tr コマンドを使うときに処理を分岐させてはいけない

以下の部分のコードです。tr の実装によって処理を分岐させています。

  case $TR in
    gnu) stdbuf -oL tr - / ;;
    bsd) tr -u - / ;;
  esac

今回は例なので tr コマンドは一回しか使いませんが、何度も tr コマンドを使う場合、そのたびに条件分岐が増えてしまいテストが必要な数も増えてしまいます。また条件文を減らすことはコードの複雑性を取り除くのに重要なことです。コードがどれだけ複雑であるかを測定でもある「循環的複雑度」は条件分岐やループの数を数えます。このコードによって条件分岐が増えていますので「コードが複雑になった」ことを意味しています。

また元のコードは tr - / というシンプルなコードであったのにコードが膨れ上がってしまっています。tr コマンドを使うたびにこのような書き方が必要だとあっという間にメインコードの量が増えてしまいコードを読むのが苦痛になってしまいます。似たようなコードが多数あるとその違いを目で見て差分があるかを見なければいけなくなります。同じかもしれませんし違うかもしれません。コードが長いとすぐにはわかりません。もしそこに違いがあればそれはミスなのだろうか?意味があるのだろうか?と考えなくてはいけなくなります。意味がある違いが含まれているかもしれないので似たようなコードだからといって読み飛ばすこともできません。コードの可読性を大きく下げるので長い重複コードは厳禁です。

ということで条件分岐をなくしてコードを減らして、元のコードに戻しましょう。・・・の前に一つだけ、多くの人がやりがちな、あまり良くない解決策を紹介します。

$STDBUF tr $TROPTS - /
# GNU 版: STDBUF="stdbuf -oL" TROPTS=""
# BSD 版: STDBUF="" TROPTS="-u"
# が入っている

$TR - /
# GNU 版: TR="stdbuf -oL tr"
# BSD 版: TR="tr -u"
# これなら少しはマシかも

これでも動きます。動きますが、コードは見づらいでしょう?それに変数に何が入っているのだろうかと、その都度考えなくてはいけなくなってしまいます。元のコードよりも長くなっていますし、単語分割が行われるため ShellCheck が ダブルクォートされていないと警告を出します。どんな場合でも禁止とまでは言いませんがこの書き方は推奨しません。

解決方法

解決方法1: 関数にしよう!

誰もが思いつく方法かもしれませんが tr コマンドをラップした関数を作ります。ただ少し関数の定義の仕方が想像と違うかもしれません。

# unbuffered な `tr` なので `utr()` です
if tr --version >/dev/null 2>&1; then
  utr() { stdbuf -oL tr "$@"; }
else
  utr() { tr -u "$@"; }
fi

while :; do
  ... # 省略
done | utr - / | cat

おそらく utr() 関数の中で条件分岐をするコードを思いついたのではないでしょうか?もちろんそれでも良いのですが、こちらの方がより関数の中に条件が含まれない分、関数の複雑度は下がることになりシンプルになります。

シェルスクリプトに詳しくないと「条件で分岐して関数が定義できるんだ」と驚くかもしれません。スクリプト言語は大抵このようなことができる言語が多いですが、シェルスクリプトでも同じように既存の関数を上書きしたり削除したりと言った柔軟なことができます。逆にコンパイル言語の他、awk はこのような条件で分岐して関数を定義することはできません。

解決方法2: コマンドをシェル関数で再定義しよう!

解決方法1では呼び出すコマンド(シェル関数)名を utr に変更しましたが、シェルスクリプト全体で使用するすべての tr コマンドのバッファリング処理を無効にしたい場合 tr コマンド自体をシェル関数で再定義して処理を上書きすることができます。

if tr --version >/dev/null 2>&1; then
  tr() { stdbuf -oL tr "$@"; }
else
  tr() { command tr -u "$@"; }
fi

while :; do
  ... # 省略
done | tr - / | cat

command コマンドは、同名のシェル関数が定義されていたとしてもそれを無視して、外部コマンド・またはシェルビルトインコマンドを呼び出すためのコマンドです。これを利用することで外部コマンドの呼び出しにシェル関数で処理を挟み込むことができます。これは他の言語で言うオーバーライドに相当します。command コマンドの代わりに env コマンドを使用すると、シェルビルトインコマンドも無視して確実に外部コマンドを呼び出すことができます。

また補足ですが Homebrew でインストールした gtr コマンドを tr で呼び出せるようにするには、以下のように tr シェル関数を定義するだけです。これだけで環境の違いを吸収することができます。

tr() { gtr "$@"; }

このようにシェルスクリプトではシェル関数を普通に定義するだけでごく自然に外部コマンドの処理を書き換えることができます。シェルスクリプトは外部コマンドを呼び出すことが多いですが、その外部コマンドには互換性問題がつきものです。しかしシェルスクリプトはそのコマンドの互換性問題を解決しやすい言語なのです。他の言語で外部コマンドを呼び出す際にこのようにスマート解決するのは難しいでしょう。

解決方法3: GNU 版のコードで動くようにしよう!

コード全体を Linux (GNU) 前提で書いてしまったが、macOS (BSD) でも動かしたくなった。しかし書き換える量が多くて大変である。どうにかして GNU 版前提のコードをほとんど書き換えずに BSD 版で動かすことはできないだろうか?という話です。

GNU 版ということなので使用しているコードは以下のようになります。

while :; do
  ... # 省略
done | stdbuf -oL tr - / | cat

これを BSD 版で動かすためには stdbuf 関数を定義することで実現できます。この stdbuf 関数は tr コマンドを -u オプションを追加して呼び出します。

if ! tr --version >/dev/null 2>&1; then
  stdbuf() {
    if [ "${1:-}" = "-oL" ]; then
      case ${2:-} in
        tr) shift 2; tr -u "$@" ;;
        *) echo "Unsupported command" >&2; exit 1 ;;
      esac
    else
      echo "Unsupported option" >&2; exit 1
    fi
  }
fi

実装は手抜きであり stdbuf のオプションは -oL しか対応していません。本気でオプション解析を実装すれば更にいろんな事に対応ができると思いますがワークアラウンドとしてはこの程度で十分だと思います。なおフル機能の stdbuf が欲しいのであればオプション解析を頑張るよりも GNU coreutils をインストールする方が遥かに楽です。

この例では stdbuf をシェル関数として実装しましたが、外部コマンドとして作れば完全に元のコードを修正することなく動くようにすることができます。必要なのはシェルスクリプト実装版の stdbuf コマンドをインストールしたディレクトリを環境変数 PATH で優先ディレクトリとして前の方に追加するだけです。

解決方法4: BSD 版のコードで動くようにしよう!

解決方法3とは反対に、BSD 版のコードを GNU 版で動くようにしようという話です。

BSD 版ということなので使用しているコードは以下のようになります。

while :; do
  ... # 省略
done | tr -u - / | cat

これを GNU 版で動くようにするには以下のような関数を定義します。

  tr() {
    if [ "${1:-}" = "-u" ]; then
      shift
      stdbuf tr "$@"
    else
      command tr "$@"
    fi
  }
fi

こちらも実装は手抜きです。-u オプションは最初に単独で指定する方法しか対応していません。完全なオプション解析を実装すればもっと互換性を上げることはできますが、その分コードは長くなるのでトレードオフの世界です。また先程と同じようにこのコードを外部コマンドとして実装すれば元のコードを一切変更せずに、動作させることもできます。ただし今回は自分自身と同じ名前の外部コマンド tr を呼び出すことになるので command ではうまく行かず /bin/tr のように絶対パスで指定するか自力で適切なコマンドを呼び出すように環境変数 PATH から探索するコードを書く必要があります。

良いコード

では解決方法2を使って「良くないコード」と比べてみましょう

#!/bin/sh

# 互換性問題を吸収するコード
if tr --version >/dev/null 2>&1; then
  tr() { stdbuf -oL tr "$@"; }
else
  tr() { command tr -u "$@"; }
fi

# メインコード
dummy=$(printf "%1000s" | command tr ' ' '\r')
while :; do
  printf "$dummy"
  date +"%Y-%m-%d %H:%M:%S"
  sleep 1
done | tr - / | cat

このコードが優れているとする理由は、メインコードが全く変わってないという所です。互換性問題を解決するコードを追加したにもかかわらず、それが完全に分離されており複雑度は全く上がっていません。互換性問題を吸収させるためのコードが独立しているため、source コマンドで読み込むシェルスクリプトライブラリにしてしまえば複数のシェルスクリプトから再利用することもできます。仮に複数箇所で tr コマンドを使ったとしても、分岐が増えることもないので、少ないテストで十分な信頼性を実現することができます。

まとめ

シェルスクリプト(正確にはシェルスクリプトから呼び出す外部コマンド)は移植性が低いため、移植性が高いシェルスクリプトを書こうとすると、環境用に応じたコードを書く必要が出てきます。そのときに「良くないコード」のように処理を抽象化(適切な名前の関数を作ること)せずに愚直に埋め込んでしまうとメンテナンス性はあっというまに下がってしまいます。それを攻略するには構造化プログラミングを理解することです。シェルスクリプトは本物の構造化プログラミング言語です。だから構造化プログラミングテクニックを応用することができます。シェルスクリプトはメンテナンス性が低いという人が多いですが、それは構造化プログラミングの技術力が足りていないだけです。

もちろん関数をただ使えばそれだけで構造化プログラミングになるわけではありません。プログラマは技術職なのですから、道具(関数等)を適切に使うには技術力が必要です。ただ導入すればそれで解決できるようなものではありません。正しく理解して使えるように経験を積まねば使えるようにはなりません。関数を正しく使えばコードを複雑にせずにメンテナンス性を下げることなく、移植性・互換性問題等を攻略できることはこの記事で説明したとおりです。

さてこの記事はここで終わりなのですが、この記事を書いている途中で、今回の問題はもっと別の視点からの指摘が必要な事に気づきました。それはパイプを使うことによる弊害です。パイプはシェルスクリプトにとって重要な機能の一つですがメリットばかりではありません。今回の問題は本質的にはパイプを使うことによって発生している問題でありパイプを使わなければ発生しません。この話はこの記事の趣旨とは外れますのでそれは別の記事として公開する予定です。(というか長くなって分離しただけでほとんど書き終わってるの明日公開できると思います。お楽しみに。) ⇒ 公開しました。シェルスクリプトのデータ出力タイミングが遅い? それはパイプ通信に起因するバッファリングが原因かもという話

2
3
0

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
2
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?