Caution: You are browsing the legacy symfony 1.x part of this website.

13日目: タグ

1.0

復習

askeetアプリケーションはWebページ、RSSフィードもしくは、Eメールを通して、データを提供することができます。問題を質問することと回答することが可能です。しかし、問題の構成はまだ開発されています。問題の組織はカテゴリとサブカテゴリは何千ものブランチを伴った切り分けられない木構造に達しており、サブブランチにおいてあなたが探しているであろう問題を知るのは簡単なことではありません。

しかしながら、Web 2.0アプリケーションは項目の組織の新しい方法を伴って世に出ます:タグです。タグと言葉、カテゴリとして。しかし、従来と異なるのは、タグの階層がないこと、項目がいくつかのタグを持つことです。カテゴリで猫を見つけるのが面倒であることが判明した一方で、タグを伴うのはとてもシンプルです(pet+cute)。すべてのユーザーが与えられた問題へタグを追加できる機能を追加することで、有名なフォークソノミーのコンセプトを得ることができます。

何だと思いますか?それがまさにaskeetの質問機能を必要していることです。時間が必要ですが(今日と明日)、しかし、結果は労力に見合う価値があります。Creoleの接続機能を使って複雑なSQLのリクエストをどのように行うのか示す機会があります。それでは行ってみましょう。

QuestionTagクラス

タグの実装方法がいくつかありますが、次の構造を持つQuestionTagテーブルを追加する方法を選びます:

ERD

ユーザーが質問をタグづけしたとき、userテーブルとquestionテーブルの両方にリンクされたquestion_tagテーブルに新しいレコードを作成します。記録されたタグには2つのバージョンがあります: ユーザーによって拡張されたものとインデックスのために使用される通常のバージョンです(特別な文字が無いときの後者のケースです)。

スキーマの更新

通常、symfonyプロジェクトにテーブルを追加する作業はschema.ymlファイルにPropelの定義を追加することで行われます:

...
<table name="ask_question_tag" phpName="QuestionTag">
  <column name="question_id" type="integer" primaryKey="true" />
  <foreign-key foreignTable="ask_question">
    <reference local="question_id" foreign="id" />
  </foreign-key>
  <column name="user_id" type="integer" primaryKey="true" />
  <foreign-key foreignTable="ask_user">
    <reference local="user_id" foreign="id" />
  </foreign-key>
  <column name="created_at" type="timestamp" />
  <column name="tag" type="varchar" size="100" />
  <column name="normalized_tag" type="varchar" size="100" primaryKey="true" />
  <index name="normalized_tag_index">
    <index-column name="normalized_tag" />
  </index>
</table>

オブジェクトモデルをリビルドします:

$ symfony propel-build-model

カスタムクラス

次のメソッドと一緒に新しいTag.class.phpaskeet/lib/ディレクトリに追加します:

<?php
 
class Tag
{
  public static function normalize($tag)
  {
    $n_tag = strtolower($tag);
 
    // 望まないすべての文字列を取り除く
    $n_tag = preg_replace('/[^a-zA-Z0-9]/', '', $n_tag);
 
    return trim($n_tag);
  }
 
  public static function splitPhrase($phrase)
  {
    $tags = array();
    $phrase = trim($phrase);
 
    $words = preg_split('/(")/', $phrase, -1, PREG_SPLIT_NO_EMPTY | PREG_SPLIT_DELIM_CAPTURE);
    $delim = 0;
    foreach ($words as $key => $word)
    {
      if ($word == '"')
      {
        $delim++;
        continue;
      }
      if (($delim % 2 == 1) && $words[$key - 1] == '"')
      {
        $tags[] = trim($word);
      }
      else
      {
        $tags = array_merge($tags, preg_split('/\s+/', trim($word), -1, PREG_SPLIT_NO_EMPTY));
      }
    }
 
    return $tags;
  }
}
 
?>    

最初のメソッドは通常のタグを返し、次のメソッドは引数としてフレーズを取得しタグの配列を返します。これら2つのメソッドはタグを操作するときにとても役立ちます。

lib/ディレクトリにクラスを追加する利点は、自動的にかつ要求されることなく必要なときだけロードされるからです。これはオートロード機能と呼ばれます。

モデルを拡張する

新しいaskeet/lib/model/QuestionTag.phpに、タグが設定されたときにnormalized_tagを設定する次のメソッドを追加します:

public function setTag($v)
{
  parent::setTag($v);
 
  $this->setNormalizedTag(Tag::normalize($v));
}

先ほど作成されたヘルパークラスはすでに役に立っています。このメソッドのコードを二行だけに減らします。

テストデータを追加する

いくつのタグのテストデートとともにaskeet/data/fixtures/ディレクトリにファイルを追加します:

QuestionTag:
  t1: { question_id: q1, user_id: fabien, tag: relatives }
  t2: { question_id: q1, user_id: fabien, tag: girl }
  t4: { question_id: q1, user_id: francois, tag: activities }
  t6: { question_id: q2, user_id: francois, tag: 'real life' }
  t5: { question_id: q2, user_id: fabien, tag: relatives }
  t5: { question_id: q2, user_id: fabien, tag: present }
  t6: { question_id: q2, user_id: francois, tag: 'real life' }
  t7: { question_id: q3, user_id: francois, tag: blog }
  t8: { question_id: q3, user_id: francois, tag: activities }

このファイルはアルファベット順でディレクトリの他のファイルを追跡することでsfPropelDateオブジェクトがQuestionUserテーブルのレコードに関連するこれらの新しいレコードにリンクすることを確認してください。呼び出しによってデータベースを再投入できます:

$ php batch/load_data.php

アクションでタグを動作させる準備ができています。しかし、最初はQuestionクラス用のモデルを拡張しましょう。

質問タグを表示する

コントロールレイヤーに何かを追加する前に、内容の構造を維持するために新しいtagモジュールを追加しましょう:

$ symfony init-module frontend tag

モデルを拡張する

任意の質問に対してすべてのユーザーがタグづけしたすべての単語リストを表示する必要があります。関連タグを取り出す機能はQuestionクラスのメソッドなので、私たちはこのクラスを拡張します(askeet/lib/model/Question.php)。ここでのトリックは重複タグを避けるための重複エントリをグループ化することです(2つの同一タグは結果に対して1回のみ表示される)。新しいメソッドはタグの配列を返します:

public function getTags()
{
  $c = new Criteria();
  $c->clearSelectColumns();
  $c->addSelectColumn(QuestionTagPeer::NORMALIZED_TAG);
  $c->add(QuestionTagPeer::QUESTION_ID, $this->getId());
  $c->setDistinct();
  $c->addAscendingOrderByColumn(QuestionTagPeer::NORMALIZED_TAG);
 
  $tags = array();
  $rs = QuestionTagPeer::doSelectRS($c);
  while ($rs->next())
  {
    $tags[] = $rs->getString(1);
  }
 
  return $tags;
}

今回、1つのカラムだけ必要なので(normalized_tag)、Propelデータベースから投入されたTagオブジェクトの配列を返すようにProperlに求める意味はありません(ちなみにこの処理はハイドレイティング(hydrating)と呼ばれます)。そこで、ずっと速く、配列を構文解析するシンプルなクエリを行います。

ビューを修正する

質問の詳細ページは任意な質問用のタグりストを表示します。そのためにサイドバーを使用します。7日目にコンポーネントスロットとして構築したので、questionモジュールだけにこのバーのための特別なコンポーネントを設定します。

そこでaskeet/apps/frontend/modules/question/config/view.ymlにおいて、次の設定を追加します:

showSuccess:
  components:
    sidebar: [sidebar, question]

このsidebarモジュールのコンポーネントはまだ作成されていませんが、とてもシンプルです(modules/sidebar/actions/components.class.php):

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

もっとも長く書かなければならない部分はフラグメントです(modules/sidebar/templates/_question.php):

<?php include_partial('sidebar/default') ?>
 
<h2>question tags</h2>
 
<ul id="question_tags">
  <?php include_partial('tag/question_tags', array('question' => $question, 'tags' => $question->getTags())) ?> 
</ul>

タグのリストをフラグメントとして挿入する方法を選びます。少し後のAJAXリクエストによってリフレッシュされるからです。

このsidebarモジュールのコンポーネントはまだ作成されていませんが、とてもシンプルです(modules/sidebar/actions/components.class.php):

<?php foreach($tags as $tag): ?>
  <li><?php echo link_to($tag, '@tag?tag='.$tag, 'rel=tag') ?></li>
<?php endforeach; ?>

rel=tag属性はマイクロフォーマットです。決して強制的なものではありませんが、ここに追加しても何も得られないので、停止することにします。

routing.yml@tagルーティングルールを追加します:

tag:
  url:   /tag/:tag
  param: { module: tag, action: show }

テストする

最初の質問の詳細を表示し、サイドバーにあるタグリストを探してください:

http://askeet/question/what-can-i-offer-to-my-step-mother

質問のためのタグリスト

質問のための人気タグのショートリストを表示する

質問用のタグリストの全体を表示するのにサイドバーは良い場所です。しかし質問リストにタグを表示するのはどうでしょうか?それぞれの質問に対して、タグのサブセットだけを表示します。しかしどちらでしょうか?私たちは最も人気のある方法を選びます。すなわち、この質問に最も付与されるタグです。この質問に関連するタグの人気度を高めるために既存のタグで質問をタグづけすることをユーザーに推奨しなければならないでしょう。すべてのユーザーがそれを行わないのであれば、"モデレーター"がそれを行うでしょう。

モデルを拡張する

ともかく、このことは->getPopularTags()メソッドにQuestionオブジェクトを追加することが必要であることを意味します。しかし、今回、データベースへのリクエストはシンプルではありません。それを行うためにPropelを使うことはリクエスト数を増やし、多くの時間が費やされます。symfonyでは、ベストソリューションであればSQLの力を借りることが許可されます。そこで、Creoleによるデータベースへの接続を必要とし通常のSQLクエリを実行します。

このクエリは次のようになります:

SELECT normalized_tag AS tag, COUNT(normalized_tag) AS count
FROM question_tag
WHERE question_id = $id
GROUP BY normalized_tag
ORDER BY count DESC
LIMIT $max

しかしながら、現実のカラムとテーブル名はデータベースへの依存を作成し、データ抽象化レイヤーを回避します。将来、カラムまたはテーブルをリネームすることを決めたら、この生のSQLクエリはもう動きません。リクエストのsymfonyバージョンは現在の名前を使わずに、代わりに抽象化されたリクエストを使うのはそういうわけです。読むのが少し難しくなりますが、メンテナンスがより簡単になります。

public function getPopularTags($max = 5)
{
  $tags = array();
 
  $con = Propel::getConnection();
  $query = '
    SELECT %s AS tag, COUNT(%s) AS count
    FROM %s
    WHERE %s = ?
    GROUP BY %s
    ORDER BY count DESC
  ';
 
  $query = sprintf($query,
    QuestionTagPeer::NORMALIZED_TAG,
    QuestionTagPeer::NORMALIZED_TAG,
    QuestionTagPeer::TABLE_NAME,
    QuestionTagPeer::QUESTION_ID,
    QuestionTagPeer::NORMALIZED_TAG
  );
 
  $stmt = $con->prepareStatement($query);
  $stmt->setInt(1, $this->getId());
  $stmt->setLimit($max);
  $rs = $stmt->executeQuery();
  while ($rs->next())
  {
    $tags[$rs->getString('tag')] = $rs->getInt('count');
  }
 
  return $tags;
}

最初に、$conでデータベースへの接続が開かれます。SQLクエリは抽象化レイヤーから由来するカラムとテーブル名による文字列において、%sトークンを置き換えることによってビルドされます。クエリを含むStatementオブジェクトとクエリの結果を含むResultSetオブジェクトが作成されます。これらはCreoleオブジェクトで、これらの使い方はCreoleのドキュメントに詳しく書かれています。質問idによるSQLクエリにおいてStatementオブジェクトの->setInt()メソッドは最初の?を置き換えます。$max引数は->setLimit()メソッドによって変えされる結果の数を制限するために使われます。

メソッドが返すのはデータベースへの1つのリクエストによって降順の人気度で並べ替えられノーマライズされたタグと人気度の連想配列です。

ビューを修正する

これで、modules/question/templates/ディレクトリにある_list.phpフラグメントにフォーマットされた質問のためのタグリストを追加できます:

<?php use_helper('Text', 'Date', 'Global', 'Question') ?>
 
<?php foreach($question_pager->getResults() as $question): ?>
  <div class="question">
    <div class="interested_block" id="block_<?php echo $question->getId() ?>">
      <?php include_partial('question/interested_user', array('question' => $question)) ?>
    </div>
 
    <h2><?php echo link_to($question->getTitle(), '@question?stripped_title='.$question->getStrippedTitle()) ?></h2>
 
    <div class="question_body">
      <div>asked by <?php echo link_to($question->getUser(), '@user_profile?nickname='.$question->getUser()->getNickname()) ?> on <?php echo format_date($question->getCreatedAt(), 'f') ?></div>
      <?php echo truncate_text(strip_tags($question->getHtmlBody()), 200) ?>
    </div>
 
    tags: <?php echo tags_for_question($question) ?>
 
  </div>
<?php endforeach; ?>
 
<div id="question_pager">
  <?php echo pager_navigation($question_pager, $rule) ?>
</div>

私たちは+記号によってタグの分離および制限を取り扱い、テンプレートで多すぎるコードを避けたいので、lib/helper/QuestionHelper.phpヘルパーライブラリでtags_for_quesiton()ヘルパー関数を書きます:

function tags_for_question($question, $max = 5)
{
  $tags = array();
 
  foreach ($question->getPopularTags($max) as $tag => $count)
  {
    $tags[] = link_to($tag, '@tag?tag='.$tag);
  }
 
  return implode(' + ', $tags);
} 

テストする

質問のリストは質問ごとの人気タグを表示します:

http://askeet/

質問リストでの人気タグ

単語でタグづけされた質問りストを表示する

タグが表示されるたびに、@tagルーティングルールへのリンクを追加します。任意のタグでタグづけされた人気のある質問を表示するページにリンクすることになります。

tag/showアクション

tagモジュールにおいてshowアクションを作成します:

public function executeShow()
{
  $this->question_pager = QuestionPeer::getPopularByTag($this->getRequestParameter('tag'), $this->getRequestParameter('page'));
}

モデルを拡張する

通常は、モデルを処理するコードはモデルの中に設置されます。今回の場合はQuestionオブジェクトのセットを返すので、QuestionPeerクラスにします。興味を示すユーザーに人気がある質問が欲しいので、今回は、複雑なリクエストは必要ありません。Propelは->doSelect()を呼び出すことで可能です:

public static function getPopularByTag($tag, $page)
{
  $c = new Criteria();
  $c->add(QuestionTagPeer::NORMALIZED_TAG, $tag);
  $c->addDescendingOrderByColumn(QuestionPeer::INTERESTED_USERS);
  $c->addJoin(QuestionTagPeer::QUESTION_ID, QuestionPeer::ID, Criteria::LEFT_JOIN);
 
  $pager = new sfPropelPager('Question', sfConfig::get('app_pager_homepage_max'));
  $pager->setCriteria($c);
  $pager->setPage($page);
  $pager->init();
 
  return $pager;
}

メソッドは人気順に並んだ、質問ページャを返します。

テンプレートを作成する

ご期待通りmodules/tag/templates/showSuccess.phpテンプレートはシンプルです:

<h1>popular questions for tag "<?php echo $sf_params->get('tag') ?>"</h1>
 
<?php include_partial('question/list', array('question_pager' => $question_pager, 'rule' => '@tag?tag='.$sf_params->get('tag'))) ?>

ルーティングルールにpageパラメータを追加する

routing.yml@tagルーティングルールにデフォルト値として:pageパラメータを追加します:

tag:
  url:   /tag/:tag/:page
  param: { module: tag, action: show, page: 1 }

テストする

activitiesタグでタグづけされたすべての質問を見るにはactivitiesページに移動してください:

http://askeet/tag/activities

activitiesでタグづけされた質問のリスト

それではまた明日

Creoleデータベース抽象レイヤーはsymfonyは複雑なSQLリクエストを可能にします。その上、オブジェクト指向マッピングでもあるPropelはオブジェクト指向の世界において動作するツールとデータベースに悩まずに済む便利なメソッドを提供します。そしてリクエストをシンプルなセンテンスに翻訳します。

上記のデータベースへのリクエストが重要な負荷をかけるかもしれないことを心配する方も中にはいらっしゃるかもしれません。最適化も可能です。たとえば、質問テーブルでpopular_tagsカラムを作成することも可能です。質問リストはそれで軽くなります。しかし、キャッシュシステム - 残りの日に討論しますが - がこの最適化を無用にします。

明日は、askeetサイトのタグ機能の解説を終わらせます。ユーザーは質問にタグを追加できるようになり、グローバルなタグバブルが利用可能になります。思い出すために読み直してください。

/tags/relase_day_13とタグづけされたaskeetのSVNリポジトリから今日の分のaskeetアプリケーションの全コードを入手できます。何か今日のチュートリアルに質問がありましたら、askeetフォーラムで気軽に質問してください。