k-holyのPHPとか諸々メモ

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

マイクロフレームワークをつくろう - Pimpleの上に(PHPTAL導入編)

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

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

Step 7 PHPTALを導入

Rendererクラス作成編にて、素のPHPをスクリプトから分離した際にRendererクラスを作成しましたが、この仕様を元にPHPTALに対応してみます。

なんでPHPTALなのかというと、PHPTALなら昔Silex用に書いたサービスプロバイダのコードが流用できるからです。

まずはRendererクラスの名前空間Acme\Renderer に変更して、PHPファイルのテンプレート用に作成した旧Rendererクラスが持つassign(), render(), fetch() の各メソッドをインタフェース化します。

src/Acme/Renderer/RendererInterface.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\Renderer;

/**
 * レンダラインタフェース
 *
 * @author k.holy74@gmail.com
 */
interface RendererInterface
{

    /**
     * 出力データに値を追加します。
     *
     * @param string 名前
     * @param mixed 値
     */
    public function assign($name, $value);

    /**
     * 指定パスのテンプレートを読み込んで配列をローカルスコープの変数に展開し、結果を出力します。
     *
     * @param string テンプレートファイルのパス
     * @param array テンプレートに展開する変数の配列
     */
    public function render($view, array $data);

    /**
     * 指定パスのテンプレートを読み込んで配列をローカルスコープの変数に展開します。
     *
     * @param string テンプレートファイルのパス
     * @param array テンプレートに展開する変数の配列
     * @return string
     */
    public function fetch($view, array $data);

}

旧RendererクラスはPhpRendererに名前を変更、新たにPhpTalRendererクラスを作成して、どちらもこのRendererInterfaceを実装することとします。

assign()メソッドとrender()メソッドはPhpRendererとPhpTalRendererのどちらも同じ実装で良さそうなので、Traitにしました。

(PHP5.4系を使い出してからは、Abstractクラスは使わずInterface + Traitをよく使ってます。オブジェクト指向的にどうなのか分かりませんが、その方が楽なので…)

src/Acme/Renderer/RendererTrait.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\Renderer;

/**
 * レンダラ用Trait
 *
 * @author k.holy74@gmail.com
 */
trait RendererTrait
{

    /**
     * 出力データに値を追加します。
     *
     * @param string 名前
     * @param mixed 値
     */
    public function assign($name, $value)
    {
        $this->data[$name] = $value;
    }

    /**
     * 指定パスのテンプレートを読み込んで配列を展開し、結果を出力します。
     *
     * @param string テンプレートファイルのパス
     * @param array テンプレートに展開する変数の配列
     */
    public function render($view, array $data)
    {
        echo $this->fetch($view, $data);
    }

}

PhpRendererは名前と配置先を変更してRendererInterfaceを実装し、RendererTraitを利用しただけです。

src/Acme/Renderer/PhpRenderer.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\Renderer;

/**
 * PHPレンダラ
 *
 * @author k.holy74@gmail.com
 */
class PhpRenderer implements RendererInterface
{

    use RendererTrait;

    /**
     * @var array 設定値
     */
    private $config;

    /**
     * @var array 出力データ
     */
    private $data;

    /**
     * コンストラクタ
     *
     * @param array | ArrayAccess 設定オプション
     */
    public function __construct($configurations = array())
    {
        $this->initialize($configurations);
    }

    /**
     * オブジェクトを初期化します。
     *
     * @param array | ArrayAccess 設定オプション
     */
    public function initialize($configurations = array())
    {
        $this->data = array();
        $this->config = array(
            'template_dir' => null,
        );
        if (!empty($configurations)) {
            foreach ($configurations as $name => $value) {
                $this->config($name, $value);
            }
        }
        return $this;
    }

    /**
     * 引数1の場合は指定された設定の値を返します。
     * 引数2の場合は指定された設置の値をセットして$thisを返します。
     *
     * @param string 設定名
     * @return mixed 設定値 または $this
     */
    public function config($name)
    {
        switch (func_num_args()) {
        case 1:
            return $this->config[$name];
        case 2:
            $value = func_get_arg(1);
            if (isset($value)) {
                switch ($name) {
                case 'template_dir':
                    if (!is_string($value)) {
                        throw new \InvalidArgumentException(
                            sprintf('The config parameter "%s" only accepts string.', $name));
                    }
                    break;
                default:
                    throw new \InvalidArgumentException(
                        sprintf('The config parameter "%s" is not defined.', $name)
                    );
                }
                $this->config[$name] = $value;
            }
            return $this;
        }
        throw new \InvalidArgumentException('Invalid argument count.');
    }

    /**
     * 指定パスのテンプレートを読み込んで配列をローカルスコープの変数に展開します。
     *
     * @param string テンプレートファイルのパス
     * @param array テンプレートに展開する変数の配列
     * @return string
     */
    public function fetch($view, array $data)
    {
        $dir = $this->config('template_dir');
        if (isset($dir)) {
            $dir = rtrim($dir, '/');
        }
        $template = (isset($dir)) ? $dir . DIRECTORY_SEPARATOR . $view : $view;
        if ('\\' === DIRECTORY_SEPARATOR) {
            $template = str_replace('\\', '/', $template);
        }
        if (false !== realpath($template)) {
            ob_start();
            $data = array_merge($this->data, $data);
            extract($data);
            include $template;
            $contents = ob_get_contents();
            ob_end_clean();
            return $contents;
        }
    }

}

PhpTalRendererも同様にRendererInterfaceを実装し、RendererTraitを利用しますが、fetch()メソッドではPHPTALを使ってテンプレート出力します。

src/Acme/Renderer/PhpTalRenderer.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\Renderer;

/**
 * PHPTALレンダラ
 *
 * @author k.holy74@gmail.com
 */
class PhpTalRenderer implements RendererInterface
{

    use RendererTrait;

    /**
     * @var array 設定値
     */
    private $config;

    /**
     * @var array 出力データ
     */
    private $data;

    /**
     * @var \PHPTAL
     */
    public $phptal;

    /**
     * @var array PHPTAL用オプション設定
     */
    private static $phptal_options = array(
        'outputMode',
        'encoding',
        'templateRepository',
        'phpCodeDestination',
        'phpCodeExtension',
        'cacheLifetime',
        'forceReparse',
    );

    /**
     * コンストラクタ
     *
     * @param array | ArrayAccess 設定オプション
     */
    public function __construct($configurations = array())
    {
        $this->initialize($configurations);
    }

    /**
     * オブジェクトを初期化します。
     *
     * @param array | ArrayAccess 設定オプション
     */
    public function initialize($configurations = array())
    {
        $this->data = array();
        $this->config = array_fill_keys(static::$phptal_options, null);
        if (!empty($configurations)) {
            foreach ($configurations as $name => $value) {
                $this->config($name, $value);
            }
        }
        $this->phptal = new \PHPTAL();
        return $this;
    }

    /**
     * 引数1の場合は指定された設定の値を返します。
     * 引数2の場合は指定された設置の値をセットして$thisを返します。
     *
     * @param string 設定名
     * @return mixed 設定値 または $this
     */
    public function config($name)
    {
        switch (func_num_args()) {
        case 1:
            return $this->config[$name];
        case 2:
            $value = func_get_arg(1);
            if (isset($value)) {
                switch ($name) {
                case 'templateRepository':
                case 'phpCodeDestination':
                    if (!is_string($value) && !is_array($value)) {
                        throw new \InvalidArgumentException(
                            sprintf('The config parameter "%s" only accepts string.', $name));
                    }
                    break;
                case 'encoding':
                case 'phpCodeExtension':
                    if (!is_string($value)) {
                        throw new \InvalidArgumentException(
                            sprintf('The config parameter "%s" only accepts string.', $name));
                    }
                    break;
                case 'outputMode':
                case 'cacheLifetime':
                    if (!is_int($value) && !ctype_digit($value)) {
                        throw new \InvalidArgumentException(
                            sprintf('The config parameter "%s" only accepts bool.', $name));
                    }
                    $value = (int)$value;
                    break;
                case 'forceReparse':
                    if (!is_bool($value) && !is_int($value) && !ctype_digit($value)) {
                        throw new \InvalidArgumentException(
                            sprintf('The config parameter "%s" only accepts bool.', $name));
                    }
                    $value = (bool)$value;
                    break;
                default:
                    throw new \InvalidArgumentException(
                        sprintf('The config parameter "%s" is not defined.', $name)
                    );
                }
                $this->config[$name] = $value;
            }
            return $this;
        }
        throw new \InvalidArgumentException('Invalid argument count.');
    }

    /**
     * 指定パスのテンプレートを読み込んで配列をローカルスコープの変数に展開します。
     *
     * @param string テンプレートファイルのパス
     * @param array テンプレートに展開する変数の配列
     * @return string
     */
    public function fetch($view, array $data)
    {
        foreach ($this->config as $name => $value) {
            if (isset($value) && in_array($name, static::$phptal_options)) {
                $method = 'set' . ucfirst($name);
                if (!method_exists($this->phptal, $method)) {
                    throw new \InvalidArgumentException(
                        sprintf('The accessor method to "%s" is not defined.', $name));
                }
                switch ($name) {
                case 'phpCodeDestination':
                case 'templateRepository':
                    if ('\\' === DIRECTORY_SEPARATOR) {
                        $value = (is_array($value))
                            ? array_map(function($val) {
                                return str_replace('\\', '/', $val);
                            }, $value)
                            : str_replace('\\', '/', $value);
                    }
                    break;
                }
                $this->phptal->{$method}($value);
            }
        }
        if (strpos($view, '/') === 0) {
            $view = substr($view, 1);
        }
        $data = array_merge($this->data, $data);
        foreach ($data as $name => $value) {
            $this->phptal->set($name, $value);
        }
        return $this->phptal->setTemplate($view)->execute();
    }

}

DI的にはクラス内で依存オブジェクトを生成してはいけないと言われてることを思い起こして、少し考え込んでしまいましたが…。

アプリケーションオブジェクト設定コードの $app->render() はSilexにおけるServiceProviderに相当するもので、それが自分には回りくどく感じたから、PHPTALのオブジェクトを楽に生成して共通のインタフェースで利用するためにこのクラスを作ったわけで。

とりあず自分が使いやすければいいかなと…アプリケーションオブジェクトの利用側スクリプトに影響がないようにしておけば、何かまずくなっても後で変えることができるので。

そんな訳で、アプリケーションオブジェクトの生成・設定コードはこうなりました。

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\Renderer\PhpTalRenderer;

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\RedirectResponse;

$app = new Application();

// レンダラオブジェクトを生成、グローバルなテンプレート変数をセット
$app->renderer = $app->share(function(Application $app) {
    $renderer = new PhpTalRenderer(array(
        'outputMode'         => \PHPTAL::XHTML,
        'encoding'           => 'UTF-8',
        'templateRepository' => realpath(__DIR__ . '/../www'),
        'phpCodeDestination' => sys_get_temp_dir(),
        'forceReparse'       => true,
    ));
    // $app
    $renderer->assign('app', $app);
    // $_SERVER
    $renderer->assign('server', $app->request->server->all());
    return $renderer;
});

// レンダラオブジェクトからのテンプレート出力でレスポンスを生成
$app->render = $app->protect(function($view, array $data = array(), $statusCode = 200, $headers = array()) use ($app) {
    return new Response($app->renderer->fetch($view, $data), $statusCode, $headers);
});

// リダイレクトレスポンスを生成
$app->redirect = $app->protect(function($url, $statusCode = 303, $headers = array()) use ($app) {
    return new RedirectResponse(
        (false === strpos($url, '://'))
            ? $app->request->getSchemeAndHttpHost() . $url
            : $url,
        $statusCode, $headers
    );
});

// リクエストオブジェクトを生成
$app->request = $app->share(function(Application $app) {
    return Request::createFromGlobals();
});

// リクエスト変数を取得する
$app->findVar = $app->protect(function($key, $name, $default = null) use ($app) {
    $value = null;
    switch ($key) {
    // $_GET
    case 'G':
        $value = $app->request->query->get($name);
        break;
    // $_POST
    case 'P':
        $value = $app->request->request->get($name);
        break;
    // $_COOKIE
    case 'C':
        $value = $app->request->cookies->get($name);
        break;
    // $_SERVER
    case 'S':
        $value = $app->request->server->get($name);
        break;
    }
    if (isset($value)) {
        $value = $app->normalize($value);
    }
    if (!isset($value) ||
        (is_string($value) && strlen($value) === 0) ||
        (is_array($value) && count($value) === 0)
    ) {
        $value = $default;
    }
    return $value;
});

// リクエスト変数の正規化
$app->normalize = $app->protect(function($value) use ($app) {
    $filters = array(
        // HT,LF,CR,SP以外の制御コード(00-08,11,12,14-31,127,128-159)を除去
        // ※参考 http://en.wikipedia.org/wiki/C0_and_C1_control_codes
        function($val) {
            return preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]|\xC2[\x80-\x9F]/S', '', $val);
        },
        // 改行コードを統一
        function($val) {
            return str_replace("\r", "\n", str_replace("\r\n", "\n", $val));
        },
    );
    foreach ($filters as $filter) {
        $value = $app->map($filter, $value);
    }
    return $value;
});

// HTMLエスケープ
$app->escape = $app->protect(function($value, $default = '') use ($app) {
    return $app->map(function($value) use ($default) {
        $value = (string)$value;
        if (strlen($value) > 0) {
            return htmlspecialchars($value, ENT_QUOTES);
        }
        return $default;
    }, $value);
});

// 全ての要素に再帰処理
$app->map = $app->protect(function($filter, $value) use ($app) {
    if (is_array($value) || $value instanceof \Traversable) {
        $results = array();
        foreach ($value as $val) {
            $results[] = $app->map($filter, $val);
        }
        return $results;
    }
    return $filter($value);
});

// アプリケーションへのリクエストハンドラ登録
$app->on = $app->protect(function($allowableMethod, $function) use ($app) {
    $allowableMethods = explode('|', $allowableMethod);
    $handler = $app->protect(function(Application $app, $method) use ($function) {
        return $function($app, $method);
    });
    if (in_array('GET', $allowableMethods)) {
        $app->onGet = $handler;
    }
    if (in_array('POST', $allowableMethods)) {
        $app->onPost = $handler;
    }
    if (in_array('PUT', $allowableMethods)) {
        $app->onPut = $handler;
    }
    if (in_array('DELETE', $allowableMethods)) {
        $app->onDelete = $handler;
    }
});

// アプリケーション実行
$app->run = $app->protect(function() use ($app) {
    $method = $app->request->getMethod();
    $handlerName = 'on' . ucfirst(strtolower($method));
    if (!$app->offsetExists($handlerName)) {
        $response = new Response('Method Not Allowed', 405);
    } else {
        try {
            $response = $app->{$handlerName}($app, $method);
        } catch (\Exception $e) {
            $response = new Response('Internal Server Error', 500);
        }
    }
    $response->send();
});

return $app;

$app->renderer の中身が少し変わっただけです。

せっかくPHPTALを使っているので、この際テンプレートファイルの設置先をドキュメントルートに設定、テンプレートを手早くブラウザで表示確認できるようにして、テンプレートキャッシュはPHPのテンポラリディレクトリをそのまま使ってもらいます。

利用側スクリプトは変化なし。

www/index.php

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

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

    $errors = array();

    $form = array(
        'name'    => $app->findVar('P', 'name'),
        'comment' => $app->findVar('P', 'comment'),
    );

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

        if (strlen($form['name']) === 0) {
            $errors['name'] = '名前を入力してください。';
        } elseif (mb_strlen($form['name']) > 20) {
            $errors['name'] = '名前は20文字以内で入力してください。';
        }

        if (strlen($form['comment']) === 0) {
            $errors['comment'] = 'コメントを入力してください。';
        } elseif (mb_strlen($form['comment']) > 50) {
            $errors['comment'] = 'コメントは50文字以内で入力してください。';
        }

        if (empty($errors)) {
            // $app->addFlashMessage('success', '投稿を受け付けました');
            return $app->redirect('/');
        }

    }

    return $app->render('index.html', array(
        'form'   => $form,
        'errors' => $errors,
    ));
});

$app->run();

テンプレートファイルはPHPTAL用に以下のように書き換えて、ドキュメントルート以下に設置しました。

www/index.html

<html>
<body>
<h1>俺のフレームワーク@<span tal:replace="server/HTTP_HOST">example.com</span></h1>

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

<ul tal:condition="php:count(errors) > 0">
<li tal:repeat="error errors" tal:content="error">名前を入力してください。</li>
</ul>

<dl>
<dt>名前</dt>
<dd>
<input type="text" name="name" class="input-xlarge" value="名前..." tal:attributes="value form/name" />
</dd>
<dt>コメント</dt>
<dd>
<textarea name="comment" tal:content="form/comment">コメント内容...</textarea>
</dd>
</dl>

<input type="submit" value="送信" />
</form>

<ul>
<li><a href="/">アプリケーション</a></li>
<li><a href="/index.html">テンプレート</a></li>
</ul>

</body>
</html>

出力結果のHTMLはほとんど変わりませんが、テンプレートファイルを直接表示するためのリンクを追加してます。

分業するわけでもないのにValidにこだわる意味はあるのかって気もしますが、そろそろこのダサダサな外見をBootstrapで飾ろうかと考えてますので、HTMLで確認しながらテンプレートを作れるという多少のメリットはあるかなーと。

マイクロフレームワークをつくろう - Pimpleの上に(RESTインタフェース編)

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

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

Step 6 RESTインタフェースの実装

今度はマイクロフレームワークの定番、RESTインタフェースをアプリケーションオブジェクトに実装します。

この場合は、利用側コードをどう書きたいかを先に考えた方が良いでしょう。

こういう完成形をイメージしました。

www/index.php

<?php
$app = include realpath(__DIR__ . '/../app/app.php');

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

    // エラーメッセージの配列
    $errors = array();

    $form = array(
        'name'    => $app->findVar('P', 'name'),
        'comment' => $app->findVar('P', 'comment'),
    );

    // POSTメソッドの時だけ…
    if ($method === 'POST') {

        // 入力値のバリデーションとか…
        if (empty($errors)) {

            // リダイレクト先で表示する完了メッセージをセッションにセットしたり…
            return $app->redirect('/');
        }

    }

    return $app->render('index.html', array(
        'form'   => $form,
        'errors' => $errors,
    ));
});

$app->run();

RESTじゃねえ!と怒られそうですが、リクエストをすべてAjaxで実装しない限り、POSTでもGETと同様にフォームのHTMLを返すのが普通ですし(バリデーションエラーとかあるじゃないですか)、この方が都合がいいわけで…。

これまでSilexアプリケーションを作っていて、結局ほとんどの画面で $app->post() を使わず $app->get() あるいは $app->match()->method('GET|POST') を使ったという実情もありますし、ここは自分の使いやすさを優先します。

$app->run() 実行時にリクエストメソッドによって $app->on() で定義されたコールバックが実行される流れで、$app->run() ではコールバックの $app->render() や $app->redirect() が返すResponseオブジェクトを受け取ってクライアントに送信、コールバックから例外がスローされたらそれをキャッチして、エラー用のレスポンスをクライアントに送信、といった感じになるでしょうか。

アプリケーションオブジェクトに登録すべき関数の内容が大体見えたところで、実装に入ります。

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\Renderer;

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\RedirectResponse;

$app = new Application();

// レンダラオブジェクトを生成、グローバルなテンプレート変数をセット
$app->renderer = $app->share(function(Application $app) {
    $renderer = new Renderer(array(
        'template_dir' => __DIR__ . DIRECTORY_SEPARATOR . 'templates',
    ));
    // $app
    $renderer->assign('app', $app);
    // $_SERVER
    $renderer->assign('server', $app->request->server->all());
    return $renderer;
});

// レンダラオブジェクトからのテンプレート出力でレスポンスを生成
$app->render = $app->protect(function($view, array $data = array(), $statusCode = 200, $headers = array()) use ($app) {
    return new Response($app->renderer->fetch($view, $data), $statusCode, $headers);
});

// リダイレクトレスポンスを生成
$app->redirect = $app->protect(function($url, $statusCode = 303, $headers = array()) use ($app) {
    return new RedirectResponse(
        (false === strpos($url, '://'))
            ? $app->request->getSchemeAndHttpHost() . $url
            : $url,
        $statusCode, $headers
    );
});

// リクエストオブジェクトを生成
$app->request = $app->share(function(Application $app) {
    return Request::createFromGlobals();
});

// リクエスト変数を取得する
$app->findVar = $app->protect(function($key, $name, $default = null) use ($app) {
    $value = null;
    switch ($key) {
    // $_GET
    case 'G':
        $value = $app->request->query->get($name);
        break;
    // $_POST
    case 'P':
        $value = $app->request->request->get($name);
        break;
    // $_COOKIE
    case 'C':
        $value = $app->request->cookies->get($name);
        break;
    // $_SERVER
    case 'S':
        $value = $app->request->server->get($name);
        break;
    }
    if (isset($value)) {
        $value = $app->normalize($value);
    }
    if (!isset($value) ||
        (is_string($value) && strlen($value) === 0) ||
        (is_array($value) && count($value) === 0)
    ) {
        $value = $default;
    }
    return $value;
});

// リクエスト変数の正規化
$app->normalize = $app->protect(function($value) use ($app) {
    $filters = array(
        // HT,LF,CR,SP以外の制御コード(00-08,11,12,14-31,127,128-159)を除去
        // ※参考 http://en.wikipedia.org/wiki/C0_and_C1_control_codes
        function($val) {
            return preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]|\xC2[\x80-\x9F]/S', '', $val);
        },
        // 改行コードを統一
        function($val) {
            return str_replace("\r", "\n", str_replace("\r\n", "\n", $val));
        },
    );
    foreach ($filters as $filter) {
        $value = $app->map($filter, $value);
    }
    return $value;
});

// HTMLエスケープ
$app->escape = $app->protect(function($value, $default = '') use ($app) {
    return $app->map(function($value) use ($default) {
        $value = (string)$value;
        if (strlen($value) > 0) {
            return htmlspecialchars($value, ENT_QUOTES);
        }
        return $default;
    }, $value);
});

// 全ての要素に再帰処理
$app->map = $app->protect(function($filter, $value) use ($app) {
    if (is_array($value) || $value instanceof \Traversable) {
        $results = array();
        foreach ($value as $val) {
            $results[] = $app->map($filter, $val);
        }
        return $results;
    }
    return $filter($value);
});

// アプリケーションへのリクエストハンドラ登録
$app->on = $app->protect(function($allowableMethod, $function) use ($app) {
    $allowableMethods = explode('|', $allowableMethod);
    $handler = $app->protect(function(Application $app, $method) use ($function) {
        return $function($app, $method);
    });
    if (in_array('GET', $allowableMethods)) {
        $app->onGet = $handler;
    }
    if (in_array('POST', $allowableMethods)) {
        $app->onPost = $handler;
    }
    if (in_array('PUT', $allowableMethods)) {
        $app->onPut = $handler;
    }
    if (in_array('DELETE', $allowableMethods)) {
        $app->onDelete = $handler;
    }
});

// アプリケーション実行
$app->run = $app->protect(function() use ($app) {
    $method = $app->request->getMethod();
    $handlerName = 'on' . ucfirst(strtolower($method));
    if (!$app->offsetExists($handlerName)) {
        $response = new Response('Method Not Allowed', 405);
    } else {
        try {
            $response = $app->{$handlerName}($app, $method);
        } catch (\Exception $e) {
            $response = new Response('Internal Server Error', 500);
        }
    }
    $response->send();
});

return $app;

前述の方針に合わせて、$app->render() でレスポンスを送信していたのをやめて、Responseオブジェクトを返すようにしました。

新たに登録した $app->redirect() は同様に、Symfony HttpFoundationのRedirectResponseオブジェクトを返しています。

(HttpFoundationにはデータをJSON形式で返せるJsonResponseや、コールバックでファイル等の内容を読みながら返せるStreamedResponseが用意されているので、Silexと同様 $app->json() や $app->stream() も似たような感じで実装できそうです)

$app->on() の内容がちょっと分かりづらいかもしれませんが、引数で指定されたHTTPメソッドに対応するコールバックを関数内で生成して、そのコールバックを on{HTTPメソッド} という関数としてアプリケーションに登録しています。

そして $app->run() でHTTPメソッドに合致するコールバックが検出されればそれを実行し、検出されなければステータス405のレスポンスを返します。

また、コールバック内で例外が発生した場合は、ステータス500のレスポンスを返します。

(ここは他にも 400,401,403,404といった典型的なエラーステータスを返すこともあるでしょうし、それらに対応した例外クラスを定義する必要がありそうです)

スクリプトはこうなりました。

www/index.php

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

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

    $errors = array();

    $form = array(
        'name'    => $app->findVar('P', 'name'),
        'comment' => $app->findVar('P', 'comment'),
    );

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

        if (strlen($form['name']) === 0) {
            $errors['name'] = '名前を入力してください。';
        } elseif (mb_strlen($form['name']) > 20) {
            $errors['name'] = '名前は20文字以内で入力してください。';
        }

        if (strlen($form['comment']) === 0) {
            $errors['comment'] = 'コメントを入力してください。';
        } elseif (mb_strlen($form['comment']) > 50) {
            $errors['comment'] = 'コメントは50文字以内で入力してください。';
        }

        if (empty($errors)) {
            // $app->addFlashMessage('success', '投稿を受け付けました');
            return $app->redirect('/');
        }

    }

    return $app->render('index.html', array(
        'form'   => $form,
        'errors' => $errors,
    ));
});

$app->run();

簡単なものではありますが、POSTメソッド時に入力値のバリデーションを行うようにしました。

エラーがあればGETメソッド時の流れでそのままフォームのHTMLを返しますが、エラーメッセージの変数出力が追加されています。

アプリケーションオブジェクトの中ではSymfony HttpFoundationコンポーネントへの依存が広がっていますが、まだ利用側スクリプトにはそれに由来するコードは登場しません。

バリデーション後の $app->redirect() 前のコメントアウトしている部分はいわゆるポストバック方式でリダイレクト先に表示するメッセージを定義していますが、ここではセッション変数を扱う必要がありそうです。

そうなるとまたアプリケーションオブジェクトに追加コードが増えそうなので、今回は後回しにします。

テンプレートはこうなりました。

app/templates/index.html

<html>
<body>
<h1>俺のフレームワーク@<?=$app->escape($server['HTTP_HOST'])?></h1>

<form method="post" action="<?=$app->escape($server['REQUEST_URI'])?>">

<?php if (count($errors) > 0) : ?>
<ul>
<?php foreach ($errors as $error) : ?>
<li><?=$app->escape($error)?></li>
<?php endforeach ?>
</ul>
<?php endif ?>

<dl>
<dt>名前</dt>
<dd>
<input type="text" name="name" value="<?=$app->escape($form['name'])?>" />
</dd>
<dt>コメント</dt>
<dd>
<textarea name="comment">
<?=$app->escape($form['comment'])?></textarea>
</dd>
</dl>

<input type="submit" value="送信" />
</form>

</body>
</html>

相変わらずHTMLのフリをしつつ中身はPHPファイルですが、ついに制御構文が出てきてしまいました。

まだ単純なテキスト入力フォームなのでましですが、ラジオボタンやチェックボックスを扱いだすともう大変です。

様々な課題が洗い出されましたが、次回はRendererクラスを差し替えて、何かテンプレートエンジンを導入してみます。

(業務ではいつもSmarty3なので、多分それ以外になるかと…)

マイクロフレームワークをつくろう - Pimpleの上に(Rendererクラス作成編)

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

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

Step 5 Rendererクラスの作成

前回Symfony HttpFoundationコンポーネントを導入したので、HTTPリクエストとレスポンスを操作する際はこれを使えばいいんですが、まだページスクリプトとHTMLが一緒になってますので、これを分離するためにRendererクラスを作成します。

なぜクラスなのかというと、将来的には外部ライブラリのテンプレートエンジンを利用することが予想されることと、(身も蓋もないですが)以前に書いたコードの中に流用できそうなものがあるからです。

とりあえず最初の実装として、普通にPHPで書かれたHTMLファイルをテンプレート的に扱うためのクラスにします。

src/Acme/Renderer.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;

/**
 * レンダラクラス
 *
 * @author k.holy74@gmail.com
 */
class Renderer
{

    /**
     * @var array 設定値
     */
    private $config;

    /**
     * @var array 出力データ
     */
    private $data;

    /**
     * コンストラクタ
     *
     * @param array | ArrayAccess 設定オプション
     */
    public function __construct($configurations = array())
    {
        $this->initialize($configurations);
    }

    /**
     * オブジェクトを初期化します。
     *
     * @param array | ArrayAccess 設定オプション
     */
    public function initialize($configurations = array())
    {
        $this->data = array();
        $this->config = array(
            'template_dir' => null,
        );
        if (!empty($configurations)) {
            foreach ($configurations as $name => $value) {
                $this->config($name, $value);
            }
        }
        return $this;
    }

    /**
     * 引数1の場合は指定された設定の値を返します。
     * 引数2の場合は指定された設置の値をセットして$thisを返します。
     *
     * @param string 設定名
     * @return mixed 設定値 または $this
     */
    public function config($name)
    {
        switch (func_num_args()) {
        case 1:
            return $this->config[$name];
        case 2:
            $value = func_get_arg(1);
            if (isset($value)) {
                switch ($name) {
                case 'template_dir':
                    if (!is_string($value)) {
                        throw new \InvalidArgumentException(
                            sprintf('The config parameter "%s" only accepts string.', $name));
                    }
                    break;
                default:
                    throw new \InvalidArgumentException(
                        sprintf('The config parameter "%s" is not defined.', $name)
                    );
                }
                $this->config[$name] = $value;
            }
            return $this;
        }
        throw new \InvalidArgumentException('Invalid argument count.');
    }

    /**
     * 出力データに値を追加します。
     *
     * @param string 名前
     * @param mixed 値
     * @return string
     */
    public function assign($name, $value)
    {
        $this->data[$name] = $value;
    }

    /**
     * 指定パスのテンプレートを読み込んで配列をローカルスコープの変数に展開し、結果を出力します。
     *
     * @param string テンプレートファイルのパス
     * @param array テンプレートに展開する変数の配列
     */
    public function render($view, array $data)
    {
        echo $this->fetch($view, $data);
    }

    /**
     * 指定パスのテンプレートを読み込んで配列をローカルスコープの変数に展開します。
     *
     * @param string テンプレートファイルのパス
     * @param array テンプレートに展開する変数の配列
     * @return string
     */
    public function fetch($view, array $data)
    {
        $dir = $this->config('template_dir');
        if (isset($dir)) {
            $dir = rtrim($dir, '/');
        }
        $template = (isset($dir)) ? $dir . DIRECTORY_SEPARATOR . $view : $view;
        if ('\\' === DIRECTORY_SEPARATOR) {
            $template = str_replace('\\', '/', $template);
        }
        if (false !== realpath($template)) {
            ob_start();
            $data = array_merge($this->data, $data);
            extract($data);
            include $template;
            $contents = ob_get_contents();
            ob_end_clean();
            return $contents;
        }
    }

}

クラスの設定項目はテンプレートファイルの配置先を指定する template_dir のみです。

assign(string $name, mixed $value) でテンプレート変数に値をセットして、fetch(string $view, array $data) で指定されたテンプレートファイルに対して配列から変数を展開して返し、render(string $view, array $data)で出力します。

要はextract()してincludeしているだけなんですが、簡易テンプレートとしては充分でしょう。

例のごとく、このクラスをアプリケーションオブジェクトに Pimple::share() で生成させるわけですが、せっかくなのでアプリケーション内でグローバルなテンプレート変数のセットも一緒にやっておきます。

また、Symfony HttpFoundation コンポーネントも導入したことですし、この際アプリケーションオブジェクトにRendererクラスの取得結果を出力するための render()関数を追加します。

その方がなんとなくフレームワークっぽく見えますし。

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\Renderer;

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

$app = new Application();

// レンダラオブジェクトを生成、グローバルなテンプレート変数をセット
$app->renderer = $app->share(function(Application $app) {
    $renderer = new Renderer(array(
        'template_dir' => __DIR__ . DIRECTORY_SEPARATOR . 'templates',
    ));
    // $app
    $renderer->assign('app', $app);
    // $_SERVER
    $renderer->assign('server', $app->request->server->all());
    return $renderer;
});

// レスポンスオブジェクトでレンダラオブジェクトからテンプレート出力
$app->render = $app->protect(function($view, array $data = array(), $statusCode = 200, $headers = array()) use ($app) {
    $response = new Response($app->renderer->fetch($view, $data), $statusCode, $headers);
    $response->send();
});

// リクエストオブジェクトを生成
$app->request = $app->share(function(Application $app) {
    return Request::createFromGlobals();
});

// リクエスト変数を取得する
$app->findVar = $app->protect(function($key, $name, $default = null) use ($app) {
    $value = null;
    switch ($key) {
    // $_GET
    case 'G':
        $value = $app->request->query->get($name);
        break;
    // $_POST
    case 'P':
        $value = $app->request->request->get($name);
        break;
    // $_COOKIE
    case 'C':
        $value = $app->request->cookies->get($name);
        break;
    // $_SERVER
    case 'S':
        $value = $app->request->server->get($name);
        break;
    }
    if (isset($value)) {
        $value = $app->normalize($value);
    }
    if (!isset($value) ||
        (is_string($value) && strlen($value) === 0) ||
        (is_array($value) && count($value) === 0)
    ) {
        $value = $default;
    }
    return $value;
});

// リクエスト変数の正規化
$app->normalize = $app->protect(function($value) use ($app) {
    $filters = array(
        // HT,LF,CR,SP以外の制御コード(00-08,11,12,14-31,127,128-159)を除去
        // ※参考 http://en.wikipedia.org/wiki/C0_and_C1_control_codes
        function($val) {
            return preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]|\xC2[\x80-\x9F]/S', '', $val);
        },
        // 改行コードを統一
        function($val) {
            return str_replace("\r", "\n", str_replace("\r\n", "\n", $val));
        },
    );
    foreach ($filters as $filter) {
        $value = $app->map($filter, $value);
    }
    return $value;
});

// HTMLエスケープ
$app->escape = $app->protect(function($value, $default = '') use ($app) {
    return $app->map(function($value) use ($default) {
        $value = (string)$value;
        if (strlen($value) > 0) {
            return htmlspecialchars($value, ENT_QUOTES);
        }
        return $default;
    }, $value);
});

// 全ての要素に再帰処理
$app->map = $app->protect(function($filter, $value) use ($app) {
    if (is_array($value) || $value instanceof \Traversable) {
        $results = array();
        foreach ($value as $val) {
            $results[] = $app->map($filter, $val);
        }
        return $results;
    }
    return $filter($value);
});

return $app;

$app->renderer がstaticに参照されるRendererオブジェクトを返し、$app->render($view, array $data = array(), $statusCode = 200, $headers = array()) でSymfony HttpFoundationのResponseクラスを使ったレスポンス出力を行います。

テンプレートを分離するついでに、グローバルなテンプレート変数 $server も参照しておきます。

app/templates/index.html

<html>
<body>
<h1>俺のフレームワーク@<?=$app->escape($server['HTTP_HOST'])?></h1>

<form method="post" action="<?=$app->escape($server['REQUEST_URI'])?>">

<dl>
<dt>名前</dt>
<dd>
<input type="text" name="name" value="<?=$app->escape($form['name'])?>" />
</dd>
<dt>コメント</dt>
<dd>
<textarea name="comment">
<?=$app->escape($form['comment'])?></textarea>
</dd>
</dl>

<input type="submit" value="送信" />
</form>

<hr />

<dl>
<dt>名前</dt>
<dd><?=$app->escape($form['name'])?></dd>
<dt>コメント</dt>
<dd><pre><?=$app->escape($form['comment'])?></pre></dd>
</dl>

</body>
</html>

HTMLのフリをしつつ中身はPHPファイルという詐欺みたいなテンプレートですが、とりあえずこれで。

利用側のスクリプトは以下のようになりました。

www/index.php

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

$form = array(
    'name'    => $app->findVar('P', 'name'),
    'comment' => $app->findVar('P', 'comment'),
);

$app->render('index.html', array(
    'form' => $form,
));

HTMLを分離し、出力はアプリケーションオブジェクトのrender()関数を利用して間接的に行うよう変更しました。

何となく、Silexでのスクリプトの実装に近づいてきた気がします。

しかし、GETメソッドとPOSTメソッドの振り分けがないあたり、なんだかレガシー感が漂ってますね…。

次はそろそろ、RESTインタフェースを考慮した実装を進めようと思います。

マイクロフレームワークをつくろう - Pimpleの上に(Symfony HttpFoundation導入編)

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

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

Step 4 Symfony HttpFoundationコンポーネントを導入

とりあえずフォーム画面を作ることにしましたが、せっかくComposerを使っていながら一つ一つ部品を自作するというのも大変ですし、この辺りで少しSymfonyコンポーネントの力を借りることにします。

Webアプリケーションということで、まずはSilexアプリケーションでもよく使ったHttpFoundationコンポーネントを導入します。

composer.json

{
    "license": "MIT",
    "authors": [
        {
            "name": "k-holy",
            "email": "k.holy74@gmail.com"
        }
    ],
    "config": {
            "vendor-dir": "vendor"
    },
    "autoload": {
        "psr-0": {
            "Acme":"src"
        }
    },
    "require": {
        "php": ">=5.4",
        "pimple/pimple": "1.0.2",
        "symfony/http-foundation": ">=2.3,<2.4-dev"
    }
}

また今後ページを増やすことも想定して、アプリケーションオブジェクトの生成とコンテナへの関数やオブジェクトの登録を別ファイルで行うことにします。

app/app.php

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

use Acme\Application;

use Symfony\Component\HttpFoundation\Request;

$app = new Application();

// リクエストオブジェクトを生成
$app->request = $app->share(function(Application $app) {
    return Request::createFromGlobals();
});

// リクエスト変数を取得する
$app->findVar = $app->protect(function($key, $name, $default = null) use ($app) {
    $value = null;
    switch ($key) {
    // $_GET
    case 'G':
        $value = $app->request->query->get($name);
        break;
    // $_POST
    case 'P':
        $value = $app->request->request->get($name);
        break;
    // $_COOKIE
    case 'C':
        $value = $app->request->cookies->get($name);
        break;
    // $_SERVER
    case 'S':
        $value = $app->request->server->get($name);
        break;
    }
    if (isset($value)) {
        $value = $app->normalize($value);
    }
    if (!isset($value) ||
        (is_string($value) && strlen($value) === 0) ||
        (is_array($value) && count($value) === 0)
    ) {
        $value = $default;
    }
    return $value;
});

// リクエスト変数の正規化
$app->normalize = $app->protect(function($value) use ($app) {
    $filters = array(
        // HT,LF,CR,SP以外の制御コード(00-08,11,12,14-31,127,128-159)を除去
        // ※参考 http://en.wikipedia.org/wiki/C0_and_C1_control_codes
        function($val) {
            return preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]|\xC2[\x80-\x9F]/S', '', $val);
        },
        // 改行コードを統一
        function($val) {
            return str_replace("\r", "\n", str_replace("\r\n", "\n", $val));
        },
    );
    foreach ($filters as $filter) {
        $value = $app->map($filter, $value);
    }
    return $value;
});

// HTMLエスケープ
$app->escape = $app->protect(function($value, $default = '') use ($app) {
    return $app->map(function($value) use ($default) {
        $value = (string)$value;
        if (strlen($value) > 0) {
            return htmlspecialchars($value, ENT_QUOTES);
        }
        return $default;
    }, $value);
});

// 全ての要素に再帰処理
$app->map = $app->protect(function($filter, $value) use ($app) {
    if (is_array($value) || $value instanceof \Traversable) {
        $results = array();
        foreach ($value as $val) {
            $results[] = $app->map($filter, $val);
        }
        return $results;
    }
    return $filter($value);
});

return $app;

Silexでもお馴染みのPimpleのshare()メソッドにより、$app->request でstaticに参照される Requestオブジェクトを生成する関数を登録しています。

リクエスト変数を取得する $app->findVar() の内容も、Requestオブジェクトを利用したものに書き換えています。

$app->normalize() など従来の処理は変わらず有効ですし、アプリケーションオブジェクトの利用側スクリプトにも影響はありません。

利用側のスクリプトは以下のようになりました。

www/index.php

<?php
/**
 * Create my own framework on top of the Pimple
 *
 * Step 4
 *
 * @copyright 2011-2013 k-holy <k.holy74@gmail.com>
 * @license The MIT License (MIT)
 */
$app = include realpath(__DIR__ . '/../app/app.php');

$form = array(
    'name'    => $app->findVar('P', 'name'),
    'comment' => $app->findVar('P', 'comment'),
);
?>
<html>
<body>
<h1>test</h1>

<form method="post" action="<?=$app->escape($app->findVar('S', 'REQUEST_URI'))?>">

<dl>
<dt>名前</dt>
<dd>
<input type="text" name="name" value="<?=$app->escape($form['name'])?>" />
</dd>
<dt>コメント</dt>
<dd>
<textarea name="comment">
<?=$app->escape($form['comment'])?></textarea>
</dd>
</dl>

<input type="submit" value="送信" />
</form>

<hr />

<dl>
<dt>名前</dt>
<dd><?=$app->escape($form['name'])?></dd>
<dt>コメント</dt>
<dd><pre><?=$app->escape($form['comment'])?></pre></dd>
</dl>

</body>
</html>

相変わらずHTMLと一緒になった醜いコードですが、まだフォーム送信後の処理が何もないだけに、すっきりしたものになりました。

アプリケーションオブジェクトに登録する関数のインタフェースが固まっていれば、その中の実装が変わっても、利用側のコードには影響を与えないのがポイントです。

直接クラスを利用している場合だと、こうはいきません。

(もちろん $app->request を直接扱うことになれば、そこに Requestクラスへの依存が生まれてしまうのですが…)

Pimpleの場合は無名関数を使ってオブジェクトを生成するため、初めからきっちりとクラスを設計する必要がないことも嬉しいです。

型による保証がない、ゆるいインタフェース依存になりますが、個人で小規模なアプリケーションをボトムアップ的に開発する場合、このスタイルが適していると感じます。

次はスクリプトとHTMLを分離するために、Rendererクラスを作成してみます。

HttpFoundationで画像の条件付きGETを実装してみる (Symfony Advent Calendar JP 2012 - Day 22)

Symfony Advent Calendar JP 2012 22日目の記事です。

まずはじめにお断りしておきますが、この記事はSymfony未経験者向けです。

(飛び入り参加なのに低レベルな内容ですみません…)

HTTP/1.1 には If-Modified-Since, If-Unmodified-Since, If-Match, If-None-Match, If-Range ヘッダによる「Conditional GET Request」、いわゆる条件付きGETリクエストの仕様があります。

これまでは熟成された秘伝のUtilクラス(という名前の $SERVER とか $REQUEST にアクセスする処理が無造作に詰め込まれたくそ関数集)で行っていたんですが、Symfonyコンポーネントの HttpFoundation を使って If-Modified-Since, If-Match への対応を実装してみました。

キャッシュを禁止する (Pragma, Expires, Cache-Control)

PHPでは、Session機能を有効にした場合、session.cache_limiter および session.cache_expires の設定に合わせて Cache-Control ヘッダの内容が自動的に変更されます。

初期設定の "session.cache_limiter = nocache" で出力されるヘッダは以下のようなものになります。

Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate, post-check=0, pre-check=0
Pragma: no-cache

php-src/ext/session/session.c at master

CACHE_LIMITER_FUNC(nocache) /* {{{ */
{
    ADD_HEADER("Expires: Thu, 19 Nov 1981 08:52:00 GMT");

    /* For HTTP/1.1 conforming clients and the rest (MSIE 5) */
    ADD_HEADER("Cache-Control: no-store, no-cache, must-revalidate, post-check=0, pre-check=0");

    /* For HTTP/1.0 conforming clients */
    ADD_HEADER("Pragma: no-cache");
}

とにかくキャッシュさせたくない場合の例として、まずはこの内容を手がかりに調べてみました。

ハイパーテキスト転送プロトコル -- HTTP/1.1 #14.21 Expires より

レスポンスが "期限切れ" という事を表すためには、オリジンサーバは Date ヘッダの値と同じである Expires の日付を送る

Expiresヘッダの値について、仕様ではこのように書かれていますが、PHPでは固定の過去日付が送られています。

これを疑問に感じる人もいるようで、検索してみるとこのような質問がありました。

epoch date -- Expires: Thu, 19 Nov 1981 08:52:00 - PHPより

Why is the date: Thu, 19 Nov 1981 08:52:00 GMT used as the value for anti-caching Expires: headers?

Why not use the UNIX epoch of Thu, 01 Jan 1970 00:00:00 GMT?

これに対する回答

It was added in this revision:

http://cvs.php.net/viewvc.cgi/php-sr...1=1.80&r2=1.81

... by CVS user "sas", who is Sascha Schumann. The date appears to be Sascha's

birthday:

http://www.phpbuilder.com/lists/php3...99911/3159.php

上記のSession拡張のソースを書いた Sascha Schumann さんの誕生日という話ですが、リンク先が消失しているため真偽のほどは分かりません。

HTTP/1.1向けの Cache-Control ヘッダの値はどのような仕様にもとづいて設定されているのでしょうか。

ハイパーテキスト転送プロトコル -- HTTP/1.1 #14.9 Cache-Controlより

no-store 指示子の目的は、取り扱いが慎重な (例えばバックアップテープ上の) 情報の不注意な漏洩や保留を防ぐ事である。 no-store 指示子はメッセージ全体に適用され、レスポンスかリクエストのどちらかで送る事ができる。 リクエストで送られる場合、キャッシュはそのリクエストやそれへのいかなるレスポンスの一部分を保存してはならない。 レスポンスで送られる場合、キャッシュはそのレスポンスやそれを引き起こしたリクエストの一部分を保存してはならない。

もし no-cache 指示子がフィールド名を指定していなければ、オリジンサーバへの再検証が成功するまで、以降のリクエストを満足させるためにそのレスポンスを使ってはならない。 これによって、オリジンサーバはリクエストするクライアント古くなったレスポンスを返すよう設定されているキャッシュによるキャッシングさえ行えなくする事ができる。

もしオリジンサーバがすべての HTTP/1.1 キャッシュを、それがどのように設定されているかに関わらず、すべてのリクエストで強制的に検証させたい場合、"must-revalidate" cache-control 指示子 (section 14.9 参照) を使うべきである。

ちょっと分かりづらいですが、no-storeはこのレスポンスを保存するなという意味、no-cacheはキャッシュを保持している場合でもオリジンサーバへの再検証に成功しない限りこのレスポンスを再利用するなという意味、must-revalidateはキャッシュを保持している場合でもオリジンサーバへの検証を必ず行えという意味のようです。

これらのディレクティブを指定することで、クライアントおよび全ての経路上のサーバに対して、該当URLへのリクエストに対するレスポンスの保存と再利用の禁止を指示しています。

post-check および pre-check については明確な仕様は発見できなかったのですが、どうやらMicrosoftがIE5から独自で対応している仕組みのようで、以下のページの「Use Cache-Control Extensions」に記載があります。

Building High Performance HTML Pages (Internet Explorer).aspx)より

When building a Web site, pages will change with varying frequency. Some pages will change daily, while others will never change once they are posted. To allow the Web site manager to indicate to a client browser how frequently an HTTP server should be queried for changes to a resource, Internet Explorer 5 introduces support for two extensions to the cache-control HTTP response header: pre-check and post-check.

By supporting these extensions, Internet Explorer reduces network traffic by sending fewer requests to the server. In addition, Internet Explorer improves the user experience by rendering resources from the cache and by fetching updates in the background after a specified interval.

The post-check and pre-check cache-control extensions are defined as follows:

  • post-check -- Defines an interval in seconds after which an entity must be checked for freshness.

The check may happen after the user is shown the resource but ensures that on the next roundtrip the cached copy will be up-to-date.

  • pre-check -- Defines an interval in seconds after which an entity must be checked for freshness

prior to showing the user the resource.

日本語文による解説は以下のPDFにありました。

無線WANでCitrixテクノロジーを活用する方法 ベストプラクティス P.20-21「IISでCache-Control HTTPヘッダーを使用する」より

Internet Explorer Version 5.0以降は、Cache-Control HTTPヘッダーをサポートしています。 このヘッダーは、キャッシュされたオブジェクトに関するブラウザの動作を定めます。 Cache-Controlヘッダーは、post-checkとpre-checkの2つのパラメータからなります。

post-checkは、エンティティの新しさをチェックする時間間隔です(秒単位で指定)。 このチェックはそのエンティティをユーザーに対して表示した後に行われます。 このチェックにより、次回のラウンドトリップ時に最新のコピーがユーザーに対して表示されるようになります(キャッシュオブジェクトは表示「後(post)」にチェックできることに注意)。

pre-checkは、エンティティをユーザーに対して表示する「前」にエンティティの新しさをチェックする時間間隔(秒単位)です(キャッシュオブジェクトは表示「前(pre)」にチェックされることに注意)。

post-checkとpre-checkを適切に設定することで、公開アプリケーションおよびWeb Interfaceのイメージのキャッシュコピーがユーザーに対して表示されます。この処理は高速に行われます。 post-checkタイムアウト値が経過すると、ブラウザはバックグラウンドでチェックを行い、イメージが更新されているかどうかを確認します。 ブラウザは、pre-checkタイムアウト値が経過したときにのみ(さらにその後post-checkタイムアウトフェーズ中にキャッシュオブジェクトが更新されなかった場合にのみ)、キャッシュオブジェクトの更新状況をチェックします。

要は、"post-check=0, pre-check=0" によって、エンティティの表示前後に時間経過によるキャッシュの有効性をチェックするという、IE専用のキャッシュ制御機能を無効にしているということでしょうか。

前置きが長くなりましたが、レスポンスのキャッシュ禁止は以下のように設定しました。

<?php
namespace Acme;

$loader = include __DIR__ . '/../../../vendor/autoload.php';

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

$request = Request::createFromGlobals();
$response = new Response();

// HTTP/1.0向けに Pragma ヘッダをセット
$response->headers->set('Pragma', 'no-cache');

// HTTP/1.1向けに Cache-Control ヘッダをセット
$response->headers->set('Cache-Control', 'no-store, no-cache, must-revalidate, private, post-check=0, pre-check=0');

$content = <<<'HTML'
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="utf-8" />
    <title>TEST</title>
</head>
<body>
    <h1>TEST</h1>
</body>
</html>
HTML;

$response->headers->set('Content-Length', strlen($content));
$response->setContent($content);

// レスポンスが期限切れということを示すために、 Date と Expires ヘッダに同じ値をセット
$currentDate = new \DateTime(null, new \DateTimeZone('UTC'));
$response->setDate($currentDate)->setExpires($currentDate)->prepare($request)->send();

動作サンプル http://kholy.gehirn.ne.jp/symfony-advent/2012/no-cache.php

Cache-Control ヘッダにカンマ区切りで複数のディレクティブを設定していますが、HeaderBag には Cache-Controlヘッダ値の解析が実装されているため、こういう書式の値もちゃんと処理してくれます。

(処理内容は HeaderBag::parseCacheControl() を参照)

ResponseHeaderBag では Cache-Control, ETag Last-Modified, Expires ヘッダがセットされた時際、自動的に Cache-Control 値が適切になるようセットし直されます。

具体的には ETag Last-Modified, Expires のいずれかが設定されており、Cache-Control ヘッダのディレクティブが何一つ設定されていない場合は private, must-revalidate ディレクティブが設定され、private または public ディレクティブが設定されておらず s-maxage ディレクティブも設定されていない場合は private ディレクティブを自動的に追記するという複雑な処理が行われます。

(処理内容は ResponseHeaderBag::computeCacheControlValue() を参照)

Response クラスにはレスポンスヘッダの設定を行うためのメソッドや、リクエストを引数に取る便利メソッドが用意されています。

Response::setDate() と Response::setExpires() もそのひとつで、DateTimeオブジェクトを渡すことで、RFC 1123形式に変換してヘッダにセットしてくれます。

Response クラスではインスタンスの生成時に自動的に現在時刻が Date ヘッダに設定されますので、少々面倒ではありますがこのように記述しました。

また、Response::prepare() ではリクエストメソッドやヘッダに合わせて以下のようにレスポンスを自動設定してくれます。

  • 要求されたフォーマットとcharsetに合わせた Content-Type ヘッダを設定
  • Transfer-Encoding ヘッダ値が設定されている場合は Content-Length ヘッダをクリア
  • リクエストメソッドが HEAD の場合はボディをクリア(Content-Length ヘッダのみ返す)
  • サーバ変数に合わせたプロトコルバージョンを設定
  • プロトコルバージョンがHTTP/1.0かつCache-Control ヘッダに no-cache が設定されている場合は Pragma, Expires ヘッダを設定

Transfer-Encoding と Content-Length については [Studying HTTP] HTTP Header Fields #Content-Length のまとめによると

  • 転送コーディングが施されている場合、Content-Length ヘッダは送られてはならないし、仮に送られてもこれを無視しなければならない
  • 転送コーディングが施されていない場合、Content-Length ヘッダは送られなければならないが、これはメッセージボディ中のオクテット数と正確に一致しなければならない

とのことです。

最近はWebサーバ側の設定で圧縮転送されていることも多いと思いますが、たとえばApacheの mod_deflate モジュールのドキュメントには、圧縮機能が実装されている DEFLATE フィルタについてこのような記載があります。

mod_deflate - Apache HTTP サーバ #圧縮を有効にするより

DEFLATE フィルタは必ず、PHP や SSI といった RESOURCE フィルタの後になります。 DEFLATE フィルタは内部的なサブリクエストを関知しません。

サンプルコードの動作確認に使っている Gehirn RS2 でもテキストの圧縮が設定されているようで、リクエストヘッダの Accept-Encoding: gzip, deflate を受け、PHPから出力するHTMLレスポンスに対して明示的に設定した Content-Length が自動的に削除されて、 Content-Encoding: gzip が設定されていました。

ただキャッシュを禁止するだけでも、色々と気をつけなければいけないところがあって、HttpFoundation にもそれらの知見が凝縮されていることが分かりました。

画像ファイルを返すレスポンスを更新日時と属性を元にキャッシュさせる (Last-Modified と If-Modified-Since, ETag と If-None-Match)

こちらが今回の本題です。

HTTPキャッシュへの対応については、静的コンテンツへのリクエストの場合、Webサーバの機能(Apache の場合は mod_expires モジュールや、コアモジュールの FileETag ディレクティブ)で行えます。

しかし実際のところ、Webアプリケーションにおいてはシステムで管理しているファイルを直接公開せず、アプリケーション経由でレスポンスを返すことが多いと思います。

そういった場合にも、仕様に従って適切にレスポンスヘッダを返すことで、キャッシュの利用を促そうというのが今回の内容です。

Last-Modified と If-Modified-Since ヘッダを利用する

Last-Modified レスポンスヘッダ と If-Modified-Since リクエストヘッダは、リソースの最終更新日時をもとにキャッシュの同一性を検証するために利用されます。

ハイパーテキスト転送プロトコル -- HTTP/1.1 #14.25 If-Modified-Sinceより

If-Modified-Since ヘッダを持っていて且つ Range ヘッダを持たない GET メソッドは、If-Modified-Since ヘッダによって与えられる時刻以降に更新された場合のみ指定したエンティティを転送する事を要求する。 これを決定するためのアルゴリズムには、以下のようなものを含む。

  • リクエストが 200 (OK) ステータス以外の結果を返すか、あるいは渡された If-Modified-Since の日付が不正なものであるような場合、レスポンスは通常の GET 時と全く同じものとなる。サーバの現在時刻より未来の時刻は無効である。
  • バリアントが If-Modified-Since にある時刻以降に更新されている場合、レスポンスは通常の GET 時と全く同じものとなる。
  • バリアントが有効な If-Modified-Since にある時刻以降に更新されていない場合、サーバは 304 (not modified) レスポンスを返すべきである。

この機能は、通信処理の負荷を最小量にするようにキャッシュされた情報を能率的に更新する事を目的にしている。

静的ファイルを返すレスポンスをキャッシュさせる場合、リソースの最終更新日時としてファイルの日付をそのまま使えるため、少しの手間を加えるだけで対応できます。

<?php
namespace Acme;

$loader = include __DIR__ . '/../../../vendor/autoload.php';

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

$request = Request::createFromGlobals();
$response = new Response();

$file = new \SplFileInfo(__DIR__ . DIRECTORY_SEPARATOR . 'src' . DIRECTORY_SEPARATOR . 'sunset.jpg');

// ファイルの更新日時を Last-Modified ヘッダにセット
$lastModified = new \DateTime();
$lastModified->setTimestamp($file->getMTime());
$response->setLastModified($lastModified);

// リクエストヘッダの値を利用して同一性を検証
// Response::isNotModified() で検証に成功した場合はステータス 304 がセットされるので、そのまま返せばOK
if ($response->isNotModified($request)) {
    $response->prepare($request)->send();
    exit;
}

$response->setPublic();
$response->setContent(file_get_contents($file->getRealPath()));

// ファイルの情報を元にレスポンスヘッダをセット
$mimeType = new \Finfo(FILEINFO_MIME_TYPE);

$response->headers->set('Content-Type', $mimeType->file($file->getRealPath()));

$response->headers->set('Content-Disposition',
    $response->headers->makeDisposition('inline', $file->getBasename())
);

$response->headers->set('Content-Length', $file->getSize());

$currentDate = new \DateTime(null, new \DateTimeZone('UTC'));
$response->setDate($currentDate)->prepare($request)->send();

動作サンプル http://kholy.gehirn.ne.jp/symfony-advent/2012/caching-by-last-modified.php

Response クラスにはリクエストヘッダの値を元にキャッシュの同一性を検証し、同一であればステータス304をセットする Response::isNotModified() メソッドがありますので、これを利用しています。

Response::setLastModified(\DateTime $date = null) と Response::isNotModified(Request $request) のコードを読んでいて気になったんですが、 [Studying HTTP] HTTP Header Fields #HTTP日付 に解説されている 「RFC 822, updated by RFC 1123」形式でセットされた値(これは仕様通りです)と If-Modified-Since リクエストヘッダから取得した値が単純に比較されています。

RFC 850, obsoleted by RFC 1036」や「ANSI C's asctime() format」でリクエストされた値だと常に FALSE が返されてしまうようですが、これは問題にならないんでしょうか。

  • HTTP/1.1アプリケーションは、HTTPに関するあらゆる日付フォーマットを「RFC 1123形式」にて生成しなければならない。
  • また、その他に「RFC 850 形式」、「ANSI Cのasctime()」形式も理解できなければならない。

それ以外の日付形式に関しては必ずしも理解できる必要はないが、ある程度有名な日付形式であれば理解できることが望まれ、その場合には適切な形式に書き換えるべきである。

現行のUAでそういったものがあるのか分かりませんが、忠実に実装するのであれば Response::isNotModified() は使わないようにするか、Requestオブジェクトの If-Modified-Since ヘッダの値を「RFC 1123形式」に変換してセットし直してから使う必要があるかと思います。

(あと蛇足かもしれませんが、熟成された秘伝のUtilクラスでは「;区切りのRFC違反ヘッダに対応」なんてこともしているので、多分コード書いた当時にはそういう日付を送信するUAもあったのではないかと…)

なお上記サンプルでは Last-Modified ヘッダのみレスポンスにセットしていますが、Response::isNotModified() メソッドは後述するエンティティタグの検証にも利用できます。

ETag と If-None-Match ヘッダを利用する

ETag レスポンスヘッダと If-None-Match リクエストヘッダは、リソースのエンティティを比較するための「エンティティタグ」を使ったキャッシュの同一性検証に利用されます。

ハイパーテキスト転送プロトコル -- HTTP/1.1 #14.26 If-None-Matchより

If-None-Match リクエストヘッダフィールドは、メソッドを条件付きにする場合に使われる。 以前にそのリソースから一つ以上のエンティティを取得しているクライアントは、If-None-Match ヘッダフィールド中にそれに対応するエンティティタグのリストを含める事によって、それらのエンティティの中に現在使用できるものがないかどうかを確かめる事ができる。 この機能は、通信処理の負荷を最小量にするようにキャッシュされた情報を能率的に更新する事を目的にしている。 また (例えば PUT 等の) メソッドを使う場合に、既にあるリソースをクライアントがそのリソースが無いと考えている場合に不注意に更新してしまわないようにするためにも使われる。

エンティティタグについては、プロトコルパラメータのひとつとして独立した説明があります。

ハイパーテキスト転送プロトコル -- HTTP/1.1 #3.11 エンティティタグより

エンティティタグは、同一の要求リソースからの二つ以上のエンティティを比較するために使用される。 HTTP/1.1 では、ETag (section 14.19), If-Match (section 14.24), If-None-Match (section 14.26), If-Range (section 14.27) 各ヘッダフィールドで、エンティティタグを使う。 それらがキャッシュバリディタとして、どのよう使われ、比較されるかの定義は、section 13.3.3 にある。 エンティティタグは、それ自体は読んでも意味のわからない{opaque} 引用符で括られた文字列から成り、weakness インジケータが前方に付く場合もある。

entity-tag = [ weak ] opaque-tag

weak = "W/"

opaque-tag = quoted-string

"W/" プレフィクスによって示される "weak entity tag" では、エンティティが等価であり、意味論においてそれぞれ重要な変更がなく互いを代わりに使う事が出来る場合のみ、リソースの2つのエンティティが共有できる。 weak エンティティタグは、弱い比較の時のみ使用される。

値は示されている通り引用符で囲まれた文字列で、「弱い比較」を行いたい場合のみ W/ プレフィクスが付与されます。

また、仕様ではエンティティタグの一意性について以下のように説明されています。

エンティティタグは、特有のリソースと関連付けられた全てのエンティティの全てのバージョンの中で一意{unique} でなければならない。 与えられたエンティティタグの値は、異なる URI へのリクエストから得られたエンティティのために使う事ができる。 異なる URI へのリクエストから得られたエンティティに同じエンティティタグの値を使っているからといって、それらのエンティティの同等性を暗に意味するものではない。

あくまで同一のリソースにおけるエンティティのバージョンを示すためのものであって、ホスト内で一意である必要はないということです。

静的ファイルの属性を元にエンティティタグを生成するにあたっては、対象のファイルパスとURIが1対1で関連付けられているのであれば、ファイルパスを考慮しなくてもよいことになります。

ただし、同じURIでも別のファイルが返されるケース(例えば月替わりで異なる画像が返される場合など)では、エンティティタグの生成にファイルパスも利用する必要がありますね。

ちなみにApacheでETag レスポンスヘッダを生成するための FileETag ディレクティブでは、エンティティタグ生成に利用するファイルの属性として、inode 番号 / 最終更新日時 / バイト数 の3つを任意で設定できるようになっています。

(inode 番号については分散環境など同じ内容のファイルでも設置先サーバが異なる場合に問題となったり、脆弱性になりうるという指摘もあって、設定しないことが多いようです)

以下はこれらを踏まえて、ファイルパス、サイズ、更新日時を元にエンティティタグを生成したサンプルです。

<?php
namespace Acme;

$loader = include __DIR__ . '/../../../vendor/autoload.php';

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

$request = Request::createFromGlobals();
$response = new Response();

$file = new \SplFileInfo(__DIR__ . DIRECTORY_SEPARATOR . 'src' . DIRECTORY_SEPARATOR . 'sunset.jpg');

// ファイルの属性を元に生成した Etag をセット
$etag = sha1(serialize(array(
    'file'  => $file->getRealPath(),
    'size'  => $file->getSize(),
    'mtime' => $file->getMTime(),
)));
$response->setEtag($etag);

// リクエストヘッダの値を利用して同一性を検証
// Response::isNotModified() で検証に成功した場合はステータス 304 がセットされるので、そのまま返せばOK
if ($response->isNotModified($request)) {
    $response->prepare($request)->send();
    exit;
}

$response->setPublic();
$response->setContent(file_get_contents($file->getRealPath()));

// ファイルの情報を元にレスポンスヘッダをセット
$mimeType = new \Finfo(FILEINFO_MIME_TYPE);

$response->headers->set('Content-Type', $mimeType->file($file->getRealPath()));

$response->headers->set('Content-Disposition',
    $response->headers->makeDisposition('inline', $file->getBasename())
);

$response->headers->set('Content-Length', $file->getSize());

$currentDate = new \DateTime(null, new \DateTimeZone('UTC'));
$response->setDate($currentDate)->prepare($request)->send();

動作サンプル http://kholy.gehirn.ne.jp/symfony-advent/2012/caching-by-etag.php

Last-Modified の場合と同様、Response::setEtag() でエンティティタグをレスポンスヘッダにセットした後、Response::isNotModified() メソッドでリクエストヘッダの値を利用した同一性の検証を行います。

Response::isNotModified() ではリクエストヘッダに If-None-Match があればそちらを優先し、なければ If-Modified-Since が利用されます。

またこのメソッドでは、If-None-Match の特別な値 "*" についても考慮されています。

ハイパーテキスト転送プロトコル -- HTTP/1.1 #14.26 If-None-Matchより

特別な場合として、"*" という値は、そのリソースの現在のあらゆるエンティティに一致する。

いずれかのエンティティタグがそのリソースにされる類似した (If-None-Match ヘッダが無い) GET リクエストのレスポンスとして返されるエンティティのエンティティタグに一致する場合か、"*" が与えられる場合にそのリソースに現在使用できるエンティティが存在する場合において、サーバはリソースの更新時刻とリクエストの中の If-Modified-Since ヘッダフィールドにて与えられた時刻が一致しなかった場合以外は、リクエストされた動作を行ってはならない。 その代わり、もしリクエストメソッドが GET か HEAD であれば、サーバは 304 (Not Modified) レスポンスを、一致したエンティティのうちの一つのキャッシュに関連するヘッダフィールド (特に ETag) を付けて返すべきである。

つまり、If-None-Match ヘッダの値に * が指定されている場合は、If-Modified-Since ヘッダの値とリソースの更新時刻も合わせて検証するということです。

ETag ヘッダと Last-Modified ヘッダの併用をどのような基準で行うべきかについては、HTTP/1.1のRFCにも記載されています。

ハイパーテキスト転送プロトコル -- HTTP/1.1 #13.3.4 エンティティタグや Last-Modified の日付を使う場合の規定より

我々は様々なバリディタタイプがいつ、何の目的で使用されるべきかに関するオリジンサーバ、クライアント、キャッシュのための規定と推薦のセットを採用する。

HTTP/1.1 オリジンサーバについて

  • エンティティタグバリディタを生成する事が不可能で無いのであれば、それを送るべきである。
  • パフォーマンス考慮が弱いエンティティタグの使用を支持しているか、強いエンティティタグを送る事を不可能であるならば、強いエンティティタグの代わりに弱いエンティティタグを送る事ができる。
  • Last-Modified 値を送る事が可能で、If-Modified-Since ヘッダ中にこの日付の使う事から生じる、意味的な透過性における故障の危険が深刻な問題を引き起さなければ、Last-Modified 値を送るべきである。

言い換えれば、HTTP/1.1 オリジンサーバにとってより望まれる動作とは強いエンティティタグと Last-Modified 値の両方を送る事である。

サンプルでは比較のためそれぞれ個別にレスポンスヘッダをセットしましたが、オリジンサーバにおいては ETag ヘッダも Last-Modified ヘッダも可能な限りセットする方が良いということですね。

Internet Explorerへの対応

先日、Internet Explorer でファイルをダウンロードできないという問題が発生、色々と調査しましたのでメモも兼ねて記載しておきます。

結論としては、IEでのファイルダウンロードに対応する場合、レスポンスヘッダの生成に特別な対策が必要になります。

キャッシュ制限とダウンロード

Content-Disposition: attachemnt と Cache-Control: no-cache によるダウンロードの問題より

Internet Explorer を使用して下記条件を満たすファイルを開いた場合、ファイル名が見つからない内容のエラーが発生し、ファイルを開くことができない場合があります。 - ダウンロード対象となるファイルに Content-Disposition:attachment ヘッダーを付加している - Cache-Control:no-cache ヘッダーなどを使用して、ファイルのキャッシュを行わない設定をしている

この不具合、更新日は 2005年4月26日 となっており、対象製品は IE5.0, 6.0, 6.0 SP1 と記載されていますが、IE8においても同様に発生することを確認しています。

Web サーバーで Content-Disposition に inline を指定する等、Content-Disposition:attachment ヘッダーを使用しない、またはキャッシュを制限しないことにより現象を回避することが可能です。

回避策として上記のような理解不能なものが挙げられており、問題の根深さを伺わせてくれます。

Pragma (HTTP/1.0) や Cache-Control (HTTP/1.1) といったヘッダによるキャッシュ制限と、後述するIE特有の余計なお世話の合わせ技で起きている現象でしょうか。

MIME-Sniffingとファイル保存ダイアログの制御

また、Content-Type ヘッダの指定を無視してファイルの拡張子や内容で判断し、関連付けられたアプリケーションを起動するという余計なお世話(MIME-Sniffing と呼ばれているようです)にも、気をつける必要があります。

参考 教科書に載らないWebアプリケーションセキュリティ(2):[無視できない]IEのContent-Type無視 (1/2) - @IT

IE8から、これらの問題への対策として X-Download-Options, X-Content-Type-Options という2つの拡張ヘッダが提供されています。

X-Download-Options ヘッダは "noopen" を指定することでダウンロードダイアログから開かせない、"nosave" を指定することでダウンロードダイアログから保存させない、といった動作の制限を設定できるというものです。

IE8 Security Part V: Comprehensive Protection - IEBlog - Site Home - MSDN Blogsより

When the new X-Download-Options header is present with the value noopen, the user is prevented from opening a file download directly; instead, they must first save the file locally.

X-Content-Type-Options: nosniff はMIME-Sniffing による意図しないブラウザの振舞いを防止するためのもので、特にユーザーによる任意のファイルアップロードを許可するWebアプリケーションの場合は必須となるでしょう。

MIME-Handling Change: X-Content-Type-Options: nosniff (Windows).aspx)より

サーバーが応答ヘッダー X-Content-Type-Options: nosniff を送信する場合、SCRIPT 要素と STYLESHEET 要素は、誤った MIME タイプを拒否します。これは、MIME タイプの問題を悪用した攻撃を防ぐためのセキュリティ機能です。

参考 1分でわかる「X-ナントカ」HTTPレスポンスヘッダ - 葉っぱ日記

多くのアプリケーションフレームワークでは、当然ながら、このような特定ブラウザのためのバッドノウハウについては本体のコードには含んでいないと思います。

レスポンスヘッダの生成がブラックボックス化されているようなフレームワークでも、こういった対策の組み込み方については、きちんと把握しておく必要がありますね。

キャッシュの効果を実験

最後に、これまでの内容を確認するために、Silex と HttpFoundation でHTMLからキャッシュを有効にした画像を読み込ませてみました。 65.7KBの同じ画像を3枚表示して、1つはキャッシュなし、1つは Last-Modified と If-Modified-Since によるキャッシュを有効に、1つは ETag と If-None-Match によるキャッシュを有効にしています。

これを Opera Dragonfly を使って、クライアントキャッシュのない初回リクエストと、2回目のリクエストのレスポンス速度を比較してみます。

初回リクエスト

favicon.icoも含めて全てのリクエストへのレスポンスにステータス 200 が返ってきています。

一覧右端の「グラフ」の黄緑色の部分が、レスポンスヘッダの読み込み+レスポンス本文の読み込み+レスポンス処理の時間です。

favicon.icoは画像サイズが小さいためあまり影響ありませんが、3枚の画像はそれなりの大きさのため結構な割合を占めています。

2回目のリクエスト

favicon.icoも含めて、キャッシュされた画像にはステータス 304 が返ってきています。

「グラフ」の黄緑色の部分は、favicon.icoは画像サイズが小さいためほとんど変わっていませんが、キャッシュされた2枚の画像についてはレスポンス時間がそれなりに短くなっていることが分かります。

上記の実験に利用したサンプルコードはこちらです。

動作サンプル http://kholy.gehirn.ne.jp/symfony-advent/2012/

と、ここまで書いてから、 HttpFoundation には BinaryFileResponse というバイナリファイルをレスポンスで返すためのクラスがあることに気付いてしまいました。

Last-Modified と ETag のほか、Range にも対応しているようです。

他に、拡張子やMIMEタイプをいい具合に扱ってくれる File クラスもあるようで…仕様を調べたことは勉強になりましたが…今回書いたサンプルは何の役にも立たないですね…。orz

参考記事

[Studying HTTP] HTTP Caching

言わずと知れたHTTP学習サイト Studying HTTP の「キャッシュ期限モデル」と「キャッシュ検証モデル」についてのコンテンツです。

オリジンサーバ、キャッシュサーバ、クライアントの3者において、キャッシュの正当性がどのような手続きを経て検証されるべきか、といった仕様が解説されています。

ハイパーテキスト転送プロトコル -- HTTP/1.1

[Studying HTTP] で翻訳された RFC 2616 の日本語訳です。

Qt での HTTP におけるキャッシュについて | Qt Japanese Blog

QtというフレームワークにおけるHTTPキャッシュについての記事ですが、HTTPキャッシュの仕様そのものが分かりやすく解説されていました。

max-age, s-maxage, must-revalidateディレクティブがどのように扱われるかが参考になりました。