Practical Symfony #25: Routerを拡張してURLのサブディレクトリを横断的に処理する

Symfony Advent Calendar 2014 (Qiita) 4日目

Webサービスで、ユーザーのアカウントごとにサブディレクトリを割り当てたいとします(最近はユーザーアカウントごとにサブドメインを割り当てる方が主流かもしれませんが)。例えば次のような形です。

トップページ

ユーザー hidenorigoto のコンテンツ

ユーザー someone のコンテンツ

このような形をとるシステムを、みなさん一度は実装したことがあるのではないでしょうか。


問題となる箇所

登録ユーザー(hidenorigoto や someone )がこのサービスのトップページへアクセスした時に「自分のプロフィール」というリンクを表示することを考えてみます。リンク先はプロフィール表示用のアクションで、ProfileControllerクラスのindexAction() で実装します。examplebundle_profile_index というルートを割り当てるとすると、ルート定義は次のようになります。

ルート定義の例

examplebundle_profile_index:
    path: /{userdir}/profile
    defaults: { _controller: ExampleBundle:Profile:index }

このように、ユーザーごとのサブディレクトリがパラメーター (userdir) になっています。

Symfonyのルーティング機能はとても便利で、コントローラーからでもテンプレートからでも(その他の任意の場所からでも)router サービスを使えば、透過的にURLを生成できます。ただし、必要なパラメーターは生成する箇所でその都度渡さなくてはなりません。今回の要件のようにほとんどすべてのURL生成にユーザーのサブディレクトリが必要な場合、どうなるでしょうか? あらゆる処理にログインユーザーオブジェクトを渡し、それを参照してサブディレクトリパラメーターを準備しなくてはなりません。次に示すようなコード(PHPコード、Twigテンプレートコード)をいろいろなところに書くことになってしまいそうです。

問題コード:コントローラー

<php
$subdirectory = $loginUser->getSubdirectory();
$url = $router->generate(‘examplebundle_profile_index’, [‘userdir’ => $subdirectory]);

問題コード:テンプレート

{% set subdirectory = loginUser.subdirectory %}
<a href=“{{ path(‘examplebundle_profile_index’, {‘userdir’: subdirectory}) }}”>プロフィール</a>

「リンクのためのURL生成」というのは、ページの処理本体(プロフィールを表示する etc)からすると補助的な処理でしかありません。補助的な処理のために、本来不要であった依存を1つ増やして機能を実現した、ということになります。このような依存関係は排除すべきです。


解決策:Router を拡張する

今回の例では、URLへのサブディレクトリの付加処理がアプリケーションの個々の機能とは直交しており、横断的な関心事であることが分かります。Webアプリケーションの横断的関心事の多くは、すでにフレームワークが担当している領域でもあります。サブディレクトリの付加はURLに関する処理なので、近い役割をすでに担当している Router を拡張するという方法をとってみます。


Router

Router を継承したクラスで generate() メソッドをオーバーライドします。ログインユーザーに応じてサブディレクトリパラメーターを自動的に追加するようにしています。

ExampleBundle/Routing/Router.php

<php
...
class Router extends RouterBase
{
    private $securityContext;

    public function setSecurityContext($securityContext)
    {
        $this->securityContext = $securityContext;
    }

    public function generate($name, $parameters = array(), $referenceType = self::ABSOLUTE_PATH)
    {
        // 除外
        if (preg_match('/^login/i', $name)) {

        // ルート名がexamplebundleから始まるものを対象
        } elseif (preg_match('/^examplebundle/i', $name)) {
            if ($this->securityContext->getToken()) {
                // ユーザーからディレクトリ名を取得
                // (エンティティの内容による)
                $loginUser = $this->securityContext->getToken()->getUser();
                $userDir = $loginUser->getSubdirectory();
            } else {
                $userDir = 'default';
            }

            // サブディレクトリパラメーターをマージ
            $parameters = array_merge($parameters, ['userdir' => $userDir]);
        }

        return parent::generate($name, $parameters, $referenceType);
    }
}

Router の置き換え定義

デフォルトの router サービスを置き換えます。

ExampleBundle/Resources/config/services.yml

router:
    class: ExampleBundle\Routing\Router
    parent: router.default
    calls:
      - [setSecurityContext, ["@security.context"]]

上記の例は router サービスを「後勝ち方式」で完全に置き換えてしまいます。といっても、もともとの router は router.default のエイリアスなので、定義本体は残っています。その router.default を、新しいルーターサービスの parent に指定しています。これで、組み込みの router サービスに変わって、拡張した router サービスが使われるようになります。

サービスの置き換えは上記の他に、router サービスをデコレートする方法などもあります。[*1]


アクセスしたリソースごとにアクセス権を確認する処理は?

リソースごとのアクセス権の処理は、ルーティングの役割とは別の話になります。ルーティングのレイヤーとしては、単にルート定義にしたがって、アクセスするリソース(someoneのプロフィール)が特定される、という基本機能をそのまま使うのもよいでしょう。

私が実務で今回紹介した拡張を利用したプロジェクトでは、サブディレクトリがそれぞれ完全に独立しており、他のユーザーのサブディレクトリのリソースは絶対に閲覧不可という要件だったため、セキュリティレイヤーの認証処理の一部として実装しました。この時は、他のユーザーのサブディレクトリは「存在しないモノ」という位置づけで処理していました。

Symfony Securityコンポーネントで推奨される方法としては、SecurityVoter を定義する方法[*2]があります。コントローラーごとに異なった指定がある場合等は、こちらの方法がよいですね。

また、Symfony勉強会で@brtriverさんが発表されていたルーティングレイヤーでアクセス権の処理を行うようにする方策[*3]もあります。

いずれにしても、ルートに対してのINとOUT(URL生成とURLへのアクセス)は対になっているので、ルート生成側で横断的な機能を作ったのであれば、URLへのアクセス側でも同じように何らかの横断的処理が必要となります。


まとめ

解決したい問題をどこで解決するとよいのか? 問題によって選択肢はいくつかあり得ますが、上手い場所、特にフレームワークやライブラリがすでに「問題」として扱っている(=その関心を対象とするクラスがある)ものに寄せると、それを軸に拡張して解決するといった方法がとれます。


参考