Do You PHP はてブロ

Do You PHPはてなからはてブロに移動しました

Symfony2でCSVダウンロードいろいろ

どのフレームワークを使おうが使うまいが、毎回必要になってる気がするCSVダウンロードですが、Symfony2でどう実装したら良いのかまとめてみました。

今回の環境

  • PHP5.4.3
  • Symfony2.0.15

Controllerで出力フォーマットを判定

まずは一番素直でベタなやり方です。
フローとしては、データを取得しView(Twig)でレンダリングした後にレスポンスヘッダを変更する、といったものです。

<?php
namespace Acme\SampleBundle\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\Controller;

class ItemController extends Controller
{
    /**
     * Lists all Item entities.
     *
     * @Route("/list.{_format}",
     *        name="item_list",
     *        requirements={"_format" = "html|csv"},
     *        defaults={"_format"="html"})
     */
    public function listAction($_format)
    {
        /**
         * データを作成し、$this->renderなどでCSV形式でレンダリングした
         * Responseオブジェクト($response)を取得する
         */
        $response = $this->render("AcmeSampleBundle:Item:index.{$_format}.twig", [...]);

        if ($this->getRequest()->getRequestFormat() === 'csv') {
            $contents = mb_convert_encoding($response->getContent(), 'SJIS-win', mb_internal_encoding());
            $response->headers->set('Content-Type', "application/octet-stream; name=item.csv");
            $response->headers->set('Content-Disposition', "attachment; filename=item.csv");
            $response->setContent($contents);
        }
        return $response;
    }
}

ルーティングパラメータ _format

Symfony2では、ルーティングに使えるパラメータ"_format"によってHTTPレスポンスのContent-Typeを自動的に変更する機能を持っていますが、せっかくならこれを使いたいところです。

しかし、標準では以下のものしか定義されておらず、_formatパラメータに'csv'を指定してもContent-Typeは標準のtext/htmlになってしまいます。

<?php

namespace Symfony\Component\HttpFoundation;

class Request
{
         :
    /**
     * Initializes HTTP request formats.
     */
    static protected function initializeFormats()
    {
        static::$formats = array(
            'html' => array('text/html', 'application/xhtml+xml'),
            'txt'  => array('text/plain'),
            'js'   => array('application/javascript', 'application/x-javascript', 'text/javascript'),
            'css'  => array('text/css'),
            'json' => array('application/json', 'application/x-json'),
            'xml'  => array('text/xml', 'application/xml', 'application/x-xml'),
            'rdf'  => array('application/rdf+xml'),
            'atom' => array('application/atom+xml'),
        );
    }
}

EventListenerを使ってみる

EventListenerについては、Symfony2日本語ドキュメント等を参照してください:-)

ざっくり言うと、

Webアプリケーションが実行されるいくつかのタイミングで「イベント」が発生するが、どのイベントでどういう処理をするか?を「リスナー」として定義する

な感じです。
今回は以下の仕様でEventListenerを作ってみました。

  • kernel.requestイベント発生時にCSV用のContent-Type設定を追加する
  • kernel.responseイベント発生時に出力フォーマットを判定し、"csv"であればContent-Dispositionヘッダを追加、かつ、レスポンスボディをSJIS-winに変換する

ちなみに、以下のURLが非常に参考になります。

作成したソースコードは以下のようになります。ダウンロードする際のファイル名は、

ルート名(item_list)+".csv"

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

<?php
namespace Acme\SampleBundle\EventListener;

use Symfony\Component\HttpKernel\Event\FilterResponseEvent;
use Symfony\Component\HttpKernel\Event\GetResponseEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;

class CsvResponseListener
{
    /**
     * kernel.requestイベント発生時にCSV用のContent-Type設定を追加する
     */
    public function onKernelRequest(GetResponseEvent $event)
    {
        $event->getRequest()->setFormat(
            'csv',
            "application/octet-stream; name={$event->getRequest()->get('_route')}.csv"
        );
    }

    /**
     * kernel.responseイベント発生時に出力フォーマットを判定し、
     * "csv"であればContent-Dispositionヘッダを追加、かつ、
     * レスポンスボディをSJIS-winに変換する
     */
    public function onKernelResponse(FilterResponseEvent $event)
    {
        if ($event->getRequest()->getRequestFormat() === 'csv') {
            $response = $event->getResponse();
            $response->setContent(
                mb_convert_encoding(
                    $response->getContent(), 'SJIS-win', mb_internal_encoding()
            ));
            $response->headers->set(
                'Content-Disposition',
                "attachment; filename={$event->getRequest()->get('_route')}.csv"
            );
        }
    }
}

続いて、EventListenerをサービスとして登録するため、Resources\config\services.ymlに設定を追加します。

#
# services.yml
#
services:
    acme.listener.request:
        class: Bizen\CommonBundle\EventListener\CsvResponseListener
        tags:
            - { name: kernel.event_listener, event: kernel.request, method: onKernelRequest }
    acme.listener.csvEncoding:
        class: Bizen\CommonBundle\EventListener\CsvResponseListener
        tags:
            - { name: kernel.event_listener, event: kernel.response, method: onKernelResponse }

これで、Controller側のコードをバッサリ削れます:-)

<?php
namespace Acme\SampleBundle\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\Controller;

class ItemController extends Controller
{
    /**
     * Lists all Item entities.
     *
     * @Route("/list.{_format}",
     *        name="item_list",
     *        requirements={"_format" = "html|csv"},
     *        defaults={"_format"="html"})
     */
    public function listAction($_format)
    {
        /**
         * ここまでに$this->renderなどでResponseオブジェクト($response)を取得
         */
        $response = $this->render("AcmeSampleBundle:Item:index.{$_format}.twig", [...]);

//        if ($this->getRequest()->getRequestFormat() === 'csv') {
//            $contents = mb_convert_encoding($response->getContent(), 'SJIS-win', mb_internal_encoding());
//            $response->headers->set('Content-Type', "application/octet-stream; name=item.csv");
//            $response->headers->set('Content-Disposition', "attachment; filename=item.csv");
//            $response->setContent($contents);
//        }
        return $response;
    }
}

まとめ

まとめってほどの事もないんですが、EventListenerを使うことでフォーマット固有の処理がうまく分離できたんじゃないかと思います。また、既存のイベントだけではなく独自のイベントを発生させることもできるので、うまく使っていきたいところ。
あと、もっといい方法があればぜひ教えてください:-)