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

0 件のコメント:

コメントを投稿