LoginSignup
31
33

More than 5 years have passed since last update.

「現在時刻」を外部入力とする仕組みを PHP で作っていた頃の思い出

Last updated at Posted at 2016-05-31

クックパッドさんの開発者ブログを読んでいて、前職で似たようなものを PHP で書いていたことを思い出したので、その思い出話を書きます。
(怒られたら消す)

解決したい問題

これについては元記事の「背景」とほぼほぼ一致します。

サービスを開発・運営している我々には、時間帯によって出し分けたり、特定の期間のみに表示したいコンテンツがたくさんあります。 そのたびにデプロイし直すというのはつらいので(特に24:00に出なくなるコンテンツなど)なんとかしたくなりますが、一方で時限式のコンテンツはその時になるまでちゃんと動いているか確証が取れないので怖いです。

このつらさをなんとか軽減できないものかと考えました。

その時担当していたサービスでは日々様々なキャンペーンが始まったり終わったりしていたので、その動作確認にはかなり HP/MP を削られていました。
技術的にはとても簡単だけど、それを土日とか深夜とかにデプロイしたり確認したり、時にはバグがあって急いでなおしたり...

解法

そこで以下のようなものを作りました。
これも Trice に似てるっちゃ似てますが、PHP だと Ruby ほどダイナミックには書けないので、PHP っぽい感じに実装しました。
一言で言うと 時計オブジェクト です。

  • date()new DateTime をそのまま呼び出すのは原則禁止
  • 現在時刻 (DateTime オブジェクト) は ClockInterface を実装した時計オブジェクトから取得する
  • ClockInterface を実装した時計オブジェクトの具象クラスは複数存在する
    • Clock: 常に現在時刻 (つまり new DateTime) を返す。本番環境用。
    • FixedClock: コンストラクタに渡した DateTime オブジェクトを常に返す。ユニットテスト用。
    • InjectableClock: 基本的には現在時刻を返すが、URL 中のクエリパラメータから任意の時刻を差し込むこともできる。ステージング環境用。
    • 時刻として使用可能なフォーマットは strtotime() と同じ (new DateTime の引数にそのまま渡す)
  • Web アプリのコンテキスト (つまり ControllerAction 内) では DI コンテナから時計オブジェクトを取得し、そこから現在時刻を取得する
  • 現在時刻を扱うモジュールにおいては時計オブジェクトか DateTime オブジェクトを差し込む
    • モジュール内で複数回現在時刻を必要とする場合はコンストラクタから時計オブジェクトを渡す
    • そうでなければメソッドの引数として DateTime オブジェクトを使う

擬似実装

ClockInterface と具象クラス

<?php
interface ClockInterface
{
    /**
     * @return DateTime
     */
    function getDateTime();
}

class Clock implements ClockInterface
{
    public function getDateTime()
    {
        return new DateTime;
    }
}

class FixedClock implements ClockInterface
{
    private $datetime;

    public function __construct(DateTime $datetime)
    {
        $this->datetime;
    }

    public function getDateTime()
    {
        return $this->datetime;
    }
}

class InjectableClock implements ClockInterface
{
    private $req;

    public function __construct(HttpRequestInterface $req)
    {
        $this->req = $req;
    }

    public function getDateTime()
    {
        if ($this->req->params->has('__time__')) {
            // パラメータに __time__ というキーの値があればそれを現在時刻とする
            return new DateTime($this->req->params->get('__time__'));
        } else {
            // それ以外はシステムの現在時刻を返す
            return new DateTime;
        }
    }
}

DI コンテナの設定

ここでは、アノテーションや XML 設定ファイルではなく、オブジェクトの生成手順を Closure で記述する Pimple 風の DI コンテナをイメージしてください。

$di->set('clock', function () use ($di) {
    if ($di->get('ENV_NAME') === 'production') {
        return new Clock;
    } else {
        return new InjectableClock($di->get('req'));
    }
});

Controller クラスでの使い方

class HelloController extends BaseController
{
    public function indexAction()
    {
        // DI コンテナから取得した ClockInterface のオブジェクトから現在時刻を取得
        // Controller は DI コンテナから必要なオブジェクトを取得するので、
        // パターン的には Dependency Injection じゃなくて Dependency Lookup
        $now = $this->di->get('clock')->getDateTime();

        // DI コンテナから articles テーブルの DataMapper を取得し、
        // 現在有効な記事を全て取得する
        // 現在時刻は DateTime オブジェクトを差し込む
        $this->view->articles = $this->di->get('data_mapper')->get('Article')->fetchAllActiveArticlesAt($now);

        return $this->renderTemplate('hello/index');
    }
}

現在時刻に依存するモジュール

class UsersWithdrawer
{
    private $clock;

    private $userMapper;

    // 各モジュールは必要なオブジェクトを DI コンテナから差し込まれる
    // DI コンテナのことは知らず、こちらはちゃんと Dependency Injection してる
    public function __construct(ClockInterface $clock, UserMapper $userMapper)
    {
        $this->clock = $clock;
        $this->userMapper = $userMapper;
    }

    public function execute()
    {
        $now = $this->clock->getDateTime();
        $expiredUsers = $this->userMapper->getExpiredUsersAt($now);

        foreach ($expiredUsers as $user) {
            $user->set('withdrawn_at', $this->clock->getDateTime());
            $userMapper->save($user);
        }
    }
}

// そのモジュール用の DI コンテナの設定
$di->set('users_withdrawer', function () use ($di) {
    return new UsersWithdrawer($di->get('clock'), $di->get('data_mapper')->get('User'));
});

気をつけるべきこと

期間の境界条件をあらかじめルール化しておく

例えば何らかのキャンペーンのように、何らかのオブジェクトが開始時刻と終了時刻を持っている、ということはよくあると思います。
例えば 2016 年 6 月 1 日の 10 時から 1 時間のキャンペーンだとして、開始時刻 (starts_at) は 2016-06-01 10:00:00 とするとして、終了時刻 (ends_at はどうするか迷うところです)

image
https://twitter.com/yuya_takeyama/status/674495348006490112

個人的には 11:00:00 として持たせるのが好みです。
なので、実装的には以下のようになります。

class Campaign
{
    public function isActiveAt($now)
    {
        return $this->get('starts_at') <= $now && $now < $this->get('ends_at');
    }
}

starts_at との比較では「小なりイコール」なのに対し、ends_at はただの「大なり」です。
非対称で気持ち悪い、という人もいると思いますが、以下のメリットがあります。

  • 小数点以下の精度を気にしなくて良くなる
    • 言語や DB、またはその中で使われる方によっては小数点以下の時刻を保持できたり、その精度もバラバラだったりします。その場合 10:59:59 という指定だと 10:59:59.50 には時間切れになってしまいます。そんな一瞬の出来事を、と思うかもしれませんが、キャンペーン A が終わると同時にキャンペーン B が始まって欲しい時などはおかしなことになります。 (どちらのキャンペーンも有効でない空白の期間が 1 秒だけできてしまう)
  • CMS 上の UI が楽
    • 例えばプルダウン式の UI を使う場合、「59」を選択しないといけない場合よりも「00」を選択すればいい場合の方が楽でしょう。

ちなみに Ruby ですが shibaraku という gem も同じルールでした。

ところで Trice はその辺をいい感じのヘルパーメソッドで解決していて便利ですね。
(境界条件のルールはこれも同じ)

SQL の NOW() とかも使用禁止

date()new DateTime だけでなく、これも使用禁止にしないと意味がありません。

例えば PDO を通じて現在有効な記事を取得する場合は、以下のようにします。

# 悪い例
$records = $pdo->query(
    'SELECT * FROM articles WHERE starts_at <= NOW() AND NOW() < ends_at'
);

# 良い例
$now = $clock->getDateTime();
$nowStr = $now->format('Y-m-d H:i:s');
$stmt = $pdo->prepare(
    'SELECT * FROM articles WHERE starts_at <= ? AND ? < ends_at'
);
$stmt->bindValue(1, $nowStr, PDO::PARAM_STR);
$stmt->bindValue(2, $nowStr, PDO::PARAM_STR);

NOW() を避けることでクエリキャッシュを効かせられる可能性も出てくるので、その辺も考えてチューニング (秒は切り捨てるとか) してみてもいいかもしれません。
(多分 PDO::ATTR_EMULATE_PREPARES が有効じゃないと効かない & セキュリティ的には必ずしもオススメできないので要注意)

その他に気をつけた方が良かったかもしれないこと

タイムゾーンどうすべきか

グローバル展開する Web サービスだと意識した方がいいでしょう。
まぁ常に UTC 使って出力時とかによろしくやるのが鉄板なんじゃないかと。
日本ローカルのサービスなら JST でいいです。

Trice における requested_at をどう考えるか

Trice では

今回の例で言えば「現在時刻」として欲しかったものは、実は厳密な意味でのコード実行時点の現在、ではなく「リクエストされた時間」で十分です。

とのことで requested_at というメソッドが提供されています。

個人的にもそれはよく分かるし、ClockInterface にそういうメソッドを持たせるか、と考えたこともあった気がしますが、「何も考えずに間違ったメソッドを呼ばれるよりは、毎回気をつけて呼ぶようにしてもらった方がいいのでは」と思って辞めた覚えがありますし、何も考えてなかったかもしれません。

あと Web アプリだけでなくバッチジョブとかのコンテキストで呼ばれる時のことも考えて HTTP に依存しない作りにした記憶もあるし、その割には InjectableClock は HTTP リクエストに依存してるし、まぁはい。

まぁ PHP 的には $_SERVER['REQUEST_TIME'] とか $_SERVER['REQUEST_TIME_FLOAT'] とかでよしなにやればいいと思います。

One more thing: TimedText

時計オブジェクトを導入した時点で、プログラマが実装するアレコレの問題は解決しましたが、それでも「Web ディレクターが土日に管理画面に入って、時間がきたら CMS から文言を書き換える」みたいな雑務が残りました。
例えば何かのキャンペーンの開催中は、ニュース記事中に応募画面へのリンクを出しておいて、期間が終わったら「終了しました」的な感じの文言に差し替える、みたいなやつです。

もちろんニュース側の機能で「キャンペーン中」「終了後」両方の記事を登録して、それぞれを表示期間の設定で出し分ける、ということも考えられますが、毎回 2 記事登録するのは手間ですし、記事の URL が変わってしまう、という問題もあります。

「記事中のこの部分だけ勝手に切り替わってくれればいいんだ!」という要望をもとに作ったのがこちら

これは、以下のような文字列を、時刻によっていい感じに出力するためのものです。

{before 2016-06-01 10:00}
ここは before で囲まれたセクションです。
このセクションは 2016-06-01 10:00 まで表示されます。
{/before}
{between 2016-06-01 10:00 - 2016-06-01 11:00}
ここは between で囲まれたセクションです。
このセクションは 2016-06-01 10:00 から 2016-06-01 11:00 まで表示されます。
{/between}
{after 2016-06-01 11:00}
ここは after で囲まれたセクションです。
このセクションは 2016-06-01 11:00 以降に表示されます。
{/after}
ここはブロック外のセクションです。
このセクションは常に表示されます。

もちろん PHP なり Smarty なり Twig なりで eval することも考えられますが、さすがにセキュリティリスクがデカすぎるので、それしかできない構文をでっち上げました。

ここまでやればあらゆるコンテンツについて任意の時刻の状態を事前にテストできるし、土日にデプロイしたり管理ツールいじったりする必要性もかなり殺せました。
よかったですね。
(まぁそれでも一応当日の本番での動作確認とかはするわけだけど)

実際のソースコードが見たいんだが

TimedText は OSS なので GitHub 見てください。

時計オブジェクトの擬似じゃない実装については @m_norii さんあたりに聞いて面接受かれば見れるんじゃないんでしょうか。

参考

31
33
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
31
33