Perl Hackers Hub

第66回モジュールによる時間の多様な取り扱い(2)

前回の(1)こちらから。

日付表現を便利にするモジュール

(1)では時刻に関わるモジュールを見てきました。しかし、長い未来を見ていく場合、日付のほうが重要です。たとえば2つの異なるタイムゾーンでの日付の比較は、タイムゾーンの差の計算と時間の計算、そしてそれに伴う日付の移動の発生が起こり得ます。これらの計算は、日付計算に優れたモジュールを使用すると便利になります。

(2)では、日付処理に関わるモジュールを見ていきます。

DateTime ─⁠─日付演算を正確に行うモジュール

日付処理の代表的なモジュールとして、DateTime(執筆時点のバージョンは1.54)があります。DateTimeオブジェクトは、newfrom_epochメソッドなどで時間を直接指定して作成するか、nowメソッドやtodayメソッドで実行日時から作ることができます。

use DateTime;
my $dt = DateTime->new(
    year => 2021,
    month => 1,
    day => 1,
    hour => 0,
    minute => 0,
    second => 0,
    # nanosecond => 0,
    time_zone => 'Asia/Tokyo'
);
my $now = DateTime->now(time_zone => 'Asia/Tokyo');

DateTimeオブジェクトは加算や減算の処理が行えますが、オブジェクトに破壊的な変更を加えるため、もとの情報は失われます。演算前の情報を残しておきたい場合は、Cloneモジュール(執筆時点のバージョンは0.45)などであらかじめオブジェクトを複製する必要があります。

use DateTime;
use Clone qw(clone);
my $dt_1 = DateTime->now(time_zone => 'Asia/Tokyo');
my $dt_2 = clone($dt_1);
$dt_2->add(months => 1, days => 1);

演算結果が月末を超える場合の最後の日の合わせ方は、wraplimitpreserveのいずれかをend_of_monthに指定できます。wrapは月末を超えて演算します。limitは月末を超えたら月末に合わせます。preserveは月末を超えたら月末に合わせたうえで、もとの日付が月末の場合は超えた月の月末に合わせます。値が正の場合の初期値はwrapで、負の場合の初期値はpreserveです。

$dt->add(months => 1, end_of_month => 'preserve');

また、DateTimeオブジェクトどうしの減算を行う場合、基点にしたいDateTimeオブジェクトのdelta_daysメソッドに、引きたいDateTimeオブジェクトを渡すことで、DateTime::Duration(執筆時点のバージョンは1.54)オブジェクトが作られます。差の結果を取り出すには、DateTimeオブジェクトのin_unitsメソッドで取り出したい単位を指定します。

use DateTime;
my $dt_1 = DateTime->new( year => 2021, month => 1, day
=> 1, time_zone => 'Asia/Tokyo' );
my $dt_2 = DateTime->new( year => 2021, month => 2, day
=> 1, time_zone => 'Asia/Tokyo' );
my $duration = $dt_2->delta_days($dt_1);
say $duration->in_units('days'); # 差は31日

異なるタイムゾーンのDateTimeオブジェクトどうしの場合は、set_time_zoneメソッドで一度UTCに変換してから演算を行い、もとのタイムゾーンに戻します。

$dt_1->set_time_zone('UTC');

DateTimeモジュールは、タイムゾーンを指定しなかった場合、フローティングタイムゾーンとして扱い、閏秒を考慮しなくなります。指定した場合はそのタイムゾーンの閏秒を扱えます。

しかし、閏秒がいつ挿入されるかは半年前にならないとわかりません。DateTimeモジュールは、閏秒の挿入が決定されるとモジュールのバージョンアップで対応しているため、閏秒に対応していくためにはそのたびにモジュールを更新する必要があります。

閏秒を考慮しない場合は、Time::Pieceモジュールや次項のTime::Momentモジュールなどの比較的軽量なモジュールを検討するとよいです。

Time::Moment ─⁠─高速な日付演算モジュール

Time::Moment(執筆時点のバージョンは0.44)は、日付演算の軽量モジュールです。DateTimeモジュールと似た感覚で扱え、タイムゾーンの代わりにUTCからの時間の差をオフセットとして分で指定することで表現します。

use Time::Moment;
my $tm = Time::Moment->new(
    year       => 2021,
    month      => 1,
    day        => 1,
    hour       => 0,
    minute     => 0,
    second     => 0,
    # nanosecond => 0,
    offset     => 60 * 9, # 日本時間
);
my $now = Time::Moment->now;
my $utc = Time::Moment->now_utc;
my $epoch = Time::Moment->from_epoch(1000);

Time::Momentオブジェクトは、DateTimeオブジェクトの破壊的な加算や減算とは異なり、演算後に新たにTime::Momentオブジェクトが作られる非破壊的な処理を行います。また、月を加算するplus_monthsや減算するminus_monthsは、その月に日が存在しないならその月の最終日になり、存在する場合は日付が維持されます。

$tm2 = $tm1->plus_months(1); # 1ヶ月を加算

DateTimeモジュールは実行速度がかなり遅いです。Benchmarkモジュール(執筆時点のバージョンは1.23)を使って各モジュールの現在時刻を取得するコードの実行速度を筆者のマシンで計測すると、DateTimeモジュールよりTime::Pieceモジュールが約25倍速く、Time::MomentモジュールにいたってはDateTimeモジュールより約140倍速いです。頻繁に日付処理を行う場合は、DateTimeモジュールからTime::Momentモジュールへの移行を検討してもよいでしょう。

use Benchmark qw(cmpthese);
use Time::Moment;
use Time::Piece;
use DateTime;
cmpthese 0, {
    'DateTime'     => sub {
        DateTime->now(time_zone => 'Asia/Tokyo')
    },
    'Time::Piece'  => sub { Time::Piece->localtime },
    'Time::Moment' => sub { Time::Moment->now },
};

特定の時刻フォーマットの読み込みと変換を行うモジュール

時間は、年月日や時刻だけでなく、曜日やフォーマットなども自由に表現できるため、読み込みや変換が独自の実装だけでは足りなくなりがちです。ここでは個々のケースに対応するモジュールを見ていきます。

POSIX::strftime ─⁠─任意の時刻フォーマットへの変換モジュール

単に現在時間を任意の時刻フォーマットに変換するためにTime::PieceモジュールやDatetimeモジュールをuseすることは、目的に対してモジュールが大きすぎます。

POSIX::strftime(執筆時点のバージョンは1.94)に変換したいフォーマットとlocaltime関数を渡すことで、任意の時刻文字列に変換できます。localtime関数のリストで返される結果と同じ順で直接指定することでも、任意の変換が行えます。

use POSIX qw(strftime);
my $now_string = strftime "%a %b %e %H:%M:%S %Y", localt
ime;

Time::Local::timelocal_posix ─⁠─UNIX時間に戻すモジュール

同様に、Time::PieceモジュールやDatetimeモジュールは、単に任意の時間をUNIX時間に変換する目的では大げさです。

Time::Local::timelocal_posix(執筆時点のバージョンは1.30)に変換したい時間のリストを渡すことで、UNIX時間に変換できます。$month$yearは、localtime関数と同じ0~11の範囲と1900を引いた値をTime::Local::timelocal_posixに渡します。

use Time::Local qw(timelocal_posix);
my ($sec, $min, $hour, $mday, $month, $year) = (0, 0, 0, 1, 0, 121);
my $epoch = timelocal_posix($sec, $min, $hour, $mday, $month, $year);

HTTP::Date::parse_date ─⁠─ 日付変換ルーチンモジュール

文字列の日付は、HTTPフォーマットやスカラコンテキストのlocaltime関数などさまざまな種類があり、変換が複雑になりがちです。

HTTP::Date::parse_date(執筆時点のバージョンは6.05)に変換したい日付文字列を渡すことで、時間情報をリストに変換できます。また、スカラコンテキストの場合はタイムゾーンの情報を含めた「YYYY-MMDD hh:mm:ss TZ」形式で返されます。

use HTTP::Date qw(parse_date);
my ($year, $month, $day, $hour, $min, $sec, $tz) = parse_date('Fri, 01 Jan 2021 00:00:00 GMT');
my $parsed_time = parse_date('Fri, 01 Jan 2021 00:00:00 GMT');
say $parsed_time;

同類のモジュールとして、変換結果をDateTimeオブジェクトにするDateTime::Format::HTTPモジュールなどもあります。

Time::Piece::MySQL ─⁠─Time::Piece用MySQL日付モジュール

MySQLで時間を扱うのは少々大変です。たとえば、DATETIME型とTIMESTAMP型は、同じフォーマットでもサポートされている時間の範囲が異なります。また、DATE型やDATETIME型、TIMESTAMP型は、無効な値を入力された場合は年月日に00を含むゼロ値に変換されます。

Time::Pieceモジュールの代わりにTime::Piece::MySQLモジュール(執筆時点のバージョンは0.06)useすることで、Time::PieceオブジェクトとしてMySQLの時刻フォーマットの相互変換がメソッドで可能になります。

use Time::Piece::MySQL;
my $now = localtime;
# DATETIME型への変換
say $now->mysql_datetime;
my $mysql_datetime = '2021-01-01 00:00:00';
# DATETIME型からの変換
my $time = Time::Piece->from_mysql_datetime($mysql_datetime);

DateTime::Format::MySQL ─⁠─DateTime用MySQL日付モジュール

DateTimeモジュールでもMySQLの相互変換が行えます。DateTimeモジュールの代わりにDateTime::Format::MySQLモジュール(執筆時点のバージョンは0.06)useすることで利用できます。

use DateTime::Format::MySQL;
# DATETIME型からの変換
my $dt = DateTime::Format::MySQL->parse_datetime('2021-01-01 00:00:00');
# DATETIME型への変換
say DateTime::Format::MySQL->format_datetime($dt);

Test::MockTime ─⁠─ユニットテストでの時間固定モジュール

ユニットテストでプログラムの実行日時を変えたいときがあります。たとえば特定の時間になるとタイムセールが始まるコードがマシンの時間に依存していた場合、テストをするたびにマシンの時間を変えるわけにもいきません。Test::MockTimeモジュール(執筆時点のバージョンは0.17)を使うと、time関数が返すUNIX時間を修正してテストを行えます。

set_fixed_time関数にUNIX時間を渡すと、time関数の戻り値が固定化されます。日時を文字列で渡す場合でも、フォーマットを指定することでその時間に固定化されます。もとの時間に戻す場合にはrestore_time関数を呼び出すだけです。

use Test::MockTime qw(set_fixed_time restore_time);
set_fixed_time(1609426800);
set_fixed_time('2021-01-01 00:00:00', '%Y-%m-%d %H:%M:%S');
restore_time();

逆に、経過時間を記録するなどテストの実行中は時間が動き続けてほしいケースもあります。この場合は、set_absolute_time関数に値を渡すか、set_relative_time関数で現在時間から見た秒数を指定して、過去や未来に時間を移動できます。

use Test::MockTime qw(set_absolute_time set_relative_time restore_time);
set_absolute_time(0);
restore_time();
set_relative_time(-600); # 600秒前に時間を戻す
restore_time();

まとめ

本稿では、時間を取り扱うさまざまなPerlモジュールを見てきました。もしPerlでの時間の取り扱いに悩んでいる場合は、本稿がモジュール選択の指針になれば幸いです。

さて、次回の執筆者は菅井茂樹さんで、テーマは「GitHub APIを使ってみよう」です。お楽しみに。

WEB+DB PRESS

本誌最新号をチェック!
WEB+DB PRESS Vol.130

2022年8月24日発売
B5判/168ページ
定価1,628円
(本体1,480円+税10%)
ISBN978-4-297-13000-8

  • 特集1
    イミュータブルデータモデルで始める
    実践データモデリング

    業務の複雑さをシンプルに表現!
  • 特集2
    いまはじめるFlutter
    iOS/Android両対応アプリを開発してみよう
  • 特集3
    作って学ぶWeb3
    ブロックチェーン、スマートコントラクト、NFT

おすすめ記事

記事・ニュース一覧