k-holyのPHPとか諸々メモ

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

SilexでUrlGeneratorを使ったサブディレクトリ運用でサブリクエスト

相変わらずSilexとPimpleを拡張した自作フレームワークを行ったり来たりしています。

SilexアプリケーションでUrlGeneratorを使ったサブディレクトリ運用への対応(たとえば http://example.com/foo/ 以下をアプリケーションのルートとする)で、コントローラから別のコントローラに処理を委譲する、いわゆるサブリクエストをApplicationクラスに実装しようとしてハマってしまいました。

Silexのサブリクエスト機能

公式ドキュメントにはサブリクエストの方法として以下のようなコードが記載されています。

<?php
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\HttpKernelInterface;

$app->get('/', function () use ($app) {
    // redirect to /hello
    $subRequest = Request::create('/hello', 'GET');

    return $app->handle($subRequest, HttpKernelInterface::SUB_REQUEST);
});

しかし、サブディレクトリで運用している場合、上記の実装ではうまく動いてくれません。

リンクのパスをUrlGeneratorで生成した際、サブリクエスト時に生成されるパスが、サブディレクトリ部分を除いたものになってしまうのです。

UrlGenerator (実体は Symfony\Component\Routing\Generator\UrlGenerator) のコードを追ったところ、Symfony\Component\Routing\RequestContext::getBaseUrl() メソッドが呼ばれており、これがURLの生成時にサブディレクトリを付与してくれていることが分かりました。

この RequestContext が上記コードでサブリクエストのために生成している Request::create() を元に生成されるのですが、このサブリクエストで本来のリクエストが持っていた baseUrl が失われているために、このような現象が起きていたわけです。

しかしRequestクラスには setBaseUrl() や setBasePath() といったメソッドは用意されていません。

更にコードを追った結果、Request::getBasePath() や Request::getBaseUrl() が返す値はRequestオブジェクトのserverプロパティの値を元に生成されており、上記のようなコードでサブリクエストを生成した場合、その値が失われてしまうことが原因だと分かりました。

SilexでRequestオブジェクトがどのように生成され利用されるかは、以下の Application::run() および Application::handle() のコードを読むと何となく分かります。

Silex\Application.php より一部抜粋

<?php

/**
 * The Silex framework class.
 *
 * @author Fabien Potencier <fabien@symfony.com>
 */
class Application extends \Pimple implements HttpKernelInterface, TerminableInterface
{

    /**
     * Handles the request and delivers the response.
     *
     * @param Request $request Request to process
     */
    public function run(Request $request = null)
    {
        if (null === $request) {
            $request = Request::createFromGlobals();
        }

        $response = $this->handle($request);
        $response->send();
        $this->terminate($request, $response);
    }

    /**
     * {@inheritdoc}
     *
     * If you call this method directly instead of run(), you must call the
     * terminate() method yourself if you want the finish filters to be run.
     */
    public function handle(Request $request, $type = HttpKernelInterface::MASTER_REQUEST, $catch = true)
    {
        if (!$this->booted) {
            $this->boot();
        }

        $current = HttpKernelInterface::SUB_REQUEST === $type ? $this['request'] : $this['request_error'];

        $this['request'] = $request;

        $this->flush();

        $response = $this['kernel']->handle($request, $type, $catch);

        $this['request'] = $current;

        return $response;
    }

}

通常は run() メソッド内でスーパーグローバル変数を元に生成されたRequestオブジェクトが handle() メソッドでルーティング処理された結果、コントローラ(無名関数)が呼ばれるようですが、前述の通り、コントローラから別途生成したRequestオブジェクトを第1引数に、HttpKernelInterface::SUB_REQUEST を第2引数に指定してhandle() メソッドを呼ぶと、一時的にコンテナ内のRequestオブジェクトが入れ替えられるようになっています。

サブリクエスト機能は、いわば擬似的なリクエストをアプリケーション内部で生成することで、サーバ側でリダイレクトのような処理を実現しているわけで、本来のリクエストが持っていた情報をちゃんと引き継ぐ必要があるということです。

Applicationクラスにサブリクエスト用のforward()メソッドを実装する

今回の問題への対策を含めて、Silexアプリケーション用のApplicationクラスにサブリクエストを実行するためのforward()メソッドを実装してみました。

<?php
/**
 * @copyright 2013 k-holy <k.holy74@gmail.com>
 * @license The MIT License (MIT)
 */

namespace Acme;

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\HttpKernelInterface;

/**
 * アプリケーションクラス
 *
 * @author k.holy74@gmail.com
 */
class Application extends \Silex\Application
{

    use \Silex\Application\UrlGeneratorTrait;
    use \Silex\Application\TwigTrait;

    /**
     * 指定されたパスへのサブリクエストを生成し、処理結果を返します。
     * パスに {foo} が含まれる場合、パラメータ foo の値に置換されます。
     *
     * @param string リクエスト対象のパス
     * @param array パラメータ
     * @return mixed サブリクエスト結果
     */
    public function forward($path, $parameters = array())
    {
        $path = preg_replace_callback('/{([^}]+)}/', function($matches) use ($parameters) {
            return (isset($parameters[$matches[1]]))
                ? rawurlencode($parameters[$matches[1]])
                : $matches[0];
        }, $path);

        $method = $this['request']->getMethod();
        $parameters = array();
        switch($method) {
        case 'GET':
            $parameters = $this['request']->query->all();
            break;
        case 'POST':
            $parameters = $this['request']->request->all();
            break;
        }

        return $this->handle(
            Request::create(
                $this['request']->getBasePath() . $path,
                $method,
                $parameters,
                $this['request']->cookies->all(),
                $this['request']->files->all(),
                $this['request']->server->all()
            ),
            HttpKernelInterface::SUB_REQUEST
        );
    }

}

/hello/{name} のようなパラメータ付きルーティングに対応するために、パスの置換機能も付けました。

Routingコンポーネントにも同様の機能があるはずですが、探すのが面倒だったのとこれだけの機能のために依存ができるのも嫌なので、自前実装です。

公式のサンプルが前述のようなコードになっていたのも何か理由があると思うのですが、とりあえず元のリクエストから引き継げるものは引き継ぐようにしています。

(まだこの機能の利用経験がなく問題を把握できていないので、理由をご存知の方は指摘してくださると助かります…)

ともあれ、こうすることで利用側のスクリプトはこんな風に書けるようになりました。

<?php
/**
 * デモアプリケーション
 *
 * @copyright 2013 k-holy <k.holy74@gmail.com>
 * @license The MIT License (MIT)
 */
$app = include __DIR__ . '/../app.php';

use Acme\Application;

use Symfony\Component\HttpFoundation\Request;

// index
$app->get('/', function(Application $app, Request $request) {

    return $app->forward('/home');

});

// ホーム
$app->get('/home', function(Application $app, Request $request) {

    return $app->render('index.html', [
        'title'  => 'ホーム',
    ]);

})->bind('home');

// Hello
$app->get('/hello', function(Application $app, Request $request) {

    return $app->forward('/hello/{name}', ['name' => '名無し']);

})->bind('hello');

// Hello (with name)
$app->get('/hello/{name}', function(Application $app, Request $request, $name) {

    return $app->render('hello.html', [
        'title'  => 'こんにちは',
        'name'   => $name,
    ]);

})->bind('hello-with-name');

$app->run();

そしてテンプレート側、たとえば hello.htmlはこんな感じに。

(TwigでUrlGeneratorも併用している場合)

{% extends 'layout.html' %}

{% block content %}
<div class="content">

<p>こんにちは {{name}} さん</p>

<ul>
  <li><a href="{{app.path('hello-with-name', {name:name})}}">{{name}}さん</a></li>
  <li><a href="{{app.path('hello')}}">こんにちは</a></li>
</ul>

</div>
{% endblock %}

Twigのように、テンプレートから呼ぶ関数やメソッドの引数に配列を渡す書式が定められている場合は良いんですが、そうでない場合は一工夫必要になりますね。

これで、hello-with-nameの方はテンプレート変数 "name" の値がTraitによって実装されたApplicationオブジェクトのpathメソッドに渡されて生成されるパスによって"/hello/{name}"のコントローラが呼ばれ、helloの方は"/hello"コントローラによってパスの変数 name に "名無し" が渡されてサブリクエスト処理されました。

[追記]

公式ドキュメントをよく読んだら、Cookbookに独立したページとして How to make sub-requests - Documentation - Silex がありました。

"Dealing with the request base URL" として項目になってます。(最初にこれを見ていれば…)

In the context of sub-requests this can lead to issues, because if you do not prepend the base path the request could mistake a part of the path you want to match as the base path and cut it off. You can prevent that from happening by always prepending the base path when constructing a request:

<?php
$url = $request->getUriForPath('/');
$subRequest = Request::create($url, 'GET', array(), $request->cookies->all(), array(), $request->server->all());

This is something to be aware of when making sub-requests by hand.

また、そちらのサンプルコードには、セッション変数にも配慮された以下のようなものもありました。

<?php
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\HttpKernelInterface;

$subRequest = Request::create('/', 'GET', array(), $request->cookies->all(), array(), $request->server->all());
if ($request->getSession()) {
    $subRequest->setSession($request->getSession());
}

$response = $app->handle($subRequest, HttpKernelInterface::SUB_REQUEST, false);