![]() |
|
The Askeet Tutorialsymfony advent calendar day four: refactoring |
|

WARNING: The SVN source code found in the release_day tags is outdated. Please refer to the current version until each day code is updated.
You are currently reading "The Askeet Tutorial" which is licensed under the Creative Commons Attribution-Noncommercial-No Derivative Works 3.0 Unported License license.
|
This work is licensed under a
Creative Commons Attribution-Noncommercial-No Derivative Works 3.0 Unported License.
Translation of this work into another language is explicitly allowed. |
3日目までは、MVC構造の全てのレイヤーを紹介し、トップページに質問のリストを表示するように変更を加えました。アプリケーションとしては良くなってきましたが、まだまだ内容が足りません。
4日目の目標は、質問への答えのリストを表示すること、質問の詳細へのもっと良いURLの設定、カスタムクラスの追加、重複したコードの塊をあるべき場所へ移動すること。これによってテンプレート、モデル、ルーティングポリシー、リファクタリングの概要を理解できます。数日しかたってないコードを変更するにはちょっと早すぎるのでは?と思うかもしれませんが、今日の終わりにはその理由が分かるでしょう。
このチュートリアルを読む為には、symfonyでのMVC適用概念を良く理解しておく必要があります。 アジャイル開発 についての理解もあるとよりよいかと思います。
まず始めに、2日目 のQuestion CRUDで生成したテンプレートから続けていきましょう。
question/show アクションは、idを渡すことによって質問の詳細を表示します。 テストするには、以下のように呼び出すだけです :
http://askeet/frontend_dev.php/question/show/id/1

前にちょっと触ったことがある人ならこの show ページを見たことがあるかもしれません。 ここが質問に対する答えを追加する場所になります。
それではちょっと askeet/apps/frontend/modules/question/actions/actions.class.php ファイルにあるこの show アクションを見てみましょう:
public function executeShow() { $this->question = QuestionPeer::retrieveByPk($this->getRequestParameter('id')); $this->forward404Unless($this->question); }
Propelを知っている人なら、ここでは Question テーブルに対して簡単なリクエストが発行されていると分かるかもしれません。ここではリクエストでの id 引数をプライマリーキーとする、ユニークなレコードを取得しようとしています。先程の例でのURLでは、 id 引数は 1 であり、QuestionPeer クラスの ->retrieveByPk() メソッドは、プライマリーキーを 1 とする Question クラスを返すことになります。Propelにあまり詳しくない人は、Propelのサイトにあるドキュメント を読んでみてください。
このリクエストの結果は $question 変数を通して showSuccess.php テンプレートに渡されます。
sfAction オブジェクトの ->getRequestParameter('id') メソッドは、 GET / POST に関わらず id という引数を受け取ります。例えば、下のように呼んだ場合:
http://askeet/frontend_dev.php/question/show/id/1/myparam/myvalue
show アクションでは、$this->getRequestParameter('myparam') と書くことで myvalue を受け取ることができます。
注意:
forward404Unless()メソッドはデータベースに特定のレコードが存在しない場合、404ページを表示します。実行時に発生するこのようなエラーケースに対応することは良い習慣で、symfonyではこれを簡単に実践できるようにシンプルなメソッドが用意されています。
showSuccess.php テンプレートの変更生成された showSuccess.php テンプレートは、今自分たちが必要な形ではないので、完全に書き換えてしまいます。 frontend/modules/question/templates/showSuccess.php ファイルを開いて、内容を以下のものに変えてください:
<?php use_helper('Date') ?> <div class="interested_block"> <div class="interested_mark"> <?php echo count($question->getInterests()) ?> </div> </div> <h2><?php echo $question->getTitle() ?></h2> <div class="question_body"> <?php echo $question->getBody() ?> </div> <div id="answers"> <?php foreach ($question->getAnswers() as $answer): ?> <div class="answer"> posted by <?php echo $answer->getUser()->getFirstName().' '.$answer->getUser()->getLastName() ?> on <?php echo format_date($answer->getCreatedAt(), 'p') ?> <div> <?php echo $answer->getBody() ?> </div> </div> <?php endforeach; ?> </div>
ここで昨日 listSuccess.php テンプレートにあった interested_block div に気づくかもしれません。これは特定の質問に対しての関心を示しているユーザーの数を表示するだけです。それ以外でHTML自体は、タイトルに link_to が無いだけで list のような内容です。これはただ質問に対しての必要な情報だけを表示する初期のコードを書き換えただけです。
新しい部分は answers divです。 ここは質問に対しての全ての回答を表示し(Propelの $question->getAnswers() メソッドを使用)、その個々で、信頼性の合計、回答した人の名前、日付、内容が表示されます。(訳注:「信頼性の合計」はコード中にないと思う SVNを見るとある)
format_date() は初期宣言が必要なテンプレートヘルパーのもう一つの使用例です。ヘルパーの文法やその他のヘルパーについては、symfonyブックの 国際化ヘルパーの章 (これらのヘルパーは面倒な日付のフォーマットを楽にしてくれます) を参照してください。
注意: Propelでは関連性のあるテーブルの取得用メソッド名には、テーブル名の後に 's' を付けるだけになっています。この為、
->getRelevancys()というような変なメソッド名になっていますが、このメソッドを使えば長いSQLコードを書かなくても良くなるので大目に見てください。(訳注:中学で習う「yをiに変えてes」の基本がなってないと言うこと)
それでは data/fixtures/test_data.yml の最後の行から answer と relevancy テーブルのデータを追加してみましょう (その他独自のデータを入れても構いませんよ):
Answer:
a1_q1:
question_id: q1
user_id: francois
body: |
You can try to read her poetry. Chicks love that kind of things.
a2_q1:
question_id: q1
user_id: fabien
body: |
Don't bring her to a donuts shop. Ever. Girls don't like to be
seen eating with their fingers - although it's nice.
a3_q2:
question_id: q2
user_id: fabien
body: |
The answer is in the question: buy her a step, so she can
get some exercise and be grateful for the weight she will
lose.
a4_q3:
question_id: q3
user_id: fabien
body: |
Build it with symfony - and people will love it.
下のコマンドでデータをリロードします:
$ php batch/load_data.php
変更がうまくいったか質問を表示するアクションに行ってみましょう:
http://askeet/frontend_dev.php/question/show/id/XX
注意: XX は、リロードされた現在の
idにしてください。

質問に加え回答も表示され、なかなか良い感じになりましたね。
このアプリケーションとして、書き込みをした人のフルネームをまたどこかで使うことはほぼ確実です。また、フルネームと言うだけに User オブジェクトの属性の一部になると考えられます。この事からするとアクション内でフルネームを組み立てるのではなく、 User モデルにフルネームを取得することができるメソッドがあるべき姿のようです。それでは書いてみましょう。askeet/lib/model/User.php を開いて下のメソッドを追加してください:
public function __toString() { return $this->getFirstName().' '.$this->getLastName(); }
なんでこのメソッドは getFullName() みたいなものではなくて __toString() なのでしょう?それは __toString() メソッドはPHP5において、オブジェクトを文字として表現する為に使われるデフォルトメソッドだからです。ということは同じ結果を表示するにも
posted by <?php echo $answer->getUser()->getFirstName().' '.$answer->getUser()->getLastName() ?>
askeet/apps/frontend/modules/question/templates/showSuccess.php テンプレートのこの行は
posted by <?php echo $answer->getUser() ?>
のように置き換えることができます。綺麗にまとまりましたよね?
アジャイルな開発で重複するコードは避けるという良い教訓があります。これは "Don't Repeat Yourself" (D.R.Y.)(訳注:同じことは繰り返さない)と言われています。重複されたコードはカプセル化されたコードを使うより、レビューするにも、変更するにも、テストするにも、確認するにも2倍かかってしまいますし、アプリケーションのメンテナンス性も下げます。また、今日のチュートリアルの最後の部分を見てみると、昨日書かれた listSuccess.php テンプレートと showSuccess.php には、重複した部分があることに気づくと思います:
<div class="interested_block"> <div class="interested_mark"> <?php echo count($question->getInterests()) ?> </div> </div>
よって最初の リファクタリング は、この2つのテンプレートから重複する部分を抜き出し、フラグメント(コードの破片) 又は再利用可能なコード にしていきます。_interested_user.php というファイルを askeet/apps/frontend/modules/question/template/ ディレクトリに作り、下のコードを書いてください:
<div class="interested_mark"> <?php echo count($question->getInterests()) ?> </div>
そして、元の (listSuccess.php と showSuccess.php) で、抜き出した場所を下のように変更してください:
<div class="interested_block"> <?php include_partial('interested_user', array('question' => $question)) ?> </div>
フラグメントはオブジェクトに対するアクセスを持っていません。このフラグメントでは $question 変数を使っているので、 include_partial の呼び出しで定義されなくてはいけません。また、フラグメントのファイル名にある _ は、 template/ ディレクトリにおいてフラグメントとそうでないものとを区別する為に使われています。フラグメントについて詳しくは、symfonyブックの ビューの章 を読んでください。
新しく作ったこのフラグメントで $question->getInterests() はデータベースにリクエストを発行し、 Interest クラスのオブジェクト配列を返します。これでは関心のある人の数を持ってくるだけにしてはかなり重い処理で、データベースへの処理もかなりのものになってしまいます。 listSuccess.php テンプレートでも同じ事をして、しかも質問のリスト内でのループです。ちょっと最適化が必要ですね。
良い方法としては、 Question テーブルに interested_users という項目を持たせ、この項目を質問に対する関心データが作られる度に更新すればいいかもしれません。
注意:
Interestレコードを現状のaskeetを通して追加する方法がない為に、明確なテストの方法なしに何かを変更しようとしています。通常では、テストできる手段がない場合は、何かを変更すると言うことはあってはいけません。幸いにも今回この変更においては、後に分かると思いますがテストする方法があります。
User オブジェクトモデルへの項目の追加それではどーんと askeet/config/schema.xml データモデルの ask_question テーブルに下の行を追加して変更してみましょう:
<column name="interested_users" type="integer" default="0" />
で、モデルの再構成:
$ symfony propel-build-model
そうです。現状の変更点に構わず、もういきなり再構成します。 User クラスに書き込んだ変更は askeet/lib/model/User.php にあり、これはPropelで生成した askeet/lib/model/om/BaseUser.php を継承しています。だから askeet/lib/model/om/ ディレクトリに変更を加えてはいけないんですね: このディレクトリのクラスは build-model が実行されるたびに上書きされてしまうからです。Symfonyでは、ウェブプロジェクトの初期に通常起こるこのような変更に柔軟に対応するよう作られています。
これに加えて、実際のデータベースにも変更が必要です。SQLをわざわざ書かない為にも、SQLスキーマを再構成し、テストデータをロードしましょう:
$ symfony propel-build-sql
$ mysql -u youruser -p askeet < data/sql/lib.model.schema.sql
$ php batch/load_data.php
注意: TIMTOWTDI: There is more than one way to do it. (訳注:TIMTOWTDIは「There is more than one way to do it」の頭文字を取ったもの。意味としては「方法は1つだけじゃない」)データベースを再構成する代わりに、MySQLのテーブルに手で直接新しい項目を追加することもできます:
$ mysql -u youruser -p askeet -e "alter table ask_question add interested_users int default '0'"
Interest オブジェクトの save() メソッドの変更この新しい項目の値は、ユーザーが質問に対して関心を付ける度に更新されなければなりません。例えるならば、 Interest テーブルにレコードが追加される度です。MySQLのトリガーでも同じ事はできますが、これだとデータベース依存になってしまいますし、データベースを変更する場合には足かせになってしまいます。
一番良い方法としては、Interest オブジェクトが新たに作られるたびに呼ばれる save() メソッドをオーバーライドし変更を加えることです。 それでは askeet/lib/model/Interest.php ファイルを開いて下のコードを加えてください:
public function save($con = null) { $ret = parent::save($con); // update interested_users in question table $question = $this->getQuestion(); $interested_users = $question->getInterestedUsers(); $question->setInterestedUsers($interested_users + 1); $question->save($con); return $ret; }
この save() メソッドでは関係している質問を取得し、 interested_users 項目をカウントアップします。その後は通常通りに save() をします。しかし $this->save(); としてしまうと永久ループになってしまうので、クラスメソッドである parent::save() を代わりに使用しています。(訳注: $ret = parent::save($con); の位置は言っている位置と逆)
Question と Interest オブジェクトの更新の間にデータベースで何か起こったらどうなるでしょう? 整合性のないデータになってしまうかもしれません。これは、銀行での”最初にある口座からある額が引き落とされ、もう一つの口座の額を増やす”送金処理で起こりえる問題と同じです。
もし2つのリクエストが互いに依存しているのであれば、トランザクションを使って安全に実行すべきです。トランザクションは片方だけのリクエストが成功するということがないことを保証してくれます。トランザクション中のリクエストで何か起こった場合、それ以前に成功していたようなリクエストがあったとしてもこれをキャンセルし、データベースをトランザクションが開始される前の状態に戻すことができます。
save() メソッドは、symfonyにおいてのこのトランザクションを説明する良い材料なので、先程のコードを下のように変更してみましょう:
public function save($con = null) { $con = Propel::getConnection(); try { $con->begin(); $ret = parent::save($con); // update interested_users in question table $question = $this->getQuestion(); $interested_users = $question->getInterestedUsers(); $question->setInterestedUsers($interested_users + 1); $question->save($con); $con->commit(); return $ret; } catch (Exception $e) { $con->rollback(); throw $e; } }
まず始めにCreole経由でのデータベースコネクションを直にオープンします。 ->begin() と ->commit() 宣言の間は、トランザクションが全ての処理の成功を保証します。何か失敗するようなことがあれば、例外が投げられ以前の状態に戻す為にロールバック処理がデータベースで実行されます。
Question オブジェクトの ->getInterestedUsers() メソッドには問題はありませんが、 _interested_user.php フラグメントを変える準備はできているので、次の部分を変更しましょう:
<?php echo count($question->getInterests()) ?>
を下のように
<?php echo $question->getInterestedUsers() ?>
注意: テンプレート内に重複したコードで散らかす代わりにフラグメントを使うと言うことで、この変更が1回で済んでしまいました。フラグメントを使ってなかったら
listSuccess.phpとshowSuccess.phpテンプレートの2つを変更しなければならず、面倒くさがりな自分たちにはやる気すら無くさせたでしょう。
リクエスト、実行回数の数を考えてもこの方が良いですね。ウェブデバッガーツールバーにあるデータベースアクセス回数を見ても一目瞭然です。また、データベースアイコンをクリックすると現在のページでの実行されたSQLの詳細が確認できます:

show アクションを呼んで何も変わってないか確認してみましょう。でもちょっとその前に、昨日書いたバッチを走らせてデータを再設定してみましょう:
$ cd /home/sfprojects/askeet/batch
$ php load_data.php
Interest テーブルにレコードを作る時、 sfPropelData オブジェクトはオーバーライドされた save() メソッドを使い、関連を持つ User レコードをちゃんと更新するはずです。Interest オブジェクト用のCRUDインターフェースはまだありませんが、これは今回の変更をテストする為に使えます。
トップページと質問を呼んでみて確認してみましょう:
http://askeet/frontend_dev.php/
http://askeet/frontend_dev.php/question/show/id/XX
関心のあるユーザーの数は変わってませんね?変更はうまくいきました。
count($question->getInterests()) にしたことは、 count($answer->getRelevancys()) にも適用できます。違いといえば、質問には”関心”という一つの票があるだけですが、回答にはプラスとマイナスな票があるということです。既にどうやってモデルに変更を加えるか分かっているので、サクッと説明します。一応下がその変更点です。askeet SVN リポジトリ を使ってコピーすれば、わざわざ手でかく必要もありません。
schema.xml の answer テーブルに下の項目を追加
<column name="relevancy_up" type="integer" default="0" /> <column name="relevancy_down" type="integer" default="0" />
モデルを再構成して、データベースを更新します
$ symfony propel-build-model
$ symfony propel-build-sql
$ mysql -u youruser -p askeet < data/sql/lib.model.schema.sql
lib/model/Relevancy.php にある Relevancy クラスの ->save() メソッドをオーバーライドします
public function save($con = null) { $con = Propel::getConnection(); try { $con->begin(); $ret = parent::save(); // update relevancy in answer table $answer = $this->getAnswer(); if ($this->getScore() == 1) { $answer->setRelevancyUp($answer->getRelevancyUp() + 1); } else { $answer->setRelevancyDown($answer->getRelevancyDown() + 1); } $answer->save($con); $con->commit(); return $ret; } catch (Exception $e) { $con->rollback(); throw $e; } }
Answer クラスに以下の2つのメソッドを追加:
public function getRelevancyUpPercent() { $total = $this->getRelevancyUp() + $this->getRelevancyDown(); return $total ? sprintf('%.0f', $this->getRelevancyUp() * 100 / $total) : 0; } public function getRelevancyDownPercent() { $total = $this->getRelevancyUp() + $this->getRelevancyDown(); return $total ? sprintf('%.0f', $this->getRelevancyDown() * 100 / $total) : 0; }
question/templates/showSuccess.php の回答に関する部分を以下のように変更:
<div id="answers"> <?php foreach ($question->getAnswers() as $answer): ?> <div class="answer"> <?php echo $answer->getRelevancyUpPercent() ?>% UP <?php echo $answer->getRelevancyDownPercent() ?> % DOWN posted by <?php echo $answer->getUser()->getFirstName().' '.$answer->getUser()->getLastName() ?> on <?php echo format_date($answer->getCreatedAt(), 'p') ?> <div> <?php echo $answer->getBody() ?> </div> </div> <?php endforeach; ?> </div>
fixturesにテストデータの追加
Relevancy:
rel1:
answer_id: a1_q1
user_id: fabien
score: 1
rel2:
answer_id: a1_q1
user_id: francois
score: -1
バッチを走らせて
question/show ページを確認

チュートリアルが始まってから、ずっと下のようなURLを使ってきました
http://askeet/frontend_dev.php/question/show/id/XX
symfonyではデフォルトのルーティングルールとしてこのリクエストは、下のように呼ばれたものと解釈されています
http://askeet/frontend_dev.php?module=question&action=show&id=XX
でもルーティングシステムによってもっと別な可能性が見えてきます。同じページを表示するにも質問のタイトルをURLの一部に出来たりします。
http://askeet/frontend_dev.php/question/what-shall-i-do-tonight-with-my-girlfriend
これによってSEO対策が出来たり、URLとしてはより意味のあるものに出来たりします。
始めに、URLとして使えるようにタイトルの変換されたバージョン(ストリップされたもの)が必要です。(訳注:ストリップとは「丸裸」という意味ではなく「列になった 列なった」という意味だと思う) There's more than one way to do it 方法は1つだけじゃない また、この変換されたタイトルは Question テーブルの項目に保存するようにします。 schema.xml の Question テーブルに下の行を追加してください:
<column name="stripped_title" type="varchar" size="255" /> <unique name="unique_stripped_title"> <unique-column name="stripped_title" /> </unique>
で、いつものようにモデルを再構成して、データベースを更新して:
$ symfony propel-build-model
$ symfony propel-build-sql
$ mysql -u youruser -p askeet < data/sql/lib.model.schema.sql
ストリップされたタイトルが同時に設定されるように出来るよう Question オブジェクトの setTitle() メソッドをオーバーライドしていきます。
と、その前にタイトルをストリップされたものに変換するカスタムクラスを作ります。カスタムクラスにする理由は、 Question オブジェクト自体には関係のない機能だからです(どのみち Answer オブジェクトにも使うでしょうし)。
askeet/lib/ ディレクトリに myTools.class.php ファイルを作ってください:
<?php class myTools { public static function stripText($text) { $text = strtolower($text); // strip all non word chars $text = preg_replace('/\W/', ' ', $text); // replace all white space sections with a dash $text = preg_replace('/\ +/', '-', $text); // trim dashes $text = preg_replace('/\-$/', '', $text); $text = preg_replace('/^\-/', '', $text); return $text; } }
askeet/lib/model/Question.php クラスを開いて下のコードを追加してください:
public function setTitle($v) { parent::setTitle($v); $this->setStrippedTitle(myTools::stripText($v)); }
myTools カスタムクラスの宣言が無いのに気づきましたか?: symfonyでは lib/ ディレクトリにあるものは、必要な時に自動的に読み込んでくれるからです。
それではテストデータのリロードをしてみましょう:
$ symfony cc
$ php batch/load_data.php
カスタムクラスやカスタムヘルパーについての詳しい情報は、symfonyブックの 拡張の章 を読んでください。
show アクションへのリンクを変更listSuccess.php テンプレートで下の行を
<h2><?php echo link_to($question->getTitle(), 'question/show?id='.$question->getId()) ?></h2>
このように変更してください
<h2><?php echo link_to($question->getTitle(), 'question/show?stripped_title='.$question->getStrippedTitle()) ?></h2>
question モジュールの actions.class.php を開いて、 show アクションを下のように変更してください:
public function executeShow() { $c = new Criteria(); $c->add(QuestionPeer::STRIPPED_TITLE, $this->getRequestParameter('stripped_title')); $this->question = QuestionPeer::doSelectOne($c); $this->forward404Unless($this->question); }
トップページを更新し、質問のタイトルをクリックしてそれぞれに行けるか確認してください:
http://askeet/frontend_dev.php/
URLは質問のタイトルで、ストリップされたものが表示されているはずです:
http://askeet/frontend_dev.php/question/show/stripped-title/what-shall-i-do-tonight-with-my-girlfriend
でもこれが表示したいURLの形ではありません。それではルーティングルールを編集してみましょう。 routing.yml 設定ファイルを開いて ( askeet/apps/frontend/config/ ディレクトリにあります) 、次のルールをファイルの一番上に追加してください:
question:
url: /question/:stripped_title
param: { module: question, action: show }
url の行で question という文字は最終的なURLに表示されるカスタムテキストです。 stripped_title は引数となります (: で始まっています)。 link_to() ヘルパーをテンプレートに使っていることによって、symfonyルーティングシステムがこの同じ パターン を question/show アクションでのリンクに適用しています。
それでは最終テストです: トップページを表示し、質問のタイトルをクリックしてください。質問が表示されるだけでなく (何もおかしなところはなく)、ブラウザのアドレスバーには下のように表示されていると思います:
http://askeet/frontend_dev.php/question/what-shall-i-do-tonight-with-my-girlfriend
ルーティング機能についての詳しい情報は、symfonyブックの ルーティングポリシーの章 を読んでください。
今日はウェブサイト自体への新しい機能はあまりありませんでした。でもテンプレートのコーディングの仕方、モデルの変更の仕方を勉強し、いろんな場所をリファクタリングしていきました。
symfonyプロジェクトでは、このようなことは常に起こります: 再利用可能なコードはフラグメントに落とし込んだり、カスタムクラスにしたり、アクションやテンプレートに書かれたようなコードで実際はモデルにあるべきものはモデルに移動されます。コード自体は至る所に散らばってしまいますが、メンテナンスや変更はより簡単になります。付け加えれば、symfonyプロジェクトのファイル構造においては、コードの破片がどこにあるか簡単に見つけることが出来るようになっています(ヘルパー、モデル、テンプレート、カスタムクラス、その他)。
今日やったリファクタリングで今後の開発がよりスピーディーになります。今のやり方(後に発生するサイトとしての機能にはあまり心配せず、今要る機能を盛り込んでいくやり方)においては、定期的にこのようなリファクタリングは繰り返していきます。
明日は?フォームに話を移して、フォームからの変数のやりとりを見ていきます。また、トップページの質問のリストを複数のページに分けていきます。それまでは今日のコードをSVN リポジトリ (release_day_4 タグ) からダウロードしてみてください:
http://svn.askeet.com/tags/release_day_4/
質問も大歓迎です askeet メーリングリスト や フォーラム まで。
If you find a typo or an error, please register and open a ticket.
If you need support or have a technical question, please post to the user mailing-list or to the forum.