hnwの日記

php-timecopをPHP 7対応させてみた

自作のPHP拡張であるphp-timecopPHP 7に対応させてみました。この機会に改めてphp-timecopの紹介をしてみます。

php-timecopとは

php-timecopというのは筆者が4年ほど前に作ったPHP拡張で、現在時刻に紐付いた値を返すPHP関数について、基準となる現在時刻を過去や未来の任意の時刻に設定することができるというものです。


以下に簡単な例を紹介します。

<?php
var_dump(date("Y-m-d")); // 今日の日付
timecop_freeze(0);
var_dump(gmdate("Y-m-d H:i:s")); // string(19) "1970-01-01 00:00:00"
var_dump(strtotime("+100000 sec")); // int(100000)


上記プログラム中2行目のtimecop_freeze()関数はphp-timecopにより導入される新たな関数で、現在時刻を指定された時刻に固定するというものです。timecop_freeze()の引数として0を指定するとグリニッジ標準時の1970年1月1日、いわゆるUNIXエポックを指定したことになります。実際、プログラム中3行目4行目の戻り値を見ると、本来なら現在時刻を基準にするはずの関数がUNIXエポック基準で値を返していることがわかります。

詳しい使い方

php-timecop拡張を導入すると、以下の組み込み関数について現在時刻をズラせるような実装に差し替えます。ただし、そのままであれば返す値は元の関数と完全に同じです(変わっているようならバグです)。

  • time()
  • mktime()
  • gmmktime()
  • date()
  • gmdate()
  • idate()
  • getdate()
  • localtime()
  • strtotime()
  • strftime()
  • gmstrftime()
  • microtime()
  • gettimeofday()
  • unixtojd()
  • DateTime::_construct()
  • DateTime::createFromFormat() (PHP >= 5.3.4)
  • date_create()
  • date_create_from_format() (PHP >= 5.3.4)


また、次の3つの関数を使って現在時刻を仮想的にズラせるようになります。

  • bool timecop_freeze(int $timestamp)
  • bool timecop_travel(int $timestamp)
  • bool timecop_return()


上の2つの関数を呼ぶと現在時刻を$timestampで指定された時刻にズラします。timecop_freeze()を呼び出すと時刻はズレたままずっと同じ値を返すようになります。一方、timecop_travel()は呼ばれたタイミングで時刻をズラしたあと時計が通常の速度で動きます。timecop_return()を呼び出すと本来の時刻に戻ります。


timecop_travel()を使う場合、現在の実装では秒の単位しかズラせず、マイクロ秒の単位は元のままである点に注意してください。つまり、timecop_travel(0)を呼び出すと仮想時刻は1970-01-01 00:00:00.000000から1970-01-01 00:00:00.999999のどこかになります。タイミングが悪いと、次の処理を行うまでに1970-01-01 00:00:01になっているかもしれません(いずれ直したいとは考えています)。


php.iniで指定できる設定値は2つあります。

  • timecop.func_override
  • timecop.sync_request_time


前者は組み込み関数の置き換えを行うかどうかの指定で、0を指定すると組み込み関数の差し替えを行いません。デフォルトは1です。1を指定した場合、差し替える前の関数をtimecop_orig_mktime()のように「timecop_orig_」というプレフィックスがついた形で呼び出すことができます。


後者はtimecop_freeze()やtimecop_travel()が呼ばれたタイミングで$_SERVER['REQUEST_TIME']を書き換えるかどうかの指定です。0を指定すると書き換えません。デフォルトは1です。

php-timecopの使いどころ

この拡張は現在時刻が絡んだ自動テストに使うと便利です。PHP標準の日付系関数・クラスを使っている場合、現在時刻によって挙動が変わってしまうので自動テスト対象にするのが難しいのですが、php-timecopを使えば既存コードの変更なしにテストできるようになります。日またぎや年またぎといったテストしづらい状況のテストに利用できるかもしれません。


また、サービスの管理者が動作確認をするような場合にも有用です。たとえば、特定の時間になったら特定の表示を行うような処理について、php-timecopを使えば事前に確認することができます。実際に、筆者の勤務する会社ではこの拡張を使って管理者向けの「仮想カレンダー」機能を実現していました*1

PHPで組み込み関数の挙動を変えるには

ところで、php-timecopはどうやって組み込み関数を差し替えているのでしょうか?RubyPythonなどの言語では組み込み関数をユーザー定義関数で上書きすることができます。一方、PHPではPHPプログラム上から組み込み関数を書き換えることはできません。


実は、組み込み関数を動的に書き換えられないというのはpure PHPレベルでの制約であり、PHP拡張にはそのような制限はありません。PHPの関数は内部的には関数名をキーにした連想配列で管理されています。この連想配列PHP拡張から操作できるので、これを書き換えれば関数の差し替えを実現できるというわけです。


php-timecopでは特定の関数だけを自前実装したCの関数で差し替えていますが、RunkitAOP-PHPを使えば任意の関数・メソッドの挙動変更をPHP関数で記述できます。時刻系関数以外にもテストの悩みがある場合はこれらの拡張を使った方が便利かもしれません。現時点では両者ともPHP 7に未対応ですが、のんびり待っていれば公式対応が来るんじゃないかな?と個人的には思っています。

PHP 5対応のPHP拡張をPHP 7対応に書き換えるコスト

PHP 7に移行したいけれども使っている拡張がPHP 5にしか対応していない、という悩みを持っている方がいらっしゃるかもしれません。PHP拡張のPHP 7への移行はそんなに大変なのでしょうか?


個人的な感覚として、PHP拡張の書き換えのコストは元のコード量に依存します。PHP 5とPHP 7とでは内部的なAPIが大幅に変更されているため、大物の拡張だと移行は大仕事です。PHP 7の方が内部APIが整理されており、バグを作り込みにくくなったのがせめてもの救いでしょう。


一方で、内部構造の変更は予想より少ないと感じました。zval周り・ハッシュ周りは大幅変更されていますが、それ以外の変更量はそれほど大きくありません。慣れてくるとPHP 7への書き換えは単純作業のような気がします。


ちなみに、php-timecopのPHP 7対応では#ifdefなどによる分岐をあきらめ、ソースコードとして別管理としました。差分に興味がある方はリポジトリ上のtimecop_php5.cとtimecop_php7.cとを見比べてみてください。

まとめ

PHP 7対応を機に、php-timecopについて紹介しました。このところ自分では全く触っていなかったのですが、想像以上に使われているようで驚いています。ご意見・ご要望などあればお気軽にどうぞ。

参考URL

*1:新しい案件では賢い時刻クラスが使われていましたが、古い案件に後から導入するのに活躍しました