読者です 読者をやめる 読者になる 読者になる

もなかアイスの試食品

「とりあえずやってみたい」そんな気持ちが先走りすぎて挫折が多い私のメモ書きみたいなものです.

【PHP】DateTimeクラスの月の計算がおかしいので対策した

PHP

PHPのDateTimeクラスで月の足し算をしていると、ちょっとおかしい挙動をする

具体的な挙動だと「3月31日」に1ヶ月足すと「5月1日」になる

予想では以下のような挙動をしている

  1. 「3月31日」に1ヶ月足し、「4月31日」になる。
  2. 「4月31日」は存在しないので、「4月30日 + 1日」になる。
  3. 「4月30日 + 1日」より、結果が「5月1日」になる

※この記事のPHPは諸事情により5.3系を使用

この挙動に対する対策を見つけたが、date関数を使用していた

loumo.jp

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で解決してるといいなー

参考サイト

koyhogetech.hatenablog.com