Ethna で綺麗な URL (Ethna_UrlHandler を使わない方法)

はいどうもー。 Ethna ってデフォルトのままだと URL がイケてないですよね。Ethna_UrlHandler を使う方法もありますがアクション毎に mapping を書く必要があって若干面倒です。

というわけで Ethna で URL を綺麗にする簡単な方法をご紹介します。フォーム値がそのままなのでちょっとダサいですが、一度設定してしまえばアクション毎にマッピングを書く必要もないのでとても楽です。

  • 使用前: http://example.org/?action_hogehoge_fugafuga=true&id=10
  • 使用後: http://example.org/hogehoge/fugagufa/?id=10

1. 以下の .htaccess ファイルをエントリポイントと同じ場所に設置する

<IfModule mod_rewrite.c>
	RewriteEngine On
	RewriteBase /

	RewriteCond %{REQUEST_FILENAME} !-f
	RewriteCond %{REQUEST_FILENAME} !-d
	RewriteRule . /index.php [L]
</IfModule>

mod_rewrite を使ってリクエストをエントリポイントに集約します。ちなみにこれは WordPress が自動生成するものを拝借しました :-p

2. Appid_Controller クラスの _getActionName_Form メソッドを以下のような感じでオーバーライドする

function _getActionName_Form()
{
	if (isset($_SERVER['REQUEST_METHOD']) == false) {
		return null;
	}
		if (strcasecmp($_SERVER['REQUEST_METHOD'], 'post') == 0) {
		$http_vars =& $_POST;
	} else {
		$http_vars =& $_GET;
	}

	foreach ($http_vars as $name => $value)
	{
		if ($value == "" || strncmp($name, 'action_', 7) != 0) {
			continue;
		}
		// オリジナル方式 http://hostname/?action_action_name
		return parent::_getActionName_Form();
	}

	// かっこいい http://hostname/action/name/ 方式
	if (!empty($_SERVER['REDIRECT_URL']))
	{
		$redirect_url = $_SERVER['REDIRECT_URL'];
		$action_name = str_replace('/', '_', $redirect_url);
		return trim($action_name, '_');
	}

	// まあ悪くはない http://hostname/?action=action_name 方式
	if (array_key_exists('action', $http_vars))
	{
		return $http_vars['action'];
	}
}

デフォルトのアクション指定方法 (18 行目) を潰してしまうとフォームヘルパが動作しなくなるので要注意です。ひとまずこれだけで簡単に URL を綺麗にすることができます。

Ktai Library for CakePHP 勉強会@関東 第二回 に参加しました

はいどうもこんにちは。

社内でも CakePHP 熱が上がってきていたり (?) モバイルの案件も増えてきているところに丁度良いタイミングで勉強会のお知らせがあったので参加してきました。

当方、Ethna がほとんどで CakePHP はちょっとだけかじったレベルでの参加で、書籍持参でコーディングするスタイルの勉強会にはきっと屈強なお兄さんたちが集まっているんじゃないかとイメージしていたのですが、そんなことはなく終始和やかな雰囲気が流れていた勉強会だったなあと思います。

最初はコーディング中心で、管理画面の実装を通して CakePHP の基礎をつかむ感じでした。管理画面の分量が結構多かったため、モバイルのところまでは到達できませんでしたが CakePHP の取っ掛かりとしては良かったと思います。

後半は皆さんと歓談して色々と興味深い話を聞かせていただきました。得に印象に残っているのがテストの話と git の話。あとは別のフレームワークの話もあったり。 events.php.gr.jp が Ethna から CakePHP に書き直されたことを知って、ちょっと寂しくなったり (笑)

最後に、いろいろと (主にコーディングと関係ないところばかりで) サポートしてくださった滝下さんに感謝です。そして主催者の穴澤さん、お疲れさまでした!今後ともよろしくお願いします。

Ethna の AppManager の getObjectPropList() で取得するカラムが異なるのに検索条件が同じだとキャッシュが返ってくるのを治す

どうもどうもこんにちは。今日は Ethna ネタですよ。

僕はどちらかというと AppObject を返してくれる getObjectList() 派なのですが、パフォーマンスが気になるときは AppObject を生成せず、カラムを制限して取得できる getObjectPropList() を使った方が良いケースもあったりします。その getObjectPropList() のキャッシュ周りでつまづいたのでそのメモでも。

サンプルとして Manager 側に id だけ取得する getIdList() と name だけ取得する getNameList() を実装してみます。

<?php
class Appid_HogeManager
{
    public function getIdList()
    {
        return $this->getObjectPropList('Hoge', array('id'), null, null, 0, 10);
    }
    public function getNameList()
    {
        return $this->getObjectPropList('Hoge', array('name'), null, null, 0, 10);
    }
}

データベースにはあらかじめこんな感じのデータを入れておきます。

mysql> select * from hoge;
+----+----------------------------------------------+
| id | name                                         |
+----+----------------------------------------------+
|  1 | Akufen - My Way                              |
|  2 | Todd Edwards - Full On                       |
|  3 | Emerson, Lake & Palmer - Brain Salad Surgery |
+----+----------------------------------------------+
3 rows in set (0.01 sec)

Action から Manager のコードを呼び出します。しかしこのコードは期待どおりの動作をしません。

<?php
    ....
    function perform()
    {
        $manager = $this->backend->getManager('Hoge');
        $hoge_id_list = $manager->getIdList();
        $hoge_name_list = $manager->getNameList();

        $this->backend->log(LOG_DEBUG, var_export($hoge_id_list, true));
        $this->backend->log(LOG_DEBUG, var_export($hoge_name_list, true));

        ....
    }

アプリケーションのログを見るとクエリがいちどきりしか実行されていなくて二つのメソッドの戻り値が同じであることが分かります。

2010/04/07 17:44:48 Appid(DEBUG): SELECT COUNT(DISTINCT `hoge`.`id`) AS `id_count` FROM `hoge`
2010/04/07 17:44:48 Appid(DEBUG): SELECT `hoge`.`id` FROM `hoge`   LIMIT 10 OFFSET 0
2010/04/07 17:44:48 Appid(DEBUG): hoge_id_list = array (
  0 => '3',
  1 =>
  array (
    0 =>
    array (
      'id' => '1',
    ),
    1 =>
    array (
      'id' => '2',
    ),
    2 =>
    array (
      'id' => '3',
    ),
  ),
)
2010/04/07 17:44:48 Appid(DEBUG): hoge_name_list = array (
  0 => '3',
  1 =>
  array (
    0 =>
    array (
      'id' => '1',
    ),
    1 =>
    array (
      'id' => '2',
    ),
    2 =>
    array (
      'id' => '3',
    ),
  ),
)

Ethna_AppManager の実装を見てみます。

    function getObjectPropList($class, $keys = null, $filter = null,
                               $order = null, $offset = null, $count = null)
    {
        global $_ETHNA_APP_MANAGER_OPL_CACHE;

        $prop_list = array();
        $class_name = sprintf("%s_%s", $this->backend->getAppId(), $class);

        // キャッシュチェック
        $cache_class = strtolower($class_name);
        if (is_array($_ETHNA_APP_MANAGER_OPL_CACHE) == false
            || array_key_exists($cache_class, $_ETHNA_APP_MANAGER_OPL_CACHE) == false) {
            $_ETHNA_APP_MANAGER_OPL_CACHE[$cache_class] = array();
        }
        $cache_key = serialize(array($filter, $order, $offset, $count));
        if (array_key_exists($cache_key, $_ETHNA_APP_MANAGER_OPL_CACHE[$cache_class])) {
            // skip
        } else {
            // キャッシュ更新
            $tmp =& new $class_name($this->backend);
            $_ETHNA_APP_MANAGER_OPL_CACHE[$cache_class][$cache_key]
                = $tmp->searchProp($keys, $filter, $order, $offset, $count);
        }

        return $_ETHNA_APP_MANAGER_OPL_CACHE[$cache_class][$cache_key];
    }

キャッシュを返す条件が $filter, $order, $offset, $count の指定が同様な場合のようです。異なる $keys が指定された場合はキャッシュを使わないように getObjectPropList() メソッドをオーバーライドして挙動を変更してしまいます。オーバーライドしたら controller への require と Appid_HogeManager の extends 先の変更を忘れずに!

<?php
class Appid_AppManager extends Ethna_AppManager
{
    function getObjectPropList($class, $keys = null, $filter = null,
                               $order = null, $offset = null, $count = null)
    {
        global $_ETHNA_APP_MANAGER_OPL_CACHE;

        $prop_list = array();
        $class_name = sprintf("%s_%s", $this->backend->getAppId(), $class);

        // キャッシュチェック
        $cache_class = strtolower($class_name);
        if (is_array($_ETHNA_APP_MANAGER_OPL_CACHE) == false
            || array_key_exists($cache_class, $_ETHNA_APP_MANAGER_OPL_CACHE) == false) {
            $_ETHNA_APP_MANAGER_OPL_CACHE[$cache_class] = array();
        }
        $cache_key = serialize(array($keys, $filter, $order, $offset, $count));
        if (array_key_exists($cache_key, $_ETHNA_APP_MANAGER_OPL_CACHE[$cache_class])) {
            // skip
        } else {
            // キャッシュ更新
            $tmp =& new $class_name($this->backend);
            $_ETHNA_APP_MANAGER_OPL_CACHE[$cache_class][$cache_key]
                = $tmp->searchProp($keys, $filter, $order, $offset, $count);
        }

        return $_ETHNA_APP_MANAGER_OPL_CACHE[$cache_class][$cache_key];
    }
}

治ったよー。

2010/04/07 17:49:10 Appid(DEBUG): SELECT COUNT(DISTINCT `hoge`.`id`) AS `id_count` FROM `hoge`
2010/04/07 17:49:10 Appid(DEBUG): SELECT `hoge`.`id` FROM `hoge`   LIMIT 10 OFFSET 0
2010/04/07 17:49:10 Appid(DEBUG): SELECT COUNT(DISTINCT `hoge`.`id`) AS `id_count` FROM `hoge`
2010/04/07 17:49:10 Appid(DEBUG): SELECT `hoge`.`name` FROM `hoge`   LIMIT 10 OFFSET 0
2010/04/07 17:49:10 Appid(DEBUG): hoge_id_list = array (
  0 => '3',
  1 =>
  array (
    0 =>
    array (
      'id' => '1',
    ),
    1 =>
    array (
      'id' => '2',
    ),
    2 =>
    array (
      'id' => '3',
    ),
  ),
)
2010/04/07 17:49:10 Appid(DEBUG): hoge_name_list = array (
  0 => '3',
  1 =>
  array (
    0 =>
    array (
      'name' => 'Akufen - My Way',
    ),
    1 =>
    array (
      'name' => 'Todd Edwards - Full On',
    ),
    2 =>
    array (
      'name' => 'Emerson, Lake & Palmer - Brain Salad Surgery',
    ),
  ),
)

そもそも同様の条件のクエリを複数回発生するコードを書く事自体が問題な気もしますが、複数のメソッドにまたがって getObjectPropList() を使っていると結構ハマりそうな仕様かなと思いました。まる。

PHP + JavaScript で Tumblr の Twitter 互換 API を叩いて Dashboard リーダを作ってみたよ

はいこんにちは!

今回は PHP + JavaScript (jQuery) で Tumblr の Dashboard リーダを作ってみました。 R&D メンバーがクリッピングしている記事を社内のグループウェアに掲示してみようかと思っていたのですが、お手軽にダッシュボードを公開するすべが無かったので自分で Twitter 互換 API を使って実装してみました。

デモ

ディレクトリ構造はこんな感じ。

  • index.html: 本体
  • proxy.php: JSON 取得用
  • lib/: ライブラリ
    • colorbox/

JSON 取得用の PHP のコード (proxy.php) はこんな感じ。

<?php

require_once "HTTP/Request.php";

$url = "http://www.tumblr.com/statuses/home_timeline.json";
$hash = md5($url);

if (isCacheExpired($url))
{
        $req =& new HTTP_Request($url);
        $req->setBasicAuth("ユーザ ID", "パスワード"); // 書き換えてね
        $req->addQueryString("count", 200); // とりあえず取得できる上限

        $response = $req->sendRequest();

        if (PEAR::isError($response))
        {
                echo $response->getMessage();
                exit;
        }
        else
        {
                file_put_contents($hash, $req->getResponseBody());
        }
}

header('Content-type: application/json');
echo file_get_contents($hash);

function isCacheExpired($url)
{
        $cache_filename = md5($url);
        if (!file_exists($cache_filename))
        {
                return true;
        }

        $interval = 60; // キャッシュの有効期限
        $mod = filemtime($cache_filename);
        $now = time();

        if ($now > $mod + $interval)
        {
                return true; // 有効期限切れ
        }

        return false;
}

?>

JSONP を使えば proxy を書いてあげる必要は無かったのですが、大量の件数を取得するので proxy で JSON をキャッシュするようにしてみました。

JavaScript 側はデモのソースをみていただければとおもいます。ライブラリのロードに Google Ajax API を使ってみたぐらいで、あまり凝ったことはしていません。 Lightbox のライブラリに ColorBox を使用しています。

Services_JSON::encode() メソッドを使って JSON への変換を行なうとページの Content-type が強制的に application/json になってしまう件について

PEAR ライブラリの Services_JSON を使って連想配列などを JSON にエンコードする時にハマったメモ。今回使用した Services_JSON のバージョンは 1.0.2 です。

$musician = array ('Techno' => 'Derrick May', 'Trance' => 'Mat Zo');
$json = new Services_JSON();
$result = file_put_contents('musician.json', $json->encode($musician));

if ($result === false)
{
    echo 'NG';
    return;
}

echo 'OK';
return;

JSON をファイルとして保存して、成功したら OK と、失敗したら NG とブラウザに表示させたいケースがあったとします。このようなコードを実行してみるとどういうわけか content-type が application/json になってしまってブラウザによってはダウンロードダイアログが出てしまい要件が満たせません。一工夫してこんな風にしてみたけどダメ。

$musician = array ('Techno' => 'Derrick May', 'Trance' => 'Mat Zo');
$json = new Services_JSON();
header('Content-type: text/plain');
$result = file_put_contents('musician.json', $json->encode($musician));

if ($result === false)
{
    echo 'NG';
    return;
}

echo 'OK';
return;

Services_JSON の encode() メソッドの実装をみると以下のようになっていました。

   /**
    * encodes an arbitrary variable into JSON format (and sends JSON Header)
    *
    * @param    mixed   $var    any number, boolean, string, array, or object to be encoded.
    *                           see argument 1 to Services_JSON() above for array-parsing behavior.
    *                           if var is a strng, note that encode() always expects it
    *                           to be in ASCII or UTF-8 format!
    *
    * @return   mixed   JSON string representation of input var or an error if a problem occurs
    * @access   public
    */
    function encode($var)
    {
        header('Content-type: application/json');
        return $this->encodeUnsafe($var);
    }

encode() メソッドは content-type を出力して encodeUnsafe() メソッドを実行している代物でした。と言うわけで正解は以下のコード。

$musician = array ('Techno' => 'Derrick May', 'Trance' => 'Mat Zo');
$json = new Services_JSON();
header('Content-type: text/plain');
$result = file_put_contents('musician.json', $json->encodeUnsafe($musician));

if ($result === false)
{
    echo 'NG';
    return;
}

echo 'OK';
return;

encodeUnsafe() メソッドを直接呼び出してあげれば良いのですね。正解者に拍手!

Ethna で MySQL の予約語とかぶっているテーブル名を AppObject で扱う場合エラーするのを治すたった一つだけの方法

はじめまして。エンジニアの今野です。社内の圧力に屈したため Blog を書くことになりました。よろしくお願いします。

Ethna 2.5.0 を案件で使用していて release など MySQL の予約語になっているテーブルを AppObject で扱う場合不正なクエリが発行され動かないケースがありました。以下は debug log からの抜粋。

2010/03/09 16:46:17 Appid(DEBUG): SELECT COUNT(DISTINCT `release`.``) AS `id_count` FROM `release`
2010/03/09 16:46:17 Appid(ERR): Query Error SQL[SELECT COUNT(DISTINCT `release`.``) AS `id_count` FROM `release` ] CODE[1054] MESSAGE[SELECT COUNT(DISTINCT `release`.``) AS `id_count` FROM `release`  [nativecode=1054 ** Unknown column 'release.' in 'field list']] [ERROR CODE(4)]

MySQL の query log はこんな感じ。 release が予約語なので不正なクエリ。

13 Query       SELECT * FROM release LIMIT 0

Ethna の AppObject は prop_def を書かなくてもテーブル構造から勝手に prop_def を作ってくれる機能があるのですが、そこでテーブル名をエスケープしていない為に不正なクエリが実行されてしまい $prop_def の定義がおかしくなってしまうようですね。

$prop_def を手書きしてしまうか、テーブル名を変えればしのげるのですが、それでは若干悔しいので Ethna_DB_PEAR クラスを継承してサクッと治してしまいます。Ethna が include 出来る場所に Appid_DB_PEAR.php と言うファイル名で以下の内容で保存しましょう。 (Appid はご利用のプロジェクト名に置き換えてね!)

<?php
require_once 'Ethna/class/DB/Ethna_DB_PEAR.php';

class Appid_DB_PEAR extends Ethna_DB_PEAR
{
    function getMetaData($table)
    {
        return parent::getMetaData($this->db->quoteIdentifier($table));
    }
}

Controller クラスに教え込みます。

var $class = array(
    /*
    *  TODO: When you override Configuration class, Logger class,
    *        SQL class, don't forget to change definition as follows!
    */
    'class'         => 'Ethna_ClassFactory',
    'backend'       => 'Ethna_Backend',
    'config'        => 'Ethna_Config',
    'db'            => 'Appid_DB_PEAR',    // ← ここを変更
    'error'         => 'Ethna_ActionError',
    'form'          => 'Appid_ActionForm',
    'i18n'          => 'Ethna_I18N',
    'logger'        => 'Ethna_Logger',
    'plugin'        => 'Ethna_Plugin',
    'session'       => 'Ethna_Session',
    'sql'           => 'Ethna_AppSQL',
    'view'          => 'Appid_ViewClass',
    'renderer'      => 'Ethna_Renderer_Smarty',
    'url_handler'   => 'Appid_UrlHandler',
);

これで治りました。 Ethna_DB_PEAR::getMetaData() 自体がエラーするわけでは無いので最初ハマりましたが、同じような現象で困っている方の参考になればと思います。 (と言うか予約語とかぶるようなテーブルを作るなって話ですよね……。)

しばらくは Ethna ネタで記事の投稿を行っていく予定です。では、お粗末さまでした!

preload preload preload