zz log

zaininnari Blog

google code の様な「/p/プロジェクト名」なルーティングを作る

  • http://code.google.com/hosting/ 風の「/p/プロジェクト名」でアクセスできるルーティングを作ります。
    • 「p」は固定の接頭語です。なくともいいけど、その場合は、予め使用する単語を予約する必要があります。
    • 「プロジェクト名」:プロジェクトを識別する文字列です。ここでは、[_0-9a-z]+ の文字を想定しています。

環境

参考

走査するテーブルをつくる

--
-- テーブルの構造 `projects`
--

CREATE TABLE IF NOT EXISTS `projects` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `name` varchar(200) COLLATE utf8_unicode_ci NOT NULL,
  `description` text COLLATE utf8_unicode_ci NOT NULL,
  `type` varchar(200) COLLATE utf8_unicode_ci NOT NULL,
  `license` varchar(200) COLLATE utf8_unicode_ci NOT NULL,
  `user_id` int(11) NOT NULL,
  `created` datetime NOT NULL,
  `modified` datetime NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `name` (`name`)
) ENGINE=InnoDB  DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci AUTO_INCREMENT=6 ;

--
-- テーブルのデータをダンプしています `projects`
--

INSERT INTO `projects` (`id`, `name`, `description`, `type`, `license`, `user_id`, `created`, `modified`) VALUES
(5, 'whisk', '1', '1', '1', 32, '0000-00-00 00:00:00', '0000-00-00 00:00:00');

ルーティングのおさらい

app/config/routes.php に記述のおさらいです。

結果からですが、
ルーティングの結果で以下のようなパラメーターが Router クラスで生成されます。
「/p/whisk」を例とした場合

 
array
'project' => string 'whisk' (length=5)
'named' =>
array
empty
'pass' =>
array
empty
'controller' => string 'tickets' (length=7)
'action' => string 'index' (length=5)
'plugin' => null


<?php
Router::connect('/', array('controller' => 'projects', 'action' => 'index'));
  • 第一引数 '/'
    • マッチさせたい url を記述します。
    • ここでは、http://example.com/ にアクセスしたときの処理を記述します。
  • 第二引数 array('controller' => 'projects', 'action' => 'index')
    • 第一引数 にマッチした際に呼び出す、コントローラーやアクションを記述します。
    • ここでは、http://example.com/ にアクセスしたとき、上記のパラメーターに、'controller' => 'projects', 'action' => 'index' がセットされ、projects コントローラー の index アクションを呼び出すということを指定しています。
<?php
Router::connect(
	'/:controller',
	array(),
	array('controller' => 'projects|users|pages')
);
  • 第一引数 '/:controller'
    • 「:controller」のように「:」が先頭についているものが登場します。
    • 「:」が先頭についていると、「/users」にアクセスした際、'controller' => 'users' がセットされ、 users コントローラー を呼び出せるというように、一般的な記述ができます。
    • 「:controller」の他に、アクションを呼び出せる「:action」があります。
    • その他にも「:project」のように、「:」をつけることで、任意の値をセットできるようになります。
    • アクションを省略すると、「'action' => 'index'」がデフォルトでセットされます。
  • 第二引数 array()
    • ここでは、array() が指定されています。Router::connect の第三引数に値をセットしたいため、デフォルト値を与えています。
    • array() は、第二引数が省略された時に、デフォルトでセットされる値です。
  • 第三引数 array('controller' => 'projects|users|pages')
    • 第一引数でセットされる値に対するバリエーションを定義します。
    • 「:controller」で任意のコントローラーを呼び出せるようになりますが、呼び出せるコントローラーを制限したいときに、「'controller' => 'projects|users|pages'」と指定します。
  • この設定では、「/users」はOK、「/posts」はNGとなります。
    • 注意点として、「/users/add」や「/users/edit/123」などはマッチしません。
<?php
Router::connect(
	'/:controller/:action/*',
	array(),
	array('controller' => 'projects|users|pages')
);
  • 上記のルーティングに加え、「/:controller」にパラメーターが付いている場合にもマッチするようになります。
    • 「/users/add」や「/users/edit/123」がマッチします。
<?php
App::import('Lib', 'routes/ProjectRoute');
Router::connect(
	'/p/:project',
	array(
		'controller' => 'tickets'
	),
	array(
		'project' => '[_a-z0-9]{3,}',
		'routeClass' => 'ProjectRoute'
	)
);
  • 第一引数 '/p/:project'
    • http://example.com/p/<プロジェクト名> でアクセスしたときのルーティングを設定します。
    • 'project' => '<プロジェクト名>' がセットされます。
  • 第二引数 array('controller' => 'tickets')
    • http://example.com/p/<プロジェクト名> にアクセスしたとき、tickets コントローラー(アクションは index )を実行する設定です。
  • 第三引数 配列で、 'project' => '[_a-z0-9]{3,}', 'routeClass' => 'ProjectRoute'
    • 「'project' => '[_a-z0-9]{3,}'」について、'project' の値が、正規表現「[_a-z0-9]{3,}」(半角英数字とアンダーバー「_」が3文字以上) にマッチするものだけ、このルーティングにヒットするようになります。
    • 'routeClass' => 'ProjectRoute'について、cakephp1.3系で新たに導入された機能で、データベースを使ったルーティングなどより複雑なルーティングを実現させる機能です。
      • ここでは、ProjectRoute というクラスに処理が移ります。
      • 事前に、App::import により、扱うクラスを読み込みます。

新機能 routeClass

cake の規則

(app|plugin)/libs/routes 以下に、<任意の名前(単数小文字)>_route.php を作成します。
ここでは、project_route.php を作成します。
また、命名規則(先頭大文字単数 + Route)により、CakeRoute を継承した ProjectRoute クラスをつくります。

<?php
class ProjectRoute extends CakeRoute // 先頭大文字単数 + Route
{
	// 略
}
<?php
App::import('Lib', 'routes/ProjectRoute');

を使って読み込むことで、スマートに読み込みができます。

与えられた url を処理する parse($url)

routeClass で処理するクラスを指定すると、そのクラスの parse メソッドが呼ばれます。
このメソッド内で、与えられた url にマッチしているかどうかを判定します。
マッチしていない場合は、false を返します。
マッチした場合は、配列でパースしたパラメータを返します。

例として、
「/p/whisk」にアクセスしたときの処理をコメントで解説しています。

<?php
	/**
	 * (non-PHPdoc)
	 *
	 * @param string $url The url to attempt to parse.
	 *
	 * @see cake/libs/CakeRoute#parse($url)
	 *
	 * @return mixed Boolean false on failure, otherwise an array or parameters
	 */
	function parse($url) // 「/p/whisk」 にアクセスすると、/p/whisk がセットさせる。
	{
// 親メソッドを呼び出すと、Router::connect に記載された設定で、$url を分解します。
// 
// $url = '/p/whisk' で、parent::parse($url) した結果。
// 
// array
//   'project' => string 'whisk' // 「:project」の部分がセットされます。
//   'named' => 
//     array
//       empty
//   'pass' => 
//     array
//       empty
//   'controller' => string 'tickets' //第二引数の設定がセットされます。
//   'action' => string 'index' // 省略されたので、デフォルトの 'index' がセットされます。
//   'plugin' => null
 
		$params = parent::parse($url);
// Router::connect に記載された設定にマッチしない場合、
// parent::parse($url) は「false」を返します。
// 「false」が返ったら、ルーティングを失敗させます。
		if ($params === false) return false;
// モデルを読み込み、$params['project'] にセットされた値が
// データベースにあるかどうかをチェックします。

// {{{!初期化
// この部分は、人によりけり。ハードコーディングでもいいかも。
// 読み込むモデルの小文字単数
		$name = 'project'; 
// 読み込むモデルを cake の命名規則に合わせる処理(先頭を大文字)
// 'project' -> 'Project'
		$modelName = Inflector::classify($name);
// 取得するレコードのフィールド(id)をセット   : 'Project.id'
		$fieldId = $modelName . '.id';
// 取得するレコードのフィールド(name)をセット : 'Project.name'
		$fieldName = $modelName . '.name';
// }}}!初期化

// 'Project'モデルを読み込む。返り値は、'Project'モデルオブジェクト
		$model = ClassRegistry::init($modelName);
// データベースを検索する。
// 生成されるSQL(Mysql)
//   SELECT  `Project`.`id` ,  `Project`.`name` 
//   FROM  `projects` AS  `Project` 
//   WHERE  `Project`.`name` =  'whisk'
//   LIMIT 1
// 
// 'conditions' 検索する条件 'Project.name' が 'whisk'
// 'fields'     取得するカラム 'Project.id' と 'Project.name'
// 'recursive'  アソシエーションを抑制する
// [結果]
// array
//   'Project' => 
//     array
//       'id' => string '5' (length=1)
//       'name' => string 'whisk' (length=5)
		$result = $model->find('first', array(
			'conditions' => array($fieldName => $params[$name]),
			'fields' => array($fieldId, $fieldName),
			'recursive' => -1
		));
// 「false」は、find メソッドに異常があった場合、返される。
// 「null」 は、レコードが見つからなかった場合、返される。
// プロジェクトが無い場合、ルーティングを失敗させます。
		if ($result === false || $result === null) { // false -> fail find(), null -> not found data
			return false;
		}
// 配列から、値を取得します。Set::extract('Project.id', $result);
// 値がなければ、「null」を返します。
// $projectId = isset($result['Project']['id']) ? $result['Project']['id'] : null;
// と同じですが、すっきりと書けます。
// 詳しくは、以下が参考になります。
// ・http://book.cakephp.org/ja/view/671/extract
// ・cake/tests/cases/libs/set.test.php にある testExtract()
// ・[CakePHP] Setクラスを使ってコード量を減らす
//   http://c-brains.jp/blog/wsg/09/09/15-213238.php
//
// Set クラスは高機能で、色々なことができます。
// (投げられるチケットも多かったので、テストも膨大です。)
// Set クラスは高機能過ぎて使い方をすぐに忘れてしまう (´・ω・`)
		$projectId   = Set::extract($fieldId, $result);
		$projectName = Set::extract($fieldName, $result);
// 値がなければ、「null」を返すので、「null」の場合は、ルーティングを失敗させます。
		if ($projectId !== null && $projectName !== null) {
// ここらは、後で使いまわす予定なので、ここでセットしておきます。
			Configure::write('projectId', $projectId);
			Configure::write('projectName', $projectName);
// ルーティングが成功したときは、パースした配列を返します。
			return $params;
		}
// ルーティングが失敗したときは、「false」を返します。
		return false;
	}

逆ルーティングをする match($url)

parse メソッドで分解されたパラメータ(array)から、url(string) を生成します。
ややこしいですが、実行されるのは、
Router::url が呼ばれたとき、(ヘルパーでは、url() から呼ばれる)
match メソッドが呼ばれます。

成功した場合は、url(string) を返します。
失敗した場合は、false を返します。

例では、
「whisk」というプロジェクト内で、
tickets コントローラー 、 index アクション を呼び、
index.ctp 内で

<?php
foreach ($tickets as $ticket) {
	echo $html->link(
		$ticket['Ticket']['title'],
		array('action' => 'view', $ticket['Ticket']['id'])
	);
}

が記述されているとして、コメントをつけています。
また、
HtmlHelper::link の 第二引数( array('action' => 'view', $ticket['Ticket']['id']) )は、
HtmlHelper::url (実質的には Helper::url )に渡され、
Router::url が呼ばれ、match メソッドに繋がります。

<?php
	/**
	 * (non-PHPdoc)
	 *
	 * @param array $url An array of parameters to check matching with.
	 *
	 * @see cake/libs/CakeRoute#match($url)
	 *
	 * @return mixed Either a string url for the parameters if they match or false.
	 */
	function match($url)
	{
// $url は 配列です。
// ここでは、以下のような値です。
// array
//   'action' => string 'view' (length=4)
//   0 => string '155' (length=3)
//   'controller' => string 'tickets' // コントローラーを省略したので、コントローラーがセットされる。
//   'plugin' => null

// 記録した 'projectName' を読み込みます。
// 記録しなかった場合、「null」が返ります。
// index など、一覧のページでは、かなりの回数が呼ばれます。
// デフォルトのファイルキャッシュでは、オーバーヘッドが大きいので、変数キャッシュの方がマシです。
		$projectName = Configure::read('projectName');
// 'projectName' がない場合、この処理を失敗させます。
		if ($projectName === null) return false;
// 「:project」の一部である「project」に値をセットします。
		$url['project'] = $projectName;
// あとは、親を呼んで、任せます。
// 「/p/whisk/tickets/view/155」が返ります。
		return parent::match($url);
	}

routes.php

細かい部分はお好みで。

<?php

App::import('Lib', 'routes/ProjectRoute');

if (!defined('WHISK_USER_URL')) {
	define('WHISK_USER_URL' , 'u');
}
if (!defined('WHISK_PROJECT_URL')) {
	define('WHISK_PROJECT_URL' , 'p');
}

Router::connect('/', array('controller' => 'projects', 'action' => 'index'));

/* Genral Routes */

Router::connect(
	'/:controller',
	array(),
	array('controller' => 'projects|users|pages')
);

Router::connect(
	'/:controller/:action/*',
	array(),
	array('controller' => 'projects|users|pages')
);

/* Project Routes */

Router::connect(
	'/' . WHISK_PROJECT_URL . '/:project',
	array(
		'controller' => 'tickets'
	),
	array(
		'project' => '[_a-z0-9]{3,}',
		'routeClass' => 'ProjectRoute',
		'controller' => 'tickets|comments|settings|states',
	)
);

Router::connect(
	'/' . WHISK_PROJECT_URL . '/:project/:controller',
	array(),
	array(
		'project' => '[_a-z0-9]{3,}',
		'routeClass' => 'ProjectRoute',
		'controller' => 'tickets|comments|settings|states',
	)
);

Router::connect(
	'/' . WHISK_PROJECT_URL . '/:project/:controller/:action/*',
	array(),
	array(
		'project' => '[_a-zA-Z0-9]{3,}',
		'routeClass' => 'ProjectRoute',
		'controller' => 'tickets|comments|settings|states',
	)
);