復習
askeetアプリケーションはWebページ、RSSフィードもしくは、Eメールを通して、データを提供することができます。問題を質問することと回答することが可能です。しかし、問題の構成はまだ開発されています。問題の組織はカテゴリとサブカテゴリは何千ものブランチを伴った切り分けられない木構造に達しており、サブブランチにおいてあなたが探しているであろう問題を知るのは簡単なことではありません。
しかしながら、Web 2.0アプリケーションは項目の組織の新しい方法を伴って世に出ます:タグです。タグと言葉、カテゴリとして。しかし、従来と異なるのは、タグの階層がないこと、項目がいくつかのタグを持つことです。カテゴリで猫を見つけるのが面倒であることが判明した一方で、タグを伴うのはとてもシンプルです(pet+cute)。すべてのユーザーが与えられた問題へタグを追加できる機能を追加することで、有名なフォークソノミーのコンセプトを得ることができます。
何だと思いますか?それがまさにaskeetの質問機能を必要していることです。時間が必要ですが(今日と明日)、しかし、結果は労力に見合う価値があります。Creoleの接続機能を使って複雑なSQLのリクエストをどのように行うのか示す機会があります。それでは行ってみましょう。
QuestionTagクラス
タグの実装方法がいくつかありますが、次の構造を持つQuestionTag
テーブルを追加する方法を選びます:
ユーザーが質問をタグづけしたとき、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.php
をaskeet/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
オブジェクトがQuestion
とUser
テーブルのレコードに関連するこれらの新しいレコードにリンクすることを確認してください。呼び出しによってデータベースを再投入できます:
$ 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
それではまた明日
Creoleデータベース抽象レイヤーはsymfonyは複雑なSQLリクエストを可能にします。その上、オブジェクト指向マッピングでもあるPropelはオブジェクト指向の世界において動作するツールとデータベースに悩まずに済む便利なメソッドを提供します。そしてリクエストをシンプルなセンテンスに翻訳します。
上記のデータベースへのリクエストが重要な負荷をかけるかもしれないことを心配する方も中にはいらっしゃるかもしれません。最適化も可能です。たとえば、質問テーブルでpopular_tagsカラムを作成することも可能です。質問リストはそれで軽くなります。しかし、キャッシュシステム - 残りの日に討論しますが - がこの最適化を無用にします。
明日は、askeetサイトのタグ機能の解説を終わらせます。ユーザーは質問にタグを追加できるようになり、グローバルなタグバブルが利用可能になります。思い出すために読み直してください。
/tags/relase_day_13
とタグづけされたaskeetのSVNリポジトリから今日の分のaskeetアプリケーションの全コードを入手できます。何か今日のチュートリアルに質問がありましたら、askeetフォーラムで気軽に質問してください。
This work is licensed under the Creative Commons Attribution-Noncommercial-No Derivative Works 3.0 Unported License license.