【PHP】DateTimeクラスの月の計算がおかしいので対策した
PHPのDateTimeクラスで月の足し算をしていると、ちょっとおかしい挙動をする
具体的な挙動だと「3月31日」に1ヶ月足すと「5月1日」になる
予想では以下のような挙動をしている
- 「3月31日」に1ヶ月足し、「4月31日」になる。
- 「4月31日」は存在しないので、「4月30日 + 1日」になる。
- 「4月30日 + 1日」より、結果が「5月1日」になる
※この記事のPHPは諸事情により5.3系を使用
この挙動に対する対策を見つけたが、date関数を使用していた
date関数は「2036年問題」があるので、そこまでアプリを使わないような気がするが、なるべく避けたい
なのでDateTimeクラスを継承して、対策したクラスを作成した
初版(まだ「バグ」が存在しているクラス)
<?php /** * 標準クラス「DateTime」の月またぎ(1/31の1ヶ月後)の計算がおかしいので、 * 月計算を独自拡張した */ class DateTimeEx extends DateTime { /** * 月を加算 * * @param int $monthNum * @throws InvalidArgumentException */ public function addMonth($monthNum) { if (empty($monthNum) || $monthNum <= 0) { throw new InvalidArgumentException(); } $tmp = clone $this; $tmp->modify("+{$monthNum} month"); $interval = $tmp->diff($this, /*absolute=*/true); // 月の「勝手な繰り上げ」が起きた時、dプロパティに // 繰り上げられた日付が入ってる。(1日~2日) if ($interval->d) { $this->modify("last day of +{$monthNum} month"); } else { $this->modify("+{$monthNum} month"); } } /** * 月を減算 * * @param int $monthNum * @throws InvalidArgumentException */ public function subMonth($monthNum) { if (empty($monthNum) || $monthNum <= 0) { throw new InvalidArgumentException(); } $tmp = clone $this; $tmp->modify("-{$monthNum} month"); $interval = $this->diff($tmp, /*absolute=*/true); // 月の「勝手な繰り上げ」が起きた時、dプロパティに // 繰り上げられた日付が入ってる。(大体30日) if ($interval->d) { $this->modify("last day of -{$monthNum} month"); } else { $this->modify("-{$monthNum} month"); } } }
動作確認
実際にアプリケーションで使ってみたらなんかオカシイ・・・
テストコード(自動ではない)を書いてみた
<?php require_once 'DateTimeEx.php'; $test = array( array('date' => '2016/01/30 00:00:00', 'period' => 1), array('date' => '2016/01/30 01:00:00', 'period' => 1), array('date' => '2016/01/30 02:00:00', 'period' => 1), array('date' => '2016/01/30 07:00:00', 'period' => 1), array('date' => '2016/01/30 08:00:00', 'period' => 1), array('date' => '2016/01/30 08:58:00', 'period' => 1), array('date' => '2016/01/30 08:59:00', 'period' => 1), array('date' => '2016/01/30 08:59:59', 'period' => 1), array('date' => '2016/01/30 09:00:00', 'period' => 1), array('date' => '2016/01/30 09:01:00', 'period' => 1), array('date' => '2016/01/30 09:02:00', 'period' => 1), array('date' => '2016/01/30 10:00:00', 'period' => 1), array('date' => '2016/01/30 11:00:00', 'period' => 1), array('date' => '2016/01/30 12:00:00', 'period' => 1), array('date' => '2016/01/30 13:00:00', 'period' => 1), array('date' => '2016/01/30 23:00:00', 'period' => 1), ); echo '<pre>'; foreach ($test as $value) { $date = new DateTimeEx($value['date']); $date->addMonth($value['period']); echo sprintf('%25s|%25s', $value['date'], $date->format('Y-m-d H:i:s')) . "\n"; echo "----------------------------------------------------\n"; } echo '</pre>';
また、減算は以下のコード
<?php require_once 'DateTimeEx.php'; $test = array( array('date' => '2016/01/31 00:00:00', 'period' => 2), array('date' => '2016/01/31 01:00:00', 'period' => 2), array('date' => '2016/01/31 02:00:00', 'period' => 2), array('date' => '2016/01/31 07:00:00', 'period' => 2), array('date' => '2016/01/31 08:00:00', 'period' => 2), array('date' => '2016/01/31 08:58:00', 'period' => 2), array('date' => '2016/01/31 08:59:00', 'period' => 2), array('date' => '2016/01/31 08:59:59', 'period' => 2), array('date' => '2016/01/31 09:00:00', 'period' => 2), array('date' => '2016/01/31 09:01:00', 'period' => 2), array('date' => '2016/01/31 09:02:00', 'period' => 2), array('date' => '2016/01/31 10:00:00', 'period' => 2), array('date' => '2016/01/31 11:00:00', 'period' => 2), array('date' => '2016/01/31 12:00:00', 'period' => 2), array('date' => '2016/01/31 13:00:00', 'period' => 2), array('date' => '2016/01/31 23:00:00', 'period' => 2), ); echo '<pre>'; foreach ($test as $value) { $date = new DateTimeEx($value['date']); $date->subMonth($value['period']); echo sprintf('%25s|%25s', $value['date'], $date->format('Y-m-d H:i:s')) . "\n"; echo "----------------------------------------------------\n"; } echo '</pre>';
加算結果
2016/01/30 00:00:00| 2016-03-01 00:00:00 ---------------------------------------------------- 2016/01/30 01:00:00| 2016-03-01 01:00:00 ---------------------------------------------------- 2016/01/30 02:00:00| 2016-03-01 02:00:00 ---------------------------------------------------- 2016/01/30 07:00:00| 2016-03-01 07:00:00 ---------------------------------------------------- 2016/01/30 08:00:00| 2016-03-01 08:00:00 ---------------------------------------------------- 2016/01/30 08:58:00| 2016-03-01 08:58:00 ---------------------------------------------------- 2016/01/30 08:59:00| 2016-03-01 08:59:00 ---------------------------------------------------- 2016/01/30 08:59:59| 2016-03-01 08:59:59 ---------------------------------------------------- 2016/01/30 09:00:00| 2016-02-29 09:00:00 ---------------------------------------------------- 2016/01/30 09:01:00| 2016-02-29 09:01:00 ---------------------------------------------------- 2016/01/30 09:02:00| 2016-02-29 09:02:00 ---------------------------------------------------- 2016/01/30 10:00:00| 2016-02-29 10:00:00 ---------------------------------------------------- 2016/01/30 11:00:00| 2016-02-29 11:00:00 ---------------------------------------------------- 2016/01/30 12:00:00| 2016-02-29 12:00:00 ---------------------------------------------------- 2016/01/30 13:00:00| 2016-02-29 13:00:00 ---------------------------------------------------- 2016/01/30 23:00:00| 2016-02-29 23:00:00 ----------------------------------------------------
減算結果
2016/01/31 00:00:00| 2015-12-01 00:00:00 ---------------------------------------------------- 2016/01/31 01:00:00| 2015-12-01 01:00:00 ---------------------------------------------------- 2016/01/31 02:00:00| 2015-12-01 02:00:00 ---------------------------------------------------- 2016/01/31 07:00:00| 2015-12-01 07:00:00 ---------------------------------------------------- 2016/01/31 08:00:00| 2015-12-01 08:00:00 ---------------------------------------------------- 2016/01/31 08:58:00| 2015-12-01 08:58:00 ---------------------------------------------------- 2016/01/31 08:59:00| 2015-12-01 08:59:00 ---------------------------------------------------- 2016/01/31 08:59:59| 2015-12-01 08:59:59 ---------------------------------------------------- 2016/01/31 09:00:00| 2015-11-30 09:00:00 ---------------------------------------------------- 2016/01/31 09:01:00| 2015-11-30 09:01:00 ---------------------------------------------------- 2016/01/31 09:02:00| 2015-11-30 09:02:00 ---------------------------------------------------- 2016/01/31 10:00:00| 2015-11-30 10:00:00 ---------------------------------------------------- 2016/01/31 11:00:00| 2015-11-30 11:00:00 ---------------------------------------------------- 2016/01/31 12:00:00| 2015-11-30 12:00:00 ---------------------------------------------------- 2016/01/31 13:00:00| 2015-11-30 13:00:00 ---------------------------------------------------- 2016/01/31 23:00:00| 2015-11-30 23:00:00 ----------------------------------------------------
9時で挙動が変わる
デバッガでDateTimeのdiffメソッドの戻り値(DateIntervalクラス)を確認してみた
0-8時のdiffメソッドの戻り値(オブジェクト)
y : 0 m : 1 d : 0 h : 0 i : 0 s : 0 invert : 0 days : 31
0-8時以降のdiffメソッドの戻り値(オブジェクト)
y : 0 m : 1 d : 2 h : 0 i : 0 s : 0 invert : 0 days : 31
「勝手な月の繰り上げ」の判定に使用しているdプロパティの格納値が時間によって違う
タイムゾーンの影響っぽいけど、php.iniでは「Asia/Tokyo」設定してるし、デバッガでオブジェクトの中にも「Asia/Tokyo」が設定されてる
解せない(´ε` )
PHPの不具合なんだろうか・・・?(5.3以降は変わっているかも)
仕方ないので改良版を作成
完成版
<?php /** * 標準クラス「DateTime」の月またぎ(1/31の1ヶ月後)の計算がおかしいので、 * 月計算を独自拡張した */ class DateTimeEx extends DateTime { /** * 月を加算 * * @param int $monthNum * @throws InvalidArgumentException */ public function addMonth($monthNum) { if (empty($monthNum) || $monthNum <= 0) { throw new InvalidArgumentException(); } $tmp = clone $this; // 0時~8時とそれ以外の間で、diffメソッドの結果が異なる。(PHPのバグかも) // そのため、0時~8時の間で「勝手な月の繰り上げ」がDateIntervalでは判別できないため、 // 時刻を別変数に保存しておき、「9時」に設定し、diffメソッドを使用する $time = array(); $terget = $this->format('H:i:s'); preg_match('/^(?P<hour>\d+):(?P<minute>\d+):(?P<second>\d+)$/', $terget, $time); $tmp->setTime(9, 0, 0); $this->setTime(9, 0, 0); $tmp->modify("+{$monthNum} month"); $interval = $tmp->diff($this, /*absolute=*/true); // 月の「勝手な繰り上げ」が起きた時、dプロパティに // 繰り上げられた日付が入ってる。(1日~2日) if ($interval->d) { $this->modify("last day of +{$monthNum} month"); } else { $this->modify("+{$monthNum} month"); } // 計算用に設定しておいた時刻を元に戻す。 $this->setTime((int)$time['hour'], (int)$time['minute'], (int)$time['second']); } /** * 月を減算 * * @param int $monthNum * @throws InvalidArgumentException */ public function subMonth($monthNum) { if (empty($monthNum) || $monthNum <= 0) { throw new InvalidArgumentException(); } $tmp = clone $this; // 0時~8時とそれ以外の間で、diffメソッドの結果が異なる。(PHPのバグかも) // そのため、0時~8時の間で「勝手な月の繰り上げ」がDateIntervalでは判別できないため、 // 時刻を別変数に保存しておき、「9時」に設定し、diffメソッドを使用する $time = array(); $terget = $this->format('H:i:s'); preg_match('/^(?P<hour>\d+):(?P<minute>\d+):(?P<second>\d+)$/', $terget, $time); $tmp->setTime(9, 0, 0); $this->setTime(9, 0, 0); $tmp->modify("-{$monthNum} month"); $interval = $this->diff($tmp, /*absolute=*/true); // 月の「勝手な繰り上げ」が起きた時、dプロパティに // 繰り上げられた日付が入ってる。(大体30日) if ($interval->d) { $this->modify("last day of -{$monthNum} month"); } else { $this->modify("-{$monthNum} month"); } // 計算用に設定しておいた時刻を元に戻す $this->setTime((int)$time['hour'], (int)$time['minute'], (int)$time['second']); } }
加算結果
2016/01/30 00:00:00| 2016-02-29 00:00:00 ---------------------------------------------------- 2016/01/30 01:00:00| 2016-02-29 01:00:00 ---------------------------------------------------- 2016/01/30 02:00:00| 2016-02-29 02:00:00 ---------------------------------------------------- 2016/01/30 07:00:00| 2016-02-29 07:00:00 ---------------------------------------------------- 2016/01/30 08:00:00| 2016-02-29 08:00:00 ---------------------------------------------------- 2016/01/30 08:58:00| 2016-02-29 08:58:00 ---------------------------------------------------- 2016/01/30 08:59:00| 2016-02-29 08:59:00 ---------------------------------------------------- 2016/01/30 08:59:59| 2016-02-29 08:59:59 ---------------------------------------------------- 2016/01/30 09:00:00| 2016-02-29 09:00:00 ---------------------------------------------------- 2016/01/30 09:01:00| 2016-02-29 09:01:00 ---------------------------------------------------- 2016/01/30 09:02:00| 2016-02-29 09:02:00 ---------------------------------------------------- 2016/01/30 10:00:00| 2016-02-29 10:00:00 ---------------------------------------------------- 2016/01/30 11:00:00| 2016-02-29 11:00:00 ---------------------------------------------------- 2016/01/30 12:00:00| 2016-02-29 12:00:00 ---------------------------------------------------- 2016/01/30 13:00:00| 2016-02-29 13:00:00 ---------------------------------------------------- 2016/01/30 23:00:00| 2016-02-29 23:00:00 ----------------------------------------------------
減算結果
2016/01/30 00:00:00| 2015-11-30 00:00:00 ---------------------------------------------------- 2016/01/30 01:00:00| 2015-11-30 01:00:00 ---------------------------------------------------- 2016/01/30 02:00:00| 2015-11-30 02:00:00 ---------------------------------------------------- 2016/01/30 07:00:00| 2015-11-30 07:00:00 ---------------------------------------------------- 2016/01/30 08:00:00| 2015-11-30 08:00:00 ---------------------------------------------------- 2016/01/30 08:58:00| 2015-11-30 08:58:00 ---------------------------------------------------- 2016/01/30 08:59:00| 2015-11-30 08:59:00 ---------------------------------------------------- 2016/01/30 08:59:59| 2015-11-30 08:59:59 ---------------------------------------------------- 2016/01/30 09:00:00| 2015-11-30 09:00:00 ---------------------------------------------------- 2016/01/30 09:01:00| 2015-11-30 09:01:00 ---------------------------------------------------- 2016/01/30 09:02:00| 2015-11-30 09:02:00 ---------------------------------------------------- 2016/01/30 10:00:00| 2015-11-30 10:00:00 ---------------------------------------------------- 2016/01/30 11:00:00| 2015-11-30 11:00:00 ---------------------------------------------------- 2016/01/30 12:00:00| 2015-11-30 12:00:00 ---------------------------------------------------- 2016/01/30 13:00:00| 2015-11-30 13:00:00 ---------------------------------------------------- 2016/01/30 23:00:00| 2015-11-30 23:00:00 ----------------------------------------------------
最近のPHPで解決してるといいなー