Caution: You are browsing the legacy symfony 1.x part of this website.
Cover of the book Symfony 5: The Fast Track

Symfony 5: The Fast Track is the best book to learn modern Symfony development, from zero to production. +300 pages showcasing Symfony with Docker, APIs, queues & async tasks, Webpack, SPAs, etc.

5日目: フォームとページャ

1.0
Language

復習

長い4日目において、コードのチャンク(塊)を性質が近い他のファイルに移動させることで、アプリケーションをリファクタリングすることに慣れました。モデルの修正も学んだので、アクションコードからデータに関連する共通メソッドを取り出すことができるようになりました。

開発はクリーンですが、多くの機能はまだ貧弱です。askeetサイトとユーザーの間に少しのインタラクティビティを持たせましょう。HTMLの基本は -ハイパーリンクに加えて -フォームです。

今日の目的はユーザーがログインとホームページの質問リストをページ分割できるようにすることです。開発の進行は速くなりますが、あなたが昨日のことを思い出すのに手助けとなるでしょう。

ログインフォーム

テストデータにユーザーがありますが、アプリケーションが認識する方法がありません。アプリケーションのすべてのページからログインできるようにしましょう。グローバルレイアウトの askeet/appas/frontend/templates/layout.phpを開いて、aboutリンクの前に次の行を追加します:

<li><?php echo link_to('sign in', 'user/login') ?></li>

note

現在のレイアウトではこのリンクはWebデバッグツールバーの後ろに設置されています。見えるようにするために、'Sf'アイコンをクリックしてツールバーを折り畳んでください。

userモジュールを作成しましょう。2日目にquestionモジュールが生成されましたが、今回はsymfonyにモジュールスケルトンを作成するように頼みコードは私たち自身が書くことにします。

$ symfony init-module frontend user

note

スケルトンはデフォルトのindexアクションとindexSuccess.phpテンプレートを含みます。必要がないので両方とも削除してください。

user/loginアクションを作成する

user/actions/action.class.phpファイル(新しいaskeet/apps/frontend/modules/ディレクトリの下)において、次の login アクションを追加します:

public function executeLogin()
{
  $this->getRequest()->setAttribute('referer', $this->getRequest()->getReferer());
 
  return sfView::SUCCESS;
}

アクションはリクエスト属性のリファラーを保存します。非表示フィールドが設置されているテンプレートに利用可能になるので、ログイン成功後にフォームのターゲットアクションがオリジナルのリファラーにリダイレクトできます。

sfView::SUCCESSが返されるとloginSuccess.phpテンプレートにアクションの結果が渡されます。このステートメントはreturnステートメントを含まないアクションに含まれています。アクションのデフォルトテンプレートが actionnameSuccess.phpという名称になるのはそういうわけです。

アクションに取り組む前に、テンプレートを見てみましょう。

loginSuccess.phpテンプレートを作成する

Web上での人間とコンピュータのやりとりにはフォームが使用され、symfonyはフォームヘルパーのセットを提供することによってフォームの作成と管理を円滑にします。

askeet/apps/frontend/modules/user/templates/ディレクトリにおいて、次の loginSuccess.phpテンプレートを作成してください:

<?php echo form_tag('user/login') ?>
 
  <fieldset>
 
  <div class="form-row">
    <label for="nickname">nickname:</label>
    <?php echo input_tag('nickname', $sf_params->get('nickname')) ?>
  </div>
 
  <div class="form-row">
    <label for="password">password:</label>
    <?php echo input_password_tag('password') ?>
  </div>
 
  </fieldset>
 
  <?php echo input_hidden_tag('referer', $sf_request->getAttribute('referer')) ?>
  <?php echo submit_tag('sign in') ?>
 
</form>

このテンプレートはフォームヘルパーの最初の紹介です。これらのsymfonyの機能はフォームタグの書き込みを自動化することを手助けします。form_tag() ヘルパーはデフォルトのPOSTのふるまいによってフォームを開き、渡されたアクションを指し示します。input_tag()ヘルパーは最初の引数として渡される名前を基準とするid属性を追加することで<input>タグ(驚きでしょう)を自動的に作成します; デフォルトの値は2番目の引数から選択されます。symfony bookの関連する章でフォームヘルパーと生成されたHTMLコードを見ることができます。

ここでの本質的なことは、フォームが投稿されたとき(form_tag()の引数)に呼び出されるアクションは表示に使用されるloginアクションと同じであることです。それではアクションに戻りましょう。

ログインフォーム投稿の取り扱い

先ほど書いたloginアクションを次のコードで置き換えてください:

public function executeLogin()
{
  if ($this->getRequest()->getMethod() != sfRequest::POST)
  {
    // フォームを表示する
    $this->getRequest()->setAttribute('referer', $this->getRequest()->getReferer());
  }
  else
  {
    // フォーム投稿を処理する
    $nickname = $this->getRequestParameter('nickname');
 
    $c = new Criteria();
    $c->add(UserPeer::NICKNAME, $nickname);
    $user = UserPeer::doSelectOne($c);
 
    // nicknameが存在するか?
    if ($user)
    {
      // passwordがOKか?
      if (true)
      {
        $this->getUser()->setAuthenticated(true);
        $this->getUser()->addCredential('subscriber');
 
        $this->getUser()->setAttribute('subscriber_id', $user->getId(), 'subscriber');
        $this->getUser()->setAttribute('nickname', $user->getNickname(), 'subscriber');
 
        // 最後のページにリダイレクトする
        return $this->redirect($this->getRequestParameter('referer', '@homepage'));
      }
    }
  }
}

ログインアクションはログインフォームの表示と処理の両方に使われます。結果として、どちらのコンテキストで呼び出されたのかを知る必要があります。アクションがPOSTモードで呼び出されていないのであれば、リンクからリクエストされたからです。先ほど私たちが話した前者のケースです。リクエストがPOSTモードであるならば、アクションはフォームから呼び出され、操作する段階にあります。

アクションはリクエストパラメータからnicknameフィールドの値を得て、データベースにこのユーザーが存在するか User テーブルをリクエストします。

それから、近い将来に、ユーザーに認証を与えるパスワードの制御機能が存在するようになります。今の時点で、このアクションが唯一行うことはセッション属性にidとユーザーのnicknameを保存することです。結局のところ、アクションはリクエストパラメータとして渡された隠しrefererフィールドのおかげでオリジナルリファラーにリダイレクトします。このフィールドが空の場合、デフォルトの値が代わりに使われます(@homepagequestion/listのためのルーティングルールの名前です)。

この例における2種類の属性セットの違いに注目してください。request属性 ($this->getRequest()->setAttribute()) はテンプレートのために保有され、回答がリファラーに送信されると同時に忘れます。session属性 ($this->getUser()->setAttribute())はユーザーセッションの寿命の間に保有され、将来、他のアクションはアクセスできるようになります。属性についてもっと知りたいのでしたら、symfony bookのパラメータホルダの章をご覧ください。

特権を与える

ユーザーがaskeetのWebサイトにログインすることができるのはよいことです。しかし、それだけではユーザーは面白くないです。ログインは新しい質問を投稿すること、質問に対する関心を示すこと、コメントを評価することが求められます。すべての他のアクションは記録されていないユーザーに対しても開かれています。

認証されたユーザーとして設定するためにはsfUserオブジェクトの->setAuthenticated()メソッドを呼び出す必要があります。このオブジェクトはプロファイルに従って洗練されたアクセス制限をするためにクレデンシャルメカニズム(->addCredential())も提供します。symfony bookのユーザークレデンシャルの章で詳細にこのことが説明されています。

2行のコードの目的はそういうわけです:

$this->getContext()->getUser()->setAuthenticated(true);
$this->getContext()->getUser()->addCredential('subscriber');

nicknameが承認されたとき、session属性に格納されたユーザーのデータだけでなくユーザーもサイトの制限された部分へのアクセス権が与えられます。明日は認証されたユーザーへのアプリケーションの部分のアクセスを制限をどのようにするのか見ます。

user/logoutアクションを追加する

->setAttribute()メソッドについて最後のトリックです。最後の引数(上の例の subscriber)は属性が保存される名前空間を定義します。名前空間は属性に任意の他の名前空間で既存の名前を許可するだけでなく1つのコマンドですべての属性をすぐに削除できます:

public function executeLogout()
{
  $this->getUser()->setAuthenticated(false);
  $this->getUser()->clearCredentials();
 
  $this->getUser()->getAttributeHolder()->removeNamespace('subscriber');
 
  $this->redirect('@homepage');
}

名前空間を使うことで2つの属性を1つ1つ削除せずに済みます。一行以下のコードです。怠けるとはまさにこのことです!

レイアウトをアップデートする

レイアウトはユーザーがすでにログインしていても'login'リンクを表示したままです。すぐに直しましょう。askeet/apps/frontend/templates/layout.phpにおいて、今日のチュートリアルの始めに追加した行を変更してください:

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

アプリケーションのページを表示し、'login'をクリックし、有効なニックネーム("anonymous"はトリックを行います)を入力し、バリデートすることで、すべてのテストする段階に来ました。ウィンドウのトップ上の'login'リンクが'sign out'に変わったら、作業内容はすべて正しいです。最終的には'login'リンクが再表示されるか確認するためにログアウトを試してください。

ログイン中

symfony bookのユーザーセッションの章 においてユーザーセッション属性の操作の情報をより多く見ることができます。

質問ページャ

大勢のsymfonyの熱狂的なユーザーがaskeetサイトに押し寄せます。ホームページに表示された質問リストがとても長く成長する可能性が非常に大きいです。遅いリクエストと過剰なスクロールを避けるために、質問リストをページ分割することが必要です。

symfonyはそのためのオブジェクト:sfPropelPagerを提供します。現在のページを表示するためのレコードだけがリクエストされるようにデータベースへのリクエストをカプセル化します。たとえば、10レコードを表示するためにページャが初期化されたとしたら、データベースへのリクエストの結果は10まで制限され、オフセットがページランクにマッチするように設定されます。

question/listアクションを修正する

3日目において、私たちはquestionモジュールのlistアクションがきわめて簡潔であることを見ました:

public function executeList ()
{
  $this->questions = QuestionPeer::doSelect(new Criteria());
}

配列の代わりにsfPropelPagerオブジェクトをテンプレートに渡すためにこのアクションを修正することにします。同時に、私たちは関心を寄せる人数によって質問を並べ替えることに取り組みます:

public function executeList ()
{
  $pager = new sfPropelPager('Question', 2);
  $c = new Criteria();
  $c->addDescendingOrderByColumn(QuestionPeer::INTERESTED_USERS);
  $pager->setCriteria($c);
  $pager->setPage($this->getRequestParameter('page', 1));
  $pager->setPeerMethod('doSelectJoinUser');
  $pager->init();
 
  $this->question_pager = $pager;
}

sfPropelPagerオブジェクトの初期化はどのオブジェクトのクラスが含むか、ページに追加されるオブジェクトの最大数(この例では2)かを指定します。->setPage()メソッドは現在のページを設定するリクエストパラメータを使用します。たとえば、このpageパラメータが2の値を持つとき、sfPropelPagerは3から5の結果を返します。デフォルトのpageリクエストパラメータの値は1で、このページャはデフォルトで1から2の値を返します。sfPropelPagerオブジェクトとそのメソッドに関するより詳しい情報はsymfony bookのページャの章で見ることができます。

カスタムパラメータを使用する

設定ファイルに定数を使うことは常によいアイディアです。たとえば、ページごとの結果数(この例では2)は独自のアプリケーションの設定パラメータによって置き換えられる可能性があります。new sfPropelPagerの上記の行を変更してください:

...
  $pager = new sfPropelPager('Question', sfConfig::get('app_pager_homepage_max'));

アプリケーションのカスタム設定ファイル (askeet/apps/frontend/config/app.yml)を開き、次のコードを追加してください:

all:
  pager:
    homepage_max: 2

ここでのpagerキーは名前空間として使われます。パラメータネームにおいても現れる理由です。symfony bookの設定の章 において、カスタム設定とカスタムパラメータの命名ルールについて詳しい情報があります。

listSuccess.phpテンプレートを修正する

listSuccess.php において、ページャに保存された結果のリストをページが表示できるように

<?php foreach($questions as $question): ?>

<?php foreach($question_pager->getResults() as $question): ?>

に置き換えてください。

ページのナビゲーションを追加する

このテンプレートを追加するために少しの作業があります。それらはページナビゲーションです。今現在、テンプレートが行うことは最初の2つの質問を表示することですが、次のページへ移動する機能および前のページに移動する機能を追加した方がよいでしょう:

<div id="question_pager">
<?php if ($question_pager->haveToPaginate()): ?>
  <?php echo link_to('&laquo;', 'question/list?page=1') ?>
  <?php echo link_to('&lt;', 'question/list?page='.$question_pager->getPreviousPage()) ?>
 
  <?php foreach ($question_pager->getLinks() as $page): ?>
    <?php echo link_to_unless($page == $question_pager->getPage(), $page, 'question/list?page='.$page) ?>
    <?php echo ($page != $question_pager->getCurrentMaxLink()) ? '-' : '' ?>
  <?php endforeach; ?>
 
  <?php echo link_to('&gt;', 'question/list?page='.$question_pager->getNextPage()) ?>
  <?php echo link_to('&raquo;', 'question/list?page='.$question_pager->getLastPage()) ?>
<?php endif; ?>
</div>

このコードはsfPropelPagerオブジェクトのさまざまなメソッドを利用します。->haveToPaginate()はリクエストへの結果の数字がページサイズを超えるときのみ trueを返します; ->getPreviousPage()->getNextPage()->getLastPage()は明らかな意味を持ちます; ->getLinks()はページ番号の配列を提供します; そして->getCurrentMaxLink()は最後のページ番号を返します。

この例は1つの手軽なsymfonyのリンクヘルパーも示します: 最初の引数がfalseである場合にテストが与えられたのであれば、link_to_unless()は通常のlink_to()を出力し、そうではなければ、シンプルな<span>で囲まれたリンク無しでテキストが出力されます。

ページャをテストしましたか? した方がいいでしょう。修正はあなた自身の目で検証しない限り終わりません。そのためには、3日目に作成されたテストデータファイルを開き、表示されるページナビゲーションのために新しい質問を追加してください。データインポートバッチを起動させ、ホームページを再びリクエストしてください。ほら。

ページ分割のリスト

次のページのルーティング・ルールを追加する

デフォルトでは、ページのURLは次のようになります:

http://askeet/frontend_dev.php/question/list/page/XX

これらのページを理解させるためにルーティングルールを利用しましょう:

http://askeet/frontend_dev.php/index/XX

apps/frontend/config/routing.ymlファイルを開き、一番上に次のコードを追加してください:

popular_questions: 
  url:   /index/:page 
  param: { module: question, action: list } 

私たちがそれを目指している間に、ログインページのための他のルーティングルールを追加してください:

login: 
  url:   /login 
  param: { module: user, action: login } 

リファクタリング

モデル

question/listアクションはモデルと緊密な関係にあるコードを実行します。このコードをモデルに移動させるのはそういうわけです。question/listアクションを置き換えてください:

public function executeList () 
{ 
  $this->question_pager = QuestionPeer::getHomepagePager($this->getRequestParameter('page', 1)); 
}

...lib/modelQuestionPeer.phpクラスに次のメソッドを追加してください:

public static function getHomepagePager($page)
{
  $pager = new sfPropelPager('Question', sfConfig::get('app_pager_homepage_max'));
  $c = new Criteria();
  $c->addDescendingOrderByColumn(self::INTERESTED_USERS);
  $pager->setCriteria($c);
  $pager->setPage($page);
  $pager->setPeerMethod('doSelectJoinUser');
  $pager->init();
 
  return $pager;
}

昨日書いたquestion/showアクションに同じアイディアを応用します。特殊文字が剥ぎ取られたタイトルから質問を読み取るためにPropelオブジェクトをモデルに所属させます。そこでquestion/showアクションを変更してください:

public function executeShow()
{
  $this->question = QuestionPeer::getQuestionFromTitle($this->getRequestParameter('stripped_title'));
 
  $this->forward404Unless($this->question);
}

QuestionPeer.phpに追加してください:

public static function getQuestionFromTitle($title)
{
  $c = new Criteria();
  $c->add(QuestionPeer::STRIPPED_TITLE, $title);
 
  return self::doSelectOne($c); 
}

テンプレート

question/templates/listSuccess.phpに表示された質問リストはそのうちどこかで再利用されます。そこで、質問リストを表示するためにテンプレートコードを _list.phpフラグメントに加え、listSuccess.phpを単純に置き換えます:

<h1>popular questions</h1> 
 
<?php echo include_partial('list', array('question_pager' => $question_pager)) ?> 

_list.phpフラグメントの内容はaskeetのSVNリポジトリで見ることができます。

それではまた明日

ログインフォームとリストページャは最近のほとんどすべてのWebアプリケーションで使用されています。 それらをsymfonyできわめて簡単に開発できることを今日理解しました。

今日はもう一度リファクタリングで終わりました。最初に大きな図面をデザインすることなく、少しずつあなたのアプリケーションを構築するために支払う代償です。

明日は、登録ユーザーがサイトの一部にアクセスする権限を制限するなど、ログインプロセスに取り組み続けます。また不正な投稿を避けるためにフォームのバリデーションを行います。