f

アーカイブ

2016-12-18

How to check if script executed as a command in POSIX shell script

#posixismadvent この記事はPOSIX原理主義Advent Calendarの17日目だ。

POSIX原理主義のシェルスクリプトがコマンドとして実行されたか,dot(.)コマンドで読み込まれたかどうかを判定する方法を記す。

結論としては,以下のようなコードでコマンドとして実行されたか判定できる。

#!/bin/sh
## \file script_name.sh

EXE_NAME='script_name.sh'
NOW_EXE=$(ps -p $$ -o args=)
case "$NOW_EXE" in *$EXE_NAME*) echo "Executed as a file." esac

Introduction

一般的に,シェルスクリプトでコマンドを自作する場合,コマンドをファイルとして実行することを前提として作られることが多い。例えば以下のようなコードだ。

cat <<- EOT >init.sh
#!/bin/sh
## \file init.sh

init(){
set -eu
umask 0022
export LC_ALL='C' PATH="$(command -p getconf PATH):$PATH"
}

main()(
init
echo 'init: $@'
)

main "$@"
EOT
./init.sh a b c
init: a b c

しかし,上記のinit.shinit関数に見られるように,どのスクリプトでも共通で使うような処理が出てくる。それぞれのスクリプトで同じ内容を記述するのは冗長なので記述を共通化したくなる。つまり,シェルスクリプトをライブラリとして活用できてもいいのではないかと考えた。

シェルスクリプトでは,dot(.)コマンドにより外部ファイルの内容を現在のシェルでそのまま実行することができる。これにより関数を現在のシェルやスクリプトで読み込むことができる。

cat <<- EOT >import.sh
#!/bin/sh
## \file import.sh

. /init.sh
main(){
echo "import: $@"
}
EOT
./import.sh 1 2 3
init: 1 2 3
import: 1 2 3

しかし,上記のimport.shの実行結果でimport.shでは記述していないinit: 1 2 3が出力されていることからわかるように,単にdotコマンドで読み込んだだけだと,当然ながら読み込んだファイルの通常のコードが実行されてしまう。関数定義を読み込みたいだけなので,通常のコマンドを実行したくない

これについて,例えばPythonやRubyではファイルとして実行されたかどうかを判定する記述方法が存在する。

cat <<- EOT >if_main.py
#!/usr/bin/env python3
## \file if_main.py

def foo():
printf(__name__)

if __name__ == '__main__':
foo()
EOT
./if_main.py
__main__
cat <<- EOT >if_main.rb
#!/usr/bin/env ruby
## \file if_main.rb

def foo
puts $0
end

if __FILE__ == $0
foo()
end
EOT
./if_main.rb
./if_main.rb
PythonとRubyでのファイルとして実行されたことの判定方法
Python
__name__変数に文字列'__main__'が入っている。
Ruby
__FILE__変数にファイル名が$0に実行中のスクリプト名が入っている。

PythonとRubyではファイルとして実行されたかどうかの情報を変数で保持している。したがって,この変数を使いファイルとして実行されている場合にだけ,関数を実行することができる。このように記述することで,ライブラリしても,コマンドとしても使える汎用性が高いスクリプトを作成できる。

このようなことをPOSIX準拠のシェルスクリプトでもできないか検討した。

Method

まず,ネット上で提案されている事例を調査した。複数の案があったので,そのうちダメな方法を以下で一覧する。

既に提案されている方法のダメな理由
方法参照元ダメな理由
[ "$_" = "$0" ]
URL$_変数がPOSIX非準拠。過去にはPOSIX規格に存在したが,多重定義時にkshで混乱するため2001年に廃止となった。
[ "$0" = "$BASH_SOURCE" ]
URLBASH_SOURCE変数がbash専用。
$(return >/dev/null 2>&1)

[ "$?" = "0" ]
URL関数以外でのreturnコマンドの挙動がPOSIX未定義。bash,zshとdashでは挙動が違う。
case $(caller) in '0 '*)
echo "main"
esac
URLcallerコマンドがPOSIX未定義。

bashの独自拡張を使ってよいのであれば,上記のようにいくつもやり方があるのだが,POSIX原理主義を通すには他の方法を探るしかない。

調べてわかった重要な前提として,dot(.)コマンドで読み込んだファイル名を保存する変数やアクセスするコマンドなどはPOSIXの範囲では存在しない。そのため,自分でファイルにファイル名を格納する変数をハードコーディング(直打ち)して,このファイル名と一致するかで判定する。

具体的には,以下のコードの$hogeに相当する変数を用意する。

#!/bin/sh
## \file file.sh

EXE_NAME='file.sh'
case "$hoge" in *"$EXE_NAME"
echo "MAIN"
esac

この視点に立って,改めて利用可能な方法を検討する。考えられる方法は以下の2通りだ。

  1. $0に格納される値と比較
  2. psコマンドの実行で得られる現在実行中のプロセス名と比較

使用する変数を以下の表で説明した。

現在実行中ファイルの特定に利用可能な変数
変数説明
$_シェルかシェルスクリプトの起動で使われたフルパス。POSIX非準拠。
$0現在のシェルかシェルスクリプト名。
$$現在のプロセスID
$PPID親プロセスのID

1点目の方法では,スクリプト実行時に$0変数に格納される値と比較する。この方法でうまくいくならこれが一番素直で簡単だ。

2点目の方法では,psコマンドを使ってプロセス名を取得する。

psコマンドを実行すると以下のようにプロセスのIDと実行コマンドが表示される。

ps
  PID TTY      STAT   TIME COMMAND
 2275 pts/2    Ss     0:00 bash
22064 pts/9    R+     0:00 ps w

この出力から[COMMAND]で表示される実行コマンドを取得することでファイル名と比較を行う。

psコマンドのオプションで,-pで表示させるプロセスIDを指定し,-oで出力項目(列)を指定できる。-oオプションで指定できる項目はいくつかあるが,今回は実行コマンド名がほしいので,commargsを試す。commでは実行中のコマンドだけ(C言語のargv[0]相当)を出力する。argsはコマンドだけではなく呼び出しコマンドも表示できる可能性がある。また,-oオプションでは指定対象の末尾に=を付けることで,ヘッダーを省略できる。

psコマンドで対象とするプロセスには現在のプロセス$$と親プロセス$PPIDが考えられる。現在のプロセスはもちろんであるが,親プロセスが何であるかで特定できるかもしれないので検討対象とした。

これらから,以下のpsコマンドで現在実行中のファイル名を取得できる可能性がある。

ps -p $PPID -o comm=
ps -p $$    -o comm=
ps -p $$ -o args=

ここまでで,$0とpsコマンドによるPOSIXに準拠した方法で現在実行中のファイル名の取得方法について検討した。実際にこれらの方法で値を取得できるかを検証していく。

検証にあたって,ファイル名での実行とdot(.)コマンドによる読み込みで考えられる全てのパターンを試す。具体的には以下のrun_if_main.shのコードで示すように,以下の2パターンを考慮した。

  • 現在のシェルでの読み込みと実行
  • 新しいシェルでの読み込みと実行

なお,参考までに$_の値も一緒に確認する。

現在実行中かの表示コマンド(if_main.sh
#!/bin/sh
# \file if_main.sh

echo "\$_: $_"
echo "\$0: $0"
echo 'ps -p $PPID -o comm=: '"$(ps -p $PPID -o comm=)"
echo 'ps -p $$    -o comm=: '"$(ps -p $$    -o comm=)"
echo 'ps -p $$ -o args=: '"$(ps -p $$ -o args=)"
echo ''
現在の状態を正しく表示できるかのテストコマンド(run_if_main.sh
#!/bin/sh
## \file run_if_main.sh

./if_main.sh
. ./if_main.sh
sh ./if_main.sh
sh -c '  ./if_main.sh'
sh -c '. ./if_main.sh'

run_if_main.shif_main.shを同じディレクトリ(/home/senooken/tmp)に配置して,run_if_main.shを実行した。実行はUbuntu 16.04のbashから以下のコマンドで行った。

cd ~tmp
./run_if_main.sh

実行結果を以下の表にまとめた。

bashからの./run_if_main.shの実行結果一覧
表示項目run_if_main.sh内での実行コマンド

./if_main.sh. ./if_main.shsh ./if_main.sh sh -c './if_main.sh'sh -c '. ./if_main.sh'
期待される値if_main.shif_main.sh以外shif_main.shsh
$_ ./run_if_main.sh ./run_if_main.sh ./run_if_main.sh ./run_if_main.sh ./run_if_main.sh
$0 ./if_main.sh ./run_if_main.sh ./if_main.sh ./if_main.sh sh
ps -p $PPID -o comm= run_if_main.sh bash run_if_main.sh sh run_if_main.sh
ps -p $$ -o comm= if_main.sh run_if_main.sh sh if_main.sh sh
ps -p $$ -o args=/bin/sh ./if_main.sh/bin/sh ./run_if_main.shsh ./if_main.sh/bin/sh ./if_main.shsh -c . ./if_main.sh

この表の結果からいえることを以下にまとめた。

$0とpsコマンドによる現在実行中コマンド名の特定方法の考察
  1. $_ではスクリプトを間に介した場合,スクリプト名の値が入っているので,ネストさせる場合などで判定に使えない
  2. $PPIDを使った場合は,確かに親のプロセス名が取得できている。しかし,この情報だけでは現在のプロセス名を特定できない
  3. psコマンドの-oオプションでは,commだと直接のプロセス名だけが表示されるが,argsだと実行コマンド全体が表示される。argsの方が情報は多いが,条件判定に不要な情報が多いので,commの方が有利
  4. $0ps -p $$ -o comm=はほとんど同じ期待される結果を返している。しかし,sh ./if_main.shのときだけ結果が異なっている

この4番目のsh ./if_main.sh実行時の結果の違いは重要な論点となる。具体的には,sh ./if_main.shはファイルを読み込んでいるのか,それともコマンドとしてファイルを実行しているのか?だ。

この議論を検証するために,実行コマンドであるPOSIXのshのマニュアルを確認する。

command_file
The pathname of a file containing commands. If the pathname contains one or more <slash> characters, the implementation attempts to read that file; the file need not be executable. If the pathname does not contain a <slash> character:
The implementation shall attempt to read that file from the current working directory; the file need not be executable.

If the file is not in the current working directory, the implementation may perform a search for an executable file using the value of PATH, as described in Command Search and Execution.

Special parameter 0 (see Special Parameters) shall be set to the value of command_file. If sh is called using a synopsis form that omits command_file, special parameter 0 shall be set to the value of the first argument passed to sh from its parent (for example, argv[0] for a C program), which is normally a pathname used to execute the sh utility.
sh - Shell & Utilities

上記で書かれているように,sh ファイルで実行された場合はファイルを実行ではなく読み込んでいる。したがって,$0ではなくps -p $$ -o comm=がベストな判定方法だろう。

なお,仮に$0ps -p $$ -o comm=が同じ正しい結果を返していた場合でも,psコマンドを採用することになる。理由は$0のzshの標準の挙動がPOSIX shellと異なるからだ。

zshではfunction_argzeroというオプションがデフォルトで有効になっている。このオプションを有効にすると,dotコマンドでファイルを読み込んだ場合,自動的にそのファイル名を$0に設定してしまう。

参考:Bash/Zshで'source'するファイルの中でで自分のパスを取得する

この機能が便利になる面もあるのだろうが,この機能のためにzshだけが他のシェルとデフォルトの挙動が異なる。つまり,$0を採用した場合zshだけ特別設定が必要となってしまう。そのため,共通で有効なpsコマンドを使うべきと判断した。

Coding

ここまでの調査で分かったことから,POSIX原理主義のシェルスクリプトでファイルとして実行中かどうかの判定は以下のようにして行える。

#!/bin/sh
## \file script_name.sh

EXE_NAME='script_name.sh'
NOW_EXE=$(ps -p $$ -o comm=)
if [ "$EXE_NAME" = "$NOW_EXE" ]; then echo "Executed as a file." fi

実際に,上記ファイルを実行したら,コマンドファイルとして実行したことを判定できている。

. ./script_name.sh
./script_name.sh # Executed as a file

この調査結果を利用して,現在実行中かどうかの判定関数を以下のように作れる。

## コマンドファイルの名前をグローバル変数EXE_NAMEに代入しておき参照
is_main()(
# EXE_NAME='script_name.sh' # またはis_main関数で定義
NOW_EXE=$(ps -p $$ -o comm=)
[ "$EXE_NAME" = "$NOW_EXE" ]
)

この関数を活用すれば,以下のようなライブラリとしても,コマンドファイルとしても利用可能なシェルスクリプトを作成できる。

#!/bin/sh
## \file is_main.sh

EXE_NAME='is_main.sh'

init(){
 set -eu
 umask 0022
 export LC_ALL='C' PATH="$(command -p getconf PATH):$PATH"
}

is_main()(
 NOW_EXE=$(ps -p $$ -o comm=)
 [ "$EXE_NAME" = "$NOW_EXE" ]
) main()( init echo "MAIN. $@." ) if is_main; then
main "$@"
fi

## 現在実行中かの判定を関数にしない場合は,上記を以下のように記述する。
# NOW_EXE=$(ps -p $$ -o comm=) # if [ "$EXE_NAME" = "$CUURENT_EXE" ]; then
# main "$@"
# fi
## もちろん1行で書いてもいい
# is_main && main "$@"
# [ "$EXE_NAME" = "$NOW_EXE" ] && main "$@"

2017-01-15追記:

ps -o comm=では,コマンド名が15文字までしか表示されないことがわかった。

linux - What is the maximum allowed limit on the length of a process name? - Stack Overflow

そこで,15文字以上のコマンド名にも対応するために,ps -p $$ -o args=を採用する。この場合,表示される内容は以下の通りにインタープリターのフルパスとコマンド引数まで表示される。

/bin/sh ./script_name.sh 0

コード中に埋め込んだコマンド名とプロセス上でのコマンド名のマッチさせるため,case文により判定を行う。

## Function
is_main()( NOW_EXE=$(ps -p $$ -o args=) case "$NOW_EXE" in *$EXE_NAME*);; *) return 1;; esac )
## Without function
NOW_EXE=$(ps -p $$ -o args=)
case "$NOW_EXE" in *$EXE_NAME*)
main "$@" esac

Conclusion

POSIX原理主義によるシェルスクリプトをライブラリとしても活用するための第一歩として,スクリプトが現在実行中かどうかの判定方法を検討し,確立できた。この方法を使えば,自作のシェルスクリプトのメイン部分を関数にまとめて,4-5行追加するだけで,コマンドだけでなくライブラリとしても活用できる

実のところ,今のままだとシェルスクリプトをライブラリ化しようが,コマンドのままとたいした違いはない。ただ,変数の命名規則(メソッドやプロパティの区切りを__に見立てたりなど)や関数の構造を工夫することで,シェルスクリプトでもオブジェクト指向的なことができるのではないかと考えている。もしこれがうまくいけば,より高度で効率的,汎用性の高いPOSIX原理主義による開発ができるかもしれない。

今回の調査はこのための第一歩だった。将来の応用に向けた基礎研究的なものだろう。POSIX原理主義なら時空を超えることができる。蓄積が重要になる。だから,今回のような細かい話で,すぐには役に立たなさそうな内容であっても,無駄にはならないだろう。

参考:

0 件のコメント:

コメントを投稿