Jobeet - 4日目: コントローラとビュー
Jobeet - Day 4: The Controller and the View - Symfony
4日目にして本家から大分置いていかれてしまいました。。あまり気にせず、マイペースでぼちぼち訳していきたいと思います。
始める前に
jobeet_job_affiliateテーブルを昨日のチュートリアルで作ったけど、ユーザからフィードバックを受けて違うリレーションテーブルを作ることにしました。jobeet_category_affiliateテーブルに置き換えます。
// config/schema.yml jobeet_category_affiliate: category_id: { type: integer, foreignTable: jobeet_category, foreignReference: id, required: true, primaryKey: true, onDelete: cascade } affiliate_id: { type: integer, foreignTable: jobeet_affiliate, foreignReference: id, required: true, primaryKey: true, onDelete: cascade }
モデルを再生成し、SQLを再生成してデータをいれましょう。
symfony propel:build-all-load
モデルを入れ替えたので必要のなくなったファイルを消します。
- lib/filter/base/BaseJobeetJobAffiliateFormFilter.class.php
- lib/filter/JobeetJobAffiliateFormFilter.class.php
- lib/form/base/BaseJobeetJobAffiliateForm.class.php
- lib/form/JobeetJobAffiliateForm.class.php
- lib/model/JobeetJobAffiliate.php
- lib/model/JobeetJobAffiliatePeer.php
- lib/model/map/JobeetJobAffiliateMapBuilder.php
- lib/model/om/BaseJobeetJobAffiliate.php
- lib/model/om/BaseJobeetJobAffiliatePeer.php
最後にsymfonyのキャッシュをクリアしましょう。
symfony cc
これで完了です。
Subversionのday4タグのリポジトリにも変更を適用してあります
前回までのJobeet
昨日はsymfonyがどうやってデータベースエンジン間の違いを吸収していたり、オブジェクト指向クラスに変換しているかを見ました。結果、Propelがいろいろ頑張ってくれてる、ということがわかりました。
今日は昨日作ったjobモジュールの基本的なカスタマイズをやっていきます。jobモジュールはもうJobeetに組み込まれ必要なものになってます。
- 仕事の一覧ページ
- 新しく仕事を投稿するページ
- 投稿した仕事を更新するページ
- 仕事を削除するページ
MVCアーキテクチャ
もしフレームワーク無しでPHPでWebサイトの開発を行うならば、大抵1HTMLページに1つのPHPファイルを使います。これらのPHPファイルは同じ種類の構造を含んでます。それは初期化、全体設定、ページリクエストのためのビジネスロジックやデータベースからレコードの取得、最終的にはページを生成するためのHTMLコードを含んでいます。
HTMLからロジックを分離するためにテンプレートエンジンを利用することができます。おそらくビジネスロジックからモデルへの作用を分離させるためにデータベース抽象化レイヤを使います。しかし大抵の場合、たくさんのコードで終わるのでメンテナンスが悪夢です。増築するのは早いですが、時間がたてば修正するのはますます難しくなります。特にどうやって構築され動いているのか、わかってない人たちには。
これら全ての問題に対し、良い解決方法があります。Web開発の分野では近年コーディングのため最適な解として認識されているのはMVCデザインパターンです。手短に言えば、MVCデザインパターンはコードの本質ごとに体系化する方法を定義しています。このパターンは3つのレイヤに分けられます。
- モデルレイヤはビジネスロジックを定義します(このレイヤはデータベースに属します)。すでに紹介したようにsymfonyはlib/modelディレクトリに関連する全てのクラスやファイルを保存しています。
- ビューはユーザと情報をやり取りするものです(テンプレートエンジンはこのレイヤの一部です)。symfonyでは、ビューレイヤは主にPHPテンプレートで作られます。それらは本日後から出てきますが、さまざまなtemplatesディレクトリに保存されます。
- コントローラはモデルからデータを取得し、クライアントへ表示するためビューにデータを渡す処理を担当します。symfonyをインストールした初日に、全てのリクエストはフロントコントローラ(index.phpとfrontend_dev.php)によって管理されているのを見ました。これらフロントコントローラは実際の動作はactionsで行われます。昨日見たようにこれらactionsはmodulesで論理的にグループ化されます。
本日は、ホームページや仕事ページ、動的に仕事ページを作るページをカスタマイズするために2日目に定義したモックアップを使います。その途中でsymfonyディレクトリ構造やレイヤごとにコードを分離するのをデモするために多くのファイルのいろんな箇所を微調整します。
レイアウト
まず、モックアップをじっと見てみると各ページのほとんどが同じ部品であることに気づくでしょう。PHPやHTMLであろうとなかろうと、コードの重複は悪いことです。だからコードが重複しているビュー要素を抑える方法が必要となります。
この問題を解決する1つの方法として各テンプレートにheaderとfooterを定義するやり方があります。
しかしこの場合headerやfooterは有効なHTMLを含んでいません。良い方法であることは違いありません。車輪の再発明をする代わりにこの問題を解決するため別のデザインパターンを使うことにします。それはデコレータデザインパターンです。デコレータデザインパターンは別のやり方で問題を解決します。グローバルテンプレートによって表示されたコンテンツの後に装飾されたテンプレートを使います。symfonyではグローバルテンプレートをlayoutと呼びます。
アプリケーションのデフォルトテンプレートとしてlayout.phpが呼ばれます。それはapps/frontend/templatesディレクトリにあります。このディレクトリにはアプリケーションのグローバルテンプレート全てが置かれます。
symfonyのデフォルトレイアウトを下記コードに置き換えましょう。
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en"> <head> <title><?php if(!include_slot("title")): ?>Jobeet - Your best job board<?php endif; ?></title> <link rel="shortcut icon" href="/favicon.ico" /> <?php include_javascripts() ?> <?php include_stylesheets() ?> </head> <body> <div id="container"> <div id="header"> <div class="content"> <h1><a href="/job"><img src="/images/jobeet.gif" alt="Jobeet Job Board" /></a></h1> <div id="sub_header"> <div class="post"> <h2>Ask for people</h2> <div> <a href="/new">Post a Job</a> </div> </div> <div class="search"> <h2>Ask for a job</h2> <form action="" method="get"> <input type="text" name="keywords" value="" id="search_keywords"/> <input type="submit" value="search" /> <div class="help"> Enter some keywords (city, country, position, ...) </div> </form> </div> </div> </div> </div> <div id="content"> <?php if($sf_user->hasFlash("notice")): ?> <div class="flash_notice"><?php echo $sf_user->getFlash("notice") ?></div> <?php endif; ?> <?php if($sf_user->hasFlash("error")): ?> <div class="flash_notice"><?php echo $sf_user->getFlash("error") ?></div> <?php endif; ?> <div class="content"> <?php echo $sf_content ?> </div> </div> <div id="footer"> <div class="content"> <span class="symfony"> <img src="/images/jobeet-mini.png" alt="jobeet-mini" /> powered by <a href="http://www.symfony-project.org/"> <img src="/images/symfony.gif" alt="symfony framework" /></a> </span> <ul> <li><a href="">About Jobeet</a></li> <li class="feed"><a href="">Full RSS Feed</a></li> <li><a href="">Jobeet API</a></li> <li class="last"><a href="">Affiliates</a></li> </ul> </div> </div> </div> </body> </html>
symfonyのテンプレートはシンプルなPHPファイルのみです。レイアウトテンプレートではPHPの関数が呼ばれたり、変数が参照されているのを見ることができます。$sf_contentsは一番興味深い変数です。その変数はフレームワーク自身で定義され、アクションで生成されたHTMLを含んでいます。
スタイルシート、画像、JavaScripts
21日目にベストデザインコンテストを開催するので、それまでの間使う基本的なデザインを用意しました。画像ファイルをダウンロードしてweb/imagesディレクトリ下へ、スタイルシートをダウンロードしてweb/cssディレクトリ下へそれぞれ置いてください。
デフォルトでは、generate:projectタスクはプロジェクトのアセットとして3つのディレクトリを生成します。web/imagesは画像用、web/cssはスタイルシート用、web/jsはJavaScripts用です。これはsymfonyで定義される多くの仕様の1つですが、もちろんwebディレクトリ下であればどこにでも置くことはできます。
鋭い読者ならmain.cssがデフォルトレイアウトのどこにも記述されていないということに気づくでしょう。main.cssは生成されたHTMLの中に確かに含まれています。しかしどこにも見当たりません。どうやって可能にしているんでしょうか?
スタイルシートファイルはレイアウトの
しかしヘルパーはどうやって含むべきスタイルシートを知るのでしょうか?
ビューレイヤはアプリケーションの設定ファイルであるview.ymlを編集することで設定することができます。generate:appタスクがデフォルトで生成するview.ymlは以下になります。
# apps/frontend/config/view.yml default: http_metas: content-type: text/html metas: #title: symfony project #description: symfony project #keywords: symfony, project #language: en #robots: index, follow stylesheets: [main.css] javascripts: [] has_layout: on layout: layout
view.ymlファイルはアプリケーションの全てのテンプレートのデフォルト設定が作られます。例えば、stylesheetsエントリはアプリケーション全てのページに含むためのスタイルシートファイルの配列が定義されます。(含めたファイルはinclude_stylesheets()ヘルパーから呼ばれます)
デフォルトのview.ymlファイルには、参照ファイルとしてmain.cssが設定されており、/css/main.cssではありません。実際のところ、両方の定義はsymfonyによって/css/相対パスがプレフィックスとして付けられます。
もし多くのファイルを定義するなら、symfonyは下記のような定義となります。
stylesheets: [main.css, jobs.css, job.css]
media属性に変更し、.cssを省略することもできます。
stylesheets: [main.css, jobs.css, job.css, { media: print }]
この設定では下記のような表示となります。
<link rel="stylesheet" type="text/css" media="screen" href="/css/main.css" /> <link rel="stylesheet" type="text/css" media="screen" href="/css/jobs.css" /> <link rel="stylesheet" type="text/css" media="screen" href="/css/job.css" /> <link rel="stylesheet" type="text/css" media="print" href="/css/print.css" />
jobs.cssファイルはホームページだけ、job.cssファイルは仕事ページだけに適用します。view.ymlファイルは単一モジュール単位でカスタマイズすることができます。アプリケーションのview.ymlファイルはmain.cssだけを持つように変更します。
# apps/frontend/config/view.yml stylesheets: [main.css]
jobモジュールのビューをカスタマイズするには、apps/frontend/modules/job/configディレクトリ内にview.ymlファイルを生成します。
# apps/frontend/modules/job/config/view.yml indexSuccess: stylesheets: [jobs.css] showSuccess: stylesheets: [job.css]
indexSuccessとshowSuccessセクション(indexとshowアクションで使われるテンプレート名であり、後で出てきます)の下で、アプリケーションview.ymlのデフォルトセクションで見たようなエントリを使ってカスタマイズできます。全ての固有のエントリはアプリケーション設定としてマージされます。allセクションを使えば、モジュールの全てのアクションに対して設定を定義することができます。
symfonyの設定の原理
symfonyの多くの設定ファイル間では、異なったレベル単位で同じ設定が定義できます。
- デフォルト設定はフレームワーク内にあります
- プロジェクトに対応するグローバル設定はconfigディレクトリにあります
- アプリケーションに対応するローカル設定はapps/APP/configディレクトリにあります
- モジュールにだけ適用されるローカル設定はapps/APP/modules/MODULE/configディレクトリにあります
実行時には、設定システムはファイルが存在するかキャッシュを見つけると全ての値をマージしようとします。
経験上、設定ファイル経由で変更可能なのは、PHPコードで完成するのと同じことです。例としてjobモジュールにview.ymlファイルを作る代わりにテンプレートからスタイルシートを呼ぶためのuse_stylesheet()ヘルパーを使うこともできます。
<?php use_stylesheet('main.css') ?>
スタイルシートを全体に含めるため、レイアウト内で上記ヘルパーを使うことも可能です。
2つの方法の違いは好みの問題です。view.ymlファイルによる方法はモジュールの全てのアクションに対し設定を定義することができます、テンプレート内で呼ぶ方法ではできません。しかし設定が非常に静的になってしまいます。一方、use_stylesheet()ヘルパはより柔軟性があり、全ての設定を同じ場所に書きます。HTMLコードでスタイルシートを定義するのと同じです。Jobeetではuse_stylesheet()ヘルパーを使うことにしました。だからjobモジュールで作ったview.ymlファイルは削除して、use_stylesheet()使うように更新してください。
対称的に、JavaScriptの設定もview.ymlのjavascriptsエントリを使ったり、テンプレート内からuse_javascriptヘルパーで呼んだりできます。
Jobホームページ
3日目に見たように、ホームページはjobモジュールのindexアクションで作られています。indexアクションはページコントローラで、関連するテンプレートはindexSuccess.phpがビューの部分です。
アクション
各アクションはクラスメソッドで表されます。ホームページではjobActions(モジュール名の末尾にActionsを添加したもの)クラスとexecuteIndex(executeの末尾にアクション名を添加したもの)メソッドが使われます。データベースから全て仕事を取得します。
<?php // apps/frontend/modules/job/actions/actions.class.php class jobActions extends sfActions { public function executeIndex(sfWebRequest $request) { $this->jobeet_job_list = JobeetJobPeer::doSelect(new Criteria()); } // ... }
コードをよく見てみましょう。executeIndex()メソッド(コントローラ)は全ての仕事を取得するためにモデルであるJobeetJobPeerを呼んでいます。JobeetJobオブジェクトが返されるので、jobeet_job_listプロパティに結果を格納しています。
このような全てオブジェクトプロパティは自動的にテンプレート(ビュー)に渡されます。コントローラからビューへデータを渡すには、下記のように新しいプロパティを作ります。
<?php public function executeIndex(sfWebRequest $request) { $this->foo = 'bar'; $this->bar = array('bar', 'baz'); }
このコードはテンプレートからアクセス可能な$fooと$bar変数を定義しています。
テンプレート
デフォルトでは、テンプレート名は仕様によりsymfonyが推定したアクションと関係があります。(アクション名にSuccessが添加されたもの)
indexSuccess.phpテンプレートは全ての仕事のHTMLテーブルを生成しています。
テンプレートコード内ではforeachでJobオブジェクト($jobeet_job_list)のリストを繰り返し取得して、各仕事ごとのカラムごとに出力させます。覚えておいて欲しいのは、カラムの値は単にgetから始まりカラム名のキャメルケースになっているアクセサメソッドが呼ばれていることです。(例えば、getCreatedAt()メソッドはcreate_atカラムからデータを取得します)
利用できるカラムのみを表示するように整理してみましょう。
<!-- apps/frontend/modules/job/templates/indexSuccess.php --> <?php use_stylesheet("jobs.css") ?> <div id="jobs"> <table class="jobs"> <?php foreach($jobeet_job_list as $i =>$job): ?> <tr class="<?php echo fmod($i, 2) ? 'even' : 'odd' ?>"> <td><?php echo $job->getLocation() ?></td> <td> <a href="<?php echo url_for('job/show?id='.$job->getId()) ?>"><?php echo $job->getPosition() ?></a> </td> <td><?php echo $job->getCompany() ?></td> </tr> <?php endforeach; ?> </table> </div>
テンプレート内で呼ばれているurl_for()関数はsymfonyのヘルパーで、明日詳しく説明します。
Jobページテンプレート
仕事ページのテンプレートをカスタマイズしましょう。showSuccess.phpファイルを開いて、下記コードに置き換えてください。
<?php use_stylesheet('job.css') ?> <?php use_helper('Text') ?> <div id="job"> <h1><?php echo $job->getCompany() ?></h1> <h2><?php echo $job->getLocation() ?></h2> <h3> <?php echo $job->getPosition() ?> <small> - <?php echo $job->getType() ?></small> </h3> <?php if ($job->getLogo()): ?> <div class="logo"> <a href="<?php echo $job->getUrl() ?>"> <img src="<?php echo $job->getLogo() ?>" alt="<?php echo $job->getCompany() ?> logo" /> </a> </div> <?php endif; ?> <div class="description"> <?php echo simple_format_text($job->getDescription()) ?> </div> <h4>How to apply?</h4> <p class="how_to_apply"><?php echo $job->getHowToApply() ?></p> <div class="meta"> <small>posted on <?php echo $job->getCreatedAt('m/d/Y') ?></small> </div> <div style="padding: 20px 0"> <a href="<?php echo url_for('job/edit?id='.$job->getId()) ?>">Edit</a> </div> </div>
テンプレートは仕事の情報を表示するためアクションから渡された$job変数を使います。テンプレートへ渡す変数を$jobeet_jobから$jobへリネームするので、showアクションの該当箇所を変更してください。
<?php // apps/frontend/modules/job/actions/actions.class.php public function executeShow(sfWebRequest $request) { $this->job = JobeetJobPeer::retrieveByPk($request->getParameter('id')); $this->forward404Unless($this->job); }
いくつかのPropelアクセサは引数がいることに注意してください。timestampとして定義されているcreated_atカラムは、getCreatedAt()アクセサでdateフォーマットのパターンを第1引数で指定できます。
<?php $job->getCreatedAt('m/d/Y');
仕事の説明文で使われているsimple_format_text()ヘルパーはHTMLを整形します。 例えば、改行を<br />へ置き換えます。このヘルパーはTextヘルパーグループに属しており、 デフォルトではロードされないのでuse_helper()ヘルパーを使って手動でロードさせています。
スロット
今のところ、全てのページのタイトルはレイアウト内の<title>タグで定義されています。
<title>Jobeet - Your best job board</title>
しかし仕事のページでは会社名や役職のようなもっと有用な情報を提供したいと考えます。
symfonyではレイアウトの領域が表示されるテンプレートに依存するとき、スロットを定義する必要があります。
動的にタイトルを変更させるためレイアウトにスロットを追加します。
// apps/frontend/templates/layout.php <title><?php include_slot('title') ?></title>
各スロットは(title)という名前で定義され、include_slot()ヘルパーで表示されます。今からshowSuccess.phpテンプレートの初めに仕事ページのコンテンツについて定義したslot()ヘルパーを使うようにします。
// apps/frontend/modules/job/templates/showSuccess.php <?php slot('title', sprintf('%s is looking for a %s', $job->getCompany(), $job->getPosition())) ?>
もしタイトルが複数行になるなら、コードブロックを使うこともできます。
// apps/frontend/modules/job/templates/showSuccess.php <?php slot('title') ?> <?php echo sprintf('%s is looking for a %s', $job->getCompany(), $job->getPosition()) ?> <?php end_slot(); ?>
いくつかのページ(ホームページのような)では、一般的なタイトルが必要となります。テンプレート内で同じタイトルを何回も繰り返す代わりに、レイアウトにデフォルトタイトルを定義します。
// apps/frontend/templates/layout.php <title> <?php if (!include_slot('title')): ?> Jobeet - Your best job board <?php endif; ?> </title>
include_slot()ヘルパーはスロットが定義されていればtrueを返します。よって、テンプレートコンテンツ内にtitleスロットが定義されていればそれを使い、なければデフォルトタイトルを使うようになります。
Jobページアクション
仕事のページはjobモジュールのexecuteShow()メソッドで定義されるshowアクションで生成されます。
<?php class jobActions extends sfActions { public function executeShow(sfWebRequest $request) { $this->job = JobeetJobPeer::retrieveByPk($request->getParameter('id')); $this->forward404Unless($this->job); } // ... }
indexアクションでは仕事を取得するのにJobeetJobPeerクラスを使っていましたが、今回はretrieveByPk()メソッドを使います。このメソッドのパラメータは仕事のユニーク名となります、それはprimary keyのことです。次のセクションではなぜ$request->getParameter('id')文が仕事のprimary keyを返すのかを説明します。
生成されたモデルオブジェクトにはプロジェクトオブジェクトと情報をやり取りするたくさんの便利なメソッドがあります。lib/omディレクトリ内を探して、組み込まれている強力なメソッドを見つけてください。
もし仕事のデータがデータベースに存在しなければ、ユーザを404ページに転送させたいと考えます、それはまさしくforward404Unless()メソッドが実行します。第1引数のBooleanをチェックして、trueでなければ現在実行中のフローを中止します。転送メソッドは実行中のアクションをsfError404Exceptionに投げて、すぐに中止させるのでアフターワードは必要ありません。
Exceptionのより、本番環境と開発環境で異なったページが表示されます。
リクエストとレスポンス
/jobページや/job/show/id/1ページをブラウザ上で見る際、データがWebサーバ間を往復し始めます。ブラウザはリクエストを送り、サーバはレスポンスを返します。
すでにsymfonyがリクエストをsfWebRequestオブジェクトでカプセル化されるのは見ました。(executeShow()メソッドを見てください)symfonyはオブジェクト指向フレームワークであるのでレスポンスもオブジェクトです。それはsfWebResponseクラスです。$this->getResponse()メソッドを呼ぶことでアクション内からレスポンスオブジェクトにアクセスすることができます。これらオブジェクトはPHP関数やグローバル変数から情報を受け取るたくさんの便利なメソッドを提供します。
なぜsymfonyは既存のPHP機能をラップしているのでしょうか?第一に、symfonyのメソッドはPHP標準のものより強力です。そして、アプリケーションのテストをするときは、グローバル変数をあれこれいじりまくったり、マジックのようなheader関数を使うよりもリクエストやレスポンスオブジェクトを使えばもっと簡単になります。
リクエスト
sfWebRequestクラスは$_SERVER, $_COOKIE, $_GET, $_POST, $_FILESといったPHPグローバル変数をラップしています。
既にgetParameter()メソッドを使ってリクエストパラメータにアクセスしたことがあります。このメソッドは$_GETまたは$_POSTグローバル変数やPATH_INFO変数から値を返します。
もしこれらの中の特定の1つを取得できるようにしたいならば、getGetParameter()やgetPostParameter()やgetUrlParameter()メソッドを利用する必要があります。
個別のメソッドによりアクションに制限をかけたい場合、例えばフォームからのポストリクエストだけを受けるようにするにはisMethod()メソッドを使えば可能となります。ex. $this->forwardUnless($request->isMethod('POST'));
レスポンス
sfWebResponseクラスはPHPのメソッドであるheader()とsetrawcookie()をラップします。
もちろんsfWebResponseクラスはレスポンスのコンテンツをセットする方法(setContent())とブラウザへレスポンスを送る方法(send())も提供します。本日のチュートリアルの最初の方でview.ymlとテンプレートの両方でスタイルシートやJavaScriptを管理するやり方を見ました。結局2つのテクニックともレスポンスオブジェクトのaddStylesheet()とaddJavascript()メソッドを使います。
sfAction, sfRequest, sfResponseクラスもたくさんの有用なメソッドを提供します。APIドキュメントを読んでsymfonyの内部クラスについてもっと学習しましょう。
また明日
今日はsymfonyで使われているデザインパターンについて説明しました。願わくばプロジェクトのディレクトリ構成についても感づいて欲しいです。レイアウトとテンプレートファイルを操作することでテンプレートをいろいろ触れようになっていますね。
もしdesign day contest(締め切りは21日目)にオリジナルのデザインを提案したいならば、今日定義したテンプレートを使って始めてください。ルールは至ってシンプルです。スタイルシートと画像だけでWebサイトをデザインしてください。HTMLコードに必要なフックは全て提供するようにしますが、もし別のidやclassを追加したいならメールで問い合わせてください。
明日は、今日使ったurl_for()ヘルパーとルーティングサブフレームワークについて学びます。