本日も乙

ただの自己満足な備忘録。

Mobile-Detectを拡張して端末別にビューファイルを切り替える

更新間隔がまた空いてしまいました。
最近異動が決まりまして10月から東京に転勤することになりましたので引越し準備に追われていました。

Webサイトを作成する際、エンジニアやデザイナーにとって考えなければならないのが端末別のデザインへの対応だと思います。
開発サービスやターゲット層によりますが、PC、タブレットスマホ、場合によってはフィーチャーフォン(ガラケー)など多くの端末に対応する必要があります。最近ではBootstrap3のようなレスポンシブデザインだとPCやスマホにも対応が簡単にできるかもしれませんが、フィーチャーフォンのようにJavaScriptCSSファイルを適用することが難しいこともありますので、多くの場合はUserAgentから端末判別して、端末別にビューファイル(HTMLなど)を作成してブラウザに表示させるか、サブドメイン(スマホならs.exaple.com, フィーチャーフォンならf.exaple.comなど)にリダイレクトさせることになるかと思います。

そこで今回は、Symfony2で端末ごとにビューファイル(twig)を切り替えることをしてみました。
端末を識別する仕組みが必要なので、Mobile-Detectを使ってみました。Mobile-DetectをSymfony2用に拡張したMobileDetectBundleもありますが、今回はビューファイルを切り替えるというシンプルな仕組みなのでこのバンドルは使いません。

今回の目標

  • Symfony2で端末別にビューファイル(twigテンプレート)を切り替えてブラウザに表示(レンダリング)する

環境

  • PHP 5.5.16
  • Symfony2 (2.3.16)
  • Mobile-Detect 2.8.4

前提条件

  • 以下の名前でバンドルを作成するとします
    • Shu1MobileDetectBundle
  • Symfony2の初期設定は完了して、正常にアプリケーションが動作しているとします

Mobile-Detectのインストール

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

# composer.json

{
...
    "require": {
        ...
        "mobiledetect/mobiledetectlib": "dev-master"
    }
}
$ composer update

Mobile-Detectを拡張したMobileDetectServiceを作成

Mobile-Detectをそのまま使っても良いのですが、後にFunctionalTestでUserAgentを設定してテストする場合、そのままでは動かなかったので拡張したサービスを作成します。

サービス登録する設定ファイルがデフォルトでXML(services.xml)になっているので、YAML(services.yml)に変更します。

// Shu1/MobileDetectBundle/DependencyInjection/Shu1MobileDetectExtension.php

<?php

namespace Shu1\MobileDetectBundle\DependencyInjection;

use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\HttpKernel\DependencyInjection\Extension;
use Symfony\Component\DependencyInjection\Loader;

/**
 * This is the class that loads and manages your bundle configuration
 *
 * To learn more see {@link http://symfony.com/doc/current/cookbook/bundles/extension.html}
 */
class Shu1MobileDetectExtension extends Extension
{
    /**
     * {@inheritDoc}
     */
    public function load(array $configs, ContainerBuilder $container)
    {
        $configuration = new Configuration();
        $config        = $this->processConfiguration($configuration, $configs);

        $loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__ . '/../Resources/config'));
        $loader->load('services.yml');
    }
}

services.ymlMobileDetectServiceを登録します。

services:
    shu1.mobiledetect.mobile_detect_service:
        class: Shu1\MobileDetectBundle\Services\MobileDetectService
        arguments: [ '@service_container' ]

MobileDetectServiceを作成します。

// Shu1/MobileDetectBundle/Services/MobileDetectService.php

<?php

namespace Shu1\MobileDetectBundle\Services;

use Detection\MobileDetect;
use Symfony\Component\DependencyInjection\Container;

/**
 * MobileDetectService Class
 *
 */
class MobileDetectService extends MobileDetect
{
    /**
     * @var \Symfony\Component\HttpFoundation\Request
     */
    protected $request;

    /**
     * @param Container $container
     */
    public function __construct(Container $container)
    {
        if ($container->isScopeActive('request')) {
            $this->request = $container->get('request');
        }
    }

    /**
     * @param null $userAgent
     * @param null $httpHeaders
     *
     * @return bool
     */
    public function isMobile($userAgent = null, $httpHeaders = null)
    {
        if (empty($userAgent)) {
            $userAgent = $this->request->headers->get('User-Agent');
        }

        if (empty($httpHeaders)) {
            $httpHeaders = [];
            // テストでHTTPヘッダを挿入する場合があるため、強制的にヘッダを書き換える
            foreach ($this->request->headers->all() as $header => $value) {
                $httpHeaders['HTTP_' . strtoupper($header)] = $value[0];
            }
        }

        return parent::isMobile($userAgent, $httpHeaders);
    }
}

Symfony2の場合、HTTPヘッダをHTTPプレフィックスを削除して格納しており、そのままMobile-Detect::isMobile()では使えないため、HTTPプレフィックスを付けたヘッダ内容に置き換えてMobile-Detect::isMobile()を呼び出しています。

Mobile-Detect::isMobile()の結果に応じてビューファイルを切り替えるイベントリスナーを作成

MobileDetectService::isMobile()を使って、ビューファイルを切り替える処理を行います。
通常なら、フロントコントローラか、各コントローラに処理を書くかもしれませんが、イベントリスナーという仕組みを使って、ビューファイルをレンダリングするタイミング(イベント)で切り替えるようにします。

services.ymlでイベントリスナーを登録します。
リスナー名は分かりやすくMobileDetectListenerという名前にします。

services:
    shu1.mobiledetect.mobile_detect_service:
        class: Shu1\MobileDetectBundle\Services\MobileDetectService
        arguments: [ '@service_container' ]

    shu1.mobiledetect.mobile_detect_listener:
        class: Shu1\MobileDetectBundle\EventListener\MobileDetectListener
        arguments: [ '@templating', '@shu1.mobiledetect.mobile_detect_service' ]
        tags:
            - { name: kernel.event_listener, event: kernel.view, method: onView, priority: 1 }

MobileDetectListenerを作成します。

// Shu1/MobileDetectBundle/EventListener/MobileDetectListener.php

<?php

namespace Shu1\MobileDetectBundle\EventListener;

use Symfony\Bundle\TwigBundle\TwigEngine;
use Shu1\MobileDetectBundle\Services\MobileDetectService;
use Symfony\Component\HttpKernel\Event\GetResponseForControllerResultEvent;

/**
 * MobileDetectListener
 *
 */
class MobileDetectListener
{
    /**
     * @var TwigEngine
     */
    protected $templating;

    /**
     * @var MobileDetectService
     */
    protected $mobileDetectService;

    /**
     * @param TwigEngine          $templating
     * @param MobileDetectService $mobileDetectService
     */
    public function __construct(TwigEngine $templating, MobileDetectService $mobileDetectService)
    {
        $this->templating = $templating;
        $this->mobileDetectService = $mobileDetectService;
    }

    /**
     * モバイル端末はモバイル用のページを表示させる
     *
     * @param GetResponseForControllerResultEvent $event
     */
    public function onView(GetResponseForControllerResultEvent $event)
    {
        // コントローラのreturn(array etc)を取得
        $data = $event->getControllerResult();
        $template = $event->getRequest()->get('_template');

        if ($this->mobileDetectService->isMobile()) {
            // ビューファイルを書き換える
            $template->set('name', $template->get('name'). '.mb');
        }

        $response = $this->templating->renderResponse($template, $data);
        $event->setResponse($response);
    }
}

モバイル端末の場合、ビューファイル(twigテンプレート)名がxxxx.mb.html.twigになり、それ以外はxxxx.html.twigとなります。
ガラケースマホを分けたい場合や、Android,iPhoneで分けたい場合など条件やテンプレートファイル名は適宜変更してください。

ビューファイルを作成します。テンプレートはtwigを使います。
モバイル版のテンプレートファイルを作成します。

<!-- Shu1/MobileDetectBundle/Resources/views/Default/index.mb.html.twig -->
<html>
<head>
    <title>モバイル版TOP</title>
</head>
<body>
モバイル版TOPページです。
</body>
</html>

モバイル以外(PC版)テンプレートファイルを作成します。

<!-- Shu1/MobileDetectBundle/Resources/views/Default/index.html.twig -->
<html>
<head>
    <title>TOP</title>
</head>
<body>
TOPページです。
</body>
</html>

コントローラを作成します。

// Shu1/MobileDetectBundle/Controller/DefaultController.php

<?php

namespace Shu1\MobileDetectBundle\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Template;


class DefaultController extends Controller
{

    /**
     * TOPページ
     *
     * @Route("/")
     * @Template()
     * @return array
     */
    public function indexAction()
    {
        return [];
    }
}

UserAgentを変更してブラウザからアクセスして、PC版とモバイル版で表示が切り替わっていればOKです。

テストコードを書く

先ほど少し書きましたが、FunctionalTestを行うことで、リクエストからレスポンスまでの総合的なテストを書くことができますので、ビューが切り替えることをテストします。

DefaultControllerTestを作成します。

// Shu1/MobileDetectBundle/Tests/Controller/DefaultControllerTest.php

<?php

namespace Shu1\MobileDetectBundle\Tests\Controller;

use Symfony\Bundle\FrameworkBundle\Client;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

/**
 * DefaultControllerTest Class
 *
 */
class DefaultControllerTest extends WebTestCase
{

    /**
     * PC版TOPページの表示テスト
     *
     */
    public function testShowIndexPageViewOfPc()
    {
        $client = static::createClient();

        // My UserAgent
        $client->setServerParameters(
            [
                'HTTP_USER_AGENT' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/36.0.1985.125 Safari/537.36',
            ]
        );
        $crawler = $client->request('GET', '/');

        $this->assertEquals(200, $client->getResponse()->getStatusCode());
        $this->assertGreaterThan(0, $crawler->filter('html:contains("TOPページです")')->count());
    }

    /**
     * モバイル端末でのTOPページの表示テスト
     *
     */
    public function testShowIndexPageViewOfMobile()
    {
        $client = static::createClient();

        $client->setServerParameters(
            [
                'HTTP_USER_AGENT' => 'Mozilla/5.0 (iPhone; U; CPU iPhone OS 2_0_1 like Mac OS X; ja-jp) AppleWebKit/525.18.1 (KHTML, like Gecko) Version/3.1.1 Mobile/5B108 Safari/525.20',
            ]
        );

        $crawler = $client->request('GET', '/');

        $this->assertEquals(200, $client->getResponse()->getStatusCode());
        $this->assertGreaterThan(0, $crawler->filter('html:contains("モバイル版TOPページです")')->count());
    }
}

モバイル版のUserAgentにiPhoneをテストに使ってみました。
アサーション内容がイマイチだし、テストケースが貧弱ですが、サンプルなのでご容赦ください。他のUserAgentを増やしてテストしたい場合は、DataProviderを使ってみてください。

PHPUnitを実行してテストが通れば完了です。

$ bin/phpunit -c app

最後に

Symfony2でイベントリスナーを利用して、PC、スマホで別々のビューファイルをレンダリングしてみました。
これよりももっと柔軟に拡張したい場合は、冒頭に紹介したMobileDetectBundleを使ったほうが楽かもしれません。

しかし、特殊な事情があり、MobileDetectBundleで対応できない場合は、自前で今回紹介したような対応を取る必要があります。
最近仕事で、PC、スマホフィーチャーフォン別にビューファイルを切り替えなければならない場合があり、しかもフィーチャーフォンの場合は、各携帯キャリアが公開しているIPアドレス帯域(後述)からアクセスしているかで端末判別する必要があったので、端末判別まで自前で書きました。*1
最近は、Wi-Fi対応したフィーチャーフォンも増えてきたため、IPアドレス帯域で判別するべきか難しい判断ですが、未だに需要があると思っています。
とはいえ、肝心のサービスには直接関係しないバックグラウンドな機能なのでなるべく楽な方法で対応したいものですね。

参考URL

*1:いつか紹介できればと思います