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が非常に参考になります。
- 新しいリクエストのフォーマットとマイムタイプの登録方法 | Symfony2日本語ドキュメント (jsonpを追加する例)
- [Symfony2]入出力の文字エンコードを変換してみよう Symfony Advent Calender JP 2011 – 6日目- | うえちょこ@ぼろぐ (入出力時の文字エンコーディング変換の例)
作成したソースコードは以下のようになります。ダウンロードする際のファイル名は、
ルート名(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を使うことでフォーマット固有の処理がうまく分離できたんじゃないかと思います。また、既存のイベントだけではなく独自のイベントを発生させることもできるので、うまく使っていきたいところ。
あと、もっといい方法があればぜひ教えてください:-)