add to hatena hatena.comment (0) add to del.icio.us (0) add to livedoor.clip (0) add to Yahoo!Bookmark (0) Total: 0

DAY4: リファクタリング

おさらい

さて、askeet1.2チュートリアルも4日目です。本家のチュートリアルに沿ってきていますので今日もそうしましょう。
本家では今日はりファクタリングのチュートリアルとなっていますが、まとめると以下のような内容です。

* 回答の一覧を表示
* アクション、テンプレートの編集
* テストデータをバッチで投入
* モデルの修正
* ルーティングの設定

かなりのボリュームです。さぁ、がんばりましょう。

質問への回答一覧を表示する

3日目でQuestionのCRUDをコマンドから作りました。そこで、以下のようなURLで指定された質問IDの内容を表示できるようにしてみましょう。

http://symfony.centos5.localhost/askeet/frontend_dev.php/question/show/id/2

すでにアクションは自動生成されていますので、再作成されたアクションファイル(./app/frontend/module/question/actions/actions.class.php)

PHP:
  1. public function executeShow($request)
  2.   {
  3.     $this->question = $this->getQuestionById($request->getParameter('id'));
  4.     $this->forward404Unless($this->question);
  5.   }

ref: 本家チュートリアル4日目

本家のチュートリアルと異なる点があります。
まず1つはデータの取得方法の呼び出し方が異なります。これはPropelとDoctrineでの記述の違いがあるからです。
ただし、処理していることは同じです。データベースから指定したIDの質問内容を取得しているだけです。

Doctrineのドキュメントは有志の方々により日本語で公開されています。かなりの量になりますが、一通り目をとおしておくことをオススメします。

ref: http://www.doctrine-project.org/documentation

もう1つの違いはメソッドの引数に$requestが存在することです。
いままでは$request = $this->getRequest() とする必要がありましたが、アクションはパラメータをモデル等への受け渡しがメインの処理のため
常に引数として渡されるようになったのです。明確になったというところでしょうか。

またこのメソッドの中の$request->getParameter('id')という表現がでてきます。
これはURLにスラッシュ区切りでkeyとvalueを含めることで、スマートなURLを表現していて、上記URLでは `?id=2`というクエリストリングと同意です。

このような表現はsymfonyでは必ずでてきますので覚えておきましょう。

また、どのようなパラメータが利用できるのか全てを見たい場合は、デバッグツールバーで確認するか、

PHP:
  1. $request->getParameterHolder()->getAll();

で全てのリクエストパラメータを確認することができます。覚えておくとデバッグのとき役に立つので覚えておくと便利です。

もう一つforward404unlessメソッドが利用されています。これはunless(ifの反対「〜でなければ」と置き換えると解りやすいです)の場合に404ページを表示させるif構文と画面遷移の両方をまとめたようなメソッドです。
今回では「$this->questionが存在しなければ404ページを表示する」というメソッドということです。

showSuccess.phpテンプレートを修正

次にテンプレートを修正します。まずここで覚えておくルールはアクションで$this->XXXでアサインしたメンバ変数はテンプレートで$XXXで利用できるというルールがあるということです。
なので、今回のテンプレートでは$questionが利用できるということになります。

また、呼び出されるテンプレートは特に指定しなければ「アクション名Success.php」となります。
Successというのは「正常に処理されました」という意味だとでも思っておいてください。

他にもErrorや好きな名前をつけることができるのですが、symfony1.2からはSuccessを使うことがほとんどだと思います。

では、さっそくテンプレートを本家と同じく以下の内奥で置き換えてみましょう

PHP:
  1. <?php use_helper('Date') ?>
  2.  
  3. <div class="interested_block">
  4.   <div class="interested_mark" id="mark_<?php echo $question->getId() ?>">
  5.     <?php echo count($question->getInterest()) ?>
  6.   </div>
  7. </div>
  8.  
  9. <h2><?php echo $question->getTitle() ?></h2>
  10.  
  11. <div class="question_body">
  12.   <?php echo $question->getBody() ?>
  13. </div>
  14.  
  15. <div id="answers">
  16. <?php foreach ($question->getAnswer() as $answer): ?>
  17.   <div class="answer">
  18.     posted by <?php echo $answer->getUser()->getFirstName().' '.$answer->getUser()->getLastName() ?>
  19.     on <?php echo format_date($answer->getCreatedAt(), 'p') ?>
  20.     <div>
  21.       <?php echo $answer->getBody() ?>
  22.     </div>
  23.   </div>
  24. <?php endforeach; ?>
  25. </div>

3日目のときと同じで、Doctrineを使っているため、リレーション先の取得方法が$question->getInterest()、$question->getAnser()と単数系になっていることにさえ注意すれば大丈夫です。
あとは、回答データがないので、回答のサンプルデータをymlで作成し投入します。

CSS:
  1. Answer:
  2.   a1_q1:
  3.     Question: q1
  4.     User:     francois
  5.     body:        |
  6.       You can try to read her poetry. Chicks love that kind of things.
  7.  
  8.   a2_q1:
  9.     Question: q1
  10.     User:     fabien
  11.     body:        |
  12.       Don't bring her to a donuts shop. Ever. Girls don't like to be
  13.       seen eating with their fingers - although it's nice.
  14.   a3_q2:
  15.     Question: q2
  16.     User:     fabien
  17.     body:        |
  18.       The answer is in the question: buy her a step, so she can
  19.       get some exercise and be grateful for the weight she will
  20.       lose.
  21.   a4_q3:
  22.     Question: q3
  23.     User:     fabien
  24.     body:        |
  25.       Build it with symfony - and people will love it.

本家サンプルとの違いは外部キーを参照しているカラム名にモデル名(Question, User)を指定することです。これは3日目と同じですね。
そして、タスクコマンドで再取り込みを行いたいのですがここで問題があります。

それは、doctrine:data-loadタスクはテーブルのデータを削除しようとすることです。しかもリレーションの定義を考慮せず削除しようとするため
場合によっては削除時にエラーで処理が中断してしまいます。

そのため、前日にように単純にdoctrine:data-loadを使用してもデータを読み込めません。残念な結果です。

タスクはとても便利なのですが、このように意図した通りに動かない場合も多々ありますので、今回のように削除できないことが原因と解っているのであれば、
drop tableするSQLを別途用意し、あらかじめ読み込ませるほうが早かったりします。

というわけで、今回は以下のようなSQLを用意しました。

./data/sql/drop.sql

SQL:
  1. DROP TABLE IF EXISTS ask_interest;
  2. DROP TABLE IF EXISTS ask_relevancy;
  3. DROP TABLE IF EXISTS ask_answer;
  4. DROP TABLE IF EXISTS ask_question;
  5. DROP TABLE IF EXISTS ask_user;

これを最初に読み込み、以前作成したテーブル作成用のクエリを実行してからdata-loadを実行します。

また、テーブルをdropしたあとはcreateが必要になりますが、ここでテーブルをリストアする方法についてもう1パターンから行ってみます。
2日目で、create.sqlというファイルをphpMyAdminから作成し、そこからテーブルを作成しました。
今回もテーブル構造に変更はないので、そのまま読み込ませても問題ありません。

しかし、すでにモデルのためのスキーマも用意できていますし、スキーマファイルに基づいたテーブルを作成したい場合もあります。
その場合はdoctrine:build-sqlで既存モデルからテーブルのcreate文を作成してくれますのでそれを利用します。
今回はこのパターンでテーブルをリストアしてみます。

* テーブルを削除する

$ mysql -uyourname -p askeet < ./data/sql/drop.sql
Enter password:

* モデルからテーブルのCreate文を作成する

$ ./symfony doctrine:build-sql
>> doctrine generating sql for models

* 作成したSQLを読み込む

$ mysql -uyourname -p askeet < ./data/sql/schema.sql
Enter password:

* テストデータを再投入

$ ./symfony doctrine:data-load --dir="data/fixtures/test"
>> doctrine loading data fixtures from "Array"

すると、ここでエラーが発生します。

$ ./symfony doctrine:data-load --dir="data/fixtures/test"
>> doctrine loading data fixtures from "Array"

Invalid fixture element "User" under "a1_q1"

すいません。phpMyAdminでテーブル定義したときにask_answerテーブルのuse_idに対してリレーションを作成するのを忘れていました。
そのためリレーションが判断できずに不明のカラムとして処理されエラーとなってしまいます。

対処方法として、以下の2パターンがあります。
テーブルを直接修正するか、それともスキーマファイルを修正する方法です。

今回はモデル名と実際のテーブル名がprefixをつけているため異なります。
つまり、テーブルを直接修正し、スキーマファイルを再作成した後に、さらにスキーマファイルにも修正が必要になります。
これはちょっと面倒ですよね。

なので、ここではスキーマファイルを修正し、そのスキーマファイルからテーブルの再作成、モデルの再作成を行います。

* スキーマファイルの修正
./config/doctrine/schema.yml

CSS:
  1. relations:
  2.     Question:
  3.       local: question_id
  4.       foreign: id
  5.       type: one
  6.     User:
  7.       local: user_id
  8.       foreign: id
  9.       type: one

User:部分を追加します。後は、モデルとSQLを再作成し、
さきほどと同じ処理をおこないます。

* モデルの再作成

$ ./symfony doctrine:build-model
>> doctrine generating model classes

* テーブルのCreate文の作成

$ ./symfony doctrine:build-sql
>> doctrine generating sql for models

* 再度実行

$ mysql -uyourname -p askeet < ./data/sql/drop.sql
Enter password:
$ ./symfony doctrine:build-sql
>> doctrine generating sql for models
$ mysql -uyourname -p askeet < ./data/sql/schema.sql
Enter password:
$ ./symfony doctrine:data-load --dir="data/fixtures/test"
>> doctrine loading data fixtures from "Array"

最後にモデルに修正を加えたのでキャッシュをクリアしておきます。

$ ./symfony cc

あとはもう一度画面をみてみます。

http://symfony.centos5.localhost/askeet/frontend_dev.php/question/show/id/1

投入した回答データが表示されていればOKです。


モデルの修正 その1

ではようやく、本日の課題であるリファクタリングを行っていきます。
PHP5ではオブジェクトそのものをechoした場合にマジックメソッドとして__toString()が呼ばれます。
それを上手に利用すればすっきりしたコードになります。

まずはUserモデルクラスに対して以下のようにメソッドを追加します。

* lib/model/doctrine/User.class.php

PHP:
  1. public function __toString()
  2.     {
  3.       return sprintf("%s %s", $this->getFirstName(), $this->getLastName());
  4.     }

そして、テンプレート側の呼び出し方をオブジェクトをechoするようにします。

app/frontend/modules/question/templates/showSuccess.php

PHP:
  1. posted by <?php echo $answer->getUser() ?>

すっきりしましたね!

同じ事を繰り返さない(DRY)

異なる画面でも表示する内容が同じ場合というのはよくあることです。そのような場合にテンプレートを部品化し、
それを読み込むようにすれば共通化できますよね。これがD.R.y(Don't Repeat Yourself)です。

symfonyでは部品化の方法としてパーシャル(partial)、コンポーネント(component)があります。
これらの違いは単純なテンプレート(partial)か、ロジックが含むちょっとややこしいテンプレート(component)です。

ここでは、パーシャルを用いてinterestモデルを利用しているところがshow,list両方で利用されているので共通化しておきます。

まず、パーシャルテンプレートを用意します。部品化されたテンプレートはアンダーバーで始まるファイル名にします。
ここでは_interested_user.phpとします。
* ./apps/frontend/modules/question/templates/_interested_user.php

PHP:
  1. <div class="interested_mark" id="mark_<?php echo $question->getId() ?>">
  2.     <?php echo count($question->getInterest()) ?>
  3.   </div>

そして、このテンプレートを呼び出すように呼び出しもとのテンプレートを修正します。

* ./app/frontend/module/question/templates/showSuccess.php

PHP:
  1. <div class="interested_block">
  2.   <?php include_partial('_interested_user', array('question' => $question)) ?>
  3. </div>

* ./app/frontend/module/question/templates/indexSuccess.php

PHP:
  1. <?php use_helper('Text') ?>
  2.  
  3. <?php foreach($questionList as $question): ?>
  4.   <div class="interested_block">
  5.     <?php echo include_partial('interested_user', array('question' => $question)) ?>
  6.   </div>
  7.  
  8.   <h2><?php echo link_to($question->getTitle(), 'question/show?id='.$question->getId()) ?></h2>
  9.  
  10.   <div class="question_body">
  11.     <?php echo truncate_text($question->getBody(), 200) ?>
  12.   </div>
  13. <?php endforeach ?>

モデルの修正 その2

interestをcountしている箇所があったのを覚えていますか?
あれはリレーションで定義した外部テーブルのデータがオブジェクトとして取得したものをカウントしているのですが、
コストを考えると、既にカウントしたデータをinterested_usersカラムに入れておくほうが良いですよね。

というわけで、本家と同じ処理をおこないます。

interested_usersカラムを追加する

ask_questionテーブルに追加します。
型はintegerでデフォルトは0です。

./config/doctrine/schema.yml

CSS:
  1. interested_users:
  2.       type: integer(4)
  3.       unsigned: 1
  4.       default: 0

今日のはじめに定義忘れで再構築したので手順の説明は省きます。

$ ./symfony doctrine:build-model
$ mysql -uyourname -p askeet < ./data/sql/drop.sql
Enter password:
$ ./symfony doctrine:build-sql
>> doctrine generating sql for models
$ mysql -uyourname -p askeet < ./data/sql/schema.sql
Enter password:
$ ./symfony doctrine:data-load --dir="data/fixtures/test"
>> doctrine loading data fixtures from "Array"

次に、Interestモデルにカウントアップするための処理を追加します。
本家とちがいこのチュートリアルはDoctrineのため異なります。

Propelよりも簡単で以下のようにするだけです。

./lib/model/doctrine/Interest.class.php

PHP:
  1. public function preSave($event)
  2.     {
  3.         $question = $this->getQuestion();
  4.         $question->interested_users++;
  5.         $question->save();
  6.     }

もともとsaveメソッドが走る前にフックできるようにpreSaveメソッドが用意されているのでこれを利用してカウントアップを行っています。
あとは、パーシャルテンプレートを修正するだけです。

./app/frontend/modules/question/templates/_interested_user.php

PHP:
  1. <?php echo $question->getInterestedUsers() ?>

また、本家ではトランザクション処理についても書かれていますが、ここではDoctrineの場合の例だけをのせておきます。実装はしていません。

PHP:
  1. $con = Doctrine_Manager::connection();
  2. try {
  3.     $conn->beginTransaction();
  4.     // 処理
  5.      $conn->commit();
  6. } catch(Doctrine_Exception $e) {
  7.     $conn->rollback();
  8. }

次に、回答テーブルに関連性(Relevancy)のupとdownをカウントするカラムを追加します。
型はintegererでデフォルトは0です

./config/doctrine/schema.yml

CSS:
  1. Answer:
  2.    ....
  3.     relevancy_up:
  4.       type: integer(4)
  5.       default: 0
  6.     relevancy_down:
  7.       type: integer(4)
  8.       default: 0

そして、モデルとテーブルを再生成します。

$ ./symfony doctrine:build-model
$ ./symfony doctrine:build-sql
$ mysql -uyourname -p askeet < ./data/sql/drop.sql
Enter password:
$ mysql -uyourname -p askeet < ./data/sql/schema.sql
Enter password:
$ ./symfony doctrine:data-load --dir="data/fixtures/test"
>> doctrine loading data fixtures from "Array"

これらのカウント処理をRelevancyモデルに実装します。
今度はトランザクションも入れてみます。

本家とほぼ同じで、異なる部分はDoctrineで処理するための違いです。

./lib/model/doctrine/Relevancy.class.php

PHP:
  1. public function preSave($event)
  2.     {   
  3.         $con = Doctrine_Manager::connection();
  4.         try {
  5.             $con->beginTransaction();
  6.             $answer = $this->getAnswer();
  7.             if ($this->getScore() == 1) {
  8.               $answer->relevancy_up++;
  9.             } else {
  10.               $answer->relevancy_down++;
  11.             }
  12.             $answer->save();
  13.             $con->commit();
  14.         } catch (Doctrine_Exception $e) {
  15.             $con->rollback();
  16.         }
  17.     }

./lib/model/doctrine/Answer.class.php

PHP:
  1. public function getRelevancyUpPercent()
  2. {
  3.   $total = $this->getRelevancyUp() + $this->getRelevancyDown();
  4.  
  5.   return $total ? sprintf('%.0f', $this->getRelevancyUp() * 100 / $total) : 0;
  6. }
  7.  
  8. public function getRelevancyDownPercent()
  9. {
  10.   $total = $this->getRelevancyUp() + $this->getRelevancyDown();
  11.  
  12.   return $total ? sprintf('%.0f', $this->getRelevancyDown() * 100 / $total) : 0;
  13. }

./app/frontend/modules/question/templates/showSuccess.php

PHP:
  1. <div id="answers">
  2. <?php foreach ($question->getAnswer() as $answer): ?>
  3.   <div class="answer">
  4.     <?php echo $answer->getRelevancyUpPercent() ?>% UP <?php echo $answer->getRelevancyDownPercent() ?> % DOWN
  5.     posted by <?php echo $answer->getUser() ?>
  6.     on <?php echo format_date($answer->getCreatedAt(), 'p') ?>
  7.     <div>
  8.       <?php echo $answer->getBody() ?>
  9.     </div>
  10.   </div>
  11. <?php endforeach; ?>
  12. </div>

あとはテストデータを作って再度投入します。
./data/fixtures/test/005.relevancy.yml

CSS:
  1. Relevancy:
  2.   rel1:
  3.     Answer: a1_q1
  4.     User:   fabien
  5.     score:     1
  6.  
  7.   rel2:
  8.     Answer: a1_q1
  9.     User:   francois
  10.     score:     -1

これで、テストデータを入れ終わった後に画面でみてみます。


ルーティング

本家ではこのあとにタイトルの一部を使ってルーティングを変更する処理を行っています。
これはIDよりタイトルがURLに含まれているほうがSEO的に良いなどの理由からなのですがここではルーティングの変更を行わずにそのままいきます。

また明日?

さすがにこれだけの量になると大変ですね。時間があるときに少しずつ実際に触りながら行うことをオススメします。

ここまでのソースは以下のリポジトリからチェックアウトすることができます。
http://svn.1ms.jp/public/symfony/askeet12/tags/release_day_4

関連するその他の記事

Comments

Leave a Reply