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

DAY8: symfonyでAJAXを使う

これまでのおさらい

ようやくそれらしいアプリケーションができてきた感があります。でも、まだ8日目なんですね。。
本日はAJAXをsymfonyでやってみるということです。ではがんばってチュートリアルを実践してみます。

symfony1.2でのJavascriptライブラリ

symfony1.0まではprototypeとscriptaculousという2つのJavaScriptライブラリを標準で用意していました。
しかし、symfony1.2では外部のライブラリをできるだけ切り離すという思想のもと、これらのライブラリはsfProtoculousPluginというプラグインとして提供されるようになりました。
といっても、symfonyのコアパッケージに含まれますので、プラグインの利用の宣言をするだけです。

また、sfJqueryPluginというjQueryをベースとしたプラグインも用意されています。
ただし、この記事を書いている時点ではsf1.1まで対応となっています。

また、JavaScriptのライブラリを公開ディレクトリ以下に用意しなければなりませんが、

C:
  1. $ ./symfony plugin:publish-assets

というタスクを叩く事でsymfonyがうまくやってくれるようになりました。

Javascriptヘルパーとsymfony

本日は、本家チュートリアルと同じようにprototypeを使った実装を行ってみたいと思います。
もし、本格的にprototypeやjQueryなどのライブラリを活用したいとするとsymfonyが提供するJavascriptヘルパーでは不足すると思います。
また、Javascriptの理解があるのであれば最初からヘルパーに頼らずに純粋にJavascriptで実装したほうが確実で早いと思います。

jQueryの使い方を覚えていればCakePHPだろうが他のアプリケーションだろうがいつでもその知識は役に立ちます。
でも、symfonyのJavascriptヘルパーを覚えたところでsymfonyを使うときにしか活用できないというデメリットもあります。

ローディング画像の用意

では、チュートリアルから話がそれつつあるので、話を戻しsymfonyでAJAXをやってみましょう。
まず、AJAXでサーバーと通信している間にローディング画像を表示させるための画像を./web/cssディレクトリに用意しておきます。

そして、レイアウトとスタイルシートに修正を加えます。

./apps/frontend/templates/layout.php

PHP:
  1. <body>
  2. <div id="indicator" style="display: none"></div>

./web/css/main.css

CSS:
  1. div#indicator
  2. {
  3.   position: absolute;
  4.   width: 100px;
  5.   height: 40px;
  6.   left: 10px;
  7.   top: 10px;
  8.   z-index: 900;
  9.   background: url(../images/indicator.gif) no-repeat 0 0;
  10. }

interested?「気になる?」リンクをAJAXで実装する

質問に対して、interested?(気になる?)フラグを立てることができるAJAXを使ったリンクをUserヘルパーとして作成します。
AJAXのリンク関数はlink_to_user_interestedという名前にし、引数にユーザーインスタンス($sf_user)、Questionレコードを渡します。
Doctrineを使った実装になっている以外は本家チュートリアルと同じです。

./apps/frontend/modules/question/template/_interested_user.php

PHP:
  1. <?php use_helper('User') ?>
  2.  
  3. <div class="interested_mark" id="mark_<?php echo $question->getId() ?>">
  4.   <?php echo $question->getInterestedUsers() ?>
  5. </div>
  6.  
  7. <?php echo link_to_user_interested($sf_user, $question) ?>

./apps/frontend/modules/question/templates/_list.php

PHP:
  1. <div class="interested_block" id="block_<?php echo $question->getId() ?>">
  2.     <?php echo include_partial('interested_user', array('question' => $question)) ?>
  3.   </div>

./apps/frontend/lib/helper/UserHelper.php

PHP:
  1. <?php
  2. use_helper('Javascript');
  3. function link_to_user_interested($user, $question)
  4. {
  5.   if ($user->isAuthenticated())
  6.   {
  7.     $interested = Interest::findById($question->getId(), $user->getSubscriberId());
  8.     if ($interested)
  9.     {
  10.       // already interested
  11.       return 'interested!';
  12.     }
  13.     else
  14.     {
  15.       // didn't declare interest yet
  16.       return link_to_remote('interested?', array(
  17.         'url'      => 'user/interested?id='.$question->getId(),
  18.         'update'   => array('success' => 'block_'.$question->getId()),
  19.         'loading'  => "Element.show('indicator')",
  20.         'complete' => "Element.hide('indicator');".visual_effect('highlight', 'mark_'.$question->getId()),
  21.       ));
  22.     }
  23.   }
  24.   else
  25.   {
  26.     return link_to('interested?', 'user/login');
  27.   }
  28. }

ヘルパーでモデルへアクセスしている点が気になりますが。。DQLはDoctrineのクラスに閉じ込めてしまうポリシーなので静的メソッドを追加します。

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

PHP:
  1. static public function findById($question_id, $user_id)
  2.   {
  3.     return Doctrine_Query::create()
  4.       ->from('Interest')
  5.       ->where('question_id = ?', $question_id)
  6.       ->addWhere('user_id = ?', $user_id)
  7.       ->execute();
  8.   }

Javascriptヘルパーを有効にする

本日の最初に書いたように、symfony1.2からは以前のJavascriptヘルパーを使うためにはsfProtoculousPluginを有効にし、plugin:publish-assetsを叩かなければなりません。

./config/ProjectConfiguration.class.php

PHP:
  1. public function setup()
  2.   {
  3.       $this->enablePlugins(array('sfDoctrinePlugin', 'sfProtoculousPlugin'));
  4.       $this->disablePlugins('sfPropelPlugin');
  5.   }

プラグインが必要とする公開ファイルへのディレクトリへシンボリックリンクを作成します。

C:
  1. $ ./symfony plugin:publish-assets
  2. >> plugin    Configuring core plugin - sfDoctrinePlugin
  3. >> plugin    Configuring core plugin - sfCompat10Plugin
  4. >> plugin    Configuring core plugin - sfPropelPlugin
  5. >> plugin    Configuring core plugin - sfProtoculousPlugin

これで、以下のようにsfPropelPluginとsfProtoculousPluginのためのシンボリックリンクが作成されました。

C:
  1. $ ls -la web/
  2. 合計 48
  3. drwxr-xr-x  7 maedaz users 4096  720 12:40 .
  4. drwxr-xr-x 13 maedaz users 4096  7月  9 14:23 ..
  5. -rw-r--r--  1 maedaz users  595  629 17:34 .htaccess
  6. drwxr-xr-x  6 maedaz users 4096  720 09:29 .svn
  7. drwxr-xr-x  3 maedaz users 4096  720 10:40 css
  8. -rw-r--r--  1 maedaz users  611  629 17:34 frontend_dev.php
  9. drwxr-xr-x  3 maedaz users 4096  720 10:40 images
  10. -rw-r--r--  1 maedaz users  236  629 17:34 index.php
  11. drwxr-xr-x  3 maedaz users 4096  629 17:34 js
  12. -rw-r--r--  1 maedaz users   26  629 17:34 robots.txt
  13. lrwxrwxrwx  1 maedaz users   36  629 17:34 sf -> /usr/local/lib/symfony12/data/web/sf
  14. lrwxrwxrwx  1 maedaz users   55  720 12:40 sfPropelPlugin -> /usr/local/lib/symfony12/lib/plugins/sfPropelPlugin/web
  15. lrwxrwxrwx  1 maedaz users   60  720 12:40 sfProtoculousPlugin -> /usr/local/lib/symfony12/lib/plugins/sfProtoculousPlugin/web
  16. drwxrwxrwx  4 maedaz users 4096  629 17:34 uploads

symfony1.0までは場合によって自分自身でシンボリックリンクを作成する必要があったので便利になりました。
でも、sfPropelPluginは使わないんだけどシンボリックリンクが作成されるのはどうなんでしょう。
というわけで、本家にチケット投げました。(記事を公開するまでに無事修正されました)

さて、一部腑に落ちない部分もありつつ最後にキャッシュをクリアしておきます。

C:
  1. $ ./symfony cc

これで、
ニックネーム: francoisz
パスワード: adventcal
でログインし
http://symfony.centos5.localhost/askeet/frontend_dev.php/question/list/page/2
を見てみましょう。


prototype.js
builder.js
effects.js
が読み込まれていることがわかります。

サーバー側の実装

AJAXで呼び出されるサーバー側の実装を行います。

./apps/frontend/modules/user/actions/actions.class.php

PHP:
  1. public function executeInterested()
  2. {
  3.   $this->question = Question::findById($request->getParameter('id'));
  4.   $this->forward404Unless($this->question);
  5.   $user = $this->getUser()->getSubscriber();
  6.   $interest = new Interest();
  7.   $interest->setQuestion($this->question);
  8.   $interest->setUser($user);
  9.   $interest->save();
  10. }

QuestionモデルにfindByIdメソッドを用意しておきます。
./lib/model/doctrine/Question.class.php

PHP:
  1. static public function findById($id)
  2. {
  3.   return Doctrine::getTable('Question')->find($id);
  4. }

最後にAJAXで書き換えられる領域のテンプレートを返すので、先ほど用意したパーシャルを返すようにします。
./apps/frontend/modules/user/templates/interestedSuccess.php

PHP:
  1. <?php include_partial('question/interested_user', array('question' => $question)) ?>

QuestionモデルにDQLを閉じ込めた以外は本家チュートリアルと同じですね。
また、Interestモデルにデータをセットしsaveする処理はPropelであってもDoctrineであっても全く同じ書き方になります。
もし、Propelで実装するとしても同じようにQuestionモデルにCriteriaを閉じ込めておくようにすればORMを変更するのもより簡単に行えるようになるというのがわかります。

AJAXでのログイン処理実装

ログインフォームをコンポーネントで用意する

以前作成したログインフォームを利用したいと思います。
sfFormで作成したログインフォームを呼び出したいので、ここでは本家チュートリアルとは異なりコンポーネントで実装するようにしてみます。

./apps/frontend/modules/user/actions/components.class.php

PHP:
  1. <?php
  2. class userComponents extents sfComponents
  3. {
  4.   public function executeLogin(sfWegRequest $request)
  5.   {
  6.     $this->form = new LoginUserForm();
  7.   }
  8. }

そしてテンプレート部分も用意しておきます。
./apps/frontend/modules/user/templates/_login.php

PHP:
  1. <?php use_helper('Javascript') ?>
  2. <?php echo $form->getErrorSchema() ?>
  3. <?php echo $form->renderGlobalErrors() ?>
  4. <?php echo link_to_function('cancel', visual_effect('blind_up', 'login', array('duration' => 0.5))) ?>
  5. <form action="<?php echo url_for('user/login') ?>" method="POST" id="loginform">
  6.   <?php echo $form['nickname']->renderLabel() ?>:<?php echo $form['nickname']->renderError() ?> <?php echo $form['nickname'] ?> <br />
  7.   <?php echo $form['password']->renderLabel() ?>:<?php echo $form['password']->renderError() ?> <?php echo $form['password'] ?> <br />
  8.   <?php echo $form['referer'] ?>
  9.   <input type='submit' value="サインイン">
  10. </form>

今回はecho $formではなくレイアウトを変えたいので、widget単位でechoするようにしています。
デザインを変更したい場合も無理なく対応が可能です。

そして、レイアウトにこのコンポーネントを呼び出すように記述します。

./apps/frontend/templates/layout.php

PHP:
  1. <div id="login" style="display: none">
  2.   <h2>Please sign-in first</h2>
  3.          <?php include_component('user', 'login') ?>
  4. </div>

これで以下のように、未ログインの状態で「interested?」を押すと、ログインフォームがにゅにゅにゅと出現し、「cancel」をクリックすることでフォームが隠れます。


回答への投票機能

最後に同様にヘルパーとパーシャルを利用して回答に対して投票を行う事ができる機能を実装します。
仕組みは一緒なので、_vote_user.phpというパーシャルを作成し、さらにAJAXを行うlink_to_user_relevancy_upメソッド, link_to_user_relevancy_downメソッドを用意したAnswerHelper.phpヘルパーを作成します。

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

PHP:
  1. <?php use_helpers('Date') ?>
  2.  
  3. <div id="answers">
  4. <?php foreach ($answer_pager->getResults() as $answer): ?>
  5.   <div>
  6.     <div class="vote_block" id="vote_<?php echo $answer->getId() ?>">
  7.       <?php echo include_partial('answer/vote_user', array('answer' => $answer)) ?>
  8.     </div>
  9.     posted by <?php echo $answer->getUser() ?>
  10.     on <?php echo format_date($answer->getCreatedAt(), 'p') ?>
  11.     <div>
  12.       <?php echo $answer->getBody() ?>
  13.     </div>
  14.   </div>
  15. <?php endforeach ?>
  16. </div>

./apps/frontend/modules/answer/templates/_vote_user.php

PHP:
  1. <?php use_helper('Answer') ?>
  2.  
  3. <span class="vote_up_mark" id="vote_up_<?php echo $answer->getId() ?>">
  4.   <?php echo $answer->getRelevancyUpPercent() ?>%
  5. </span> <?php echo link_to_user_relevancy_up($user, $answer) ?>
  6.  
  7. <span class="vote_down_mark" id="vote_down_<?php echo $answer->getId() ?>">
  8.   <?php echo $answer->getRelevancyDownPercent() ?>%
  9. </span> <?php echo link_to_user_relevancy_down($user, $answer) ?>

./apps/frontend/lib/helper/AnswerHelper.php

PHP:
  1. <?php
  2. function link_to_user_relevancy_up($user, $answer)
  3. {
  4.   return link_to_user_relevancy('up', $user, $answer);
  5. }
  6. function link_to_user_relevancy_down($user, $answer)
  7. {
  8.   return link_to_user_relevancy('down', $user, $answer);
  9. }
  10. function link_to_user_relevancy($name, $user, $answer)
  11. {
  12.   use_helper('Javascript');
  13.   if ($user->isAuthenticated())
  14.   {
  15.     $relevancy = Relevancy::findById($answer->getId(), $user->getSubscriberId())
  16.     if ($relevancy)
  17.     {
  18.       // already interested
  19.       return $name;
  20.     }
  21.     else
  22.     {
  23.       // didn't declare interest yet
  24.       return link_to_remote($name, array(
  25.         'url'      => 'user/vote?id='.$answer->getId().'&score='.($name == 'up' ? 1 : -1),
  26.         'update'   => array('success' => 'vote_'.$answer->getId()),
  27.         'loading'  => "Element.show('indicator')",
  28.         'complete' => "Element.hide('indicator');".visual_effect('highlight', 'vote_'.$name.'_'.$answer->getId()),
  29.       ));
  30.     }
  31.   }
  32.   else
  33.   {
  34.     return link_to_function($name, visual_effect('blind_down', 'login', array('duration' => 0.5)));
  35.   }
  36. }

投票できるかどうかのために、Relevancyモデルからデータを取得するメソッドを追加します。
./lib/model/doctrine/Relevancy.class.php

PHP:
  1. static public function findById($question_id, $user_id)
  2.   {
  3.     return Doctrine_Query::create()
  4.       ->from('Interest')
  5.       ->where('question_id = ?', $question_id)
  6.       ->addWhere('user_id = ?', $user_id)
  7.       ->execute();
  8.   }

投票処理を実行するメソッドを追加します。
./apps/frontend/modules/user/actions/actions.class.php

PHP:
  1. public function executeVote()
  2.   {
  3.     $this->answer = Answer::findById($this->getRequestParameter('id'));
  4.     $this->forward404Unless($this->answer);
  5.  
  6.     $user = $this->getUser()->getSubscriber();
  7.  
  8.     $relevancy = new Relevancy();
  9.     $relevancy->setAnswer($this->answer);
  10.     $relevancy->setUser($user);
  11.     $relevancy->setScore($this->getRequestParameter('score') == 1 ? 1 : -1);
  12.     $relevancy->save();
  13.   }

./apps/frontend/modules/user/templates/voteSuccess.php

PHP:
  1. <?php echo include_partial('answer/vote_user', array('answer' => $answer)) ?>

さぁこれで完成と思いきや、投稿リンクを押してもカウントアップ/ダウンが処理されていません。
Firebugで見るとレスポンスはエラーになっていません。処理自体は正しく行われているようです。
怪しそうなのはクエリーの処理だと思われます。
symfonyではWebデバッグツールバーという開発環境時に右上に表示されるデバッグ情報があり、そこで発行されたSQLを確認できる機能があるのですがAJAXなどで実行されたクエリーについては確認できません。
そういった場合のために、AJAXでも確認できる拡張されたWebデバッグツールバーというプラグインもありますが、てっとり早い方法はDBで実行されたログが見れればよいので、私の場合はローカル開発時は以下のようにmy.cnfを設定しています。

/etc/my.cnf

C:
  1. [mysqld]
  2. log=/var/log/mysqld_bin.log

これで、発行されたログは/var/log/mysqld_bin.logに保存されていきます。
あとはターミナルで

C:
  1. $ tail -f /var/log/mysqld_bin.log

としておけばいつでも発行されたクエリを確認できます。

そして、今回の場合はクエリー発行後に何故かROLLBACKが発行されていることがわかりました。
ということは、余計なROLLBACKを行っているかCOMMITを忘れている可能性が大です。

さっそくモデルを確認してみると

./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.       } catch (Doctrine_Exception $e) {
  14.         $con->rollback();
  15.       }
  16.   }

$answer->save()の後にcommitを忘れています!
単体テストをきちんと行っていないとこういう間違いに気づかないので駄目ですね。。
というわけで、以下のように変更します。

PHP:
  1. $answer->save();
  2. $con->commit();

これでようやくVoteの実装が終わりました。

また明日

複雑なAJAXは直接prototypeなどのライブラリを利用するとして、この程度の実装であればヘルパーを使う事で簡単に実装できてしまうことがわかります。
また、symfony1.0のころと比べると準備までの手間が変わりましたが、使い勝手そのものは変わっていない事がわかります。

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

関連するその他の記事

Comments

Leave a Reply