f

2016-12-27

How to get date time & time zone in POSIX shell script

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

POSIX原理主義によるシェルスクリプトを作成した際に,作成日や更新日などの日時情報をスクリプト内や--helpなどに記述することがある。このときの日時の形式はどうすべきか?という議論がある。この記事ではPOSIX原理主義で採用すべき日時形式とタイムゾーンの取得方法について説明する。

結論としては,ISO 8601の拡張形式(YYYY-MM-DDThh:mm±hh:mm)を採用し,以下のコマンドでタイムゾーンを取得すればよい。

get_tz()(
 set $(date -u '+%j %H %M'); U_D=${1#0}; U_D=${U_D#0}; U_H=${2#0}; U_M=${3#0}
 set $(date    '+%j %H %M'); L_D=${1#0}; L_D=${L_D#0}; L_H=${2#0}; L_M=${3#0}

 # Fix if year is crossed
 IS_CROSSED_YEAR="[ $L_D = 1 -o $U_D = 1 ] && [ $((L_D+U_D)) -gt 3 ]"
 eval "$IS_CROSSED_YEAR" && U_D=$((L_D == 1 ? L_D-1 : L_D+1))

 dm=$(( (L_D*24*60 + L_H*60 + L_M) - (U_D*24*60 + U_H*60 + U_M) ))
 DIGIT_1=$((dm<0 ? -dm%10 : dm%10))

 # Fix if minute is changed during running date command
 [ $DIGIT_1 = 1 -o $DIGIT_1 = 6 ] && dm=$((dm<0 ? dm+1 : dm-1))
 [ $DIGIT_1 = 4 -o $DIGIT_1 = 9 ] && dm=$((dm<0 ? dm-1 : dm+1))

 printf '%+03d:%+03d\n' $((dm / 60)) $((dm % 60)) | sed 's/:[+-]/:/'
)

get_tz # +09:00
echo "$(date +%Y-%m-%dT%H:%M:%S)$(get_tz)" # 2016-12-27T21:57:32+09:00

Introduction

プログラムにおいて,そのバージョンがいつ作られたかという情報が大事になることがある。例えば,依存ソフトウェアのバージョンや,開発の活発さの指標として参考になることがある。

そこで,ソースコードやREADMEなどに,直接そのプログラムの作成日などや更新日時を記述することがある。しかし,日付や時刻の形式は国や地域,人によっていくつかの書き方がある。

以下に比較的よく見かける日時の表記例を示す。

日付の形式
形式
YYYY-MM-DD2016-12-25
YYYYMMDD20161225
YYYY/MM/DD2016/12/25
DD/MM/YYYY25/12/2016
MM/DD/YYYY12/25/2016
YYMMDD161225
YY/MM/DD16/12/26
YYYY/MMM/DD2016/Dec/25
MMM/DD/YYYYDec/25/2016
DD/MMM/YYYY25/Dec/2016
時刻の形式
形式
hh:mm:ss20:30:40
hhmmss203040
TT hh:mm:ssPM 08:30:40
hh:mm:ss TT08:30:40 PM
T.T. hh:mm:ssP.M. 08:30:40
hh:mm:ss T.T.08:30:40 P.M.
t.t. hh:mm:ssp.m. 08:30:40
hh:mm:ss t.t.08:30:40 p.m.
tt hh:mm:sspm 08:30:40
hh:mm:ss tt08:30:40 pm

この他にも年,月,日を空白区切りで並べる方法などもあり,実に多くの日時の表記方法があることがわかる。特に,この中ではDD/MM/YYYYとMM/DD/YYYY表記が紛らわしい。実際にこれらはそれぞれイギリスとアメリカで使われる日付の標識であり,2016-09-07などお互いに1-12の範囲の月日であるときに見分けがつかない

さらに,これとは別にタイムゾーンもある。例えば,13:00という時刻をアメリカと日本とでみると,それぞれの国では時差があるので,実際にはそれぞれ別の時間をみていることになる。例えば,誰が世界で一番最初に公開したかなどのように世界中で時間を競う場合に,この時差を考慮する必要がある。時差を考慮するには,13:00 JSTや13:00+09:00などのようにタイムゾーンも明記しなければ特定できない

こうした日時の表記方法に対して,どのような形式を採用するべきだろうか?

POSIX standard date time

XBD 1.3 Normative ReferencesでPOSIX規格に含んでいる国際規格が掲載されている。この中に,日時形式の国際規格であるISO 8601が存在している。

実際に,POSIX規格内でも以下の場所でISO 8601が参照されている。

また,POSIX以外にもW3CのHTML 5.1のtime要素でもISO 8601の形式しか日時の形式として認められていない。さらに,ECMAScript 2016でも日時として受け付ける文字列にもISO 8601の形式のみが採用されている。

したがって,ISO 8601の日時形式に従うべきだろう。日時形式の国際標準としてはISO 8601しか存在しないので順当な判断だ。

ISO 8601の日時の形式は以下の形式となる。

ISO 8601の書式
項目書式
ISO 8601YYYYMMDDThhmmss±hhmm20161226T230000+0900
ISO 8601拡張形式YYYY-MM-DDThh:mm:ss±hh:mm2016-12-26T23:00:00+09:00

ISO 8601の形式は日付と時刻を文字Tで区切り,タイムゾーンを末尾に付けることで日時を表記する。

1個目の形式は年月日時刻を詰めた形式となっており,可読性は悪いが,Windowsなどでファイル名として利用不可である:がないことから,ファイル名やデータ名などに適した形式である。

2個目の形式は,ISO 8601の拡張形式(extended format)と呼ばれており,各項目の間に-:といった区切り文字を入れることで可読性に優れた形式となっている。そのため,通常の文書ではこちらの形式が適している。

常に日時+タイムゾーンを明記する必要はなく,必要に応じて後ろの部分を省略することが許されている。例えば,2016-12-26のように日付だけ表記したり,23:00といった具合に時刻の部分だけ表記してもよい。また,タイムゾーンを指定しない場合は現地時間を意味する

しかし,ここで一つ疑問が起きる。それは,現在日時を表示するPOSIX準拠コマンドであるdateコマンドの標準の出力形式がISO 8601と異なる点だ。dateコマンドの出力形式は以下となっており,ISO 8601とは異なっている。

When no formatting operand is specified, the output in the POSIX locale shall be equivalent to specifying:
date "+%a %b %e %H:%M:%S %Z %Y"
date - XCU

実際にコマンドを実行すると以下のように出力される。

date
Mon Dec 26 22:39:45 JST 2016

POSIX文書に書かれている以上,これも一つの標準とみなすことができるかもしれない。ただ,この原因は,憶測だが以下のように考えることもできる。

  1. POSIX前のUNIXのデファクトがそうなっていた。
  2. 対話的な用途として視認性がよいフォーマットが優先された。

1点目の理由として,元々の形式がこのようになっていたので,差し障りがないように標準の出力はそのままにしたのではないかと考えることができる。

2点目の理由だが,「今何時かな」と思ってdateの4文字を入力した結果としては,視認性のよいものがよいという思想が働いた可能性がある。例えば,ISO 8601の形式だと曜日は月曜日から始まる1-7の数字で表記されることとなり,現在が何曜日かぱっとわかりにくい。その他,カレンダーを表示するcalコマンドも,標準出力は機械向けというよりは視認性を優先した出力結果となっている。

cal
   December 2016      
Su Mo Tu We Th Fr Sa  
             1  2  3  
 4  5  6  7  8  9 10  
11 12 13 14 15 16 17  
18 19 20 21 22 23 24  
25 26 27 28 29 30 31

dateコマンドの標準出力結果がISO 8601でないことについて考察した。この考察した結果としても,やはり基本はISO 8601に従うべきだろう。ISO 8601の拡張形式(YYYY-MM-DD)を使えば,視認性を損なわずに機械可読な日時にできるからだ。

How to get time zone

ここまでで,日時の形式にはISO 8601を採用すべきだと結論づけた。実際にdateコマンドでISO 8601の形式で出力するには,以下のように変換指定子を組み合わせる。

date +%Y-%m-%dT%H:%M:%S
2016-12-26T23:06:48

ただし,POSIXのdateコマンドのオプションでは現在のタイムゾーンを取得できないという問題がある。変換指定子に%Zというのがあるのだが,これはJSTというようなタイムゾーン名が表示されるだけで,残念ながら協定世界時(UTC)からの時差(オフセット)を数字で表示できない。なお,POSIXで規定されるC言語のstrftime関数には%zという変換指定子があり,これでタイムゾーンが±hhmmの形式で取得できる。dateコマンドにも%zの変換指定子が存在すれば簡単だったのだが,ないならばしかたない。

タイムゾーンを省略する場合は,現地時間と解釈できる。この場合,同じ文書に国や地域名を含める必要があり,これはこれで煩雑になる。より汎用性をあげるには日時にタイムゾーンも明記したほうがよいだろう。そこで,自分でタイムゾーンを取得する方法を検討する。

なお,GNU dateであれば,-Iオプションを使えば簡単にタイムゾーンも含めてISO 8601による現在日時を表示できる。当然ながら,GNUの独自拡張に依存すれば交換可能性がなくなるのでPOSIX原理主義では使ってはいけない。

date -I'seconds'
2016-12-26T23:11:06+09:00

タイムゾーンを取得するにあたって,環境変数TZを使えば簡単にできるかと思ったが,TZ環境変数は定義されていない環境もあり,これに頼ることができない。

dateコマンドはTZ環境変数が存在すれば,このタイムゾーンに基づいて日時を表示する。dateコマンドのTZ環境変数の説明をみればわかる通り,TZ環境変数が存在しなければ,システム標準のタイムゾーンが使われることになっている。

システム標準のタイムゾーンとは,実装依存になるのだが,例えばGNU C Libraryでは/etc/localtime/usr/etc/localtimeが参照され,Ubuntuでは/etc/timezoneが参照される。

しかし,当然ながらこれらのファイルはPOSIXでは未定義であり,これらに依存すれば交換可能性はなくなるので使うことはできない。

POSIX規格を調べたが,現在のタイムゾーンを数値で取得する方法はstrftime以外に存在しない。ではシェルスクリプトではどうするか?

date -uで常にUTC-0での日時が表示されることを利用して,datedate -uの差分をとり,タイムゾーンを取得する。

具体的には,以下のような関数により現在のタイムゾーンを取得できるようになる。

#/bin/sh
## \file get_tz.sh

get_tz()( L_D=$(date +%j); U_D=$(date -u +%j) is_crossed_year="[ $L_D -eq 1 -o $U_D -eq 1 ] && [ $((L_D+U_D)) -ne 3 ]" eval $is_crossed_year && [ $L_D -eq 1 ] && U_D=0 || L_D=0 LOCAL_MIN=$(echo "$L_D*24*60 + $(date +%H)*60 + $(date +%M)" | bc) UTC_0_MIN=$(echo "$U_D*24*60 + $(date -u +%H)*60 + $(date -u +%M)" | bc) DELTA_MIN=$((LOCAL_MIN - UTC_0_MIN)) printf '%+03d:%+03d\n' $((DELTA_MIN/60)) $((DELTA_MIN%60)) | sed 's/:[+-]/:/' )

get_tz

上記のget_tz関数を実行すると,日本であれば+09:00と表示される。

get_tzの仕組みを解説する。

まず,dateコマンドの-uオプションでは,常にUTC-0での日時が表示される。一方,dateコマンドはTZ環境変数が設定されていればそのタイムゾーンに従い,TZ環境変数がなければシステム標準のタイムゾーンに従い日時を表示する。つまり,date -udateコマンドの結果の差分で現在のタイムゾーンを取得できる。

ただし,そのまま差分を取ると問題が起こる。時間は12進数であり,分は60進数である。そのまま単純に減算すれば,10進数での減算となってしまい,値が想定と異なる。例えば,現在が日本時間の01:00である場合,単純に協定世界時との差分を取ると,01:00-14:00=-13となり,期待する9と異なってしまう。

この問題を回避するため,日時の単位を分に統一させる。分に単位を統一して減算を行い,得られた差分を時間と分に戻すことでタイムゾーンを取得している。通常であれば,シェルスクリプトでの日時の演算は煩雑な作業であり,1970-01-01からの経過秒であるエポックタイム(UNIX時間)に変換して行うのが汎用的だ。しかし,この変換自体複雑である。幸い今回は2時刻の差分をとるだけで済むので,素直に日時を分に変換することで対応できた。

最後のsedの処理について説明する。イギリスより西の地域など現地時間とUTC-0との差分がマイナスのとき,時間と分に換算するときにもマイナスの符号が付く。このままだと,出力するときに-09:-30のように分の部分に符号が付くため,これをsedで除去した。

なお,当初はLOCAL_MINUTC_0_MINで日時を分に換算する際に,算術展開(Arithmetic Expansion)($(()))を使っていたのだが,これだと問題が起きたのでbcコマンドに切り替えた。算術展開だと数字が0から始まる場合に8進数とみなされてしまう。現在日時の取得に使用しているdateコマンドの%d%H%M変換指定子では,1桁の数字を常に先頭に0を付けて出力する。そのため,00-07までは10進数と同じであるので問題ないが,08-09に関しては問題が起きる。具体的には以下のようなエラーが出る。

sh
echo "$((08*2))"
sh: 1: arithmetic expression: expecting EOF: "08*2"

bcコマンドであれば数字を常に10進数として扱うのでこの問題を回避できる。同様の問題に対応するのに,exprコマンドも利用できるのだが,こちらは演算子の前後を空白で区切る必要があり,記述量が長くなってしまうので今回は避けた。

2016-12-29追記:

当初掲載していたコードでは,月や年をまたぐ場合に日数が不連続になり(例:12/31と1/1),タイムゾーンを算出できていなかった。ユリウス日(通算日付)をdate +jで取得すれば,月またぎは問題ないが,やはり年をまたぐときにタイムゾーンを算出できない。そこで,年をまたぐかどうかの判定を入れた。判定は以下の手順で行った。

  1. 確実に年をまたぐことを判定。
    1. 現地時間とUTC-0のどちらかが1/1で,もう片方が最終日であるかを判定。
    2. うるう年では最終通算日366がありえるので,残りの日にちが1/1か1/2でないという条件,つまり両方の日数の合計が3以上であるで判定。
  2. 差分を取れるように,日を0か366か367に更新。

これで,年をまたぐ場合であってもタイムゾーンを算出できるようになった。以下のコマンドで現在の端末でだけ日付を1/1に変更してget_tzを実行すれば,きちんと現在のタイムゾーンを取得できることを確認できる。

sudo date 01010000
./get_tz.sh # +09:00

2017-01-07追記:

関数内で1回目のdateを実行してから2回目のdateコマンドを実行するまでの間に,万が一分をまたいでしまうと1分ずれてしまい,タイムゾーンが08:59や09:01となってしまう。幸いなことに,タイムゾーンの分は00,30,45しか存在しないので,差分をとった後の分の1桁目が1,6,4,9なら±1することで誤差を調整する。現在のタイムゾーンがUTC+00:00より西か東かで符号が変わるのでその処理を入れている。

また,当初は日時の先頭に0が登場するために,bcコマンドを使って計算していた。今回の対応で,setコマンドで日時分を個別の変数に代入するように処理を変えたので,ついでに先頭の0を削除するようにして,算術展開$(())で計算できるようにした。

get_tz()(
 set $(date -u '+%j %H %M'); U_D=${1#0}; U_D=${U_D#0}; U_H=${2#0}; U_M=${3#0}
 set $(date    '+%j %H %M'); L_D=${1#0}; L_D=${L_D#0}; L_H=${2#0}; L_M=${3#0}

 # Fix if year is crossed
 IS_CROSSED_YEAR="[ $L_D = 1 -o $U_D = 1 ] && [ $((L_D+U_D)) -gt 3 ]"
 eval "$IS_CROSSED_YEAR" && U_D=$((L_D == 1 ? L_D-1 : L_D+1))

 dm=$(( (L_D*24*60 + L_H*60 + L_M) - (U_D*24*60 + U_H*60 + U_M) ))
 DIGIT_1=$((dm<0 ? -dm%10 : dm%10))

 # Fix if minute is changed during running date command
 [ $DIGIT_1 = 1 -o $DIGIT_1 = 6 ] && dm=$((dm<0 ? dm+1 : dm-1))
 [ $DIGIT_1 = 4 -o $DIGIT_1 = 9 ] && dm=$((dm<0 ? dm-1 : dm+1))

 printf '%+03d:%+03d\n' $((dm / 60)) $((dm % 60)) | sed 's/:[+-]/:/'
)

setコマンドの後に以下の日時を強制的にセットしてちゃんと算出できていることを確認できる。

 # +5:45 Nepal
 # U_D=365; U_H=23; U_M=59
 # L_D=1;   L_H=5;  L_M=45

 # U_D=1;   U_H=5;  U_M=45
 # L_D=365; L_H=23; L_M=59

 # -3:30 Canada
 # U_D=1;   U_H=3;  U_M=28
 # L_D=365; L_H=23; L_M=59

 # L_D=1;   L_H=3;  L_M=28
 # U_D=365; U_H=23; U_M=59

Conclusion

POSIXにおける日時の表示形式とタイムゾーンの取得方法について説明した。

現在日時は一時ファイルを作る時などで重宝するので,以下のように関数にして~/.bashrcなどに書いておけば,現在日時を即座にISO 8601形式で出力できるので便利だ。

get_tz()(
 set $(date -u '+%j %H %M'); U_D=${1#0}; U_D=${U_D#0}; U_H=${2#0}; U_M=${3#0}
 set $(date    '+%j %H %M'); L_D=${1#0}; L_D=${L_D#0}; L_H=${2#0}; L_M=${3#0}

 # Fix if year is crossed
 IS_CROSSED_YEAR="[ $L_D = 1 -o $U_D = 1 ] && [ $((L_D+U_D)) -gt 3 ]"
 eval "$IS_CROSSED_YEAR" && U_D=$((L_D == 1 ? L_D-1 : L_D+1))

 dm=$(( (L_D*24*60 + L_H*60 + L_M) - (U_D*24*60 + U_H*60 + U_M) ))
 DIGIT_1=$((dm<0 ? -dm%10 : dm%10))

 # Fix if minute is changed during running date command
 [ $DIGIT_1 = 1 -o $DIGIT_1 = 6 ] && dm=$((dm<0 ? dm+1 : dm-1))
 [ $DIGIT_1 = 4 -o $DIGIT_1 = 9 ] && dm=$((dm<0 ? dm-1 : dm+1))

 printf '%+03d:%+03d\n' $((dm / 60)) $((dm % 60)) | sed 's/:[+-]/:/'
)


now()( EXE_NAME='now' dt=$(date +%Y%m%dT%H%M%S) OPTSTR=':lst-:' for opt in $(echo $OPTSTR | sed 's/[:-]//g' | fold -w 1); do eval is_opt_$opt='false' done while getopts $OPTSTR opt; do case "$opt${OPTARG-}" in l|-long) dt=$(date +%Y-%m-%dT%H:%M:%S);; s|-short) dt=$(date +%Y%m%dT%H%M%S );; t|-time-zone) is_opt_t='true';; \?*) echo "$EXE_NAME: invalid option -- '$OPTARG'" >&2; exit 1;; *) echo "$EXE_NAME: unrecognized option '-$opt$OPTARG'" >&2; exit 1;; esac done if $is_opt_t; then TIME_ZONE=$(get_tz) [ -n "${dt%%*:*}" ] && TIME_ZONE=$(printf '%s\n' "$TIME_ZONE" | sed 's/://') dt="$dt$TIME_ZONE" fi echo "$dt" )

上記のnow関数は以下のように使う。

now     # 20161227T232101          Same as now -s or now --short
now -t # 20161227T232119+0900 With time zone, same as now -st or now --time-zone
now -l # 2016-12-27T23:22:07 ISO 8601 extended format. same as now --long now -tl # 2016-12-27T23:22:36+09:00 Same as now --long --time-zone

上記のように,関数化までする必要がないと感じるならalias程度にしておくのもよいだろう。こちらだと,タイムゾーンや表示形式の自由度はないが,1行で書けるのですっきりしている。

alias now='date +%Y%m%dT%H%M%S'

このnow関数は,例えば,以下のようにコマンド代入(Command Substitution)$()で現在日時をファイル名として使える。

ls > $(now).log

POSIX規格だけでなく,ISOなどの国際規格に準拠することでも交換可能性を担保できる。日頃からこうしたデジュレ標準を意識して,高品質で高寿命なコードや文書の作成を心がけよう。

2016-12-21

How to check if commands enabled in POSIX shell script

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

POSIX原理主義を実践していくうえで,POSIX規格外のコマンドを使う場合は,交換可能性を担保しなければならない。その際にコマンドが利用可能かどうかの判定が必要となる。そこで,POSIXに準拠したコマンドの利用可能判定方法を解説する。

結論としては,用途に応じて以下の2コマンドを利用すればよい。

command -v hoge >/dev/null  && echo "OK"
hash hoge 2>/dev/null && echo "alias OK"

コマンドが存在するかどうかを判定するコマンドとしてwhichコマンドが存在する。しかし,このwhichコマンドはPOSIXで未定義なので,whichコマンド自体が常に使えると保証できない。

実はコマンドの有無を判定できるコマンドはいくつか存在する。これらを説明していく。

コマンドの存在の判定コマンド

コマンドの有無の判定については以下のコマンドが利用可能である。

  • command -v
  • command -V
  • hash
  • type
  • kshとzshの組み込みコマンドのwhence
  • which

これらのコマンドは判定できるコマンドの種類や,出力形式がまちまちである。これらの対応を以下の表にまとめた。

コマンドの存在判定コマンドの出力内容一覧
コマンド出力形式不在時

存在時




外部コマンド組み込みコマンド関数予約後alias
command -v 定義済み。不在時は無表示。 無表示 絶対パス 名前 名前 名前 定義
command -V unspecified。それぞれの種別がわかるように出力。不在時はエラー表示。 エラー 絶対パス 名前 名前 名前 定義
hash aliasは定義もチェックする。存在時は無表示 エラー 無表示 無表示
(zsh:検出不可)
無表示
(zsh:検出不可)
エラー 無表示
type unspecified。それぞれの種別がわかるように出力。不在時はエラー表示。 エラー 絶対パス 名前 名前 名前 定義
whence POSIX未定義。kshとzsh組み込みコマンド。不在時は無表示。 無表示 絶対パス 名前 名前 名前 定義
which POSIX未定義。aliasは展開してチェックする。不在時は無表示。 無表示
(zsh:表示)
絶対パス
無表示
(検出不可)
(zsh:名前)

無表示
(検出不可)
(zsh:定義)
無表示
(検出不可)
(zsh:名前)
絶対パス
(zsh:定義)

この結果をまとめる。

コマンドの有無の判定コマンドの表示結果の解説
command -v
POSIX準拠。存在時の出力形式が決まっている。全項目の有無の判別可能。ただし,aliasは定義の有無しか判定できない。aliasで定義されたコマンドが実際に存在するかは判定不能。
hash
POSIX準拠。全項目の有無の判別可能。aliasの実際のコマンドも展開して判定できる。zshの組み込みコマンドの場合,組み込みコマンドと関数の判定不能。
command -Vtype
ともにPOSIXで定義されており,挙動もほぼ同じ。コマンドの存在および種類の識別ができるが,出力形式は未定義。
whence
command -vと同等。ただし,POSIX未定義なので,kshとzshでしか使えない。
which
外部コマンドしか判定できない。zshの組み込みwhichに限り,whenceと同等。

command,hash,typeはPOSIXで定義されている。この内,typeとcommand -Vは出力形式が未定義。command -vのみコマンド存在時の出力形式が決まっている。外部コマンドの絶対パスの取得に適している。

hashコマンドはコマンドが存在する場合に,何も表示しない。代わりに,aliasの定義元のコマンドまでチェックできる。ただし,zshの組み込みコマンドのhashは挙動が異なり,組み込みコマンドや関数の存在を判定できない。

zsh組み込みのhashが組み込みコマンドや関数を判別できないのは,おそらくPOSIX規格の以下の一文のためだろう。

Utilities provided as built-ins to the shell shall not be reported by hash.
hash - Shell & Utilities

シェルでは,最近実行されたコマンドをハッシュテーブルに記憶しており,これを利用することでコマンド検索速度をあげているらしい。hashコマンドは,指定したコマンドをハッシュテーブルに追加したり,現在のハッシュテーブルを表示したりできる。

hash
hits command
   1 /bin/grep
   8 /usr/bin/xset
   5 /usr/bin/vim
   1 /usr/bin/xkbcomp

上記一文は,おそらく引数を指定しないハッシュテーブル一覧に組み込みコマンドをを表示させないことを意図していたのだと思うが,zshではこれを誤って解釈して実装したため,hashコマンドで組み込みコマンドや関数が判定できなくなったのだと思われる。

コマンドの存在判定の書き方

実際にこれらのコマンドを使って,if文などでコマンドの有無を判定することを考える。大きく2種類の判定方法がある。

  1. 実行結果の終了ステータスで判定
  2. 出力文字で判定

1.の方法は,例えば以下のように出力結果を捨てて行う。

command -v which >/dev/null && echo "OK"

この方法では,確実に標準出力と標準エラー出力を捨てる必要があるので,>/dev/null 2>&1などといったリダイレクトを記述する必要がある。

2.の方法は,command -vwhencewhichコマンドは対象コマンドが存在しなければ,何も表示しないことを利用している。testコマンドでは,出力文字があればtrue,なければfalseとなるので,コマンド代入$()を使って,コマンドが存在しているかどうかを出力される文字列を使って判定する。

[ "$(command -v which)" ] && echo "OK"

testコマンドを使う場合,入力文字数が少ないという利点があるが,実行コマンドが増えるためパフォーマンスが悪くなる。

この2通りの方法で判定方法を以下の表にまとめた。

2種類のコマンドの存在判定方法
コマンド終了ステータスでの判定方法標準出力の有無で判定
command -v
command -v hoge >/dev/null
[ "$(command -v hoge)" ]
command -V
command -V hoge >/dev/null 2>&1

hash
hash hoge 2>/dev/null
hash hoge 2>&- #(zsh:エラー)
[ ! "$(hash hoge 2>&1)" ] 
type
type hoge >/dev/null 2>&1

whence
whence hoge >/dev/null
[ "$(whence hoge)" ]
which
which hoge >/dev/null
[ "$(which hoge)" ]

POSIXの範囲内で実現する場合,hashコマンドを使う方法が最短記法となる。しかし,hashコマンドはzshで実装が異なっており,汎用性が若干欠けてしまう。hashコマンドで使っている2>&-というリダイレクトは見慣れないと思うので簡単に解説する。これは標準エラー出力を閉じている。これにより,エラーメッセージの表示を強制的に禁止してしている。

同様に>&-という記法で標準出力を閉じることができる。これにより,>/dev/nullよりも手短に標準出力を捨てられてよいと思うかもしれない。しかし,標準出力を閉じた場合,シェルによってはエラーが出てしまうので残寝ながら使えない。

sh
echo "ABC" >&-
sh: echo: I/O error

しかし,標準エラー出力を閉じる>&-であれば,そのエラーすら禁止するので問題なく使える。

hoge 2>&- && echo OK || echo NG
NG

しかし,これをzshの組み込みhashコマンドでやる場合に限り,以下のようなエラーが出る。

hash lv 2>&-
zsh: write error

zshがコマンド実行前にパースしてエラーを出しているようだ。

Re: precmd: write error: interrupted

上記で書かれているようにexec 2>&-を先に掛けば回避できる。しかし,これはみにくく,記述の短さの利点がなくなってしまう。

速度比較

これだけコマンドや判定方法があれば,どれを使えばいいのかわからなくなってしまう。そこで,実行速度を計測して判断材料とする。実際に以下のコードで1万回実行して実行速度を計測してみる。

コマンドの存在判定の実行速度比較コード
#!/bin/sh
## \file time_if_exe.sh

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

export N=10000
export NULL="/dev/null"

## test vs. >/dev/null
time -p sh -c 'for i in $(yes|head -$N); do command -v which >$NULL;   done'
time -p sh -c 'for i in $(yes|head -$N); do [ "$(command -v which)" ]; done'
echo ""

## Vs. all commmand
time -p  sh -c 'for i in $(yes|head -$N); do command -v which  >$NULL; done'
time -p  sh -c 'for i in $(yes|head -$N); do command -V which  >$NULL; done'
time -p  sh -c 'for i in $(yes|head -$N); do hash       which 2>$NULL; done'
time -p  sh -c 'for i in $(yes|head -$N); do hash       which 2>&-;    done'
time -p  sh -c 'for i in $(yes|head -$N); do type       which  >$NULL; done'
time -p zsh -c 'for i in $(yes|head -$N); do whence     which  >$NULL; done'
time -p  sh -c 'for i in $(yes|head -$N); do which      which  >$NULL; done' 

計測結果を以下の表に示す。

コマンドの存在判定の実行速度比較結果


Time [s]
Commandrealusersys
[ “$(command -v )” ] 1.17 0.04 0.42
command -v 0.03 0.01 0.01
command -V 0.03 0.02 0.00
hash 2>/dev/null 0.05 0.01 0.03
hash 2>&- 0.04 0.01 0.02
type 0.02 0.01 0.00
whence 0.05 0.02 0.01
which 5.69 0.08 0.53
コマンド存在判定の実行速度比較結果
  • testコマンド[ "$()" ]は,リダイレクトの約40倍時間がかかる。
  • whichコマンドは外部コマンドのため,速度が最も遅い。
  • 最速はtypeコマンドの0.02 s,ついでcommandコマンドの0.03 s。0.01 sの差であり誤差の範囲内と思われる。

whichコマンドとtestコマンドを使う方法以外は,どれも十分速いので,速度差は無視していい。

commandコマンド

ここまででコマンドの存在の有無の判定コマンドや,その判定の記述方法について説明してきた。結局のところ,実行速度も記述量もそんなに違いがない。ではどれを選ぶべきか?答えはcommand -vだ。

なぜ,コマンドの有無の判定でcommand -vを使うべきかは,POSIXで記載されている-vVオプションが追加された経緯を読めばわかる。

The command -v and -V options were added to satisfy requirements from users that are currently accomplished by three different historical utilities: type in the System V shell, whence in the KornShell, and which in the C shell. Since there is no historical agreement on how and what to accomplish here, the POSIX command utility was enhanced and the historical utilities were left unmodified. The C shell which merely conducts a path search. The KornShell whence is more elaborate-in addition to the categories required by POSIX, it also reports on tracked aliases, exported aliases, and undefined functions.


RATIONALE - command - Shell & Utilities

上記内容を簡単に解説する。commandコマンドの-vと-Vは,もともと以下の3種類のコマンドで達成していたことを実現するために2001年に追加された。

  • System V shellのtypeコマンド
  • KornShellのwhenceコマンド
  • C shellのwhichコマンド

これらのコマンド間では,何をどのように実現するかの合意がなかったので,これらのコマンドを修正せずにcommandが拡張された。

上記3種類のコマンドには,それぞれ以下のような欠点や特徴がある。

歴史的なコマンドの有無の判定コマンドの違い
System V shellのtypeコマンド
元々実行時のコマンド名を表示するためのコマンド。出力形式は未定義。
KornShellのwhence
aliasや未定義関数などPOSIXで要求する以上に詳しい。
C shellのwhich
単純なパス検索。組み込みコマンドなどを検出不可。

command -Vはtypeコマンドに相当しており,typeコマンドと同様に出力書式は未定義だが,シェルでどのように解釈されるかユーザーにとって役に立つ情報が多く表示される。-VはSystem VのVを意味しているのだと思われる。

command -vはKornShellのwhenceコマンドに相当しており,組み込みコマンドや関数まで判別できている。また,出力形式も規格て定義されている。

Conclusion

ここまでで,コマンドの存在の判定方法について説明してきた。結論として,コマンドの存在の判定は,原則command -vで判定するのがよいだろう。理由は以下となる。

  • commandコマンドの-vVオプションがシェル共通で使うことを念頭に作られた。
  • POSIXで出力書式が唯一規定されている。
  • トータルの記述量はtypeコマンドで判定する場合より1文字多いだけ。
  • 実行速度も十分速い。

command -vは唯一出力書式が決まっているので,外部コマンドの絶対パスを取得したい場合はほぼこれを使うしかない。ただし,commandコマンドはaliasの本体を判定できないという欠点もある。このときのために,hashコマンドを使うのはありだろう。ただし,hashコマンドはzsh組み込みの場合に挙動が変わるので,あまり使わないほうがいいように思う。zshで使わないというのならありかもしれない。

command -v hoge >/dev/null  && echo "OK"
hash hoge 2>/dev/null && echo "alias OK"
hash hoge 2>&- && echo "OK" # except for zsh

リダイレクトの記述を省略したり,より意図がわかるように以下のように関数にしてしまうのもよいだろう。

コマンドの有無の判別関数
is_exe_enabled(){
command -v "$@" >/dev/null
}

POSIX原理主義における交換可能性を担保したシェルスクリプトを作るうえで,極めて重要なコマンドの存在の判定方法について解説した。command -vを使ってコマンドの存在を判定し,POSIX原理主義を実践していこう。

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原理主義なら時空を超えることができる。蓄積が重要になる。だから,今回のような細かい話で,すぐには役に立たなさそうな内容であっても,無駄にはならないだろう。

参考:

2016-12-15

How to initialize POSIX shell script

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

POSIX原理主義によるシェルスクリプトの実行環境の最善と思われる初期化方法を解説する。結論としては,シェルスクリプトの冒頭に常に以下のコードを記述すれば,安全でより互換性が高くなる。

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

Introduction

POSIX原理主義の解説書である「Windows/Mac/UNIX すべてで20年動くプログラムはどう書くべきか」(以下「すべてで20年動く」と略記)のp. 37「1-9 環境変数などの初期化」において,シェルスクリプトの環境変数などの初期化方法として以下のコードが記述されている。

set -u
umask 0022
PATH='/usr/bin:/bin'
IFS=$(printf ' \t\n_'); IFS=${IFS%_}
export IFS LC_ALL=C LANG=C PATH

初めて見たときはとても参考になった。しかし,POSIX規格について勉強しているとこの方法に疑問を持つようになった。

  • PATH変数がPOSIXに規定のない/usr/bin:/binで決め打ち。
  • IFSの初期化方法が汚い。
  • ロケールであるLC_ALLLANGが重複。

シェルスクリプトの初期化は重要なテーマだ。というのも,環境変数を変えられてしまうとプログラムの挙動が変わってしまうというのは脆弱性の元だからだ。特に,サーバーサイドのシェルスクリプトを書く場合にクリティカルとなる。

POSIX規格を徹底的に見直すことで,より望ましい初期化方法を発見した。

set -eu
umask 0022
unset IFS
export LC_ALL='C' PATH="$(command -p getconf PATH):$PATH"

それぞれの意味の確認もこめて説明してく。

set -eu

このコマンドでは,2個のオプションを有効にしている。

setコマンドのオプションの説明
-e
while, until, if, &&, ||など条件判定以外でコマンドが失敗したら強制終了
-u
未定義の変数にアクセスしたら強制終了

開発中のデバッグのしやすさと,予想外の事態での安全性を考えて,基本的にはこれらのオプションを有効にしておいたほうがいいだろう。

ただし,set -eを付けるかどうかは議論がわかれる。というのも,正常な処理だがexitステータスに非0を返す場合がありうるからだ。例えば,grepコマンド。検索フォームから入力されたデータを受け付けてgrepコマンドで検索を書ける場合,何もヒットしなくてもそれは正常だ。

filer1 | filter2 | ... | grep '(HOGEHOGE)'

しかし,grepコマンドでは何もヒットしなければ,失敗扱いにしてしまい,set -eを有効にしていればそこで強制終了してしまう。grepコマンドの最後に| catなどの無害なコマンドをおけば,set -eの強制終了は免れることはできる。しかし,これを理由に余計なコードを書くのは本末転倒となる。おそらく,「すべてで20年動く」の著者の松浦さんはこの点を懸念してset -eを記載しなかったのだろう。

そのため,set -eについては以下の方針をとるのがよいと考えた。

  • 安全・デバッグのため,常にset -eを付ける。
  • 必要に応じて,set -eは外す。

2017-01-22追記開始

やはり,set -eは常に付けるべきだと考えなおした。理由は以下2点だ。

  1. grepコマンドの挙動として,ヒットしなければエラーにするのが自然。
  2. ()でサブシェルとしてグループ化したときにexit 1で実行元も終了させるため。

まず,1点目はgrepコマンドの挙動に関する理由だ。grepコマンドはパターンをファイルから検索するコマンド。検索はパターンの存在/不在を期待して行うので,あれば成功,なければ失敗とするのが自然な挙動だ。検索フォームで検索をかけて見つからなかったときにエラーにしてほしくないというのは,検索フォームの一部の実装者の都合の話であって,「検索」自体にまで拡大して適用すべき考えではない。

実際に,以下のように文字列がヒットするかどうかの条件判定にも使われるし,それを想定したような-qオプションまである。

echo "$STR" | grep -q target && echo ok

grepの検索時の終了ステータスが,みつからなくてもエラーにならない場合,常に標準出力が空かどうかの判定が必要になる。これは都合が悪い。

2点目はより実務的な理由だ。シェルではコマンドをグループ化させるときに波括弧と{}と丸括弧()が使える。丸括弧を使えば,そのグループはサブシェルとして実行させる。サブシェルとなるため,変数のスコープを限定でき,親への影響を減らせるので役に立つ。しかし,サブシェルであるがゆえに,exitの挙動が波括弧と異なる。

例えば,エラーが発生したときにメッセージを表示するような関数・コマンドを書くとき,以下のように最後にexit 1でエラーにして終了させる。

exit_try_help()(
MESSAGE="Try command --help"
echo "$MESSAGE" >&2
exit 1
)

波括弧で囲っている場合,親が実行元となるのでそのままプログラム全体が終了となる。しかし,丸括弧で囲っている場合,そのサブシェルだけが終了する。エラー処理の意図として,そこでプログラム全体を終了させなくてはならない。しかし,丸括弧で囲っている場合は,さらに自分で関数の終了ステータスをみてexitするコードが必要になる。

始めから波括弧で囲っておけばよいという話かもしれないが,丸括弧を使えば変数のスコープを限定できるので,できれば活用したい。このときに,set -eを適用させておけば,例え丸括弧でサブシェルとして起動したとしても,関数の終了ステータスを見て親も終了させることができる。

上記2点の理由から,setコマンドのオプションには-uだけでなく-e常につけるべきと判断した。

2017-01-22追記終了

2017-01-29追記開始

では,grepの検索結果として0件があり得る場合はどうするばいいか?その場合は,上記で記載した通り,エラー処理を書けばいい。検索結果が0件の場合に何もしたくなければ,文字通り何もしないコマンド:を実行すればいい。

filter1 | filter2 | grep || :

| catを最後に付ける場合は,常に無関係な内容が標準出力に流れてしまい,やり方としてよくない。これに対して,|| :を付ける場合は,失敗したときにだけしか影響を与えず,文字通り何も処理を行わないので,grepに限らずあらゆるコマンドに適用できる。

このset -eについては1点注意すべきことがある。それは,bash 3系では機能しない場面があることだ。

例えば,以下のようにパイプで繋いだ上でのwhileなどの制御構造内でexit 1した場合や,(){}でグループ化した中でexit 1場合に,set -eの機能により親シェルが終了しない。

#!/bin/bash

set -e

echo 1 | while read line; do
  exit 1 # not exit
  echo 'echo 1 | while'
done

while [ $((i+=1)) -lt 3 ]; do
  exit 1 # exit
  echo 'while'
done

exit 1 ## exit
echo 'echo 1 | exit 1'

(exit 1) ## not exit
echo 'echo 1 | (exit 1)'

echo 1 | { exit 1; } ## not exit
echo 'echo 1 | { exit 1; }'

{ exit 1; } ## exit
echo '{ exit 1; }'

これはbash 3系固有の挙動であり,3.0と3系最新の3.2.57で確認できた。この他のdashやzsh,kshでは問題なかった。

この現象は以下で最初に報告された。

この原因については,以下のツイートで解説されている。

bash 3ができたのは2000年代前半であり,現在のPOSIXのベースとなっている2008年の改訂内容(POSIX:2008,SUSv4相当)とは異なっている。当時のPOSIX規格(POSIX:2004,SUSv3相当)では,set-eオプションはsimple commandが失敗したときに作用することとされている。

-e
When this option is on, if a simple command fails for any of the reasons listed in Consequences of Shell Errors or returns an exit status value >0, and is not part of the compound list following a while, until, or if keyword, and is not a part of an AND or OR list, and is not a pipeline preceded by the ! reserved word, then the shell shall immediately exit.
set - XCU - POSIX:2004

なお,POSIX:2008ではこのsimple commandsの縛りがなくなっており,単にcommandsとされている。

パイプを使って,制御構造やグループ化コマンドを使うと,simple commandsの条件から外れるため,-eの効果を受けなくなったのだと思われる。

このbash 3のset -eの挙動をカバーするなら,以下3点のアプローチが考えられる。

  1. set -eを使わない
  2. (exit 1)を諦める
  3. 制御構造や複合文((){})でのexit 1に注意する

1と2は確実だが,それだとbash 3のためだけに利便性が損なわれる。3の方法に従い注意するのがよいだろう。また,set -eだけに頼るのではなく,クリティカルな部分ではきちんと専用のエラー処理・終了処理を書くべきだろう。

2017-01-29追記終了

また,setコマンドはシェルのオプションとしても指定できるので,shebangに以下のように記述してもよい。

#!/bin/sh -eu

umask 0022

umaskコマンドの実行自体に異論はない。このコマンドで新しく生成するファイルのアクセス権を変更できる。事前に作成するファイルのアクセス権を設定しておかないと,アクセス権を変更するまでの間にわずかに第三者にファイルの改ざんを許すことになってしまう。

Ubuntu 16.04では0002となっていた。

umask
0002
echo a >a.dat
umask 0022
echo b >b.dat
ls -l
-rw-rw-r-- 1 senooken 2 2016-12-14 21:42 a.dat
-rw-r--r-- 1 senooken 2 2016-12-14 21:42 b.dat

Ubuntu 16.04のデフォルト状態(0002)では,自分の作成したファイルであっても同じグループのユーザーが勝手に書き込むことができてしまう。umask 0022で書き込みできるのは自分だけにしたほうが無難だろう。

また,個人情報のように機密性の高い情報を扱う場合は,umask 0077のようによりアクセス制限を厳しくすることを検討してもよいだろう。

unset IFS

IFS変数は,forやread,$@$*などの区切り文字を指定する。何かと重宝する。例えば,IFS=,として,カンマ区切りのデータをforで1個ずつ処理することができる。IFS変数の期待される初期値は <space> <tab> <newline>だ。

「すべてで20年動く」の本では以下のようにしてIFSを初期化していた。

IFS=$(printf ' \t\n_'); IFS=${IFS%_}

また,POSIX規格で例示される初期化方法は,以下の通り素直に文字を記入している。

IFS='
'
#    The preceding value should be <space><tab><newline>.
#    Set IFS to its default value.

「すべてで20年動く」のやり方はごちゃごちゃしすぎている。また,POSIX規格のサンプルは改行をそのまま入れるため見た目が悪い。

両者のアプローチの通り,直接IFS変数に値を入れてももちろんいい。しかし,POSIX規格でIFS変数の値は未設定なら標準の値(スペース,タブ,改行)が設定されることが保証されている。

If IFS is not set, it shall behave as normal for an unset variable, except that field splitting by the shell and line splitting by the read utility shall be performed as if the value of IFS is <space> <tab> <newline>; see Field Splitting.
2.5.3 Shell Variables - Shell Command Language
1. If the value of IFS is a <space>, <tab>, and <newline>, or if it is unset, any sequence of <space>, <tab>, or <newline> characters at the beginning or end of the input shall be ignored and any sequence of those characters within the input shall delimit a field.
2.6.5 Field Splitting - Shell Command Language

よって,単純に以下のようにIFSを解除するだけで,POSIX規格に準拠して安全にIFS変数を初期化できる。

unset IFS

同じ結果が得られるのなら,よりシンプルなこの方法が優れているだろう。

2016-12-16追記:

他の指摘を受けて改めてIFS変数についてPOSIX規格を確認していたら,見落としを発見した。

IFS
A string treated as a list of characters that is used for field splitting, expansion of the '*' special parameter, and to split lines into fields with the read utility. If the value of IFS includes any bytes that do not form part of a valid character, the results of field splitting, expansion of '*', and use of the read utility are unspecified.
If IFS is not set, it shall behave as normal for an unset variable, except that field splitting by the shell and line splitting by the read utility shall be performed as if the value of IFS is <space> <tab> <newline>; see Field Splitting.

The shell shall set IFS to <space> <tab> <newline> when it is invoked.
2.5.3 Shell Variables - Shell Command Language

つまり,IFS変数はシェルの起動時にスペース,タブ,改行で自動で初期化されることがPOSIX規格で保証されるので,そもそもスクリプトでの初期化は不要だった。

export LC_ALL='C'

「すべてで20年動く」のp. 41-43「1-16 ロケール」で解説されているように,一部のコマンドはロケール系環境変数(Internationalization Variables)の値によって挙動が変化してしまう。例えば,LANG=ja_JP.UTF-8であれば,dateコマンドの出力結果が日本語となったり,joinやsortコマンドの列区切りに全角空白が加わってしまう。これを防ぐために,ロケールを固定する。

LC_から始まるロケール変数は時間や金額などいくつも存在するが,一括で設定できる3種類の変数が存在する。

  • LANG
  • LC_ALL
  • LANGUAGE

この内,LANGとLC_ALLはPOSIXで定義されており,LANGUAGEはGNU gettextで定義されている。LANGUAGEコマンドは主にコマンドのヘルプの言語などで使われる。LANGとLC_ALLよりも優先順位が高い。

これらの変数は以下の関係がある。

一括設定ロケール変数
LANG
LC_ALLを含め,値の設定されていないLC_変数についてはLANGの値を利用
LC_ALL
全LC_変数とLANGの値を上書き
LANGUAGE
LANGまたはLC_ALLの値が'C'でなければ,LANGUAGEの値を利用

つまり,基本的に以下の順番の優先順位となる。

  1. LANGUAGE
  2. LC_ALL
  3. LANG

LC_ALLやLANGの値がCの場合のときだけ,例外的にLANGUAGEよりもLC_ALLやLANGの値が優先される。

それぞれの根拠は以下の通り。

LANG
This variable shall determine the locale category for native language, local customs, and coded character set in the absence of the LC_ALL and other LC_* (LC_COLLATE, LC_CTYPE, LC_MESSAGES, LC_MONETARY, LC_NUMERIC, LC_TIME) environment variables. This can be used by applications to determine the language to use for error messages and instructions, collating sequences, date formats, and so on.
8.2 Internatinalization Variables - XBD
LC_ALL
The value of this variable overrides the LC_* variables and LANG, as described in XBD Environment Variables.
2.5.3 Shell Variables - Shell Command Language

GNU gettext gives preference to LANGUAGE over LC_ALL and LANG for the purpose of message handling, but you still need to have LANG (or LC_ALL) set to the primary language; this is required by other parts of the system libraries.


Note: The variable LANGUAGE is ignored if the locale is set to ‘C’. In other words, you have to first enable localization, by setting LANG (or LC_ALL) to a value other than ‘C’, before you can use a language priority list through the LANGUAGE variable.

2.3.3 Specifying a Priority List of Languages - GNU gettext utilities

ロケールの値には以下で記載されている通り,CまたはPOSIXを指定することで,POSIXロケールとなりPOSIX規格で規定される標準的なコマンドの振る舞いにできる。

If the locale value is "C" or "POSIX", the POSIX locale shall be used and the standard utilities behave in accordance with the rules in POSIX Locale for the associated category.

8.2 Internationalization Variables - XBD

「すべてで20年動く」のp. 43ではGNU gettextのLANGUAGE変数の設定を上書きするために,LANGとLC_ALLの値にCを設定しないといけないと勘違いしている。実際のところ,マニュアルや規格を確認すればわかる通り,単にLC_ALL='C'と記述するだけで全てのロケールをPOSIXロケールに設定できる。

また,このp. 43とp. 37ではもう一つ誤りがある。それはexportコマンドにより変数を環境変数として再設定している点だ。POSIX規格に書かれている通り,IFS,LC_ALL,PATH変数は環境変数の値を初期値としてもつただのシェル変数だ。

Variables shall be initialized from the environment (as defined by XBD Environment Variables and the exec function in the System Interfaces volume of POSIX.1-2008) and can be given new values with variable assignment commands.
2.5.3 Shell Variables - Shell Command Language

したがって,設定を有効にするためにexportコマンドを実行する必要はなく,単に値を変更すれば即座に適用される。

2016-12-16追記:

LC_ALLとPATH変数についてはこれは間違いだった。exportコマンドにより,これらは環境変数として設定しなければならない。

理由はシェルスクリプトの起動元(#!/bin/sh)である,shが環境変数としてこれらの変数を参照しているからだ。

特に,LC_ALL変数はほとんどのPOSIX準拠コマンドが環境変数として参照している。

The entire manner in which environment variables described in this volume of POSIX.1-2008 affect the behavior of each utility is described in the ENVIRONMENT VARIABLES section for that utility, in conjunction with the global effects of the LANG, LC_ALL, and [XSI] [Option Start] NLSPATH [Option End] environment variables described in XBD Environment Variables.

1.4 Utility Desciription Defaults - Shell & Command Utilities

例えば,dateコマンドのENVIRONMENT VARIABLESのセクションに環境変数としてLC_ALLやLANGを参照している。実際に以下のようなコマンドを実行すると環境変数が優先されていることがわかる。

#!/bin/sh
export LANG=ja_JP.UTF-8 date LC_ALL=C date (date) sh -c "date"
2016年 12月 16日 金曜日 21:41:26 JST
2016年 12月 16日 金曜日 21:41:26 JST
2016年 12月 16日 金曜日 21:41:26 JST
2016年 12月 16日 金曜日 21:41:26 JST

ここで,LC_ALL=Cexport LC_ALL=Cに変えるだけで,ロケールがサブシェルなどにも反映される。

2016年 12月 16日 金曜日 21:41:49 JST
Fri Dec 16 21:41:49 JST 2016
Fri Dec 16 21:41:49 JST 2016
Fri Dec 16 21:41:49 JST 2016

したがって,LC_ALLはシェル変数ではなく環境変数として設定しなければならない。

また,PATH変数もshだけでなく,typeコマンドのように一部のコマンドはPOSIX規格で環境変数として参照している。多くの環境ではシェル変数としてPATH変数に値を代入した場合にサブシェル(シェルスクリプト内でのsh -cなど)にも引き継がれるのだが,FreeBSDのshでは引き継がれない(FreeBSD 11.0で確認)。exportによりPATHを環境変数としてやればきちんと反映される。

export PATH="$(command -p getconf PATH):$PATH"

PATH変数の初期化は極めて重要だ。例えば,.bashrcの最後にunset PATHを追記されたり,lsという名前でrmコマンドが実行されるなどのコマンド名は見慣れているが中身が全く違う悪意あるプログラムのPATHを先頭に持ってこられたりしたら致命的な問題となる。

「すべてで20年動く」p. 37「1-9 環境変数などの初期化」においては以下のように決め打ちで指定されている。

PATH='/usr/bin:/bin'

これには以下2点の問題がある。

  1. /usr/bin:/binはPOSIXで未保証
  2. /usr/bin:/bin以外にインストールされているPOSIX準拠コマンドが利用不可能

/usr/bin/binにPOSIX準拠コマンドが全て存在しているとは限らない。POSIXではこれらのディレクトリは規定されていないからだ。また,/usr/bin:/binで固定化してしまうと,システム管理者やユーザーが追加でインストールしたPOSIX準拠コマンドを利用できない。例えば,bcコマンドは標準でインストールされてないOSがあり,システム管理者に/usr/local/binなどにインストールされていることも考えられる。

したがって,「すべてで20年動く」のPATH変数の初期化方法はよろしくない

それではどうするのが最善か?実はこのPATH変数を安全に完全に初期化する方法がある。それはcommandコマンドを使う方法だ。初期化するコードは以下となる。

PATH="$(command -p getconf PATH):$PATH"

上記コードは以下の手順でPATH変数を設定している。

commandコマンドによるPATH変数の初期化手順
  1. command -pによりPOSIXで保証される標準PATHからgetconfコマンドを検索
  2. getconfコマンドにより標準PATHの値を取得
  3. コマンド代入$()により,getconfコマンドの出力結果を取得
  4. 既存のPATH変数の直前にgetconfで取得した安全な標準PATHを配置

この方法が優れている理由は以下3点だ。

commandコマンドによるPATH変数の初期化方法の利点
  1. commandコマンドは組み込みであり,PATH変数が未定義でも,同名コマンドがあっても利用可能
  2. commandコマンドの-pオプションで標準のPATHを保証
  3. POSIX規格で例示される公認コード

決め打ちでPATH変数を初期化しない場合,なんらかのコマンドにより標準的なPATH変数の値を取得する必要がある。その目的として使えるコマンドとしてgetconfが存在する。このことは,bashクックブックの「レシピ14.3 安全な $PATHの設定」にも書かれている。

getconfコマンドは設定値を取得するコマンドであり,引数の最大値ARG_MAXなどシステム設定値を取得できる。ただし,getconfコマンドは外部コマンドなので,まずgetconfコマンドを安全に起動する必要がある。そのために,command -pを使う。commandコマンドは-vか-Vオプションをつけなければ,引数に渡されたコマンドを実行するコマンドだ。

1個目の利点だが,commandコマンドはPOSIXで規定されるBuilt-In Utilities(組み込みコマンド)だ。そのため,仮にPATH変数が空でも実行できる。また,POSIXの「2.9 Command Search and Execution - Shell Command Language」を見ればわかる通り,組み込みコマンドは外部コマンドよりも検索優先順位が高い。そのため,同名コマンドがPATHに存在していても必ず本物が優先される。

なお,初期化で既に利用したumaskもbuilt-In utilitiyであり,setとunsetはspecial built-in utility(特殊組み込みコマンド)であり,安全に実行できることが保証されている。

2点目の利点だが,commandコマンドのオプション-pは,標準コマンドの存在が保証されるPATHの標準値からコマンド検索を実行する。したがって,command -pにより実行されるコマンドは安全であることが保証される。これにより,getconfコマンドを安全に起動することができる。

3点目の利点だが,この方法はcommandコマンドのEXAMPLESで例示されておりPOSIX規格公認のPATH変数の初期化方法とみなせる。実際のところ,getconfコマンドはXSHで規定されるようなシステム値(何かの最大値であったり最小値)の取得はできるが,こうした環境変数の取得については「The implementation may add other local values.」とあるように実装依存のところがあった。しかし,POSIX規格で例示されているので安心して利用できる。

commandコマンドのEXAMPLESで例示では,より安全にPATHを設定するためにunaliasunset -f commandなども書かれている。しかし,サブシェルで起動した時点でaliasや関数は初期化されるので,シェルスクリプトの冒頭で書くのならこれらは不要だ。

なお,冒頭でset -uを有効にしたため,万が一PATH変数が設定されていない環境でエラーが出ないように以下のように未定義の場合は空文字を返すようにした。

export PATH="$(command -p getconf PATH):${PATH:-}"

これは,set -uより前にPATHを初期化すれば不要になる。個人的に,umaskやsetの方がシェルそのものの挙動に影響を与え,影響範囲が大きいと判断してこの順番にした。

2016-12-19追記

当初はset -uで万が一PATHが未定義の場合でもエラーで落ちないように${PATH:-}としていたが,やはり通常通りの$PATHとした。

export PATH="$(command -p getconf PATH):$PATH"

理由は以下4点。

  1. 見栄えが悪い。
  2. set -uで落ちないように,ガードするのは本末転倒。
  3. PATH変数が未定義の場合は異常事態なので,そこで止まってほしい。
  4. PATH変数が未定義の場合,sh起動時に実装依存で標準値が設定されていることが多く,未定義なことが考えにくい。

シェルスクリプトの初期化は今後何度も見ることになるため,きれいなコードを維持したい。set -uで落ちることを防ぐという,本末転倒な理由のために汚いコードを残すのは忍びない。だから,POSIX規格で書かれている通りのコードにした。

Conclusion

以上のことから,POSIX原理主義によるシェルスクリプトでは,常に以下のコードを初期化のために冒頭で記述することが望まれる。

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

例えば,以下のように関数にまとめてしまってもよいだろう。

#!/bin/sh

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

main()(
init
# command
)

main

「徹底的に調べ尽くす」という自分のPOSIX原理主義を実践できた。自分一人ではここまでのことを思いつけなかった。最初にサンプルがあり,そこに疑問をもってPOSIX規格を徹底的に読み込むことで今回の結論が得られた。今回の結論は「巨人の肩の上に立つ」ことで得られたものであり,叩き台として最初に例を挙げてくださった「すべてで20年動く」の著者である松浦さんに感謝したい。

2016-12-13

LibreOffice Kaigi 2016.12参加・発表レポート

#posixismadvent この記事は以下の2個のアドベントカレンダーの11日目の記事だ。

LibreOffice Kaigi 2016.12で「Standardization of Document Style」という題で発表したので参加・発表レポートを記す。

開催概要

LibreOffice Kaigi 2016.12開催概要
項目 内容
イベント名 LibreOffice Kaigi 2016.12
URL https://wiki.documentfoundation.org/JA/Events/LibOKaigi/201612
https://libojapan.connpass.com/event/42685/
開催地 サイボウズ株式会社( 東京都中央区日本橋2-7-1 (東京日本橋タワー27F))
開催日 2016-12-10
参加人数 37

会場はサイボウズという会社のフロアだった。なんというかBarというフロア名にそった,くだけた感じのフロアで個人的には落ち着かなかった。どこに座ればいいかわからなかったので入り口に入ってすぐの椅子に座った。到着が12:00頃と早くつきすぎた。スタッフミーティングの最中だったようだ。部屋には入れてもらえたけどなんか緊張した。

今回のカンファレンスは参加人数が40人程度と年次カンファレンスとしては人数が少ない印象だ。Pythonの日本の年次カンファレンスなんて700人も参加者があるというのに…LibreOfficeのユーザーでいえば100人くらいの参加はあってもおかしくないと思うのだけど,この差は一体何なのだろうか?

発表・公開資料

今回は登壇者として参加した。発表資料をまずは公開する。動画以外のライセンスはCC0。僕の著作権は放棄しているので好きにしたらいい。

発表資料(SlideShare)
発表資料(GitHub)

発表動画(音声・スライドのみ):https://www.youtube.com/watch?v=wizPpJFOSuc

発表動画(運営撮影映像): 妹尾 賢:Standardization of Document Style〜文書スタイルの適用パターン〜 - YouTube

その他発表関連資料一式:https://github.com/lamsh/slide/tree/master/2016/20161210_Standardization_of_Document_Style_(LibreOffice_Kaigi_2016.10)

slide
スライド本体。HTMLとPDFの両方を格納。
PrintStyle.bas
Writerの標準スタイルの取得に使用したBasicのコード。
default_style.ods
発表のために収集・整理したWriter・Word・HTMLの標準スタイルの一覧。
draft.html
発表資料作成に際してのメモ。発表申し込みの概要など。
note.txt
カンファレンス中のメモ。
report.html
この記事。

発表レポート: ITpro Report - (5/5)台湾で進行するLibreOffice導入、「完全移行を強いないのがコツ」:ITpro

自分の発表について

発表した理由

今回参加・発表した理由は4個ある。

  1. POSIX原理主義の普及活動
  2. 組版についての興味関心
  3. CSS組版の挑戦
  4. 自分の活動実績

1番目の理由。まず,第一にPOSIX原理主義の普及活動だ。ここ1年ほどで登場したPOSIX原理主義という方法論を僕はとても気に入っている。初日の記事でも書いたとおり,OSSに匹敵するほどの強力で素晴らしいものだと思った。だから,POSIX原理主義 Advent Calendar 2016な んてものまで主催した。もっと世の中に広めていくことが正しいことだろうと思った。そこで,注目を集めるにはカンファレンスなどで発表するのがよいだろう と考えた。アドベントカレンダーの開催期間にLibreOffice Kaigi 2016.12というカンファレンスが開催されるということでタイミングが良かった。このカンファレンスに参加・発表して普及活動の始まりにしろということなのだろうと思った。

2番目の理由。LibreOfficeには以前から興味があった。結局のところ世の中に出回る文書は(MS)Office文書であることが多い。世界中で最も開発が盛んで優れた自由なOfficeソフトはLibreOfficeしかない。今後もますます重要になってくる。もっとLibreOfficeが盛り上がっていいと思っている。Office文書の中でも主なものとしてワープロ文書がある。WriterのodtやWordのdocxなどだ。この文書 にはスタイルがあるのだけれど,一体どれだけあって,それぞれどう対応しているのかという情報が不足していると思った。普段使っていて,もしかしたらもっ と適切なスタイルがあるのかもしれない。実際に,昔は自分でソースコードのスタイルがないと思っていたので,ユーザー定義スタイルを使っていた。誰か一人 が徹底的に調べて公開すれば,細かいことを気にする人の役には立つだろうと思った。少なくとも僕はこういう資料が欲しかった。「ないから諦めるのではな く,なければ自分で作る」というマインドと,文書の互換性というものを考えることはPOSIX原理主義に通じることだと思った。

3番目の理由。僕は悪しきTeX組版を滅ぼして,CSS組版がこの世界の標準的な組版になってほしいと願っている。そのためには3のことを心がけている。

  1. TeX組版の批判
  2. CSS組版の勉強
  3. CSS組版の普及

この中で自分にとっては[2.のCSS組版の勉強]が主なフェーズだと思っている。CSS組版は最先端の技術であり僕も手探り状態だ。自分のCSS組版を高めるために実際にCSS組版で文書を作って公開してく必要がある。既に世の 中にはCSSやJavaScriptを使ったスライド作成技術が存在している。主要なそれらの技術を実際に試して評価するということも重要な作業だ。ただし,実際にやり始めるには動機が必要だ。その動機付けとしカンファレンスでの発表を活用している。今回はShowerというライブラリーを試した。

4番目の理由。今年になって社会人3年目に入り年も20代の後半に入ってきた。今までもライトニングトークとか小さな勉強会などで発表してきたけれど,もう少し大きな場で発表して自分の実績を作っていったほうがいいんじゃないかなと思った。もしかしたら,この先転職活動するときに役に立つかもしれないし,同じ考えの人が助けてくれるかもしれない。

発表資料について

発表資料の導入で書いたイギリス政府のODF導入は非常に重要で大きなニュースだと思った。このニュースと日頃組版に思っていたスタイルの話,さらにPOSIX原理主義を組み合わせた発表にしようと決めた。題材は決まっていたが,発表資料作成は難航した。カンファレンスの丁度2周間前から調査や資料作成を開始した。

まず,Writerのスタイルに関する情報が全然見当たらなかった。どこかにスタイルついての網羅的な文書がないかなとけっこうな時間をかけて探したのだけど結局なかった。Basicでアクセスできる変数リストみたいなにでもないかなとDoxygenで作られたAPI文書を2-3日さまよい続けた。結局マクロの杜さんのBasicのコードを参考にして作った。といってもBasicの知識自体があまりなかったので理解するのにも時間がかかった。

Wordに関しても,僕が知らないだけで実はMSが公式のPDFマニュアルとか用意してくれてるんじゃないかと思ったけど,やっぱりなかった。こちらも結局はAPIから取得するしかなさそうだったのだけど,幸運なことにリストを後悔しているDocToolsという会社のサイトがあって助かった。

用意したスタイルの一覧の数が多く,表にまとめてスライドに貼り付けるだけでもけっこうな時間がかった。

そして,これは今でも納得できていないのだけど,スライドの組版がダメだった。今回の発表の理由の一つのCSS組版なのだけど,当初はImpress.jsを試すつもりでいた。しかし,これはたくさんのスライドを作るには向いていないことがわかった。そこでShowerに変えた。発表1周間前の日曜日の話だった。4月にReveal.jsを使った資料を作ったので,同じような感じでいけるのかなと思ったけど,やはり勝手が違った。そもそもCSS組版でのスライド作成のノウハウは足りておらず不十分だった。

コンテンツの位置調整だったり,ボックスでの囲み,着色など。見栄えが悪いところが多々あるのはわかっているのだけど,スライドの枚数もけっこうあったのと時間に余裕がなかったのでやむを得ずこの状態での公開となった。スライドを作成するためにCSSやマークアップを事前に検討しておく必要があると強く感じた。この失敗を次回に活かしていきたい。

ただ,Showerの感触はよかった。依存するJavaScriptは1ファイルだけであとはCSS。Reveal.jsと異なりBlueGriffonでそのまま編集できるのがとてもよかった。

今回のカンファレンスでは基調講演が英語ということで外国の人も来ることが頭にあった。また,POSIX原理主義を普及させていくうえでも英語での情報公開が大事だろうと思った。だから,スライド前半では英語や日本語との併記を心がけた。まあ,後半は余裕がなかったのでほぼ日本語になってしまったわけだけど…。だけど,これは自分の中でよい取り組みだと思った。それぞれのスライドのタイトルを英語にするだけでも十分意味があると思った。簡単な日本語を心がけていれば,それを英語にしてもみんな理解はできるだろうと思った。これは今後も心がけたい。

発表スライド自体に参考文献となる情報やURLを随所に散りばめたので,資料としてある程度価値のあるものができたんじゃないかと思っている。内容や着眼点自体はよかったと自分で思っている。

他の人の発表の感想

それぞれの発表の気になった箇所のメモを掲載したり感想を記す。

基調講演:Franklin Weng : LibreOffice/ODF Migration In Taiwan

台湾政府におけるLibreOfficeの導入推進者とのこと。今回のカンファレンスで一つの大きなテーマと感じたのがODFだ。LibreOfficeというよりはこのODFというデータ形式が本質のように思えた。以下はメモ。

ヘルプをしないとユーザーは使えないと言ってMS Officeに戻ってしまうので,サポートが重要。

なぜODFを使うべきかの3の視点。

  1. もしOOXMLを選んだら?MS Officeは約3年おきにバージョンアップしている。Windows XPをアップデートしないといけなくなる。予算が余計にかかるし,データの互換性もなくなってしまうのではないか?
  2. Open Standard。特定企業にサポートを打ち切られて作品やデータを殺される心配がない。
  3. 国際トレンド。イタリアの防衛省がLibreOfficeを導入→LibreOfficeの安全性が証明。

LibreOffice本体よりもODFという形式に焦点を当てる必要がある。LibreOfficeというソフトに焦点を当てる場合,機能不足やMS Officeとの互換性などで簡単に反撃を受ける。だけど,ODFならそうはなりにくい。

野方 純:スタイルを利用した文書設計について考える

Designing with LibreOfficeというLibreOfficeのスタイルにだけかかれた本があり,電子書籍は無料でHPからダウンロードできるらしい。この本をベースにしてスタイルについての発表だった。スライドのデザインが洗練されていてよいと思った。

WriterやLibreOfficeでのスタイルの概念について説明していて参考になった。ただ,もう少し具体的な内容があってもよいと思った。例えば,CSSのようにWriterのスタイルは別の文書から参照できるくだりなど。たぶん知っている人のほうが少ないので具体的な手順の説明があっても良かったなと思った。

以下はメモ。

LibreOfficeのスタイルは書式とちょっと違う。

2個の役割がある。

  1. 文書の構成要素を定義
  2. その要素に書式を設定

LibreOfficeもCSSのようにスタイルだけ分離できる。

MS Wordはページ設定は制御コードを入れるイメージ。

Writerは本当に1ページずつ。

Calcのセルスタイルは,継承される。

条件付き書式。Wordだと本当に書式を変える。条件付きスタイルというイメージ。

安部 武志:Maintaining LibreOffice Math

LibreOffice Mathの開発に関する話。現実世界で久しぶりにTeXという単語をきいた(小並感)。

LibreOffice MathはStarOffice 5.0から5.2でほぼ現在の形になったとのこと。TeXと異なりWYSIWYGができるのがいい。

LibreOfficeの中では規模が小さいように見えるが,実際には低レイヤーのライブラリーが多くて簡単なわけではないとのこと。

小笠原 徳彦:Make it Better Together: コミュニティを主体としたLibreOffice UI翻訳

LibreOfficeのユーザーインターフェースの翻訳の流れや仕組み,ルールや今後の展望などについての発表。

翻訳支援ソフトとしてOmegaTなどがあるらしいが,UIの翻訳は単発者なのであまり効果はないと考えているらしい。

LibreOffice以外の翻訳コミュニティも同じような悩みをもっているらしい。

個人的には,議論のためにメーリングリストに入ろうというのがひっかかった。というのも個人的にはメーリングリストはあまり好きでないからだ。自分に関係のない未読メールが溜まっていくのがストレスに思う。Googleグループのようにみたいときにみたり,GitHubのissueみたいに自分から能動的にみたり,関係者だけ集中して議論できたほうがいいんじゃないかなとぼんやり思ったり。

パネルディスカッション

発表が終わった後に運営スタッフと識者による合計3人のパネルディスカッションがあった。このセッションでもODFが一つのテーマだったように思う。

中でもドキュメントの拡張子を意識するというのがピンときた。今やMS Officeを使っている会社ではたぶんどこもdocxやdoc,xlsxとxlsなど古くて標準化されていない形式と新しい形式が混在しているだろう。しかも,互換性だとかなんとかで標準化されていないxのつかない形式を優先する風潮まであるかもしれない。ODFを採用すればこのドキュメント形式を統一できる。これもメリットだと思う。

以下にメモを載せる。

スライドシェアにアップロードするときはハイブリッドPDFがいい。

ITリテラシーのためにわざとLibreOfficeを使わせるとかあるらしい。

複数のものを使うということで勉強になるらしい。

Windows以外のOSに向けたある程度の準備ができる。

Windowsに縛られないという利点。

ヨーロッパの大規模LibreOffice導入はLinuxも同時のことが多い。

日本だとフォント周りが問題になる。というのもフォントがないので。
1.2. ODFを導入するときの注意点

ドキュメントとか外字の問題がある。ちゃんと変換できるかを確認するのも大事。

テンプレート的なものをどんどん導入しないと,しんどくなる。

空白とかタブでレイアウトしているとコンバートするとよりひどくなる。

世界標準だけど,マイナー扱いされる。利用者が注意しておかないと,外部とのやりとりで問題が起きる。

省庁などで問われるテンプレートをどうするか。

文書構造とか論理構造,テンプレート。外界とのインターフェース。

ここからまたメタにして全体としてのコミュニケーションプラン。

なにか問題が起きたときになんで使いにくい不自由なのを使っていると文句をいわれる。

懇親会

最後にDebian mini Conferenceとの合同懇親会だった。ここで僕はPOSIX原理主義の論文を3人に配ることができた。

ただ,POSIXという言葉だけで過剰に反応する人もいて困惑した。布教しようとクリアファイルに入れた論文を机の上に置いていたのだけど,なんか目に入ったらしく興味ありそうにみていたので差し上げたのだけど。pthreadだとかsignalとかsigwaitだとかのPOSIXのシステムコールに欠陥があるだとかでなんか怒っているような感じでちょっと怖かった。初対面の見ず知らずの人同士なのになんだか無礼な人だなと思った。何一つこちらは迷惑をかけた覚えはないのに。

POSIX原理主義のPOSIXという単語にばかり気を取られていて交換可能性という重要な本質をよくわかっていないまま,POSIXに準拠したシェルスクリプトは難しいだとか,「原理主義という言葉は,従わないものは殺すくらいの強烈な意味がある」とか,そんな意味はないにも関わらずに勝手な思い込みで意固地になっている人もいて困惑した。しかし,理解してくれる人もいたのでよかった。

POSIX原理主義(POSIXism)とOpen Sourceは似ていると思う。Open Sourceはソフトウェアを開発するための手法であり,オープンにするというのはただの手段でしかなくて,多くの人の参加を促すことが本質なんじゃないだろうか。同じようにPOSIX原理主義というのも,POSIX規格はただの手段であり交換可能性を担保することが本質だろう。

まとめ

POSIX原理主義とODFやLibreOfficeは相性がいいと思っている。それぞれ互換性を重視しており,時空を超えることができる。結局のところ文書化されていなければ,後世や他の人に伝え残していくことはできない。自分の興味関心の一つに組版があり,HTMLと並んで今後残っていく組版技術としてODF・LibreOfficeは今後も注目していく。

POSIX原理主義とこれらの組版技術で何かコラボレートできたら面白いなと考えている。POSIX原理主義と同じように長く付き合っていけると思うので,LibreOffice Kaigiにはまた遊びに参加したいと思う。