LaravelのView CreatorでViewテンプレートを切り替える

こんにちは。エキサイト株式会社のあはれんです。

スマートフォンとその他デバイス(パソコン/タブレット)で表示するViewテンプレートを切り替える際の方法として、 LaravelのView Creatorを利用する方法を試してみましたので紹介します。

サンプルコードは以下で公開しています。

github.com

実装方法

1. ユーザーエージェント判定ライブラリを導入

スマートフォンとその他デバイス(パソコン/タブレット)を判定するためのライブラリを導入します。

github.com

composerでインストールします。

composer require jenssegers/agent

config/app.php にプロバイダー登録します。

Jenssegers\Agent\AgentServiceProvider::class,

2. 切り替え対象のViewテンプレートを用意

切り替え対象のViewテンプレートを以下のように用意します。 スマートフォン用のViewテンプレートには _sp というsuffixをつけます。

├── resources
│   └── views
│       ├── pages
│       │   ├── index.blade.php  // その他のデバイス(パソコン/タブレット)用
│       │   └── index_sp.blade.php // スマートフォン用

3. Viewテンプレートの切り替え処理を書いたクラスを作成

Viewテンプレートの切り替え処理を書いたクラスを作成します。

<?php

namespace App\View\Creators;

use Illuminate\View\View;
use Illuminate\View\Factory as ViewFactory;
use Jenssegers\Agent\Agent;

final class ViewSwitchCreator
{
    private const VIEW_NAME_SUFFIX = '_sp';

    public function __construct(
        private readonly Agent $agent,
        private readonly ViewFactory $viewFactory,
    ) {
    }

    public function create(View $view): void
    {
        $viewName = $view->getName();

        if ($this->agent->isMobile() !== true) {
            // spではない場合は置換する必要がないのでスキップ
            return;
        }

        if (str_contains($viewName, self::VIEW_NAME_SUFFIX)) {
            // sp向けviewテンプレートの指定がある場合は置換する必要がないのでスキップ
            return;
        }

        $viewSpName = $viewName . self::VIEW_NAME_SUFFIX;

        // sp向けviewテンプレートがない場合は何もせず、設定済みのviewテンプレートを表示する
        if ($this->viewFactory->exists($viewSpName)) {
            $finder = $this->viewFactory->getFinder();
            $view->setPath($finder->find($viewSpName));
        }
    }
}

3. ViewCreatorを登録

View Creatorを登録するサービスプロバイダを用意します。 ViewCreatorを利用しすべてのViewに切り替え処理が実行されるようにします。

<?php

namespace App\Providers;

use App\View\Creators\ViewSwitchCreator;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider;

class ViewCreatorServiceProvider extends ServiceProvider
{
    /**
     * Register any application services.
     */
    public function register(): void
    {
        //
    }

    /**
     * Bootstrap any application services.
     */
    public function boot(): void
    {
        View::creator("*", ViewSwitchCreator::class); // ワイルドカード(*)ですべてのViewに適応
    }
}

config/app.php にプロバイダー登録します。

App\Providers\ViewCreatorServiceProvider::class,

4. 確認ページを用意

確認用のページのコントローラーを用意します。

<?php

namespace App\Http\Controllers\Root;

use Illuminate\Contracts\View\View;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;

final class RootIndexGetController extends Controller
{
    public function __construct()
    {
    }

    public function __invoke(Request $request): View
    {
        return view('pages.index');
    }
}

routes/web.php にルートを設定します。

<?php

use App\Http\Controllers\Root\RootIndexGetController;
use Illuminate\Support\Facades\Route;

Route::get('/', RootIndexGetController::class);

該当のページにアクセスするとViewテンプレートが切り替わることを確認できます。

テスト方法

コントローラーのテスト (HTTPのテスト)

コントローラーのテストでは、 ユーザーエージェント情報を込めてリクエストを送り、ユーザーエージェントによって利用されるViewテンプレートが異なることを確認します。

<?php

namespace Tests\Feature\App\Http\Controllers\Root;

use Tests\TestCase;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\Response as HttpResponse;

class RootIndexGetTest extends TestCase
{
    public const MOBILE_USER_AGENT =
    'Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/W.X.Y.Z Mobile Safari/537.36 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)';
    public const PC_USER_AGENT =
    'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; Googlebot/2.1; +http://www.google.com/bot.html) Chrome/W.X.Y.Z Safari/537.36';

    public function test_ユーザーエージェントがSPである場合SP用の表示になるべき(): void
    {
        // 1. テストデータの用意
        // 無し

        // 2. テスト対象の実行
        $response = $this->get(
            uri: '/',
            headers: [
                'User-Agent' => self::MOBILE_USER_AGENT,
            ]
        );

        // 3. 検証
        $this->assertSame($response->status(), HttpResponse::HTTP_OK, 'http status codeが200になるべき');

        /**
         * @var View $view
         */
        $view = $response->original;
        $actualViewPath = $view->getPath();
        // ViewSwitchCreatorでPathしか変更しないのでview名を評価するassertViewHasは利用できない。またテストの環境によって絶対Pathが異なるためSP用テンプレートが含まれるかで判定する
        $this->assertStringContainsString('index_sp.blade.php', $actualViewPath);
    }

    public function test_ユーザーエージェントがPCである場合デフォルト用の表示になるべき(): void
    {
        // 1. テストデータの用意
        // 無し

        // 2. テスト対象の実行
        $response = $this->get(
            uri: '/',
            headers: [
                'User-Agent' => self::PC_USER_AGENT,
            ]
        );

        // 3. 検証
        $this->assertSame($response->status(), HttpResponse::HTTP_OK, 'http status codeが200になるべき');
        $response->assertViewIs('pages.index');
    }
}

コントローラー自体にはViewテンプレートの切り替え処理が書かれていないのに切り替えのテストケースが存在する、違和感のあるテストになりました。

また、Viewテンプレートの切り替え処理ではPath変更しておらず、View名は変わらないままなのでassertViewHasが利用できない問題があります。

Viewテンプレートの切り替え処理のテスト

実際にViewテンプレートを操作してテストするのは難しいので、 Viewテンプレートを操作する関数をMockし適切に呼び出せているかで検証しています。

<?php

declare(strict_types=1);

namespace Test\Unit\View\Creators\ViewSwitchCreator;

use Illuminate\View\Factory as ViewFactory;
use Illuminate\View\View;
use Illuminate\View\ViewFinderInterface;
use Mockery;
use Mockery\MockInterface;
use App\View\Creators\ViewSwitchCreator;
use Jenssegers\Agent\Agent;
use Tests\TestCase;

final class ViewSwitchCreatorCreateTest extends TestCase
{
    public function test_ユーザーエージェントがspである場合sp指定のviewのPathに変更されているべき(): void
    {
        // 1. テストデータの用意と 3.検証
        // Illuminate関数を適切に呼び出せているかを検証する

        $spViewName = 'pages.test.index_sp';
        $spViewPath = '/resources/views/pages/test/index_sp.blade.php';

        $view = Mockery::mock(View::class, function (MockInterface $mock) use ($spViewPath) {
            // view名が取得されているべき。返り値はデフォルトのview名。
            $mock->shouldReceive('getName')
                ->once()
                ->andReturn('pages.test.index');

            // sp版のviewのpathを設定するべき。
            $mock->shouldReceive('setPath')
                ->once()
                ->with($spViewPath);
        });

        $viewFinder = Mockery::mock(ViewFinderInterface::class, function (MockInterface $mock) use ($spViewName, $spViewPath) {
            // sp版のview名でファイルPathの検索が行われているべき。
            $mock->shouldReceive('find')
                ->once()
                ->with($spViewName)
                ->andReturn($spViewPath);
        });
        $viewFactory = Mockery::mock(ViewFactory::class, function (MockInterface $mock) use ($spViewName, $viewFinder) {
            // sp版のview名でファイルの存在チェックが行われているべき。
            $mock->shouldReceive('exists')
                ->once()
                ->with($spViewName)
                ->andReturnTrue();

            // Finderインスタンスが取得されているべき。
            $mock->shouldReceive('getFinder')
                ->once()
                ->andReturn($viewFinder);
        });

        $agent = Mockery::mock(Agent::class, function (MockInterface $mock) {
            // ユーザーエージェントによるsp判定処理が行われているべき。
            $mock->shouldReceive('isMobile')
                ->once()
                ->andReturnTrue();
        });

        // 2. テスト対象の実行
        $viewSwitchCreator = new ViewSwitchCreator(
            agent: $agent,
            viewFactory: $viewFactory
        );
        $viewSwitchCreator->create($view);
    }
:

テストコードが長くなったのでブログ内では省略しています。 残りの部分に関しては公開しているサンプルコードで確認ください。

読めないことはないですが読みやすくはないテストコードになりました。 テスト対象がファイル操作なこともあってシンプルに書くのは少し難しかったです。

まとめ

実装してテストまでした結果、 LaravelのView Creatorを利用する方法には以下のメリット・デメリットがあるのではないかと思いました。

  • メリット:View Creatorを使うことで各ページのコントローラーに出し分けの処理を書く手間が省ける。
  • デメリット:コントローラー自体にはないテンプレートの切り替え処理をテストに記述する必要があり直感的でない。また、初めての人がコントローラーを見てもテンプレートが2種類あることが分かりづらい。

なにかの役に立てば幸いです。

スペシャルさんくす

コードレビューをしてくれた @nukisashinekoさん