メールの Date ヘッダを Time::Piece でパースしようとしてはまった話
メールに含まれている Date ヘッダを Perl の Time::Piece でパースしようとしたところ、タイムゾーンがうまく扱えなくてはまった。たとえば、
#!/usr/bin/env perl
use strict;
use warnings;
use POSIX qw(setlocale LC_TIME);
use Time::Piece;
setlocale(LC_TIME, "C");
my $format = '%a, %e %b %Y %T %z';
my $datetime = "Tue, 14 Oct 2014 18:23:53 +0900";
my $t = Time::Piece->strptime($datetime, $format);
print $t->strftime($format), "\n";
とした時、元と同じ結果が返ってくることが期待されるが、実際には Tue, 14 Oct 2014 09:23:53 +0900
となって、 JST の 9 時間分ずれる。この辺については
など多々まとめがあるが、一応自分でも Time::Piece のコードを読んで確認したことと、とりあえずの回避策をメモしておきたい。なお、検証は Perl 5.18.2 と現時点での Time::Piece 最新版である 1.29 を使って行っている
回避策
検証部分が長いので、まず回避策を先に書いてしまうが、結論としては先ほどのコードを
#!/usr/bin/env perl
use strict;
use warnings;
use POSIX qw(setlocale LC_TIME);
use Time::Piece;
setlocale(LC_TIME, "C");
my $format = '%a, %e %b %Y %T %z';
my $datetime = "Tue, 14 Oct 2014 18:23:53 +0900";
my $t = localtime(Time::Piece->strptime($datetime, $format)->epoch);
print $t->strftime($format), "\n";
のように書き換えればよい、と言うことになる。パースした結果の epoch
を取り出して localhost
( Time::Piece でオーバーライドされてる)に渡せば、内部的には自身のタイムゾーンの値としてうまく扱ってくれる。以下はその理由
検証
strptime の %z で何をやっているか
関連記事を読むと、 %z
を使ってる時に問題が出るケースが多く、かつ、この例でも使ってるので、まずはそこを調べてみる。 Piece.xs
によると、 strptime
の内部実装 _strptime
は
void
_strptime ( string, format )
char * string
char * format
PREINIT:
struct tm mytm;
time_t t;
char * remainder;
int got_GMT;
PPCODE:
t = 0;
mytm = *gmtime(&t);
got_GMT = 0;
remainder = (char *)_strptime(aTHX_ string, format, &mytm, &got_GMT);
if (remainder == NULL) {
croak("Error parsing time");
}
if (*remainder != '\0') {
warn("garbage at end of string in strptime: %s", remainder);
}
return_11part_tm(aTHX_ SP, &mytm);
return;
となっていて、 gmtime
として初期化されている。さらにここで呼んでいる _strptime
の中では、 %z
の処理は
case 'z':
{
int sign = 1;
if (*buf != '+') {
if (*buf == '-')
sign = -1;
else
return 0;
}
buf++;
i = 0;
for (len = 4; len > 0; len--) {
if (isdigit((int)*buf)) {
i *= 10;
i += *buf - '0';
buf++;
} else
return 0;
}
tm->tm_hour -= sign * (i / 100);
tm->tm_min -= sign * (i % 100);
*got_GMT = 1;
}
break;
}
となっていて、つまり +0900
の部分を時と分に分解して元の時と分の値を調整した後、 GMT
として扱うようフラグを立てている。従って内部的には GMT
で正しく計算された値が入っていることになる
strftime は何をやっているか
では、出力を生成している strftime
で何をやっているかというと、 Piece.pm
では
sub strftime {
my $time = shift;
my $tzname = $time->[c_islocal] ? '%Z' : 'UTC';
my $format = @_ ? shift(@_) : "%a, %d %b %Y %H:%M:%S $tzname";
if (!defined $time->[c_wday]) {
if ($time->[c_islocal]) {
return _strftime($format, CORE::localtime($time->epoch));
}
else {
return _strftime($format, CORE::gmtime($time->epoch));
}
}
return _strftime($format, (@$time)[c_sec..c_isdst]);
}
となっていて、この場合、 strptime
で GMT
として生成されたものを _strftime($format, CORE::gmtime($time->epoch))
という形で渡すことになる。 _strptime
は Piece.xs
で定義されていて、
void
_strftime(fmt, sec, min, hour, mday, mon, year, wday = -1, yday = -1, isdst = -1)
char * fmt
int sec
int min
int hour
int mday
int mon
int year
int wday
int yday
int isdst
CODE:
{
char tmpbuf[128];
struct tm mytm;
int len;
memset(&mytm, 0, sizeof(mytm));
my_init_tm(&mytm); /* XXX workaround - see my_init_tm() above */
mytm.tm_sec = sec;
mytm.tm_min = min;
mytm.tm_hour = hour;
mytm.tm_mday = mday;
mytm.tm_mon = mon;
mytm.tm_year = year;
mytm.tm_wday = wday;
mytm.tm_yday = yday;
mytm.tm_isdst = isdst;
my_mini_mktime(&mytm);
len = strftime(tmpbuf, sizeof tmpbuf, fmt, &mytm);
となっているが、どうやら my_init_tm
(この関数は Perl 5.8.0 以降では Perl 本体の util.c
に定義されている init_tm
のエイリアスになっている)でローカルタイムとして定義した後、 C 標準関数の strftime
を呼び出しており、そこで %z
は実行環境のタイムゾーンである +0900
を返してくることになる。つまり、 GMT
の時刻 + 実行環境のタイムゾーンが生成されていて、これがおかしな表示の理由ということになる。
gmtime を localtime に変換する
ここまでの結果からズレを直すには
- 時刻はそのまま、タイムゾーンとして
GMT
,UTC
,+0000
が返ってくるようにする - タイムゾーンはそのまま、時刻をローカルタイムに変換する
のどちらかを取ればよさそうだということがわかった。前者については GitHub に Time::Piece への Pull Request があって、これがマージされれば解消できるように見える。ここでは後者について考えたい。
まず、単純に
my $format = '%a, %e %b %Y %T %z';
my $datetime = "Tue, 14 Oct 2014 18:23:53 +0900";
my $t = localtime(Time::Piece->strptime($datetime, $format));
としてみたが、これはうまくいかなかった。 Piece.pm
を見ると
sub localtime {
unshift @_, __PACKAGE__ unless eval { $_[0]->isa('Time::Piece') };
my $class = shift;
my $time = shift;
$time = time if (!defined $time);
$class->_mktime($time, 1);
}
sub _mktime {
my ($class, $time, $islocal) = @_;
$class = eval { (ref $class) && (ref $class)->isa('Time::Piece') }
? ref $class
: $class;
if (ref($time)) {
$time->[c_epoch] = undef;
return wantarray ? @$time : bless [@$time[0..9], $islocal], $class;
}
_tzset();
my @time = $islocal ?
CORE::localtime($time)
:
CORE::gmtime($time);
wantarray ? @time : bless [@time, $time, $islocal], $class;
}
となっていて、 $time
が Time::Piece オブジェクトの場合、 $islocal
フラグを立てて単に中身をコピーしてるだけ、ということなので、これだと時刻のズレはそのままフラグが立ってしまうという結果になる。これを回避するためには、
my $format = '%a, %e %b %Y %T %z';
my $datetime = "Tue, 14 Oct 2014 18:23:53 +0900";
my $t = localtime(Time::Piece->strptime($datetime, $format)->epoch);
としてエポックタイムを渡すようにし、 _mktime
の CORE::localtime($time)
を通るようにしてやればよい。結果、前述の回避策、ということになる
まとめ
一つ一つ Time::Piece のコードを読んでいった結果、なんとか当初の目的は達成できた。が、標準モジュールとして入っている割には Time::Piece は細かな挙動が読めなくて使うのが難しいなとは感じた