メールの 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]);
}

となっていて、この場合、 strptimeGMT として生成されたものを _strftime($format, CORE::gmtime($time->epoch)) という形で渡すことになる。 _strptimePiece.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);

としてエポックタイムを渡すようにし、 _mktimeCORE::localtime($time) を通るようにしてやればよい。結果、前述の回避策、ということになる

まとめ

一つ一つ Time::Piece のコードを読んでいった結果、なんとか当初の目的は達成できた。が、標準モジュールとして入っている割には Time::Piece は細かな挙動が読めなくて使うのが難しいなとは感じた