COLOPL Tech Blog

コロプラのエンジニアブログです

時間変更で開発を支える colopl_timesfhiter

こんにちは、 Platform Engineer の工藤です。

突然ですが、 PHP でプロダクトを開発していて、テストやデバッグのために現在時刻を変更したくなることってありませんか?

昨今のフレームワークを利用している場合は Carbon::setTestNow メソッドなどで解決するような簡単な話ですが、運用が長期に渡るシステム (レジェンドプロダクト) においては歴史的経緯により PHP 組み込みの date() 関数や MySQL クエリ内で NOW() 関数を利用してしまっている場合があり、後からロジック的な時間変更機能を導入することは非常に困難な場合があります。

今回はそうした課題に対応するために開発した開発補助用 PHP Extension 、 colopl_timeshifter について紹介します。

github.com

※ 開発環境用です、本番環境では絶対に使用しないでください

レジェンドプロダクトと時間変更

最近のフレームワークなどを使っていればそのような機能が最初から提供されているのでそれを使えば良いですが、 10 年前に作成されたレジェンドプロダクトではロジックの中で直接 date() 関数を使っていたりして、なかなかテストしにくい構造になってしまっている場合も多いのではないかと思います。

もちろん地道に直していくのがベストな解決策ではありますが、往々にしてコード量の多いレジェンドプロダクトではなかなか実現が難しい問題があります。

リファクタリングが必要ない低レイヤーでの時間変更手段としては libfaketime を用いて PHP そのものに偽りの現在時刻を指定する方法などが考えられます。

libfaketimeLD_PRELOAD で libc の現在時刻に関わる関数をフックし、 FAKETIME 環境変数で指定された時間に偽るというライブラリです。

github.com

$ TZ=Asia/Tokyo date "+%Y-%m-%d %H:%M:%S"
2024-11-14 16:18:07
$ LD_PRELOAD=./libfaketime.so.1 FAKETIME="1994-10-26 11:22:33" TZ=Asia/Tokyo date "+%Y-%m-%d %H:%M:%S"
1994-10-26 11:22:33

or

$ TZ=Asia/Tokyo date "+%Y-%m-%d %H:%M:%S"
2024-11-14 16:25:33
$ TZ=Asia/Tokyo faketime "1994-10-26 11:22:33" date "+%Y-%m-%d %H:%M:%S"
1994-10-26 11:22:33

しかし libfaketime を用いた時間変更はあくまでも環境変数が渡されたプロセスにしか影響しないため、 php-fpm などのプロセスをフォークする環境では変更を適用するのが困難です (clear_env などの適切な設定が必要) 。

また systemd などを利用している場合にも環境変数を引き渡す際に同様の問題が発生し、時間変更を実現する環境を整えるために考慮すべきことが非常に多くなってしまいます。 faketime コマンドを利用する例もありますが、何にせよ複雑です。

また、当該プロジェクトでは MySQLNOW() 関数を多用しており (やるべきではない) 、 PHP だけを時間変更することができたとしても MySQL 側の時間と食い違うことになってしまう状況で、 MySQLSET @@session.timestamp を使おうにもフレームワークの ORM を利用していたりしていなかったりするため漏れなく対応するのが困難な状況に陥っていました。

現在は開発環境では同一の仮想マシンやコンテナ上で PHP, HTTP サーバー, MySQL を実行し、 OS の時間変更を用いることで対応していましたが、 SaaS への移行などに伴って今後の運用においてこの方法が通用しなくなる可能性が高く、何らかの対応が必要な状況でした。

そこで、 PHP Extension の仕組みを用いて時間変更を行える拡張機能 colopl_timeshifter を作ることになりました。

colopl_timeshifter について

github.com

colopl_timeshifterPHP Extension (PHP 拡張機能) として以下の機能を提供しています。

  • 現在時刻を扱う PHP 組み込み関数に対する時間変更機能
  • PDO MySQL ドライバでのクエリ実行時、透過的に @@session.timestamp で変更された時刻を設定する機能

次のようなコードで時間変更を行うことができます。

<?php

declare(strict_types=1);

use function Colopl\ColoplTimeShifter\{register_hook, unregister_hook};

/* 今より 3 日前に設定: https://www.php.net/manual/ja/dateinterval.construct.php */
register_hook(new \DateInterval('P3D'));

echo date('Y-m-d H:i:s'), \PHP_EOL;

/* 元に戻す */
unregister_hook();

/* 特定の日時に設定 */
register_hook((new DateTime('1994-10-26 11:22:33'))->diff(new DateTime('now')));

echo date('Y-m-d H:i:s'), \PHP_EOL;

/* 元に戻す */
unregister_hook();

また、接続先のデータベースが MySQL (または互換のあるエンジン) であればクエリに対しても現在時刻をモックすることができます。

<?php

declare(strict_types=1);

use function Colopl\ColoplTimeShifter\{register_hook, unregister_hook};

/* 特定の日時に設定 */
register_hook((new DateTime('1994-10-26 11:22:33'))->diff(new DateTime('now')));

echo (new \PDO('mysql:dbname=testing;host=mysql', 'testing', 'testing'))->query('SELECT NOW() AS `now`;')->fetch()[0], \PHP_EOL;

/* 元に戻す */
unregister_hook();

動作原理

colopl_timeshifter は既存の PHP の関数やメソッドをフックする仕組みで実装されており、実際に PHP Extension として読み込まれた時に以下ように行われます。

  1. PHP の関数定義テーブル (HashMap) CG(function_table) から対象となる関数ポインタを検索
  2. 既存の関数ポインタを colopl_timeshifter のグローバル領域に保管
  3. 関数ポインタを colopl_timeshifter 側の関数に置き換え

PHP における主な日付関連処理 (ext-date) は著名な PHP 用デバッガである Xdebug の作者でもある Derick Rethans さんの timelib をベースとしています。

github.com

timelib は非常に高機能で、日の出 (sunrise) の時刻や日没 (sunset) の時刻を取得したり各国の夏時間に対応していますが、中でも特徴的なものとして 非常に口語的な日時のパースをサポートしている というものがあります。

次の日時指定構文は有効なフォーマットです。

<?php

declare(strict_types=1);

/* 今が 2024-11-14 だとすると結果は 2024-11-18 12:30:15 となる */
echo date('Y-m-d H:i:s', (new \DateTime('next monday 15sec 12pm 30min'))->getTimestamp()), \PHP_EOL;

これは場合によっては直感的に利用できて便利な一方、実際にパースが終わるまで指定されたフォーマットが絶対時刻 (現在時刻に依存しない常に一定の時刻を示す文字列) なのか相対時刻 (現在時刻に対する差分で表される文字列) なのかがわからないという問題を抱えていることになります。

実際にtimelib ではフォーマットのパース実装のために re2c レキサジェネレータが利用されており、その複雑性を伺うことができます。

github.com

しかし、現在時刻をモックする colopl_timeshifter にとってこの仕様は非常に厳しいものです。というのも、 渡されたフォーマットが絶対時刻なのか相対時刻なのかを判別することができず、時間変更を適用すべきかしないべきかの判断ができない のです。

timelib の内部実装などを調べてみましたが、やはりパース結果が絶対時刻なのか相対時刻なのかを適切に判別する方法は見当たらず、仕方がないので ごく僅かなウェイトを挟んでパースし、結果が異なっているかどうかで判別する という力技の実装を行うこととなりました。

github.com

このウェイトは INI ディレクティブ colopl_timeshifter.usleep_sec で設定することができます。

PDO のフック

では PDO MySQL への時間変更はどのように行っているのでしょうか?

PDO は PHP の汎用データベースドライバ構造で、 Java の世界での JDBC のようなものです。簡単に言うと様々なデータベースを抽象化し、ある程度共通化した仕組みで利用することができるようにするものです。

PDO は内部実装もある程度抽象化されており、 PHP の組み込み関数同様に関数ポインタを用い、構造体にセットする形で行われています。また、この構造体は PHP Extension に対して公開されており、やろうと思えば PHP の組み込み関数同様処理を乗っ取ることができます。

colopl_timeshifter は次のような順序で PDO の MySQL ドライバに対してフックを行います。

  1. INI 設定 colopl_timeshifter.is_hook_pdo_mysql が有効であるかチェック
  2. PDO クラスのコンストラクタ (__construct) の関数ポインタを取得し colopl_timeshifter のグローバル領域に保管
  3. PDO::__construct メソッドの関数ポインタを colopl_timeshifter の関数ポインタに置き換え
  4. コンストラクタ内で現在の PDO ドライバを取得し、ドライバ名が mysql であれば doerpreparer 関数ポインタ (クエリ実行関数) を乗っ取り
  5. doerpreparer 実行時、クエリの先頭に SET @@session.timestamp = <モックした日時>; を付加

幸いにもコロプラのプロジェクトはすべて MySQL でのクエリ実行に PDO を用いているため、この手法で解決することができました。 ext-mysqli を利用しているものがあった場合にはそちらについても対応する必要があるでしょう。

php-timecop

どうしても必要になったので colopl_timeshifter を作っていたのですが、途中で KLab の hnw さんによるほぼ同様の PHP Extension php-timecop があったことに気付きました...とはいえ近年の PHP では動作しなかったのと、 PDO のフックも必要だったので結果オーライだと思いたい...

github.com

自分が思いつくものの大半をすでにやられている hnw さんはやっぱりすごいなと思いました。勝てない...!

colopl_timeshifter について

colopl_timeshifterオープンソースソフトウェアとしてコロプラGitHub 上で公開しています。 issue や PR も受け付けています (必ず対応するわけではないですが...) ので、興味があればぜひどうぞ

github.com

でも絶対にプロダクション環境では使わないでくださいね!



ColoplTechについて

コロプラでは、勉強会やブログを通じてエンジニアの方々に役立つ技術や取り組みを幅広く発信していきます。
connpassおよびXで情報発信していますので、是非メンバー登録とフォローをよろしくお願いいたします。

colopl.connpass.com

x.com

 

また、コロプラではゲームや基盤開発のバックエンド・インフラエンジニアを積極採用中です!
興味を持っていただいた方はぜひお気軽にご連絡ください。