Chapter 07

TwigExtensionとUrlGenerator(など)を自作して面倒な処理を効率化

たつきち
たつきち
2022.07.29に更新

この章に対応するコミット

デモアプリは日本語と英語に対応するためロケールに応じて日時の文字列表現のフォーマットを変えているので、コミットの内容は本文の解説と若干異なります。

TwigExtensionとUrlGenerator(など)を自作して面倒な処理を効率化

ここでさらに、画面の描画や画面遷移の制御のための便利な処理を部品化しておきます💪

TwigExtensionを自作

まず、Twig内でよく使う処理を関数やフィルタとして定義するための TwigExtension を自作します。

config/services.yaml で以下のようにタグ付けをしておいた上で、

App\Twig\AppExtension:
    tags: ['twig.extension']

以下のように Twig\Extension\AbstractExtension クラスを継承して実装すればOKです。

class AppExtension extends AbstractExtension
{
    private RoleManager $rm;
    private TranslatorInterface $translator;

    public function __construct(RoleManager $rm, TranslatorInterface $translator)
    {
        $this->rm = $rm;
        $this->translator = $translator;
    }

    public function getFunctions()
    {
        return [
            new TwigFunction('roles', [$this, 'roles'], ['is_safe' => ['html']]),
        ];
    }

    public function getFilters()
    {
        return [
            new TwigFilter('datetime', [$this, 'datetime']),
        ];
    }

    public function roles(UserInterface $user): string
    {
        $badges = [];

        foreach ($this->rm->getReachableRoles($user) as $role) {
            $badges[] = sprintf('<span class="badge badge-secondary">%s</span>', $this->translator->trans($role));
        }

        return implode(' ', $badges);
    }

    public function datetime(?\DateTimeInterface $datetime): string
    {
        $days = ['日', '月', '火', '水', '木', '金', '土'];

        return $datetime === null ? '' : sprintf($datetime->format('Y/m/d(%\s) H:i:s'), $days[(int) $datetime->format('w')]);
    }
}

これで、Twigファイル内で roles()|datetime という機能を使えるようになります。

これを使って、ビューのコードを変更します。例えば、 user/_detail.html.twig は以下のようになります。

  <div class="table-responsive">
    <table class="table table-sm table-hover">
      <tbody>
      <tr>
        <th>{% trans %}Id{% endtrans %}</th>
        <td>{{ user.id }}</td>
      </tr>
      <tr>
        <th>{% trans %}Email{% endtrans %}</th>
        <td>{{ user.email }}</td>
      </tr>
      <tr>
        <th>{% trans %}Roles{% endtrans %}</th>
-       <td>
-         {% for role in user.roles %}
-           {% if role != 'ROLE_USER' %}
-             <span class="badge badge-secondary">{{ role|trans }}</span>
-           {% endif %}
-         {% endfor %}
-       </td>
+       <td>{{ roles(user) }}</td>
      </tr>
      <tr>
        <th>{% trans %}Display name{% endtrans %}</th>
        <td>{{ user.displayName }}</td>
      </tr>
      <tr>
        <th>{% trans %}Last logged in at{% endtrans %}</th>
-       <td>{{ user.lastLoggedInAt|date('Y/m/d H:i:s') }}</td>
+       <td>{{ user.lastLoggedInAt|datetime }}</td>
      </tr>
      <tr>
        <th>{% trans %}Created at{% endtrans %}</th>
-       <td>{{ user.createdAt|date('Y/m/d H:i:s') }}</td>
+       <td>{{ user.createdAt|datetime }}</td>
      </tr>
      <tr>
        <th>{% trans %}Updated at{% endtrans %}</th>
-       <td>{{ user.updatedAt|date('Y/m/d H:i:s') }}</td>
+       <td>{{ user.updatedAt|datetime }}</td>
      </tr>
      </tbody>
    </table>
  </div>

スッキリ書けて嬉しいですね!

こんな感じで、この後もTwigで使い回せると楽になりそうな処理や表現が出てきたら随時 AppExtension に追加していくことにします👍

UrlGenerator(など)を自作

もう1つ作っておくのがこれで、デフォルトのUrlGeneratorをラップして便利な機能を生やし、それをコントローラやTwigから使えるようにしておくというものです。

何を言っているか分からないと思うので、もう少し詳しく説明します💪

例1

例えば、以下のような操作を想像してみてください。

  1. ユーザー一覧画面を最終ログイン日時降順でソートして、2ページ目を表示している
  2. この状態から、あるユーザーの 編集 リンクをクリックする
  3. ユーザー編集画面でフォームを送信して、編集完了後にユーザー一覧画面にリダイレクトされる
  4. ソートもページ送りもリセットされた状態でユーザー一覧画面が表示される

4のステップにおいて、できれば元どおり最終ログイン日時降順の2ページ目が表示されてほしくないですか?

少なくとも僕の感覚ではそうなってほしいです。

なのでこういう場合、一覧画面から編集画面へ行くときに ?returnTo={もともと閲覧していた一覧画面のURL(ページネーション情報のパラメータ付き)} をURLパラメータにぶら下げた状態で移動して、編集画面からリダイレクトするときは

  • returnTo パラメータがあればその値へ
  • なければ /user/

という感じでリダイレクトするようにします。

例2

例1とほとんど同じですがもう1パターン。

  1. ユーザー一覧画面を最終ログイン日時降順でソートして、2ページ目を表示している
  2. この状態から、あるユーザーの 編集 リンクをクリックする
  3. ユーザー編集画面で キャンセル リンクをクリックしてユーザー一覧画面に戻る
  4. ソートもページ送りもリセットされた状態でユーザー一覧画面が表示される

この場合も、4のステップにおいて 元どおり最終ログイン日時降順の2ページ目が表示されてほしい ですよね。

なのでこういう場合、編集画面の キャンセル のリンク先は

  • returnTo パラメータがあればその値
  • なければ /user/

となるように実装しています。

実際の対応方法

上記のような処理をそれぞれの箇所で書くのはDRYじゃないので、以下の3つを作ることでこれらの処理を効率的に呼び出せるようにします。

  • コントローラに $this->redirectToRoute() の代わりに使える $this->redirectToRouteOrReturn() メソッド( returnTo パラメータがあればそちらにリダイレクトする)を生やすためのTrait
  • 現在のリクエストURLを returnTo パラメータとして付加する機能を持った自作UrlGenerator
  • その自作UrlGeneratorをビューから使うためのTwig拡張

それぞれコードを見てみましょう。

コントローラ用のTrait

// src/Routing/ReturnToAwareControllerTrait.php

namespace App\Routing;

use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;

/**
 * @property ContainerInterface $container
 */
trait ReturnToAwareControllerTrait
{
    protected function redirectOrReturn(string $url, int $status = 302): RedirectResponse
    {
        if ($returnTo = $this->container->get('request_stack')->getCurrentRequest()->query->get('returnTo')) {
            return new RedirectResponse($returnTo, $status);
        }

        return new RedirectResponse($url, $status);
    }

    protected function redirectToRouteOrReturn(string $route, array $parameters = [], int $status = 302): RedirectResponse
    {
        return $this->redirectOrReturn($this->container->get('router')->generate($route, $parameters), $status);
    }
}

こんな感じです。

コントローラは AbstractController を継承していることを前提にしているので、 $this->container から現在のリクエストやデフォルトのUrlGeneratorを取得して使っています。

コントローラでは、このTraitを use した上で $this->redirectToRouteOrReturn('user_index'); などと書いておけば、

  • returnTo パラメータがあればそこへリダイレクト
  • なければ /user/ へリダイレクト

という振る舞いをしてくれます。

というわけで、今後はコントローラでのリダイレクト処理はすべて このように $this->redirectToRoute() の代わりに $this->redirectToRouteOrReturn() を使うことにします👍

自作UrlGenerator

// src/Routing/ReturnToAwareUrlGenerator.php

namespace App\Routing;

use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;

class ReturnToAwareUrlGenerator
{
    private RequestStack $requestStack;
    private UrlGeneratorInterface $generator;

    public function __construct(RequestStack $requestStack, UrlGeneratorInterface $generator)
    {
        $this->requestStack = $requestStack;
        $this->generator = $generator;
    }

    public function generate(string $name, array $parameters = [], int $referenceType = UrlGeneratorInterface::ABSOLUTE_PATH): string
    {
        if ($returnTo = $this->requestStack->getCurrentRequest()->query->get('returnTo')) {
            return $returnTo;
        }

        return $this->generator->generate($name, $parameters, $referenceType);
    }

    public function generateWithReturnTo(string $name, array $parameters = [], int $referenceType = UrlGeneratorInterface::ABSOLUTE_PATH): string
    {
        $request = $this->requestStack->getCurrentRequest();

        $parameters = array_merge_recursive($parameters, ['returnTo' => $request->query->get('returnTo') ?? $request->getUri()]);

        return $this->generator->generate($name, $parameters, $referenceType);
    }
}

こんな感じです。

UrlGeneratorInterface を実装してアプリ全体でサービスを差し替えてもいいのですが、依存が多くて実装が面倒なのでやりません😅

実質このあと作るTwig拡張からしか利用しないサービスなので、デフォルトのUrlGeneratorをラップしたサービスとして作るだけで十分です。

generate() メソッドは、 returnTo パラメータがあればそのURLを返し、なければデフォルトUrlGeneratorと同じ結果を返します。

generateWithReturnTo() メソッドは、デフォルトのUrlGeneratorと同じ結果を作成した上で、さらに returnTo パラメータを付加したURLを返ます。
ただし、現在のリクエストURLにすでに returnTo パラメータが含まれている場合は、最終的な returnTo パラメータの内容は現在のリクエストURLではなくもともとの returnTo パラメータの内容を踏襲します。

このあたりの条件、文章で読むとかなりややこしく感じると思いますが、実際に画面を動かしてみればこの条件でよさそうということが直感的に分かっていただけると思うので、あまり深く考えずにあとで実際に画面を動かしてみてください😅

Twig拡張

// src/Twig/RoutingExtension.php

namespace App\Twig;

use App\Routing\ReturnToAwareUrlGenerator;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;

class RoutingExtension extends AbstractExtension
{
    private ReturnToAwareUrlGenerator $returnToAwareUrlGenerator;

    public function __construct(ReturnToAwareUrlGenerator $returnToAwareUrlGenerator)
    {
        $this->returnToAwareUrlGenerator = $returnToAwareUrlGenerator;
    }

    public function getFunctions()
    {
        return [
            new TwigFunction('pathOrReturnTo', [$this, 'pathOrReturnTo']),
            new TwigFunction('pathWithReturnTo', [$this, 'pathWithReturnTo']),
        ];
    }

    /**
     * @see \Symfony\Bridge\Twig\Extension\RoutingExtension::getPath()
     */
    public function pathOrReturnTo(string $name, array $parameters = [], bool $relative = false)
    {
        return $this->returnToAwareUrlGenerator->generate($name, $parameters, $relative ? UrlGeneratorInterface::RELATIVE_PATH : UrlGeneratorInterface::ABSOLUTE_PATH);
    }

    public function pathWithReturnTo(string $name, array $parameters = [], bool $relative = false)
    {
        return $this->returnToAwareUrlGenerator->generateWithReturnTo($name, $parameters, $relative ? UrlGeneratorInterface::RELATIVE_PATH : UrlGeneratorInterface::ABSOLUTE_PATH);
    }
}

こんな感じです。

さっき作った ReturnToAwareUrlGenerator を使って、 pathOrReturnTo()pathWithReturnTo() という2つのTwig関数を定義しています。

あえて既存の AppExtension とは分けて RoutingExtension という別クラスで定義しました。なので、 config/services.yaml

App\Twig\RoutingExtension:
    tags: ['twig.extension']

を追記しておく必要があります✋

あとは、

するようにビューを修正すれば完了です🙌

手元でコードを再現して動かしている方や、完成品のデモ環境を見ている方はぜひ実際に一覧画面と追加・編集画面の間の画面遷移をいろいろ試してみてください。

直感的に気持ちよい動作になっていることが実感いただけると思います💪