モノノフ日記

普通の日記です

Jobeet - 5日目: ルーティング

Day 5: The Routing (1_2) - Symfony

始める前に

昨日、Jobeetデザインコンテストを開始しました。もし参加したいならばチュートリアルで開発しているメインページのアーカイブを使ってください(アーカイブは静的なHTMLファイル、スタイルシート、画像を含みます)。21日目に投票をするので、前日までにデザインのモックアップを(fabien.potencier[.at.]symfony-project.org)まで送ってください。健闘を祈ります!

前回までのJobeet

4日目を完璧にこなしているなら、MVCパターンはもう熟知できていて、ますますコーディングの方法を性質ごとにしたいと考えるようになっているでしょう。もっと時間をかけて学ぶことで、振り返らないようになるでしょう。昨日のチュートリアルで、Jobeetのページデザインや処理のカスタマイズをし、レイアウトやヘルパー、スロットといったsymfonyのコンセプトについても見直しました。
今日は、symfonyのルーティングフレームワークのすばらしい世界へダイブしましょう。

URL

Jobeetホームページ上の仕事をクリックすると、URLは /job/show/id/1 のように見えます。もしPHPでWebサイトの開発をしたことがあるなら、おそらく /job.php?id=1 というURLを見慣れているでしょう。symfonyはどうやって動作しているのでしょうか?symfonyはどうやってこのURLを基本としてactionを決めているのでしょうか?なぜ仕事のidは$request->getParameter('id')で取得できるのでしょうか?今日は、これら全ての問題の答えを見ていきます。

しかしまず初めに、URLとURLが正確に指すものについて話します。Webコンテキスト上で、URLはWebリソースのユニークな名前です。URL先へ行くと、ブラウザにURLによって分類されているリソースを取得するように頼みます。そしてURLはWebサイトとユーザ間のインターフェースとして、リソースが参照している意味のある情報を伝えます。しかし旧来のURLは実際にはリソースについての説明をしておらず、アプリケーションの内部構造を公開してしまっています。ユーザはWebサイトがPHPで開発されているとか、仕事が持つデータベースのある識別子というようなことはあまり気にしません。アプリケーションの内部動作を公開することはセキュリティの観点から見ても、非常にまずいです。ユーザがURL先にアクセスすることなくリソースを予想することができたらどうだろうか?開発者は適切な方法でアプリをセキュアすべきで、機密情報は隠した方がよいです。

URLはsymfonyフレームワーク全体を管理するのに重要なものです。それはルーティングフレームワークで管理します。ルーティングは内部URIと外部URLを管理します。リクエストを受け取った時、ルーティングはURLを解析して内部URIに変換します。

showSuccess.phpテンプレートで仕事のページの内部URIをすでに見ています。

'job/show?id='.$job->getId()

url_for()ヘルパーはこの内部URIを適切なURLに変換します。

/job/show/id/1

内部URIはいくつかのパーツから構成されます。jobはモジュール名で、showはアクション名、その後にアクションに渡すパラメータをクエリストリングとして追加します。内部URIの一般的なパターンを下記に示します。

MODULE/ACTION?key=value&key1=value1&...

symfonyのルーティングは2つの処理方法があるので、技術的実装を変更することなくURLを変換することができます。このことはフロントコントローラデザインパターンの主なアドバンデージの1つです。

ルーティング設定

内部URIと外部URL間のマッピングはrouting.ymlファイルで行われます。

# apps/frontend/config/routing.yml
homepage:
  url:   /
  param: { module: default, action: index }
 
default_index:
  url:   /:module
  param: { action: index }
 
default:
  url:   /:module/:action/*

routing.ymlはルートについて記述されています。ルートは名前(homepage)、パターン(/:module/:action/*)といくつかのパラメータ(paramキー下の値)を持ちます。
リクエストが来たとき、URLから得られるパターンにマッチするかを試します。routing.yml内の最初にマッチしたルートが重要となります。このルーティングの動作を理解するためにもっとたくさんの例を見ることにしましょう。
/job URLを持つJobeetホームページにリクエストをすると、マッチする最初のルートはdefault_indexです。パターン内ではコロン(:)を接頭辞に持つ単語が変数であり、/:moduleパターンは「/ の後にマッチする何か」ということを意味します。この例の中では、module変数は値としてjobを持ちます。この値は$request->getParameter('module')で取得することができます。このルートはaction変数にはデフォルト値が定義されています。よってこのルートにマッチする全てのURLのリクエストはactionパラメータにはindexという値を持つようになります。
もし/job/show/id/1 ページにリクエストするなら、symfonyは最後のパターンである(/:modules/:action/*)にマッチします。パターン内ではスター(*)はスラッシュ(/)で分けられた変数と値のペアの一群にマッチします。

リクエストパラメータ
module job
action show
id 1

module、action変数は実行するアクションを決定するため、symfonyによって使われる特別なものです。

/job/show/id/1 URLは下記で使われているurl_for()ヘルパーによってテンプレートから作られます。

<?php
url_for('job/show?id='.$job->getId())

@をプリフィックスにしたルート名も使えます。

<?php
url_for('@default?id='.$job->getId())

上記2つは同じものですが、後者の方が全てのルートを解析することなくベストなマッチングをするため速く動作しますし、実装する上でもより少ないコードになります(モジュール名、アクション名を内部URI内に含まないので)。

ルートのカスタマイズ

今のところ、ブラウザで/ URLにリクエストすると、symfonyのデフォルトのcongratulationsページになります。その理由はこのURLがhomepageルートにマッチしているからです。しかしJobeetホームページとして意味をなすために変更します。変更するには、homepageルートのmodule変数の値をjobに変更します。

# apps/frontend/config/routing.yml
homepage:
  url:   /
  param: { module: job, action: index }

レイアウト内のJobeetロゴのリンクをhomepageルートを使うように変更します。

<h1>
  <a href="<?php echo url_for('@homepage') ?>">
    <img src="/images/jobeet.gif" alt="Jobeet Job Board" />
  </a>
</h1>

簡単でしょう!少しややこしくなりますが、もっと意味のある仕事のページのURLに変更してきましょう。

/job/sensio-labs/paris-france/1/web-developer

Jobeetについての知識や、ページを見ることなくURLからSensio LabsがフランスのパリでWeb開発者を探しているということが理解できます。

きれいなURLはユーザに情報を伝える上で重要となります。メールの中でURLをコピペしたり検索エンジン向けに自分のWebサイトを最適化するのに役立ちます。

URLを下記のようなパターンにマッチさせます

/job/:company/:location/:id/:position

routing.ymlファイルを編集し、先頭にjobルートを追記します。

job:
  url:   /job/:company/:location/:id/:position
  param: { module: job, action: show }

Jobeetホームページをリフレッシュするなら、仕事へのリンクは変更しません。ルートを生成するなら、必要な変数を全て渡すことが必要となります。だから、indexSuccess.php内で呼ばれるurl_for()を変更する必要があります。

<?php
url_for('job/show?id='.$job->getId().'&company='.$job->getCompany().
  '&location='.$job->getLocation().'&position='.$job->getPosition())

内部URIは配列として表すこともできます。

<?php
url_for(array(
  'module'   => 'job',
  'action'   => 'show',
  'id'       => $job->getId(),
  'company'  => $job->getCompany(),
  'location' => $job->getLocation(),
  'position' => $job->getPosition(),
))

必要条件

初日のチュートリアルの間、良い結果をもたらすバリデーションとエラーハンドリングについて話しました。ルーティングシステムはバリデーション要素がビルトインされています。各パターンの変数はルート定義の中のrequirementsエントリを使って正規表現によるバリデーションができます。

job:
  url:   /job/:company/:location/:id/:position
  param: { module: job, action: show }
  requirements:
    id: \d+

上記のrequirementsエントリはidが数値であることを強制しています。もし数値でなければルートにはマッチしません。

ルートクラス

routing.ymlで定義されている各ルートは内部でsfRouteオブジェクトに変換されます。このクラスはルート定義のclassエントリで定義することで変更可能です。HTTPプロトコルをよく知っているのなら、GET、POST、HEAD、DELETE、PUTのようなメソッドを定義することもできます。最初の3つ(GET, POST, HEAD)は全てのブラウザでサポートされますが、それ以外の2つ(DELETE, PUT)はサポートされていません。
あるリクエストメソッドだけにマッチするようルートを制限するには、sfRequestRouteクラスを使うようにルートクラスを変更して、requirementsエントリにsf_method変数を追加できます。

job:
  url:   /job/:company/:location/:id/:position
  class: sfRequestRoute
  param: { module: job, action: show }
  requirements:
    id: \d+
    sf_method: [GET]

あるHTTPメソッドだけに必要なルートがマッチするようにするのはsfWebRequest::isMethod()をアクション内で使うのと同じことです。

オブジェクトルートクラス

仕事ページの新しい内部URIはかなり長く、書くのがつまらないものです。しかし前のセクションで学習したようにルートクラスは変更できます。jobルートにとってPropelオブジェクトやその一群を示すために最適化されたクラスであるsfPropelRouteを使うのはよりよいことです。

job_show_user:
  url:     /job/:company/:location/:id/:position
  class:   sfPropelRoute
  options: { model: JobeetJob, type: object }
  param:   { module: job, action: show }
  requirements:
    id: \d+
    sf_method: [GET]

optionエントリはルートの振る舞いをカスタマイズします。ここでは、modelオプションはルートに関係するPropelモデルクラス(JobeetJob)を定義して、typeオプションではこのルートに関係するオブジェクトを定義します(オブジェクトの一群を示すならlistも使えます)。
job_show_userルートはJobeetJobオブジェクトの関係を知らないので、url_for()で呼ぶのは簡単です。

<?php
url_for(array('sf_route' => 'job_show_user', 'sf_subject' => $job))

または

<?php
url_for('job_show_user', $job)

最初の例はオブジェクトに複数の引数を渡したいとき必要となります。

ルート内の全ての変数はJobeetJobクラスのアクセサと対応して動きます(例えば、companyルートの変数はgetCampany()の値に置き換えれます)。
生成されたURLを見ると、それらはまだ完全に欲しいURLにはなっていません。

http://jobeet.localhost/frontend_dev.php/job/Sensio+Labs/Paris%2C+France/1/Web+Developer

全ての非ASCII文字をハイフン(-)に置き換えたカラム値にする必要があります。JobeetJobファイルを開いて、クラスへ下記メソッドを追加してください。

<?php
// lib/model/JobeetJob.php
public function getCompanySlug()
{
  return Jobeet::slugify($this->getCompany());
}
 
public function getPositionSlug()
{
  return Jobeet::slugify($this->getPosition());
}
 
public function getLocationSlug()
{
  return Jobeet::slugify($this->getLocation());
}

それから、lib/Jobeet.class.phpファイルを作ってslugifyメソッドを追加してください。

<?php
// lib/Jobeet.class.php
class Jobeet
{
  static public function slugify($text)
  {
    // replace all non letters or digits by -
    $text = preg_replace('/\W+/', '-', $text);
 
    // trim and lowercase
    $text = strtolower(trim($text, '-'));
 
    return $text;
  }
}

3つの新しい仮想アクセサを定義しました。その3つはgetCompanySlug(), getPositionSlug(), getLocationSlug()です。このアクセサは対応するカラム値をslugify()メソッドに適用した値を返します。今からjob_show_userルートの実際のカラム名をこれら仮想カラム名に置き換えます。

job_show_user:
  url:     /job/:company_slug/:location_slug/:id/:position_slug
  class:   sfPropelRoute
  options: { model: JobeetJob, type: object }
  param:   { module: job, action: show }
  requirements:
    id: \d+
    sf_method: [GET]

Jobeetホームページをリフレッシュする前に新しいクラスを追加したのでキャッシュを消す必要があります。

$ symfony cc

これで期待したURLが利用できるようになります。

http://jobeet.localhost/frontend_dev.php/job/sensio-labs/paris-france/4/web-developer

しかし、話の半分が終わっただけです。ルートはオブジェクトを基づいたURLを生成できますが、得られたURLから関連するオブジェクトを見つけることもできます。関連オブジェクトはルートオブジェクトのgetObject()メソッドを使って取得できます。入ってきたリクエストを解析するとき、ルーティングはアクション内で使うためマッチしたルートオブジェクトを保存します。だから、executeShow()メソッドをgetObject()を使うように変更できます。

<?php
class jobActions extends sfActions
{
  public function executeShow(sfWebRequest $request)
  {
    $this->job = $this->getRoute()->getObject();
    $this->forward404Unless($this->getRoute()->getObject());
  }
 
  // ...
}

もし未知のIDの仕事を取得しようとするなら404エラーページを見れますが、エラーメッセージが変更されているでしょう。

この理由は404エラーがgetRoute()メソッドによって自動で投げられているからです。だからexecuteShow()メソッドはもっと単純にできます。

<?php
class jobActions extends sfActions
{
  public function executeShow(sfWebRequest $request)
  {
    $this->job = $this->getRoute()->getObject();
  }
 
  // ...
}

もしルートで404エラーを作りたくないなら、allow_emptyルーティングオプションにtrueをセットできます。

コレクションルートクラス

jobモジュールに関して、showアクションのルートはすでにカスタマイズしていますが、その他のメソッド(index, new, edit, create, update, delete)のURLはまだdefaultルートで管理されています。

default:
  url:  /:module/:action/*

defaultルートは多くのルートを定義することなくコーディングを始めれるすばらしい方法です。しかし全てのアクションをキャッチしてしまうので固有の設定が必要でも設定できません。jobアクションはJobeetJobモデルクラスと関連しているので、もうshowアクションに適応させているように簡単にカスタムしたsfPropelRouteルートを定義できます。jobモジュールはモデルで利用される典型的な7つのアクションを定義しますが、sfPropelRouteCollectionクラスも利用できます。

// apps/frontend/config/routing.yml
 
# put this definition just before the job_show_user one
job:
  class:   sfPropelRouteCollection
  options: { model: JobeetJob }

上記jobルートは実際には下記に示す7つのsfPropelRouteルートを自動的に生成しています。

job:
  url:     /job.:sf_format
  class:   sfPropelRoute
  options: { model: JobeetJob, type: list }
  param:   { module: job, action: index, sf_format: html }
  requirements: { sf_method: GET }
 
job_new:
  url:     /job/new.:sf_format
  class:   sfPropelRoute
  options: { model: JobeetJob, type: object }
  param:   { module: job, action: new, sf_format: html }
  requirements: { sf_method: GET }
 
job_create:
  url:     /job.:sf_format
  class:   sfPropelRoute
  options: { model: JobeetJob, type: object }
  param:   { module: job, action: create, sf_format: html }
  requirements: { sf_method: POST }
 
job_edit:
  url:     /job/:id/edit.:sf_format
  class:   sfPropelRoute
  options: { model: JobeetJob, type: object }
  param:   { module: job, action: edit, sf_format: html }
  requirements: { sf_method: GET }
 
job_update:
  url:     /job/:id.:sf_format
  class:   sfPropelRoute
  options: { model: JobeetJob, type: object }
  param:   { module: job, action: update, sf_format: html }
  requirements: { sf_method: PUT }
 
job_delete:
  url:     /job/:id.:sf_format
  class:   sfPropelRoute
  options: { model: JobeetJob, type: object }
  param:   { module: job, action: delete, sf_format: html }
  requirements: { sf_method: DELETE }
 
job_show:
  url:     /job/:id.:sf_format
  class:   sfPropelRoute
  options: { model: JobeetJob, type: object }
  param:   { module: job, action: show, sf_format: html }
  requirements: { sf_method: GET }

sfPropelRouteCollectionで生成されたいくつかのルートは同じURLを持ちます。それらは要求するHTTPメソッドが全て異なっているので使うことができます。

job_deleteとjob_updateルートが必要としているHTTPメソッドはブラウザでサポートされていません(DELETEとPUT)。この動作はsymfonyがシミュレートしているので動きます。例を見るために_form.phpテンプレートを開いてください。

// apps/frontend/modules/job/templates/_form.php
<form action="..." ...>
<?php if (!$form->getObject()->isNew()): ?>
  <input type="hidden" name="sf_method" value="PUT" />
<?php endif; ?>
 
<?php echo link_to(
  'Delete',
  'job/delete?id='.$form->getObject()->getId(),
  array('method' => 'delete', 'confirm' => 'Are you sure?')
) ?>

全てのsymfonyヘルパーは特有のsf_methodパラメータを通って要求されたHTTPメソッドは何でもシミュレートさせます。

symfonyはsf_methodのようなsf_を接頭辞とする特有のパラメータをそれ以外にも持ちます。上記のルート生成の中で、別のパラメータが見れます。それはsf_formatであり、次の日に説明します。

ルートのデバッグ

コレクションルートを使うなら、生成されたルートを一覧するのが時々役立ちます。app:routesタスクはアプリケーションから得られた全てのルートを出力します。

$ symfony app:routes frontend

引数にルート名を追加することで指定したルートに関するたくさんのデバッグ情報を取得できます。

$ symfony app:routes frontend job_edit

デフォルトルート

全てのURLにルートを定義することは良い練習になります。定義し終えたなら、routing.ymlファイルからdefaultルートを削除するかコメントアウトしましょう。

// apps/frontend/config/routing.yml
#default_index:
#  url:   /:module
#  param: { action: index }
#
#default:
#  url:   /:module/:action/*