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

DAY5: ログインフォームとパジネーション

ログインフォームとパジネーション

4日目ではリファクタリングを中心に作業を行いました。
5日目の内容はログインフォームの実装と質問ページにページ送りの機能(パジネーション)を実装していきます。

sfForm フォームフレームワーク

今日の流れで一番理解しなくてはならないことはフォームの処理をsymfonyで行う箇所でしょう。
symfony1.1からはsfFormというフォーム処理のためのフォームフレームワークを使うことが前提になっています。

なんだかsymfonyそのものもフレームワークなのにややこしいなぁと思われる方が多いと思います。
これはフォーム処理に関する部分をsymfonyそのものの機能に依存しないようにすることでアクション部分のコードがスッキリし、役割が明確にできるというメリットがあります。

また、その一方で書かなくてはなならないコード量は増えてしまいます。そういう点ではデメリットかもしれません。
それに覚えなくてはならないことも増えますので、学習コストという面でもデメリットかもしれません。

しかし、それ以上にアクションがフォームに関するバリデーションの処理から解放されたことのメリットのほうが大きいと思います。

本家ではこのsfFormに関する専用のドキュメントが用意されています。
詳しいことはThe symfony form bookで理解してください。
日本語情報についてはこちらから確認できます。

ログインフォーム

では、さっそくログインフォームを作っていきます。
まずはログインフォームの入力画面を表示するまでの流れを作ります。

最初に考えることはこのログインフォームへのリンクをどこに設置するかです。
普通はログインフォームへのリンクは全てのページにあると便利ですよね。なので全ページで共通して利用するテンプレートであるレイアウトにリンクを埋め込みます。

./app/frontend/templates/layout.php

PHP:
  1. <div id="header">
  2.     <ul>
  3.       <li><?php echo link_to('about', '@homepage') ?></li>
  4.       <li><?php echo link_to('sign in/register', 'user/login') ?></li>
  5.     </ul>
  6.     <h1><?php echo link_to(image_tag('askeet_logo.gif', 'alt=askeet'), '@homepage') ?></h1>
  7.   </div>

つぎにこのリンク先であるuserモジュールを作成します。

C:
  1. $ ./symfony generate:module frontend user

そして、ログインアクションを用意しましょう。
ポイントはsfFormを使うことです。
ユーザーフォームのオブジェクトを取得してくる処理も行います。
今回利用するフォームはユーザーモデルに関連するフォームです。
そのため、
./lib/form/doctrine/UserForm.class.php
を派生したユーザーログインフォームクラスを用意します。
./lib/form/doctrine/LoginUserForm.class.php

まずは、基底クラスとなるUserForm.class.phpを実装していきます。
このファイルではこれから派生していく各フォームで共通となる処理を記述します。

本当はさらに基底となるクラスが/baseディレクトリに作成されているのですが、メッセージを変えたり、バリデーションを作成したりしたいので、UserFrom.class.phpできちんと定義をしておくことにします。

また記述方法がいくつかありますが、ここでは冗長な書き方になっていますが、各項目単位で書いていく方法にしています。

また、ログインフォームではリクエストされた値が合っているかどうかの確認が必要です。
そのため、グローバルバリデータとしてvalidUserメソッドでデータベースに問い合わせるようにしています。
この辺りはsfFormフレームワークを理解していないとややこしいのですが、どのように実装しているかは解りやすいかと思います。

PHP:
  1. ...
  2.   public function configure()
  3.   {
  4.     // unset fields
  5.     $unset_fields = array(
  6.                           'created_at',
  7.                           );
  8.     foreach ($unset_fields as $value) {
  9.       $this->offsetUnset($value);
  10.     }
  11.     //////////////////////////////////////////////////
  12.     // nickname
  13.     $key = 'nickname';
  14.     $this->widgetSchema[$key] = new sfWidgetFormInput();
  15.     $this->widgetSchema->setLabel($key, 'ニックネーム');
  16.     $this->validatorSchema[$key] = new sfValidatorString();
  17.     $_min = 6;
  18.     $this->validatorSchema[$key]->setMessage('min_length', sprintf('ニックネームの文字数は%d文字以上で入力してください', $_min));
  19.     $this->validatorSchema[$key]->setOption('min_length', $_min);
  20.     $_max = 50;
  21.     $this->validatorSchema[$key]->setMessage('max_length', sprintf('ニックネームの文字数は%d文字以内で入力してください', $_max));
  22.     $this->validatorSchema[$key]->setOption('max_length', $_max);
  23.     $this->validatorSchema[$key]->setOption('required', true);
  24.     $this->validatorSchema[$key]->setMessage('required', 'ニックネームは必須です');
  25.     //////////////////////////////////////////////////
  26.     // first_name
  27.     $key = 'first_name';
  28.     $this->widgetSchema[$key] = new sfWidgetFormInput();
  29.     $this->widgetSchema->setLabel($key, '名前');
  30.     $this->validatorSchema[$key] = new sfValidatorString();
  31.     $_max = 50;
  32.     $this->validatorSchema[$key]->setMessage('max_length', sprintf('名前の文字数は%d文字以内で入力してください', $_min));
  33.     $this->validatorSchema[$key]->setOption('max_length', $_max);
  34.     //////////////////////////////////////////////////
  35.     // last_name
  36.     $key = 'last_name';
  37.     $this->widgetSchema[$key] = new sfWidgetFormInput();
  38.     $this->widgetSchema->setLabel($key, '苗字');
  39.     $this->validatorSchema[$key] = new sfValidatorString();
  40.     $_max = 50;
  41.     $this->validatorSchema[$key]->setMessage('max_length', sprintf('苗字の文字数は%d文字以内で入力してください', $_max));
  42.     $this->validatorSchema[$key]->setOption('max_length', $_max);
  43.   }

次にLoginUserForm.class.phpを作成します。
これはログインフォームのためのクラスです。
UserForm.class.phpを親クラスとし、ログイン用に変更点があれば記述します。

今回は以下の点をログインフォームのために変更しています。
* パスワードを入力させるのでパスワード用のWidgetを作成
* リファラーをhiddenで渡すのでhiddenのwidgetを作成
* first_name, last_nameは不要なのでUnset

./lib/form/doctrine/LoginUserForm.class.php

PHP:
  1. ...
  2.   public function configure()
  3.   {
  4.     parent::configure();
  5.     // unset fields
  6.     $unset_fields = array(
  7.                           'created_at',
  8.                           'first_name',
  9.                           'last_name',
  10.                           );
  11.     foreach ($unset_fields as $value) {
  12.       $this->offsetUnset($value);
  13.     }
  14.     //////////////////////////////////////////////////
  15.     // password
  16.     $key = 'password';
  17.     $this->widgetSchema[$key] = new sfWidgetFormInputPassword();
  18.     $this->widgetSchema->setLabel($key, 'パスワード');
  19.     $this->validatorSchema[$key] = new sfValidatorString();
  20.     $this->validatorSchema[$key]->setOption('required', true);
  21.     $this->validatorSchema[$key]->setMessage('required', 'パスワードは必須です');
  22.     //////////////////////////////////////////////////
  23.     // referer
  24.     $key = 'referer';
  25.     $this->widgetSchema[$key] = new sfWidgetFormHiden();
  26.     $this->validatorSchema[$key] = new sfValidatorPass();
  27.     // setDefault
  28.     $this->setDefaults(array(
  29.                             'referer' => sfContext::getInstance()->getRequest()->getReferer(),
  30.                             ));
  31.     // check valid user
  32.     $this->validatorSchema->setPostValidator(new sfValidatorCallback(array(
  33.                                                                            'callback' => array($this, 'validUser'),
  34.                                                                            )));
  35.   }
  36.   public function validUser($validator, $values)
  37.   {
  38.     // ToDo: implement after...
  39.     return $values;
  40.   }

認証済みのフラグはユーザークラスに対して行います。
そこで、validUserメソッドで定義したsetLoginAuthをユーザークラスに実装しておきます。
メソッド名と実装方法を6日目に変更しますのでこの時点ではmyUserクラスに切り分けしているという点にだけ着目しておいてください。

./apps/frontend/lib/myUser.class.php

PHP:
  1. <?php
  2.  
  3. class myUser extends sfBasicSecurityUser
  4. {
  5.   public function setloginAuth($is_login, $user)
  6.   {
  7.     $this->setAuthenticated($is_login);
  8.     $this->addCredential('subscriber');
  9.     $this->setAttribute('subscriber_id', $user->getId(), 'subscriber');
  10.     $this->setAttribute('nickname', $user->getNickname(), 'subscriber');
  11.   }
  12. }

次にユーザーモジュールのログインアクションを実装します。
まずはフォームを表示できればいいので、さきほど作成したフォームインスタンスを作成し、
ビューで利用できるように$this->formにアサインしているだけです。

C:
  1. ./apps/frontend/modules/user/actions/actions.class.php

PHP:
  1. public function executeLogin(sfWebRequest $request)
  2. {
  3.   // get sfForm object
  4.   $this->form = new LoginUserForm();
  5.  
  6.   return sfView::SUCCESS;
  7. }

symfony1.0系との違いはアクションの引数に$requestを持っているということですね。

あとは、テンプレートを用意します。
symfony1.0までのaskeetではフォームヘルパーを使ってフォームを作成していましたが、すでにsfFormのウィジットによって作成できるので、フォームヘルパーは不要です。
そのため、コードはフォームのコンテンツをecho $formで出力しているだけです。

「submitボタンがそのまま書かれているではないか」と思われる方がいるかもしれませんが、もともとヘルパーやウィジットで自動生成させるパーツは動的に決定されるパーツだけです。
ボタンなどはそのままHTMLを記述することがフレームワークに依存しないことからも自然ですね。

PHP:
  1. <div id="login">
  2. <h3>ログイン</h3>
  3. <form action="<?php echo url_for('user/login') ?>" method="POST">
  4. <table>
  5. <?php echo $form ?>
  6. <tr>
  7.   <td colspan="2" style="text-align: right"><input type='submit' value="サインイン"></td>
  8. </tr>
  9. </table>
  10. </form>
  11. </div>

これでできた画面は以下のようになります。


ログインアクションの実装

次に実際のログインボタンを押されたときの処理を実装します。

実装する内容は以下の点です。

1. フォームの値の検証
3. 検証結果による画面遷移の分岐

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

PHP:
  1. public function executeLogin(sfWebRequest $request)
  2. {
  3.   // get sfForm object
  4.   $this->form = new LoginUserForm();
  5.   // check Request Method
  6.   if (!$request->isMethod('post')) {
  7.     return sfVieww::SUCCESS;
  8.   }
  9.   // login check
  10.   $this->form->bind($request->getParameter('user'));
  11.   if ($this->form->isValid()) {
  12.     return $this->redirect($request->getParameter('user[referer]', @homepage));
  13.   }
  14.   return sfView::SUCCESS;
  15. }

次にさきほどのLoginUserFormクラスにログインチェックの実装(validUserメソッド)を行いましょう。

そのために、
* データベースへの問い合わせ
* OKであればユーザーのセッションに認証済みのフラグをたてる
の作業が必要になります。

またこの時点では本家と同じくパスワードのチェックは行わず、ユーザーがデータベースに存在すればログインできるようにします。

./lib/form/doctrine/LoginUserForm.class.php

PHP:
  1. public function validUser($validator, $values)
  2.   {
  3.     // if nickname is empty, not check.
  4.     if ($values['nickname'] == "") {
  5.       return $values;
  6.     }
  7.     // check if valid user.
  8.     // TODO: refactaring this code, not to access Doctrine directly.
  9.     $user = Doctrine_Query::create()
  10.         ->from('User')
  11.         ->where('nickname = ?', $values['nickname'])
  12.         ->execute();
  13.     if ($user->count() != 1) {
  14.       throw new sfValidatorError($validator, '該当するユーザーがいません');
  15.       return $values;
  16.     }
  17.     // set auth
  18.     $sf_user = sfContext::getInstance()->getUser();
  19.     $sf_user->setLoginAuth(true, $user[0]);
  20.  
  21.     return $values;
  22.   }

認証済みのフラグはユーザークラスに対して行います。
そこで、validUserメソッドで定義したsetLoginAuthをユーザークラスに実装しておきます。

./apps/frontend/lib/myUser.class.php

PHP:
  1. <?php
  2.  
  3. class myUser extends sfBasicSecurityUser
  4. {
  5.   public function setloginAuth($is_login, $user='')
  6.   {
  7.     $this->setAuthenticated($is_login);
  8.     if ($is_login) {
  9.       $this->addCredential('subscriber');
  10.       $this->setAttribute('subscriber_id', $user->getId(), 'subscriber');
  11.       $this->setAttribute('nickname', $user->getNickname(), 'subscriber');
  12.     } else {
  13.       $this->clearCredentials();
  14.       $this->getAttributeHolder()->removeNamespace('subscriber');
  15.     } 
  16.   }
  17. }

symfonyに用意されている充実したUserクラスを利用することで、認証済みのフラグであったり、権限などの設定を行っています。
詳細についてはユーザーの認証についてのドキュメントを参照してください

以前のaskeetのアクションのコードと比べてコードがすっきりしていることがわかります。

次にログアウトの処理を実装します。
ログアウト時は先ほど用意したsetLoginAuthをfalseで設定し、ルーティングでhomepageで設定したURLへリダイレクトさせます。

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

PHP:
  1. public function executeLogout(sfWebRequest $request)
  2.   {
  3.     $this->getUser()->setLoginAuth(false);
  4.     $this->redirect(@homepage);
  5.   }

次に、ログインとログアウトのリンクをログイン状態によって変更するようにします。
ログインのリンクはレイアウトに設置したので、レイアウトを変更します。

./apps/frontend/templates/layout.php

PHP:
  1. <?php if ($sf_user->isAuthenticated()): ?>
  2.   <li><?php echo link_to('sign out', 'user/logout') ?></li>
  3.   <li><?php echo link_to($sf_user->getAttribute('nickname', '', 'subscriber').' profile', 'user/profile') ?></li>
  4. <?php else: ?>
  5.   <li><?php echo link_to('sign in/register', 'user/login') ?></li>
  6. <?php endif ?>

これで、ログイン、ログアウトを行うとメニューが切り替わることが確認できます。

質問ページでページャ

次に行う実装はパジネーションです。
いわゆる10件以上は次のページへというリンクで制御するあれですね。
今回利用しているDoctrineがページャの機能を持っているのでそれを利用して実装してみます。

なので、本家の解説とは異なります。
日本語ではprocess S+D Doctrineの使い方のまとめ - ページ処理が参考になります。

まずは、一覧取得画面を用意します。
一覧画面はindexアクションで行うようにしているので、本家にあるlistアクションはindexアクションへフォワードさせています。

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

PHP:
  1. public function executeIndex($request)
  2.   {
  3.     // ToDo: refactaring below
  4.     $q = Doctrine_Query::create()
  5.          ->from('Question');
  6.     $pager = new Doctrine_Pager($q, 1, 2);
  7.     $this->questionList = $pager->execute();
  8.     $this->pager = $pager;
  9.   }
  10.  
  11.   public function executeList($request)
  12.   {
  13.     $this->forward('question', 'index');
  14.   }

一覧表示はIndexアクションで行っているのでそこにページャであるDoctrine_Pagerを使って実装しています。
DQL、ページ番号、表示する記事数を引数として渡してインスタンスを作成し、executeメソッドを呼ぶだけなので簡単ですね。
ここでは仮に1ページに2つずつ表示させるようにし、1ページ目を実際に表示しようとしています。
executeした結果はDoctrineレコードを返すので、これをquestionListとして新しくセットしています。

あとは、テンプレートにパジネーション部分の実装を行います。

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

PHP:
  1. <div id="question_pager">
  2. <?php if ($pager->haveToPaginate()): ?>
  3.   <?php echo link_to('&laquo;', 'question/list?page=1') ?>
  4.   <?php echo link_to('&lt;', 'question/list?page='.$pager->getPreviousPage()) ?>
  5.  
  6.   <?php for ($page_no = 1, $max = $pager->getLastPage(); $page_no <= $max; $page_no++): ?>
  7.     <?php echo link_to_unless($page_no == $pager->getPage(), $page_no, 'question/list?page='.$page_no) ?>
  8.     <?php echo ($page_no != $pager->getLastPage()) ? '-' : '' ?>
  9.   <?php endfor; ?>
  10.  
  11.   <?php echo link_to('&gt;', 'question/list?page='.$pager->getNextPage()) ?>
  12.   <?php echo link_to('&raquo;', 'question/list?page='.$pager->getLastPage()) ?>
  13. <?php endif; ?>
  14. </div>

詳しい説明は行いませんが、アクションで取得したpagerから
* $pager->haveToPaginate() ... パジネートできるだけの項目があるかどうか
* $pager->getPreviousPage() ... 1つ前のページ番号を取得
* $pager->getLastPage() ... 最後のページ番号を取得
* $pager->getPage() ... 現在のページ番号を取得
* $pager->getNextPage() ... 次のページ番号を取得

を利用しています。

すると以下のようなパジネートが完成しました。


あとは、実際にパジネートされるように、ページ番号をアクションに渡して上げるようにします。
また、ページに表示する質問数はアプリケーションの設定ファイルで変更できるようにします。

まずはさきほどのアクションでDoctrine_Pagerインスタンス作成の引数を変更します。

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

PHP:
  1. $pager = new Doctrine_Pager($q, $request->getParameter('page'), sfConfig::get('app_pager_homepage_max'));

pageというパラメータを渡してあげるのと、sfConfig::getで設定ファイルから取得するようにしました。

あとは、設定ファイル(app.yml)に設定値を書き込みます。

./apps/frontend/config/app.yml

CSS:
  1. all:
  2.   pager:
  3.     homepage_max: 2

設定ファイルの仕組みについては設定の章を参考にしてください。

ルーティングの追加

これ本家のものと同じですね。

* ページ番号を直接URLに埋め込めるようにする
./apps/frontend/config/routing.yml

CSS:
  1. popular_questions:
  2.   url:   /index/:page
  3.   param: { module: question, action: index }

これで、

http://symfony.centos5.localhost/askeet/frontend_dev.php/index/2

で質問一覧ページが指定したページ番号で表示されます。

* ログイン画面へのスマートなURLを用意する

CSS:
  1. login:
  2.   url:   /login
  3.   param: { module: user, action: login }

これで、

http://symfony.centos5.localhost/askeet/frontend_dev.php/login

ログイン画面が表示されます。

リファクタリング

さて、本日の最後はリファクタリングです。
今日の実装してきたコードの中でモデルの扱い部分が散乱してしまっていることに注目してください。

* LoginUserFormクラスから直接Doctrine_Queryを呼び出している
* executeIndexアクションで直接Doctrine_Queryを呼び出している

これらをモデル部分に閉じ込めてしまいましょう。

ログインできるユーザーかどうか

ログインできるユーザーを取得するメソッドをモデル(User.class.php)に追加します。

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

PHP:
  1. static public function getValidUserByNickname($nickname)
  2.   {
  3.    return  Doctrine_Query::create()
  4.       ->from('User')
  5.       ->where('nickname = ?', $nickname)
  6.       ->execute();
  7.   }

./lib/model/doctrine/Question.class.php

PHP:
  1. static public function createGetAllDql()
  2.   {
  3.     return Doctrine_Query::create()
  4.        ->from('Question');
  5.   }

あとは、呼び出しもとをこれらを使用するように書き換えます。

./lib/form/doctrine/LoginUserForm.class.php

PHP:
  1. public function validUser($validator, $values)
  2.   {
  3.     // if nickname is empty, not check.
  4.     if ($values['nickname'] == "") {
  5.       return $values;
  6.     }
  7.     // check if valid user.
  8.     $user = User::getValidUserByNickname($values['nickname']);
  9.     if ($user->count() != 1) {
  10.       throw new sfValidatorError($validator, '該当するユーザーがいません');
  11.       return $values;
  12.     }
  13.     // set auth
  14.     $sf_user = sfContext::getInstance()->getUser();
  15.     $sf_user->setLoginAuth(true, $user[0]);
  16.  
  17.     return $values;
  18.   }

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

PHP:
  1. public function executeIndex($request)
  2.   {
  3.     // ToDo: refactaring below
  4.     $q = Question::createGetAllDql();
  5.     $pager = new Doctrine_Pager($q, 1, 2);
  6.     $this->questionList = $pager->execute();
  7.     $this->pager = $pager;
  8.   }

コーディング量が減ったわけではありませんが、「データベースへ問い合わせしている実態はモデルにある」というルールで統一できました。

最後に、本家にあわせてこの一覧画面は再利用されるので_list.phpという名前のパーシャルにしてしまいましょう。
個人的には、アクションでの実装ではなくコンポーネントによる実装が綺麗になると思うので、納得できませんが。。

それはおいおいリファクタリングすることにするとします。

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

PHP:
  1. <?php include_partial('list', array('questionList' => $questionList, 'pager' => $pager)) ?>

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

PHP:
  1. <?php foreach($questionList as $question): ?>
  2.   <div class="interested_block">
  3.     <?php echo include_partial('interested_user', array('question' => $question)) ?>
  4.   </div>
  5.  
  6.   <h2><?php echo link_to($question->getTitle(), 'question/show?id='.$question->getId()) ?></h2>
  7.  
  8.   <div class="question_body">
  9.     <?php echo truncate_text($question->getBody(), 200) ?>
  10.   </div>
  11. <?php endforeach ?>
  12.  
  13. <div id="question_pager">
  14. <?php if ($pager->haveToPaginate()): ?>
  15.   <?php echo link_to('&laquo;', 'question/list?page=1') ?>
  16.   <?php echo link_to('&lt;', 'question/list?page='.$pager->getPreviousPage()) ?>
  17.  
  18.   <?php for ($page_no = 1, $max = $pager->getLastPage(); $page_no <= $max; $page_no++): ?>
  19.     <?php echo link_to_unless($page_no == $pager->getPage(), $page_no, 'question/list?page='.$page_no) ?>
  20.     <?php echo ($page_no != $pager->getLastPage()) ? '-' : '' ?>
  21.   <?php endfor; ?>
  22.  
  23.   <?php echo link_to('&gt;', 'question/list?page='.$pager->getNextPage()) ?>
  24.   <?php echo link_to('&raquo;', 'question/list?page='.$pager->getLastPage()) ?>
  25. <?php endif; ?>
  26. </div>

また次回

6日目はセキュリティー関連とフォームのバリデーションについて行う予定です。
ただし、フォーム関連はsfFormで今日かなり実装できてしまっています。
やはりsymfony1.2ではsfFormをどこまで使いこなせるかがポイントになりそうです。

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

関連するその他の記事

Comments

Leave a Reply