k-holyのPHPとか諸々メモ

Webで働くk-holyがPHP(スクリプト言語)とか諸々のことをメモしていきます。ソースコードはだいたいWindowsで動かしてます。

マイクロフレームワークをつくろう - Pimpleの上に(システム日付編)

Pimpleを拡張して自分好みに使うために作成した小さなアプリケーションクラスを使って、マイクロフレームワークっぽいものを作る試みです。

コードはWindows版 PHP5.4 ビルトインWebサーバにて動作確認しています。

フォームの方も並行してちょこちょこ触ってますが、今回はWebアプリケーションの作成では必須となる日時処理に取り組みます。

ちょっと前に コード内で「現時刻」を気軽に取得してはいけない - nekoya press という記事を読みました。

以前から、SQLではいわゆるシステム日付を扱うキーワード(Oracleなら"SYSDATE" SQL標準では"CURRENT_TIMESTAMP")を多用していましたが、Webアプリケーションの場合、必要なのはクライアントからのリクエストに対する処理単位の日付であることが多く、そのためにWebサーバの日時とDBサーバの日時を合わせるとか(時刻整合はアプリケーション要件に関係なく運用上の問題としてやっておくべきですが)、なんか違うんじゃないかと感じていました。

折しもPHP5.5で DateTimeInterface が導入されたということで、その対応も視野に入れつつ解決法を実装してみます。

日時を扱う DateTime クラスを作る

前述の記事は要するに、システム日付を扱うことがそもそも要件として妥当なのかという話と、日時の状態を下位層に依存することで上位層でのテストが困難になる問題をどう解決するかという話だと思います。

後者に関しては以前、PHPメンターズにも 時計オブジェクト(ドメインクロック)を導入してテスト容易性と意図性を高める という記事がありました。

DateTimeInterfaceへの適合を考慮しつつ扱いやすいメソッドを追加したDateTimeクラスを作成して、アプリケーションではインスタンス生成時に現在の日時をセットするような実装にしておけば良いでしょうか。

こんな感じにしました。

src/Acme/DateTime.php

<?php
/**
 * Create my own framework on top of the Pimple
 *
 * @copyright 2013 k-holy <k.holy74@gmail.com>
 * @license The MIT License (MIT)
 */

namespace Acme;

/**
 * DateTimeクラス
 *
 * @author k.holy74@gmail.com
 */
class DateTime implements \ArrayAccess
{

    /**
     * @var \DateTime
     */
    private $datetime;

    /**
     * @var string 日時の書式
     */
    private $format;

    /**
     * コンストラクタ
     *
     * @param string|int|\DateTime 日時
     * @param string 日時の書式
     */
    public function __construct($datetime, $format = null)
    {
        if (is_string($datetime)) {
            $datetime = new \DateTime($datetime);
        } elseif (is_int($datetime)) {
            $datetime = new \DateTime(sprintf('@%d', $datetime));
        }
        if (false === ($datetime instanceof \DateTime)) {
            throw new \InvalidArgumentException(
                sprintf('Invalid type:%s', (is_object($datetime))
                    ? get_class($datetime)
                    : gettype($datetime)
                )
            );
        }
        $this->datetime = $datetime;
        $this->format = (isset($format)) ? $format : 'Y-m-d H:i:s';
    }

    /**
     * 日時の書式をセットします。
     *
     * @param string 日時の書式
     * @return $this
     */
    public function setFormat($format)
    {
        $this->format = $format;
        return $this;
    }

    /**
     * タイムゾーンをセットします。
     *
     * @param string|\DateTimeZone タイムゾーン
     * @return $this
     */
    public function setTimeZone($timeZone)
    {
        if (is_string($timeZone)) {
            $timeZone = new \DateTimeZone($timeZone);
        }
        if (false === ($timeZone instanceof \DateTimeZone)) {
            throw new \InvalidArgumentException(
                sprintf('Invalid type:%s', (is_object($timeZone))
                    ? get_class($timeZone)
                    : gettype($timeZone)
                )
            );
        }
        $this->datetime->setTimeZone($timeZone);
        return $this;
    }

    /**
     * 書式化した日付文字列を返します。
     *
     * @return string 書式化した日付文字列
     */
    public function format($format)
    {
        return $this->datetime->format($format);
    }

    /**
     * UTCからのタイムゾーンオフセット秒数を返します。
     *
     * @return int UTCからのタイムゾーンオフセット秒数
     */
    public function getOffset()
    {
        return $this->datetime->getOffset();
    }

    /**
     * Unixタイムスタンプを返します。
     *
     * @return int Unixタイムスタンプ
     */
    public function getTimestamp()
    {
        return $this->datetime->getTimestamp();
    }

    /**
     * タイムゾーンを返します。
     *
     * @return \DateTimeZone タイムゾーン
     */
    public function getTimezone()
    {
        return $this->datetime->getTimezone();
    }

    /**
     * 現在の年を数値で返します。
     *
     * @return int 年 (4桁)
     */
    public function year()
    {
        return (int)$this->format('Y');
    }

    /**
     * 現在の月を数値で返します。
     *
     * @return int 月 (0-59)
     */
    public function month()
    {
        return (int)$this->format('m');
    }

    /**
     * 現在の日を数値で返します。
     *
     * @return int 日 (1-31)
     */
    public function day()
    {
        return (int)$this->format('d');
    }

    /**
     * 現在の時を数値で返します。
     *
     * @return int 時 (0-23)
     */
    public function hour()
    {
        return (int)$this->format('H');
    }

    /**
     * 現在の分を数値で返します。
     *
     * @return int 分 (0-59)
     */
    public function minute()
    {
        return (int)$this->format('i');
    }

    /**
     * 現在の秒を数値で返します。
     *
     * @return int 秒 (0-59)
     */
    public function second()
    {
        return (int)$this->format('s');
    }

    /**
     * UnixTimeを返します。
     *
     * @param int
     */
    public function timestamp()
    {
        return (int)$this->format('U');
    }

    /**
     * 現在の月の日数を数値で返します。
     *
     * @return int 日 (28-31)
     */
    public function lastDay()
    {
        return (int)$this->format('t');
    }

    /**
     * 現在の日時をデフォルトの書式文字列で返します。
     *
     * @return string
     */
    public function __toString()
    {
        return $this->format($this->format);
    }

    /**
     * メソッドへのプロパティアクセッサ
     *
     * @param string プロパティ名
     */
    public function __get($name)
    {
        if (method_exists($this, $name)) {
            return $this->{$name}();
        }
        throw new \BadMethodCallException(
            sprintf('The property "%s" could not defined.', $name)
        );
    }

    /**
     * __set
     *
     * @param string
     * @param mixed
     */
    public function __set($name, $value)
    {
        throw new \BadMethodCallException(
            sprintf('The property "%s" is read only.', $name)
        );
    }

    /**
     * __wakeup
     *
     * @param void
     * @return void
     */
    public function __wakeup()
    {
        $this->initialize(time());
    }

    /**
     * ArrayAccess::offsetExists()
     *
     * @param mixed
     * @return bool
     */
    public function offsetExists($offset)
    {
        return method_exists($this, $offset);
    }

    /**
     * ArrayAccess::offsetGet()
     *
     * @param mixed
     * @return mixed
     */
    public function offsetGet($offset)
    {
        if (method_exists($this, $offset)) {
            return $this->{$offset}();
        }
        throw new \BadMethodCallException(
            sprintf('The offset "%s" could not defined.', $offset)
        );
    }

    /**
     * ArrayAccess::offsetSet()
     *
     * @param mixed
     * @param mixed
     */
    public function offsetSet($offset, $value)
    {
        throw new \BadMethodCallException(
            sprintf('The offset "%s" is read only.', $offset)
        );
    }

    /**
     * ArrayAccess::offsetUnset()
     *
     * @param mixed
     */
    public function offsetUnset($offset)
    {
        throw new \BadMethodCallException(
            sprintf('The offset "%s" is read only.', $offset)
        );
    }

}

簡単にまとめると、DateTimeクラスに年月日時分秒の数値での部分取得用メソッドを追加して、プロパティアクセス/配列アクセス(取得のみ)に対応して、__toString()でデフォルトの書式を使って文字列を返します。

月の日数を返す date('t') は引数を忘れるので lastDay() で取得できるようにしました。

これで、コードから date() 関数を抹殺する準備が整いました。(たぶん)

アプリケーション内のdate()関数を置き換える

現在日時を表す上記 DateTime オブジェクトを、アプリケーション共通初期処理で $app->clock にセットして、これを利用するよう書き換えていきます。

app/app.php

<?php
/**
 * Create my own framework on top of the Pimple
 *
 * アプリケーション共通初期処理
 *
 * @copyright 2013 k-holy <k.holy74@gmail.com>
 * @license The MIT License (MIT)
 */
include_once realpath(__DIR__ . '/../vendor/autoload.php');

use Acme\Application;
use Acme\Configuration;
use Acme\DateTime;

// …中略…

$app = new Application();

//-----------------------------------------------------------------------------
// アプリケーション設定オブジェクトを生成
//-----------------------------------------------------------------------------
$app->config = $app->share(function(Application $app) {
    $config = new Configuration(array(
        'debug'      => true,
        'app_id'     => 'acme',
        'app_root'   => __DIR__,
        'web_root'   => realpath(__DIR__ . '/../www'),
        'log_dir'    => __DIR__ . DIRECTORY_SEPARATOR . 'log',
        'log_file'   => null,
        'error_log'  => null,
        'error_view' => 'error.html',
        'secret_key' => 'CCi:wYD-4:iV:@X%1zun[Y@:',
        'timezone'   => 'Asia/Tokyo',
    ));
    $config['log_file'] = function($config) use ($app) {
        return sprintf('%d-%02d.log', $app->clock->year(), $app->clock->month());
    };
    $config['error_log'] = function($config) {
        return $config['log_dir'] . DIRECTORY_SEPARATOR . $config['log_file'];
    };
    return $config;
});

//-----------------------------------------------------------------------------
// システム時計
//-----------------------------------------------------------------------------
$app->clock = $app->share(function(Application $app) {
    $datetime = new DateTime(new \DateTime(sprintf('@%d', $_SERVER['REQUEST_TIME'])));
    $datetime->setTimeZone($app->config->timezone);
    return $datetime;
});

// …中略…

//-----------------------------------------------------------------------------
// ロガー
//-----------------------------------------------------------------------------
$app->logger = $app->share(function(Application $app) {
    $app->logHandler = function() use ($app) {
        return new StreamHandler(
            $app->config->error_log,
            ($app->config->debug) ? Logger::DEBUG : Logger::NOTICE
        );
    };
    $logger = new Logger($app->config->app_id);
    $logger->pushHandler($app->logHandler);
    return $logger;
});

//-----------------------------------------------------------------------------
// ログ
//-----------------------------------------------------------------------------
$app->log = $app->protect(function($message, $level) use ($app) {
    return $app->logger->addRecord($level ?: Logger::INFO, $message);
});

// …中略…

return $app;

年と月をもとにログファイル名を設定していた部分を置き換えました。

$_SERVER['REQUEST_TIME'] はてっきりWeb以外では利用できないものと思ってたんですが、調べてみたところCLIでも定義されていました。

ログディレクトリおよびログファイル名の設定値がコールバックのため分かりづらいのですが、$app->log()が呼ばれた際の処理順序は以下の通りとなります。

  1. $app->log() が呼ばれた際に $app->logger が評価される
  2. $app->logger が評価された際に $app->config->error_log が評価される
  3. $app->config->error_log が評価された際に $app->config->log_dir および $app->config->log_file が評価される
  4. $app->config->log_file が評価された際に $app->clock が評価される
  5. $app->clock が評価された際に $_SERVER['REQUEST_TIME'] がセットされる

なお $_SERVER['REQUEST_TIME'] と time() の違いについては、インタラクティブシェルで試してみるとよく分かります。

$ php -a
Interactive shell

php > $dateTime = new DateTime('now');
php > var_dump(time(), $_SERVER['REQUEST_TIME'], $dateTime->getTimestamp());
int(1375093431)
int(1375093417)
int(1375093426)
php > var_dump(time(), $_SERVER['REQUEST_TIME'], $dateTime->getTimestamp());
int(1375093435)
int(1375093417)
int(1375093426)
php > var_dump(time(), $_SERVER['REQUEST_TIME'], $dateTime->getTimestamp());
int(1375093436)
int(1375093417)
int(1375093426)
php > exit

この例だと time()は実行のたびにOSのタイムスタンプを取得、 $_SERVER['REQUEST_TIME'] はおそらくシェル起動時の値、DateTime::getTimestamp()はDateTimeのインスタンス生成時の値になっています。

次はテストも兼ねて、Web側共通初期処理でテンプレートのレンダラに、システム時計オブジェクトをアサインします。

www/app.php

<?php
/**
 * Create my own framework on top of the Pimple
 *
 * Web共通初期処理
 *
 * @copyright 2013 k-holy <k.holy74@gmail.com>
 * @license The MIT License (MIT)
 */
$app = include realpath(__DIR__ . '/../app/app.php');

// …中略…

//-----------------------------------------------------------------------------
// アプリケーション初期処理
//-----------------------------------------------------------------------------
$app->addHandler('init', function(Application $app) {
    // 現在時刻
    $app->clock->setFormat('Y/n/j G:i');
    $app->renderer->assign('clock', $app->clock);
    // $_SERVER
    $app->renderer->assign('server', $app->request->server->all());
    // セッション開始
    $app->session->start();
    // フラッシュメッセージ
    $app->renderer->assign('flash', $app->flash);
});

// …中略…

return $app;

デフォルトの書式をゼロパディングなしの 年/月/日 時:分 に設定しました。

トップページのテンプレートで現在日時を表示してみます。

www/index.html

<!DOCTYPE html>
<html lang="ja">

<head metal:use-macro="__layout.html/head">
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="bootstrap/css/bootstrap.min.css" />
<link rel="stylesheet" href="bootstrap/css/bootstrap-responsive.min.css" />
<link rel="stylesheet" href="font-awesome/css/font-awesome.min.css" />
<title>トップページ</title>
</head>

<body metal:use-macro="__layout.html/body">

<div class="container-fluid">

  <header class="header">
    <h1>トップページ@example.com</h1>
  </header>

  <div class="content" metal:fill-slot="content">
    <dl>
      <dt>現在の日時</dt>
      <dd>
        <span tal:replace="clock/year">2013</span><span tal:replace="clock/month">7</span><span tal:replace="clock/day">24</span><span tal:replace="clock/hour">7</span><span tal:replace="clock/minute">50</span><span tal:replace="clock/second">10</span></dd>
      <dt>今月の日数</dt>
      <dd tal:content="clock/lastDay">31</dd>
      <dt>現在の日時 __toString()</dt>
      <dd tal:content="clock"></dd>
    </dl>
  </div>

  <footer class="footer">
    <p>Copyright &copy; 2013 k-holy &lt;k.holy74@gmail.com&gt; Code licensed under <a href="http://opensource.org/licenses/MIT">MIT</a></p>
  </footer>

</div>

</body>
</html>

こんな感じで表示されました。

動的なタイムゾーンの変更もテストしてみます。

アジア地域のタイムゾーンを選択して切り替え表示できるようにします。

www/test.php

<?php
/**
 * Create my own framework on top of the Pimple
 *
 * フレームワーク機能テスト
 *
 * @copyright 2013 k-holy <k.holy74@gmail.com>
 * @license The MIT License (MIT)
 */
$app = include __DIR__ . DIRECTORY_SEPARATOR . 'app.php';

$app->on('GET|POST', function($app, $method) {

    $errors = array();

    $form = array(
        'enable_debug'      => $app->findVar('P', 'enable_debug'),
        'move_log_dir'      => $app->findVar('P', 'move_log_dir'),
        'ignore_error'      => $app->findVar('P', 'ignore_error'),
        'change_secret_key' => $app->findVar('P', 'change_secret_key'),
        'validate_token'    => $app->findVar('P', 'validate_token'),
        'timezone'          => $app->findVar('P', 'timezone'),
        'timezones'         => \DateTimeZone::listIdentifiers(\DateTimeZone::ASIA),
    );

    if ($method === 'POST') {

        // タイムゾーンを動的に切り替える
        if (isset($form['timezone'])) {
            $app->clock->setTimeZone($form['timezone']);
        }

// …中略…

    }

    return $app->render('test.html', array(
        'title'  => 'フレームワーク機能テスト',
        'form'   => $form,
        'errors' => $errors,
    ));
});

$app->run();

テンプレート変数にアサインした時計オブジェクトに対して、フォームから選択したタイムゾーンに変更します。

www/test.html

<!DOCTYPE html>
<html lang="ja">

<head metal:use-macro="__layout.html/head">
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="bootstrap/css/bootstrap.min.css" />
<link rel="stylesheet" href="bootstrap/css/bootstrap-responsive.min.css" />
<link rel="stylesheet" href="font-awesome/css/font-awesome.min.css" />
<title>エラー/例外・セッションテスト</title>
</head>

<body metal:use-macro="__layout.html/body">

<div class="container-fluid">

  <header class="header">
    <h1>エラー/例外・セッションテスト@example.com</h1>
  </header>

  <div class="content" metal:fill-slot="content">

    <form class="form-horizontal" method="post" tal:attributes="action server/REQUEST_URI">

      <fieldset>
        <legend><span class="icon-time"></span>日時のテスト</legend>
        <dl>
          <dt>タイムゾーン</dt>
          <dd tal:content="clock/getTimezone/getName">Asia/Tokyo</dd>
          <dt>現在の日時 __toString()</dt>
          <dd tal:content="clock"></dd>
        </dl>
        <dl class="control-group">
          <dt class="control-label">日時設定</dt>
          <dd class="controls">
            <select name="timezone" class="input-large">
              <option value=""></option>
              <option
 tal:repeat="timezone form/timezones"
 tal:content="timezone"
 tal:attributes="value timezone; selected php:timezone==form['timezone']"
>タイムゾーン名</option>
            </select>
          </dd>
        </dl>
        <div class="form-actions">
          <input type="submit" value="送信" class="btn btn-primary btn-large" />
        </div>
      </fieldset>

      <fieldset>
        <legend><span class="icon-wrench"></span>ログ/エラーのテスト</legend>
        <dl class="control-group">
          <dt class="control-label">デバッグ設定</dt>
          <dd class="controls">
            <label class="checkbox">
              <input type="checkbox" name="enable_debug" value="1" tal:attributes="checked exists:form/enable_debug" />
              有効にする
            </label>
          </dd>
        </dl>
        <dl class="control-group">
          <dt class="control-label">ログディレクトリ設定</dt>
          <dd class="controls">
            <label class="checkbox">
              <input type="checkbox" name="move_log_dir" value="1" tal:attributes="checked exists:form/move_log_dir" />
              ドキュメントルートに変更する
            </label>
          </dd>
        </dl>
        <dl class="control-group">
          <dt class="control-label">エラー設定</dt>
          <dd class="controls">
            <label class="radio">
              <input type="radio" name="ignore_error" value="info" tal:attributes="checked php:form['ignore_error']=='info'" />
              Info以下を無視
            </label>
            <label class="radio">
              <input type="radio" name="ignore_error" value="notice" tal:attributes="checked php:form['ignore_error']=='notice'" />
              Notice以下を無視
            </label>
            <label class="radio">
              <input type="radio" name="ignore_error" value="warning" tal:attributes="checked php:form['ignore_error']=='warning'" />
              Warning以下を無視
            </label>
            <label class="radio">
              <input type="radio" name="ignore_error" value="error" tal:attributes="checked php:form['ignore_error']=='error'" />
              Error以下を無視
            </label>
          </dd>
        </dl>
        <div class="form-actions">
          <p>
            <input type="submit" value="PHPエラー(Info)" class="btn btn-primary btn-warning" name="trigger-info" />
            <input type="submit" value="PHPエラー(Notice)" class="btn btn-primary btn-warning" name="trigger-notice" />
            <input type="submit" value="PHPエラー(Warning)" class="btn btn-primary btn-warning" name="trigger-warning" />
            <input type="submit" value="PHPエラー(Error)" class="btn btn-primary btn-warning" name="trigger-error" />
          </p>
          <p>
            <input type="submit" value="HTTP例外(400)" class="btn btn-primary btn-danger" name="throw-http-exception-400" />
            <input type="submit" value="HTTP例外(403)" class="btn btn-primary btn-danger" name="throw-http-exception-403" />
            <input type="submit" value="HTTP例外(404)" class="btn btn-primary btn-danger" name="throw-http-exception-404" />
            <input type="submit" value="HTTP例外(405)" class="btn btn-primary btn-danger" name="throw-http-exception-405" />
            <input type="submit" value="RuntimeException" class="btn btn-primary btn-danger" name="throw-runtime-exception" />
          </p>
        </div>
      </fieldset>

      <fieldset>
        <legend><span class="icon-repeat"></span>セッション関連のテスト</legend>
        <dl class="control-group">
          <dt class="control-label">CSRFトークン</dt>
          <dd class="controls">
            <label class="checkbox">
              <input type="checkbox" name="validate_token" value="1" tal:attributes="checked exists:form/validate_token" />
              検証を有効にする
            </label>
            <label class="checkbox">
              <input type="checkbox" name="change_secret_key" value="1" tal:attributes="checked exists:form/change_secret_key" />
              秘密のキーを変更する
            </label>
          </dd>
        </dl>
        <dl class="control-group">
          <dt class="control-label">フラッシュメッセージ</dt>
          <dd class="controls">
          <input type="submit" value="Error" class="btn btn-primary btn-danger" name="flash-error" />
          <input type="submit" value="Alert" class="btn btn-primary btn-warning" name="flash-alert" />
          <input type="submit" value="Success" class="btn btn-primary btn-success" name="flash-success" />
          <input type="submit" value="Info" class="btn btn-primary btn-info" name="flash-info" />
          </dd>
        </dl>
        <div class="form-actions">
          <input type="submit" value="送信" class="btn btn-primary btn-large" />
        </div>
      </fieldset>

    </form>

  </div>

  <footer class="footer">
    <p>Copyright &copy; 2013 k-holy &lt;k.holy74@gmail.com&gt; Code licensed under <a href="http://opensource.org/licenses/MIT">MIT</a></p>
  </footer>

</div>

</body>
</html>

ちゃんと反映されました。

次こそはコメント投稿フォームの中身に手を入れようと思います。