PHPでMVVMっぽいことをする(View向けデータ加工処理の分離)

サービス基盤事業部のpokehanaiです。

ポケラボでもエンジニアブログを始めることになりました。

最初の話題は軽めにと、PHPのユーティリティクラスの紹介から。

MVVM

2014年のweb界隈ではJavaScriptフレームワークにおけるMVVMが話題になりましたが、PHPではどうでしょう。
MVCなフレームワークを使う中で、どちらかというとViewとControllerの境界があいまいだったりしないでしょうか。

問題

たとえばSmartyテンプレートでこういう判定文があるなら、問題領域にまつわる処理がViewに混じっていることになります。 (電池残量警告をするのは20%未満の時かあるいは10%未満のときか、などの条件定義は通常、問題領域に属します)

{* 電池残量が20%未満なら警告表示 *}
<div>電池残量: 
{if $gadget.battery_level<20}
  <span class="battery low">
{else}
  <span class="battery">
{/if}
    $gadget.battery_level
  </span>
</div>

ならば、と、Controllerなどで次のようにしてしまうと、Viewのためだけの処理がControllerに記述されることになります。

$smarty->assign('is_battery_level_low' => ($gadget.battery_level < 20));

よくあるコードですが、これを続けるとControllerが必要以上に肥大化することに。
また、DRY原則にしばしば違反する一因にもなってきます。(コピペ問題)

ViewとModelの分離

そこで第4の層、Viewにまつわる処理を切り出すレイヤを用意してみましょう。

<?php

namespace pokelabo\framework\core\view\adapter;

use ArrayAccess;

/**
 * @class 配列またはクラスインスタンスにView用の処理を追加(wrap)するためのクラス。
 *
 * Smartyテンプレートで次のように記述した場合:<br/>
 *  `{$item.item_id}`<br/>
 *  - もしこのインスタンスが getItemId() というメソッドを実装していたら、
 *    そのメソッドの返り値がテンプレートに渡される。
 *  - もしwrap対象インスタンスが getItemId() というメソッドを実装していたら、
 *    そのメソッドの返り値がテンプレートに渡される。
 *  - もしwrap対象インスタンスが配列で、item_id というキーで値を格納していたら、
 *    その値がテンプレートに渡される。
 *  - もしwrap対象インスタンスがオブジェクトで、item_id というプロパティを持っていたら、
 *    その値がテンプレートに渡される。
 */
class ViewAdapter implements ArrayAccess 
{
    /** @var array|object wrap対象 */
    protected $_subject;

    /**
     * コンストラクタ
     *
     * @param mixed $subject wrap対象オブジェクト
     */
    public function __construct($subject) 
    {
        $this->_subject = $subject;
    }

    /**
     * キーは存在するか
     *
     * @param string|int $offset 取得用キー
     * @return boolean 取得用キーで値が取得できるならtrue
     */
    public function offsetExists($offset)
    {
        if (is_array($this->_subject)) {
            return array_key_exists($offset, $this->_subject);
        }

        if (isset($this->_subject->$offset)) return true;

        $method = $this->getMethodName('get', $offset);
        if (is_callable(array($this, $method))) return true;
        if (is_callable(array($this->_subject, $method))) return true;

        return false;
    }

    /**
     * 値を取得する
     *
     * @param string|int $offset 取得用キー
     * @return mixed 取得した値
     */
    public function offsetGet($offset) 
    {
        $method = $this->getMethodName('get', $offset);
        if (is_callable(array($this, $method))) {
            return $this->$method();
        }
        if (is_callable(array($this->_subject, $method))) {
            return $this->_subject->$method();
        }

        if (is_array($this->_subject)) {
            if (array_key_exists($offset, $this->_subject)) {
                return $this->_subject[$offset];
            }
        } else if (is_object($this->_subject)) {
            if (isset($this->_subject->$offset)) {
                return $this->_subject->$offset;
            }
        }
    }

    /**
     * 値の設定処理だが、何もしない
     *
     * @param string|int $offset 取得用キー
     * @param mixed $value
     */
    public function offsetSet($offset, $value)
    {
        // nop
    }

    /**
     * 値の削除処理だが、何もしない
     *
     * @param string|int $offset 取得用キー
     */
    public function offsetUnset($offset)
    {
        // nop
    }

    /**
     * メソッド名を生成する。
     *
     * @param string $prefex 接頭辞
     * @param string|int $offset 取得用キー
     * @return メソッド名
     */
    protected function getMethodName($prefix, $offset)
    {
        $ar = explode('_', $offset);
        return $prefix . implode('', array_map('ucfirst', $ar));
    }
}

このクラスを継承して、Gadget向けクラスを定義します。

namespace app\view\adapter;

use pokelabo\framework\core\view\adapter;
use app\config\BatteryConfig;

class GadgetViewAdapter extends ViewAdapter
{
    protected function getIsBatteryLevelLow()
    {
        return ($this->_subject['battery_level'] < BatteryConfig::get('warn_less_than');
    }
}

このようにしておくと、Smartyテンプレートでこのようにかけるようになります。

{* 電池残量が少ない場合には警告表示 *}
<div>電池残量: 
{if $gadget.is_battery_level_low}   {* 詳細な判定は不要に *}
  <span class="battery low">
{else}
  <span class="battery">
{/if}
    $gadget.battery_level
  </span>
</div>

ちょっとしたコードですが、この例でも

  • 問題領域に属する処理の隠蔽
  • マジックナンバーの定義化
  • マジックナンバー定義方法の隠蔽
    (例ではdefine()などではなく設定クラス化していますが、Viewはどの方法を使っているか知らない)

をしています。 (このようにPHP側でコードを書けるため、Viewよりしばしば自由が利きます。)

さらに良い点は、View側の要求があって初めてコードが実行されることです。
ViewのためにControllerが逐一値を用意してあげる、という場面が少なくなります。

こうしたことからController、View側ともにコードが簡潔になります。
なかなかに使い出があるのではないでしょうか。

終わりに

光あるところ影あり。メリットにはデメリットがつきものです。

今回の方法は、パフォーマンスに影響します。
主にPHP内部のArrayAccessの実装に起因するのですが、けっこう遅いです。
遅いけど便利だからとたくさん使いすぎて、ページ表示時間が問題視されたため、それまでこの方法書いたコードをすべて投げ捨ててしまったアプリがあるほどです。

そのような黒歴史もありましたが、近年FacebookからHHVMという高速環境が世に送り出されました。
はじめからこの上で動作できていたら、オーバーヘッドも気にならなかったかもしれません。
既存PHPコードの移植もさほど難しくないことを最近経験したこともあり、今回はこのトピックを紹介してみました。

ハードウェアにおけるムーアの法則が終わっても、なおたくさんの知恵によって問題が問題でなくなっていく。
エキサイティングな時代ですね。