TreeBehaviorを使う

Webアプリケーションを構築する際、必要となる機能の中に「メニューの階層構造(木構造)」が設計段階で検討の俎上にしばしば上がるのではないだ ろうか?このサイトのメニューも、データを木構造(以下ツリー構造と呼ぶ)で構築して、ビューで再帰関数を用意してメニューのためのリストを構成してい る。

データをツリー構造で扱うと、階層で表現するのでカテゴリごとに分かりやすくまとめることができる。またデータはノードとして表し、入れ替えや付け替えの作業を容易に行うことができるので便利である。

CakePHP1.2になって、ツリーデータをテーブル上に表現するという、モデルにツリー構造を扱うモジュールを追加するだけで、容易にツリー構 造を扱える機能が付加された。モデルに決められたデータ構造を扱わせることから、「ビヘイビア」と呼ばれ、ツリー構造を扱う機能を「Tree Behavior」と呼んでいる。

ツリー構造を見える形(HTML)にしてくれるヘルパー、「Tree Helper」もBakeryで公開してくれたおかげで、後はデータをメンテナンスする簡単なフォームを用意するだけで、ツリー構造のメニューが利用でき るようになった。ここでは、上記の機能を利用したサンプルを掲載する。CakePHPを利用して、ツリー構造のデータを扱いたい人の手掛かりになれば幸い です。

以下のページの内容を参考にした。

  1. CakePHPのCookbook「3.7.7.3 Tree Behaviors
  2. CakePHPのBakery「Tree Helper

利用手順の概要

ターゲットとなるツリーデータの例はこのサイトのメニュー項目で、「Tree Behavior」と「Tree Helper」を利用してデータの操作と表示を行うまでの説明となる。

最初にツリーデータを保存するテーブルを用意し、そのテーブルを扱うモデルと、モデルで「Tree Behavior」機能を利用するための設定を示す。

次に、「Tree Behavior」機能が付加されたモデルのメソッドでツリーデータの操作を行うアクションと、「Tree Helper」のヘルパーを利用してツリーの形を表示する、それぞれのプログラムを作る。

最後に、HTMLのUL、LI要素を利用したメニュー(ナビゲーション)のリンク表示のさせ方を、CakePHPのエレメントを利用して作る。

テーブルとモデルの作成

メニューテーブルを、MySQLデータベース上にスキーマ定義する。

CREATE TABLE `menus` (
  `id` int(10) unsigned NOT NULL auto_increment,
  `parent_id` int(10) unsigned default '0',
  `lft` int(10) default '0',
  `rght` int(10) default '0',
  `name` varchar(255) default NULL,
  `href` varchar(255) default NULL,
  PRIMARY KEY  (`id`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8 COMMENT='メニューテーブル'

Tree Behaviorでは、id、parent_id、lft、rghtの項目名をデフォルトで利用する。nameとhrefは、メニュー名とリンク先を登録するために独自に追加した。

このテーブル上のデータを、ツリー構造データとして扱うモデルを定義する。

<?php
class Menu extends AppModel {
  var $name = 'Menu';
  var $actsAs = array('Tree');
}
?>

CakePHP 1.2で利用できるようになったTree Behavior機能の付加は、「$actAs」に「Tree」キーワードを配列で宣言するだけだ。

コントローラとビューの作成

メニューコントローラでは、ツリー構造のテーブルデータに対して、

  • ツリー構造をリストの入れ子で表示する
  • メニュー項目を新規追加する
  • メニュー項目の親を付け替える
  • メニュー項目を同レベル内で上へ移動する
  • メニュー項目を同レベル内で下へ移動する
  • メニュー項目を削除する

の、6つのアクションとビューを作った。

<?php
class MenusController extends AppController {
  // コントローラ名
  var $name = 'Menus';
  var $scaffold = array('index', 'delete');
  var $helpers = array('Tree');

  // メニュー項目の新規作成
  function new_menu() {
    if (!empty($this->params['form'])) {
      $parent = $this->Menu->findByName($this->params['form']['parent']);
      $this->Menu->create();
      if (!empty($parent)) {
        $data['Menu']['parent_id'] = $parent['Menu']['id'];
      }
      if (!empty($this->params['form']['newname'])) {
        $data['Menu']['name'] = $this->params['form']['newname'];
        $this->Menu->save($data);
        $this->Session->setFlash('Menu ' . $this->params['form']['newname'] . ' created');
        $this->redirect(array('action'=>'menu_index'), null, true);
      }
    }
  }

  // メニューの表示
  function menu_index($rootName=null) {
    if ($rootName != 1) {
      $rootName = 'メインメニュー';
    }

    if (!empty($rootName)) {
      //$rootName = mb_convert_encoding($rootName, 'UTF-8', 'SJIS');
      $rootMenu = $this->Menu->findByName($rootName);
      $this->set('rootMenu', $rootMenu);
      if (!empty($rootMenu)) {
        $menuArray = $this->Menu->children($rootMenu['Menu']['id']);
        $this->set('menuArray', $menuArray);
        $this->set('setting', array('model'=>'Menu', 'alias'=>'name'));
      }
    } else {
      $rootMenus = $this->Menu->find('all'
       , array('conditions'=>array('parent_id'=>0), 'order'=>'id ASC'));
      $this->set('rootMenus', $rootMenus);
    }
  }

  // メニューの親を変更する
  function change_parent() {
    if (!empty($this->params['form'])) {
      $child = $this->Menu->findByName($this->params['form']['child']);
      if (!empty($child)) {
        $parent = $this->Menu->findByName($this->params['form']['parent']);
        if (!empty($parent)) {
          $this->Menu->id = $child['Menu']['id'];
          if ($this->Menu->saveField('parent_id', $parent['Menu']['id'])) {
            $this->Session->setFlash($child['Menu']['name'] . ' moved under ' . $parent['Menu']['name']);
          }
        }
      }
      $this->redirect(array('action'=>'menu_index'), null, true);
    }
  }

  // メニューの順番を上げる
  function moveup() {
    if (!empty($this->params['form'])) {
      $child = $this->Menu->findByName($this->params['form']['child']);
      if (!empty($child)) {
        if ($this->Menu->moveup($child['Menu']['id'], intval($this->params['form']['step']))) {
          $this->Session->setFlash($child['Menu']['name'] . ' moved up ' . intval($this->params['form']['step']) . ' steps');
        }
      }
      $this->redirect(array('action'=>'menu_index'), null, true);
    }
  }

  // メニューの順番を下げる
  function movedown() {
    if (!empty($this->params['form'])) {
      $child = $this->Menu->findByName($this->params['form']['child']);
      if (!empty($child)) {
        if ($this->Menu->movedown($child['Menu']['id'], intval($this->params['form']['step']))) {
          $this->Session->setFlash($child['Menu']['name'] . ' moved down ' . intval($this->params['form']['step']) . ' steps');
        }
      }
      $this->redirect(array('action'=>'menu_index'), null, true);
    }
  }

  // メニューを削除する
  function remove() {
    if (!empty($this->params['form'])) {
      $child = $this->Menu->findByName($this->params['form']['child']);
      if (!empty($child)) {
        if ($this->Menu->removefromtree($child['Menu']['id'], true)) {
          $this->Session->setFlash($child['Menu']['name'] . ' removed from tree');
        }
      }
      $this->redirect(array('action'=>'menu_index'), null, true);
    }
  }
}
?>

各アクションに対するビューは次の通り。

new_menu.ctp
 <form method="post" action="">
 親のメニュー名:<input type="text" name="parent" value="" /><br />
 追加メニュー名:<input type="text" name="newname" value="" /><br />
 <input type="submit" value="新規作成" />
 </form>

menu_index.ctp
 <?php if (!empty($rootMenu)) : ?>
 <h1><?php e($rootMenu['Menu']['name']) ?></h1>

 <?php e($tree->generate($menuArray, $setting)) ?>

 <?php else : ?>
 <table>
   <tr><th>ルートメニュー</th></tr>
 <?php foreach($rootMenus as $rm) : ?>
   <tr><td><?php e($rm['Menu']['name']) ?>
 <?php endforeach; ?>
 </table>

 <?php endif; ?>

 <h1>メニューの変更処理</h1>
 <ul>
   <li><?php e($html->link('新規追加', 'new_menu')) ?></li>
   <li><?php e($html->link('付け替え', 'change_parent')) ?></li>
   <li><?php e($html->link('上方移動', 'moveup')) ?></li>
   <li><?php e($html->link('下方移動', 'movedown')) ?></li>
   <li><?php e($html->link('削除する', 'remove')) ?></li>
 </ul>

change_parent.ctp
 <form method="post" action="">
 変更するメニュー名:<input type="text" name="child" value="" /><br />
 変更先の親メニュー名:<input type="text" name="parent" value="" /><br />
 <input type="submit" value="付け替え" />
 </form>

moveup.ctp
 <form method="post" action="">
 変更するメニュー名:<input type="text" name="child" value="" /><br />
 上方移動ステップ数:<input type="text" name="step" value="" /><br />
 <input type="submit" value="上方移動" />
 </form>

movedown.ctp
 <form method="post" action="">
 変更するメニュー名:<input type="text" name="child" value="" /><br />
 下方移動ステップ数:<input type="text" name="step" value="" /><br />
 <input type="submit" value="下方移動" />
 </form>

remove.ctp
 <form method="post" action="">
 削除するメニュー名:<input type="text" name="child" value="" /><br />
 <input type="submit" value="削除する" />
 </form>

表示はCakePHPをインストールしたときのデフォルトで行ったので、各自の環境に合わせてもらえればよい。

Treeヘルパーは、Bakeryの「Tree Helper」から/app/views/helpersに「tree.php」を保存すればよい。

操作のまとめ

実行の例題を当サイトのメニューで行ったので、ルートノードは「new_menu」を利用して「メインメニュー」を登録した。親はないので、親の指定は何も入力せず登録すればよい。

その他適当に登録して、どのように表示されるか見てみよう。削除について少し説明すると、削除指定されたノードの下のノードは、削除指定されたノードと同じレベルに展開されてから、指定されたノードが削除されるようになっている。

さて、ツリー構造を操作するための命令は至って簡単なのが分かったと思う。モデルにビヘイビア機能を付加しただけで、準備されたメソッドで簡単に操作ができた。

「Tree Helper」は、「children」メソッドで取得したデータをヘルパーの「generate」メソッドに渡すと、リストの階層で構成されたHTML データを戻してくれるので、それをそのままビューで出力するようにしている。パラメータに渡している内容は、モデル名と表示するテーブルの項目名である。

最後に、「Tree Helper」を利用してメニューをリンク表示する方法を説明する。

「Tree Helper」の「generate」メソッドへ渡すパラメータに

'element' => 'menu_link'

のように、エレメントのファイル名が「menu_link.ctp」を配列に追加するだけである。エレメントでは、$data変数にテーブルのデータが入れられて渡されるので、

<?php e($html->link($data['Menu']['name'], $data['Menu']['href'])) ?>

などとすれば、<a href=””></a>でリストの項目に出力される。これで、ナビゲーションのHTMLソースを出力することができるわけだ。その 他、サンプルではメニュー名を使ったが、IDを表示させて番号で指定できればさらに簡単に操作できるようになる。

あるいは、JavaScriptのExtと組み合わせてドラッグ&ドロップを利用した、視覚的に操作するためのインプリメントが紹介されているので、興味のある方はどうぞ。

Drag and drop using Ext JS with the CakePHP Tree Behavior

その他、Cookbookにツリー構造をテーブル上に構成する技法について、

MPTT(Modified Preorder Tree Traversal) logic

で、MySQLの解説ページにリンクされていたのでこちらの方も興味ある方はどうぞ。

(080330)