5日目: ルーティング

4日目を完璧にこなしているなら、MVC パターンに慣れてきて、コーディング方法がより自然に感じるようになっていることでしょう。もっと時間をかけて学ぶことで、振り返らないようになるでしょう。昨日のチュートリアルで、Jobeet のページデザインや処理のカスタマイズをし、レイアウトやヘルパー、スロットといった symfony のコンセプトについても見直しました。

今日は、symfony のルーティングフレームワークのすばらしい世界に飛び込みましょう。

~URL~

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

しかしまず初めに、URL と URL が正確に指すものについて話します。Web コンテキスト上で、URL は Web リソースの一意性のある名前です。URL の先へ行くと、ブラウザに URL によって分類されているリソースを取得するように頼みます。そして URL は Web サイトとユーザー間のインターフェイスとして、リソースが参照している意味のある情報を伝えます。しかし旧来の URL は実際にはリソースについての説明をしておらず、アプリケーションの内部構造を公開してしまっています。ユーザーは Web サイトが PHP で開発されているとか、求人情報がもつデータベースのある識別子というようなことはあまり気にしません。アプリケーションの内部動作を公開することは~セキュリティ~の観点から見ても、非常にまずいことです。ユーザーが URL 先にアクセスすることなくリソースを予想することができたらどうだろうか?開発者は適切な方法でアプリをセキュアすべきで、機密情報は隠したほうがよいです。 URL は symfony でフレームワーク全体を管理するのに重要なものです。これは~ルーティング~ (routing) フレームワークで管理します。ルーティングは内部 URI と外部 URL を管理します。リクエストを受け取るとき、ルーティングは URL を解析して内部URIに変換します。

求人ページの内部 URI はすでに indexSuccess.php テンプレートで見ています:

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

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

/job/show/id/1

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

MODULE/ACTION?key=value&key_1=value_1&...

symfony のルーティングは2つの処理方法があるので、技術的実装を変更することなく URL を変換することができます。このことは Front Controller デザインパターンの主な利点の1つです。

ルーティングコンフィギュレーション

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

[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 |

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

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

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

@ をプレフィックスとするルート名も使えます:

[php]
url_for('@default?module=job&action=show&id='.$job->getId())

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

ルートのカスタマイズ

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

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

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

[php]
<!-- apps/frontend/templates/layout.php -->
<h1>
  <a href="<?php echo url_for('homepage') ?>">
    <img src="/images/logo.jpg" alt="Jobeet Job Board" />
  </a>
</h1>

簡単でした!

TIP ルーティングコンフィギュレーションを更新するとき、開発環境では変更は即座に反映されます。しかし運用環境でもこれらを動かすには、キャッシュをクリアする必要があります。

もう少し内容を含めるために、求人ページの URL をより意味のある文字列に変更してみましょう:

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

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

NOTE わかりやすい URL はユーザーに情報を伝える上で重要となります。メールのなかで URL をコピペしたり、検索エンジンに合わせて自分の Web サイトを最適化するのに役立ちます。

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

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

routing.yml ファイルを編集し、ファイルの冒頭に job_show_user ルートを追加します:

[yml]
job_show_user:
  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|要件~ エントリを使って正規表現によるバリデーションができます。

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

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

ルートクラス

~routing.yml~ で定義されている各ルートは内部で sfRoute オブジェクトに変換されます。このクラスはルート定義の class エントリで定義することで変更可能です。~HTTP~ プロトコルをよく知っているのなら、~GET|GET (HTTP メソッド)~~POST|POST (HTTP メソッド)~~HEAD|HEAD (HTTP メソッド)~~DELETE|DELETE (HTTP メソッド)~~PUT|PUT (HTTP メソッド)~ のようなメソッドを定義することもできます。 最初の3つ (GETPOSTHEAD) はすべてのブラウザでサポートされますが、それ以外の2つ (DELETEPUT) はサポートされていません。

特定のリクエストメソッドだけにマッチするようルートを制限するには、sfRequestRoute クラスを使うようにルートクラスを変更して、sf_method 変数を requirements エントリに追加できます:

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

NOTE ~HTTP メソッド~にのみマッチするルートを要求することは、アクションで sfWebRequest::isMethod() を使うこととは全体的に同じではありません。メソッドが要求されたルートにマッチしない場合、ルーティングはマッチするルートを探し続けるからです。

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

求人用の新しい内部 URI はとても長くて書くのが退屈ですが (url_for('job/show?id='.$job->getId().'&company='.$job->getCompany().'&location='.$job->getLocation().'&position='.$job->getPosition()))、前の節で学んだように、ルートクラスは変更できます。job_show_user ルートに関しては、~sfPropelRoute~ を使うほうがよいです。このクラスが ##ORM## オブジェクトもしくは ##ORM## オブジェクトのコレクションを表すルートに合わせて最適化されているからです:

[yml]
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]

options エントリはルートのふるまいをカスタマイズします。ここでは、model オプションはルートに関係する ##ORM## モデルクラス (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)

NOTE オブジェクト以外に複数の引数を渡すことが必要な際に最初の例が役に立ちます。

ルートのなかのすべての変数は JobeetJob クラスのアクセサと対応して動きます (たとえば、company ルートの変数は getCampany() の値に置き換わります)。

生成された URL を見ると、これらはまだ完全にほしい URL にはなっていません:

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

すべての ASCII ではない文字をハイフン (-) に置き換えることで、カラム値の値を 「~slugify|スラッグ~」処理 する必要があります。JobeetJob ファイルを開き、次のメソッドをクラスに追加してください:

[php]

// lib/model/JobeetJob.php // lib/model/doctrine/JobeetJob.class.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)
  {
    // 文字ではないもしくは数値ではないものすべてを - に置き換える
    $text = preg_replace('/\W+/', '-', $text);

    // トリムして小文字に変換する
    $text = strtolower(trim($text, '-'));

    return $text;
  }
}

NOTE このチュートリアルでは、スペースを最適化してツリーを節約するために純粋な PHP コードのみを含むコードの例では開きの <?php ステートメントを示しません。新しい PHP ファイルを作るとき、このステートメントを必ず追加することを覚えておいてください。

「バーチャルな」3つの新しいアクセサ: getCompanySlug()getPositionSlug()、と getLocationSlug() を定義しました。これらは slugify() メソッドに適用した後で対応するカラムの値を返します。job_show_user ルートでこれらのバーチャルアクセサで実際のカラムの名前を置き換えることができます:

[yml]
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]

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

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

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

[php]
class jobActions extends sfActions
{
  public function executeShow(sfWebRequest $request)
  {
    $this->job = $this->getRoute()->getObject();

    $this->forward404Unless($this->job);
  }

  // ...
}

もし未知の id の求人情報を取得しようとするなら~404エラー~ページが表示されますが、エラーメッセージが変更されているでしょう:

sfPropelRoute での404エラー

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

[php]
class jobActions extends sfActions
{
  public function executeShow(sfWebRequest $request)
  {
    $this->job = $this->getRoute()->getObject();
  }

  // ...
}

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

-

NOTE ルートに関連するオブジェクトは遅延ロードされます。getRoute() メソッドを呼び出す場合、データベースからのみ読み取られます。

アクションとテンプレートにおけるルーティング

テンプレートでは url_for() ヘルパーは内部 URI を外部 URL に変換します。その他の symfony ヘルパーのなかにも、引数として内部 URI をもつものがあります。<a> タグを生成する ~link_to() ヘルパー~がその1つです:

[php]
<?php echo link_to($job->getPosition(), 'job_show_user', $job) ?>

下記のような HTML コードを生成します:

[php]
<a href="/job/sensio-labs/paris-france/1/web-developer">Web Developer</a>

url_for()link_to() の両方とも絶対パスでも URL を生成できます:

[php]
url_for('job_show_user', $job, true);

link_to($job->getPosition(), 'job_show_user', $job, true);

アクションから URL を生成したいなら、generateUrl() メソッドを使います:

[php]
$this->redirect($this->generateUrl('job_show_user', $job));

SIDEBAR 「~redirect|リダイレクト~」 メソッドファミリ

昨日のチュートリアルでは、「forward」メソッドを話しました。これらのメソッドはブラウザでの往復なしに現在のリクエストを別のアクションに転送します。

「redirect」メソッドはユーザーを別の URL に転送します。転送に関しては、redirect() メソッド、もしくは redirectIf()redirectUnless() ショートカットメソッドを利用できます。

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

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

[yml]
default:
  url: /:module/:action/*

default ルートは多くのルートを定義しなくても、コーディングを始められるすばらしい方法です。しかし「すべてのアクションをキャッチ」してしまうので固有の設定が必要でも設定できません。

すべての job アクションは JobeetJob モデルクラスに関連しており、show アクションに対してすべて行っているので、それぞれに対してカスタムの ~sfPropelRoute~ ルートを簡単に定義できます。

job モジュールはモデルのために古典的な7つのアクションを定義するので、~sfPropelRouteCollection~ クラスも使えます。

routing.yml ファイルを開き、次のように修正します:

[yml]
# apps/frontend/config/routing.yml
job:
  class:   sfPropelRouteCollection
  options: { model: JobeetJob }

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]

# default rules
homepage:
  url:   /
  param: { module: job, action: index }

default_index:
  url:   /:module
  param: { action: index }

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

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

[yml]
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 }

NOTE sfPropelRouteCollection で生成されるいくつかのルートは同じ ~URL~ をもちます。それらはリクエストされる ~HTTP メソッド~がすべて異なるので、使うことができます。

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

[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?')
) ?>

特別な sf_method パラメータを渡すことで、すべての symfony ヘルパーに望む HTTP メソッドをシミュレートするように指示できます。

NOTE これ以外にも symfony は sf_method のような sf_ を~プレフィックス~とする固有のパラメータをもちます。上記のルート生成の中で、別のパラメータが見れます。これは sf_format であり、次の日に説明します。

ルートのデバッグ

コレクションルートを使うなら、生成されるルートの一覧の表示が役に立つことがあります。app:routes タスクはアプリケーションから得られたすべてのルートを出力します:

$ php symfony app:routes frontend

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

$ php symfony app:routes frontend job_edit

デフォルトルート

すべての URL に対して~ルート~を定義するのはよい習慣です。job ルートは Jobeet アプリケーションを記述するために必要なすべてのルールを定義するので、routing.yml 設定ファイルからデフォルトのルートを削除もしくはコメントアウトします:

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

Jobeet アプリケーションは以前と同じように動作します。

また明日

今日はたくさんの情報を詰め込みました。symfony のルーティングフレームワークの使い方と URL を技術的な実装から分離する方法を学びました。

明日は、新しい概念を紹介しませんが、これまでカバーしてきたことをより深く追求することに時間をかけます。

ORM