CakePHPとMySQLで作るタグ機能 ②HABTMを使用した実装

はじめに

前回の投稿では、タグ機能の実装手法4つと、今回使用するHABTMについて簡単に述べました。

今回は実際にHABTMを使用してタグ機能を実装した例を紹介します。

ソースコードはGitHubにあげていますので、合わせてご覧ください。
GitHub | CakePHPで作るタグシステム

テーブルの作成

前述のとおり、今回はHABTMを使用するため、「コンテンツ用のテーブル」、「タグ用のテーブル」、「中間テーブル」の3つのテーブルを作成します。

公式のリファレンスによると、

HABTMアソシエーションを操作するには、別テーブルを準備する必要があります。この新しいテーブルの名前は、両モデルの名前をアルファベット順にアンダースコア( _ )で区切ったものにする必要があります。そして、それぞれのモデルのプライマリキーを指す外部キーを2つ(integer型)定義します。色々な問題が起こるため、これら2つのフィールドを複合主キーとして定義しないでください。もしそうする必要があるなら、ユニークインデックスを定義してください。テーブルに追加の情報をもたせたり、またはモデルで使ったりする場合は、別途このテーブルにプライマリキーを追加してください。(規約では’id’)

とのことなので、この規約に従ってテーブルを作成します。

動画コンテンツを例に話を進めていきます。

moviesテーブル

以下のように作成します。実際には動画のURL等も必要になると思いますが、今回は練習なので割愛します。


CREATE TABLE `movies` (
     `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
     `title` varchar(64) NOT NULL,
     `deleted` char(1) NOT NULL DEFAULT '0',
     `created` datetime DEFAULT NULL,
     `modified` datetime DEFAULT NULL,
     PRIMARY KEY (`id`)
)  DEFAULT CHARSET=utf8;

tagsテーブル

同じ名前のタグが出来ないように、nameをユニークにしています。


CREATE TABLE `tags` (
     `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
     `name` varchar(10) NOT NULL,
     PRIMARY KEY (`id`),
     UNIQUE KEY `name` (`name`)
) DEFAULT CHARSET=utf8;

movies_tagsテーブル

規約に従い、movies_tagsというテーブル名にします。

また、コンテンツのIDとタグのIDを複合主キーにするようなことは避けたほうがよいそうなので、別途IDを設けて、movie_idとtag_idは複合ユニークキーとしています。


CREATE TABLE `movies_tags` (
     `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
     `movie_id` int(11) unsigned NOT NULL,
     `tag_id` int(11) unsigned NOT NULL,
     `deleted` char(1) NOT NULL DEFAULT '0',
     PRIMARY KEY (`id`),
     UNIQUE KEY `unique` (`movie_id`,`tag_id`)
) DEFAULT CHARSET=utf8;


あとは適当なデータを入れれば準備完了。モデルの作成に移ります。

モデルの作成

モデルではhasAndBelongsToManyの設定を行う必要があります。

それぞれのテーブルに対するモデルは以下のようにします。

Movieモデル

app/Model/Movie.php

<?php

class Movie extends AppModel
{
    public $name = 'Movie';
    public $hasAndBelongsToMany = array(
        'Tag' =>
            array(
                'className'              => 'Tag',
                'joinTable'              => 'movies_tags',
                'foreignKey'             => 'movie_id',
                'associationForeignKey'  => 'tag_id',
                'unique'                 => false,
                'conditions'             => '',
                'fields'                 => '',
                'order'                  => '',
                'limit'                  => '',
                'offset'                 => '',
                'finderQuery'            => '',
                'deleteQuery'            => '',
                'insertQuery'            => '',
                'with'                   => 'MoviesTag'
            )
    );
}

Tagモデル

app/Model/Tag.php

<?php

class Tag extends AppModel
{
    public $name = 'Tag';
    public $hasAndBelongsToMany = array(
        'Movie' =>
            array(
                'className'              => 'Movie',
                'joinTable'              => 'movies_tags',
                'foreignKey'             => 'tag_id',
                'associationForeignKey'  => 'movie_id',
                'unique'                 => false,
                'conditions'             => '',
                'fields'                 => '',
                'order'                  => '',
                'limit'                  => '',
                'offset'                 => '',
                'finderQuery'            => '',
                'deleteQuery'            => '',
                'insertQuery'            => '',
                'with'                   => 'MoviesTag'
            )
    );
}

MoviesTagモデル

Movieがあって、Tagがあって、初めて中間テーブルが存在します。

このように中間テーブルがMovieモデルやTagモデルに依存している状態を示すには、belongsToアソシエーションを使用します。

MoviesTagモデルは以下のようになります。

app/Model/MoviesTag.php

<?php

class MoviesTag extends AppModel
{
    public $name = 'MoviesTag';

    public $belongsTo = array(
        'Movie' => array(
            'className' => 'Movie',
            'foreignKey' => 'movie_id',
        ),
        'Tag' => array(
            'className' => 'Tag',
            'foreignKey' => 'tag_id',
        ),
    );
}

これでモデルの準備もOKです。以降では、コントローラーを実装して実際にタグが機能するところを見ていきます。

コントローラーの実装

モデルとデータの準備はできているので、あとはデータを取得する処理を記述するコントローラーが作成出来ればOKです。

それぞれ以下のように実装します。

Moviesコントローラー

特に特筆すべきこともなく、普通にfindを実行すればOKです。

app/Controller/MoviesController.php

<?php

class MoviesController extends AppController
{
    public $name    = 'Movies';
    public $helpers = array('Html', 'Form');

    public function index()
    {
        $movies = $this->Movie->find('all');
        $this->set('movies', $movies);
    }
}

Tagsコントローラー

recursiveに-1を指定すると、タグに紐付いた動画を取得せず、無駄なDBアクセスを回避出来ます。

app/Controller/TagsController.php

<?php

class TagsController extends AppController
{
    public $name    = 'Tags';
    public $helpers = array('Html', 'Form');

    public function index()
    {
        $tags = $this->Tag->find(
            'all',
            array(
                'recursive' => -1
            )
        );
        $this->set('tags', $tags);
    }

    public function view($id = null)
    {
        $this->Tag->id = $id;
        $this->set('tag', $this->Tag->read());
    }
}

MoviesTagsコントローラー

recursiveの設定が後で重要となります。

app/Controller/MoviesTagsController.php

<?php

class MoviesTagsController extends AppController
{
    public $name    = 'MoviesTags';

    public function index()
    {
        $find_array = array();
        $find_array['recursive'] = 2;
        if( isset($this->params['url']['tag_id']) ) {
            $find_array['conditions'] = array('MoviesTag.tag_id' => $this->params['url']['tag_id']);
        }

        $movies_tags = $this->MoviesTag->find(
            'all',
            $find_array
        );
        $this->set('movies_tags', $movies_tags);
    }

    public function view($id = null)
    {
        $this->MoviesTag->id = $id;
        $this->set('movies_tag', $this->MoviesTag->read());
    }
}

動作確認

最後に動作確認を行います。

ここでは、
  • 動画一覧と一緒に動画に紐付いたタグ一覧を取得する
  • タグIDから、そのタグがついた動画を検索する
の2つについて動作確認を行います。

動画と一緒に動画に紐付いたタグを取得する

app/View/Movies/index.ctp を以下のように実装し、 http://your.domain/cakephp/movies/ にアクセスしてみましょう。

<h2>動画一覧</h2>
<pre>
<?php
var_dump($movies);
?>
</pre>

出力は以下のようになると思います。

array(2) {
  [0]=>
  array(2) {
    ["Movie"]=>
    array(5) {
      ["id"]=>
      string(1) "1"
      ["title"]=>
      string(69) "きゃりーぱみゅぱみゅ|インベーダーインベーダー"
      ["deleted"]=>
      string(1) "0"
      ["created"]=>
      NULL
      ["modified"]=>
      NULL
    }
    ["Tag"]=>
    array(2) {
      [0]=>
      array(3) {
        ["id"]=>
        string(1) "1"
        ["name"]=>
        string(6) "音楽"
        ["MoviesTag"]=>
        array(4) {
          ["id"]=>
          string(1) "1"
          ["movie_id"]=>
          string(1) "1"
          ["tag_id"]=>
          string(1) "1"
          ["deleted"]=>
          string(1) "0"
        }
      }
      [1]=>
      array(3) {
        ["id"]=>
        string(1) "2"
        ["name"]=>
        string(12) "きゃりー"
        ["MoviesTag"]=>
以下略

動画情報と一緒にタグの情報も取得できていることが分かります。

タグIDから、そのタグがついた動画を検索する

動画からタグが出来たから、タグから動画も同じように。と思ったのですが、問題にぶち当たってしまいました。

TagsControllerを使用し、タグ情報と一緒にタグと紐付いた動画を取得することはもちろん可能でした。

しかし、さらにその動画に紐付いたタグを同時に取得することができませんでした。

(recursiveの値を変更しても駄目でした。自分の知識不足の可能性が大いにあるので、間違いがあればコメント等頂きたく思います。)

単純に動画一覧を出す時(Movies/index.ctp)に動画に紐付いたタグを表示するとしたら、タグ検索のときも動画に紐付いたタグを表示したいところです。

この処理を実現するために、中間テーブルに対してクエリを投げることにしました。

中間テーブルには動画のIDと、それに紐づくタグのIDが入っており、両方の情報を一気に取ってくることが可能です。

※Controllerでrecursiveオプションを設定しておく必要があります。

app/View/MoviesTags/index.ctp を以下のように実装し、 http://your.domain/cakephp/movies_tags/?tag_id=1  にアクセスしてみましょう。

<h2>動画・タグ一覧</h2>
<pre>
<?php
foreach ($movies_tags as $movies_tag) {
    echo h($movies_tag['Movie']['title'] . "\n");
    foreach ($movies_tag['Movie']['Tag'] as $tag) {
        echo "    > " . h($tag['name']) . "\n";
    }
}
?>
</pre>

結果は以下のようになると思います。

きゃりーぱみゅぱみゅ|インベーダーインベーダー
    音楽
    きゃりー

動画に紐付いたタグが取得できていることが分かります。

GETで渡しているtag_idを変えることで、任意のタグの検索を行うことが可能です。

以上が、私がCakePHPで実装したタグ機能です。

終わりに

参考になったでしょうか。

CakePHPのアソシエーション機能のおかげで、わりと簡単に実装できたと思います。

もっと良い方法や間違いがあったら是非ご指摘ください。では。

 

※ 2013/11/01 追記

この方法で運用していたのですが、データ量が増えるとものすごく検索が遅くなることが分かりました。

検索速度を改善した手法に関してはまた後日書こうと思います。(実装は大きくは変わりません)

これを参考に既に実装してしまった方々には大変申し訳ないことをしてしまいました…

すみませんm(_ _)m