Zend_Db_Tableを使った複数DB接続

Zend_Db_Tableを使ったモデルクラス周りは、Zend Frameworkクイックスタート モデルとデータベーステーブルの作成に書かれていたようにTable Data Gatewayパターンを使って書くとして、そういう場合にレプリケーション構成の複数DBを使い分ける(selectを複数のスレーブに分散させる)のはどうやって書けばいいのか考えてみた。

最近はミドルウェアレイヤーでその辺に対応するための情報も増えてきているんで、本格的に対応するならばそういうのを使った方がいいような気もするけど、PHPロジックレイヤーで対応する方法もあってもいいだろう。

実際にコードを動作させてみたわけじゃないんだけど、たぶんこんな感じで動くんじゃないかと思われる擬似コードを書いてみる。

application.ini(の一部)

# マスターDB設定
db.adapter = PDO_MySQL
db.params.host = ****
db.params.username = ****
db.params.password = ****
db.params.dbname = ****
# スレーブDB設定1
slaveDbs.slave1.adapter = PDO_MySQL
slaveDbs.slave1.params.host = ****
slaveDbs.slave1.params.username = ****
slaveDbs.slave1.params.password = ****
slaveDbs.slave1.params.dbname = ****
# スレーブDB設定2
slaveDbs.slave2.adapter = PDO_MySQL
slaveDbs.slave2.params.host = ****
slaveDbs.slave2.params.username = ****
slaveDbs.slave2.params.password = ****
slaveDbs.slave2.params.dbname = ****

Bootstrapクラス(の一部)

class Bootstrap extends Zend_Applicaiton_Bootstrap_Bootstrap
{
    protected function _initDb()
    {
        $options = new Zend_Config($this->getOptions());
        $masterConfig = $options->db;
        $masterDb = Zend_Db::factory($masterConfig->adapter, $masterConfig);
        Zend_Registry::set('db', $masterDb);

        $slaveDbs = array();
        $slaveConfigs = $options->slaveDbs;
        foreach ($slaveConfigs as $configName => slaveConfig) {
            $slaveDbs[$configName] = Zend_Db::factory($slaveConfig->adapter, $slaveConfig);
        }

        Zend_Db_Table::setDefaultAdapter($masterDb);
        Zend_Registry::set('slaveDbConnections', $slaveDbs);

        return $masterDb;
    }
}

Zend_Db_Tableを使ったDBアクセスクラス

class My_DbTable_Foo extends Zend_Db_Table
{
    protected $_name = 'foo';
}

マッパークラス

class My_Mapper_Foo
{
    protected $_masterDbTable;
    protected $_slaveDbTable;

    // バックエンドにマスターDBを使ったend_Db_Tableオブジェクト
    public function getMasterDbTable()
    {
        if (null == $this->_masterDbTable) {
            $this->_masterDbTable = new My_DbTabel_Foo();
        }
        return $this->_masterDbTable;
    }

    // バックエンドにスレーブDBを使ったZend_Db_Tableオブジェクト
    public function getSlaveDbTable()
    {
        if (null == $this->_slaveDbTable) {
            // スレーブDBの中からランダムに一つのコネクションを選択する
            $slaveDbs = Zend_Registry::get('slaveDbConnections');
            $slaveDb = array_rand($slaveDbs);
            $this->_slaveDbTable = new My_DbTable_Foo(array('db' => $slaveDb));
        }
        return $this->_slaveDbTable;
    }

    public function find($fooId, $foo)
    {
        // プライマリーキーからのデータ取得はマスターDBから
        $row = $this->getMasterDbTable()->find($fooId)->current();
        // 中略
        return $foo;
    }

    public function save($foo)
    {
        $fooId = $foo->getFooId();
        $data = array(
            // $dataに$fooの各値をセット
        );
        if (is_null($fooId)) {
            // 更新処理はマスターDBに
            $this->getMasterDbTable()->insert($data);
        } else {
            // 更新処理はマスターDBに
            $this->getMasterDbTable()->update($data, array('foo_id = ?', $fooId);
        }
    }

    public function searchBySomeConditions($someConditions)
    {
        // 複雑な検索処理などはスレーブDBを使う
        $rowset = $this->getSlaveDbTale()->fetchAll($someConditions);
        // $rowsetを汎用形式$resultに変換
        return $result;
    }
}

モデルクラス

class My_Foo
{
    protected $_mapper;

    public funstion getMapper()
    {
       if (null == $this->_mapper) {
           $this->_mapper = new My_Mapper_Foo();
       }
       return $this->_mapper;
    }

    public function save()
    {
        return $this->getMapper()->save($this);
    }

    public function find($fooId)
    {r
        return $this->getMapper()->find($fooId, $this);
    }

    public function searchBySomeConditions($someConditions)
    {
        return $this->getMapper()->searchBySomeConditions($someConditions);
    }
}

標準のリソースと組み合わせて使うならもうちょい練った方がよさそうだ。とか。スレーブを選択するロジックをもうちょい融通を利かせられるようにした方がいいかも。とか、考え始めるといろいろ気になりはじめるんだけど、雰囲気としてはこんな感じの書き方で、Zend_Db_Table自体に手を加えずに、クエリーごとに接続するバックエンドDBを切り替えることができそうだよね。

テストの書き方

zfコマンドを使ってアプリケーションのスケルトンを生成すると、testsディレクトリが生成され、


tests
|-- application
| `-- bootstrap.php
|-- library
| `-- bootstrap.php
`-- phpunit.xml

みたいな配置になっているんだけど、そこにどんな感じで記述すればいいのかよくわからない。phpunit.xmlが1個しかないのに、applicationとlibraryにそれぞれbootstrap.phpがあるって、どういう感じで使うイメージなんだろう?

公式ドキュメントのテスト周りの部分とか、具体的な手順を説明している「Set up a Zend Framework application using Zend_Application (including PHPUnit setup) - mafflog」とかを参考に、コントローラのテストを追加してみた。applicationとlibraryの分かれている部分をどうやって活かすイメージなのかよくわからなかったんで、試行錯誤しつつ。ひとまずはこんな感じになった。

基本的には、「Set up a Zend Framework application using Zend_Application (including PHPUnit setup) - mafflog」で紹介されているやり方をほぼそのまま使いつつ、PHPUnitのブートストラップファイルは、tests/bootstrap.phpとして記述。その中から、tests/application/bootstrap.phpとtests/library/bootstrap.phpをそれぞれ読み込んでいる。つまり、共通のブートストラップはtests/bootstrap.phpに、application、libraryに特化したブートストラップは各ディレクトリ以下に書くイメージ。

主だった部分をコピペしておくと、

tests/phpunit.xml

<phpunit bootstrap="bootstrap.php" colors="true">
  <testsuite name="NetJockey Application">
    <directory>.</directory>
  </testsuite>
</phpunit>

tests/bootstrap.php

<?php 
define('BASE_PATH', realpath(dirname(__FILE__) . '/../'));
define('APPLICATION_PATH', BASE_PATH . '/application');

// Include path
set_include_path(
    '.'
    . PATH_SEPARATOR . BASE_PATH . '/library'
    . PATH_SEPARATOR . get_include_path()
);

// Define application environment
define('APPLICATION_ENV', 'testing');

require_once 'application/bootstrap.php';
require_once 'library/bootstrap.php';

tests/application/bootstrap.php

<?php 
require_once dirname(__FILE__) . '/controllers/ControllerTestCase.php';

tests/application/controllers/ControllerTestCase.php

<?php
require_once 'Zend/Application.php';
require_once 'Zend/Test/PHPUnit/ControllerTestCase.php';

abstract class ControllerTestCase extends Zend_Test_PHPUnit_ControllerTestCase
{
    public $application;

    public function setUp()
    {
        $this->application = new Zend_Application(
            APPLICATION_ENV,
            APPLICATION_PATH . '/configs/application.ini'
        );

        $this->bootstrap = array($this, 'appBootstrap');
        parent::setUp();
    }

    public function appBootstrap()
    {
        $this->application->bootstrap();
    }
}

tests/application/controllers/IndexControllerTest.php

<?php
class IndexControllerTest extends ControllerTestCase
{
    public function testIndexAction()
    {
        $this->dispatch('/');
        $this->assertRoute('top');
        $this->assertController('index');
        $this->assertAction('index');
        
        $this->assertResponseCode('200');
        $this->assertQueryContentContains('title', 'NetJockey');
    }

    public function testErrorURL()
    {
        $this->dispatch('/not/exists/path/');
        $this->assertRoute('default');
        $this->assertController('error');
        $this->assertAction('error');
        
        $this->assertResponseCode('404');
    }
}

コントローラ用のテストケースのベースクラスを作ったりする部分は、「Set up a Zend Framework application using Zend_Application (including PHPUnit setup) - mafflog」の内容をそのまま借用。Zend Frameworkには専用のテストクラスとかも追加されていたんで。MVC周りのテストがナチュラルに書けて結構便利そう。コントローラ(MVC)周り以外のテストに関しては、ふつうにPHPUnitらしいテストを書いていけばいいんだろう。

[開発] 基本的なブートストラップリソースの組み込みとレイアウトの適用

別件でZend Framework 1.8をいろいろいじっているうちに、こっちをどういう順番でいじっていくか迷いが深くなってしまい、悪い方向に煮詰まってきてしまったので、頭を使わなくても済むレベルのところから手をつけることにした。

作業内容としては、更新履歴r3r15のあたり。

  • 今後のアップデートなどのことを考えて、application.iniをapplication.ini.orgに変更。動かしたい場合は、必要に応じてapplication.iniにリネームしつつ、内容を環境に合わせて修正を。
  • ZFが自動生成するerror.phtmlでショートタグを使っているので、それを除去しつつ日本語化。あとdoctypeとかをテンプレートに直書きしていたのを、ビューヘルパーを使うように修正。
  • ブートストラップ内の初期化コードとして、
    • Viewの初期化処理
    • クラスのオートローダーの初期化処理(NetJockey_というネームスペースのクラスもオートローダーの対象として追加)
    • アプリケーションローカルのモジュール(application/modelsとか)のオートローダー初期化処理。ローカルモジュールのネームスペースはMyにして、Zend_Db_TableによるORMはMy_DbTableに、マッパーはMy_Mapperに。
    • 設定ファイルをあちこちで使い回しやすくするために、配列からZend_Config形式に変換しつつ、$bootstrap->getResource('config')とかZend_Registry::get('config')とかで取得できるように初期化。
    • オートローダーの組み込みキャッシュ(要は読み込んだファイルをまとめてrequire_onceするphpファイルの生成)やZend_Db_Tableのスキーマキャッシュを行うためのキャッシュ関連初期化処理。キャッシュファイルの置き場も用意。キャッシュファイルの場所は設定ファイルに記述。
  • 標準のDBリソースを使ったDB設定の追加。
  • ビュー内でecho $this->escape()するのはいやなので、ちょっとでも短くするために$this->h()というビューヘルパーを追加。
  • Yahoo UI LibraryのGridsを使ったレイアウトファイルを用意し、Zend_Layoutを使ってレイアウト処理を行うように設定&ファイルを用意。ZFのサンプルとかだとレイアウトファイル置き場はapplication/layouts/scriptsとかになっているけど、気に入らないんでapplication/views/layoutsにしてみた。標準的なお作法と違うので注意。

実際の動作イメージは以下な感じ。

DIV構造は以下な感じ。横幅ブラウザ依存でサイドバーが240pxのやつ。

今のところ、ヘッダ、サイドバー、フッターはそれぞれviews/scripts/partial/の中にパーツテンプレートして放り込んであるけれども、その内容にロジックが発生する場合はこういうやり方ではなく、ロジックをアクションスタックに詰んで、それぞれのレイアウト要素をプレースホルダーごとにレンダリングして……、ってやり方にする必要が出てくると思われる。が、本当にそれでいけるのか、いろいろ試してみないとわからない。

モデル周りの作り方もひとまずはドキュメント通りにやってみようと思っているんだけど、最初のうちはかなり実験的に試行錯誤してみるしかないかなー。単に動くものを作るだけだったら何とでもなるんだけど、ZFらしい標準的なやり方ってのはライブラリのソースとそういう実装に至る思想を一通りかみ砕きつつ、さらに実践で2、3回痛い目にあって学習しないと身につかなそうだ。ここ最近(1年くらい)のZF関連のドキュメントも全然見てなかったからなー。追いつくのがきつい。

[開発] まずはプロジェクトスケルトンから

Zend Framework 1.8を使ったアプリケーションの標準的な作り方がだいぶわかってきたんで、早速zfコマンドを使ってプロジェクトスケルトンを生成させてみた。んでもって、publicディレクトリをバーチャルホストとして、dev.netjockey.jpという名前*1でマウントして、こんな感じのデフォルト画面が出るところまで。

さて、ここからどういう風に開発を進めていくべきか。だいたいの仕様ができているモデル周りは比較的手を付けやすいけれども、その辺は時間がかかる割には動かせるものが何もなくてつまらない*2。実際にいくつかの簡単なページから作ってみるかと思っても、まだデザインをどうするか決めていないんで手を出しにくい。

ひとまずは、最終的に作るアプリケーションの一部として使えるコードを書くんじゃなくて、Zend Framework 1.8に準拠したアプリケーションの練習的なコードをいくつか書いてみようかな。フォーム、モデル、フィルター、バリデータのコンビネーションあたりを、Zend_Applicationベースで書くとどういう感じになるのか早めに実践しておきたいし。

ユーザー登録とかログインあたりが、それっぽいコンポーネントを使えていいかな?

*1:hostsファイルに書いただけでlocalhost上にある

*2:モデルを作っただけで動かせるものと言ったらユニットテストくらいか

Zend Frameworkクイックスタート モデルとデータベーステーブルの作成

モデル周りの標準的な扱いに関しての説明は、この文書がもっとも詳しい資料になりそうなんで、これも翻訳しておく。

Zend Frameworkクイックスタート モデルとデータベーステーブルの作成の原文はこちら

モデルとデータベーステーブルの作成

作業を始める前に、これから作成されるクラスはどこに配置され、どうやってその場所を見つけられるようにするのかについて、考えておこう。デフォルトのプロジェクトではオートローダーが生成される。そこに通常とは*1異なるクラスに対応するための別のオートローダーを追加することができる。普通は、application/のようなディレクトリツリーの中に、共通のプレフィックス持つようなさまざまなMVCクラスをまとめておきたい。

Zend_Controller_Frontは、独立したミニアプリケーションである“モジュール”という概念を持っている。モジュールは、zfコマンドでapplication/ディレクトリ以下に生成されるディレクトリ構造を真似ており、その中のクラスはすべてモジュール名を使った共通のプレフィックスを持つ。application/自体も"default"モジュールである。その考え方に基づいて、このディレクトリ内のリソースには、"Default"というプレフィックスがついているものとして、オートロードできるように設定しよう。そのために、もう一つブートストラップリソースを作成する。

Zend_Application_Module_Autoloaderは、モジュール内のさまざまなリソースを適切なディレクトリにマッピングする機能や、標準的な名前解決メカニズムを提供する。ブートストラップでZend_Application_Module_Autoloaderリソースを生成することによって、その機能を有効にする。具体的には以下のように書く。


// application/Bootstrap.php

// Bootstrapクラスにこのメソッドを追加する

protected function _initAutoload()
{
$autoloader = new Zend_Application_Module_Autoloader(array(
'namespace' => 'Default_',
'basePath' => dirname(__FILE__),
));
return $autoloader;
}

最終的には、ブートストラップクラスは以下のようになる。


class Bootstrap extends Zend_Application_Bootstrap_Bootstrap
{
protected function _initAutoload()
{
$autoloader = new Zend_Application_Module_Autoloader(array(
'namespace' => 'Default',
'basePath' => dirname(__FILE__),
));
return $autoloader;
}

protected function _initDoctype()
{
$this->bootstrap('view');
$view = $this->getResource('view');
$view->doctype('XHTML1_STRICT');
}
}

さて、ゲストブックとしてどういうものを作るのか考えてみよう。一般的には、「コメント」「投稿日時」、多くの場合は「メールアドレス」などの情報を持つ投稿のリストというのが、シンプルな仕様だろう。その情報をデータベースに保存することを考えると、各投稿には一意のIDも必要になる。投稿を保存したり、投稿を一つ一つ取得したり、あるいはすべての投稿をまとめて取得したりといった機能も欲しい。そのようなシンプルなゲストブックモデルのAPIは、以下のようになるだろう。


// application/models/Guestbook.php

class Default_Model_Guestbook
{
protected $_comment;
protected $_created;
protected $_email;
protected $_id;

public function __set($name, $value);
public function __get($name);

public function setComment($text);
public function getComment();

public function setEmail($email);
public function getEmail();

public function setCreated($ts);
public function getCreated();

public function setId($id);
public function getId();

public function save();
public function find($id);
public function fetchAll();
}

__get()や__set()メソッドは、投稿オブジェクトの各プロパティにアクセスする便利な手段であり、ゲッターやセッターメソッドのプロキシーとなる。これにより、ホワイトリストで指定されたプロパティのみにアクセスを制限することも可能になる。

find()やfetchAll()メソッドは、単一もしくはすべての投稿データを取得する手段となる。

続いて、データベースのセットアップについて考えていこう。

まず、Dbリソースを初期化する必要がある。LayoutやViewリソース同様に、Dbリソース用の設定を行う。application/configs/application.iniファイルの適切な場所に、以下のような行を追加する。


; application/configs/application.ini

; Add these lines to the appropriate sections:
[production]
resources.db.adapter = "PDO_SQLITE"
resources.db.params.dbname = APPLICATION_PATH "/../data/db/guestbook.db"

[testing : production]
resources.db.params.dbname = APPLICATION_PATH "/../data/db/guestbook-testing.db"

[development : production]
resources.db.params.dbname = APPLICATION_PATH "/../data/db/guestbook-dev.db"

最終的な設定ファイルは以下のようになる。


; application/configs/application.ini

[production]
phpSettings.display_startup_errors = 0
phpSettings.display_errors = 0
bootstrap.path = APPLICATION_PATH "/Bootstrap.php"
bootstrap.class = "Bootstrap"
resources.frontController.controllerDirectory = APPLICATION_PATH "/controllers"
resources.layout.layoutPath = APPLICATION_PATH "/layouts/scripts"
resources.view[] =
resources.db.adapter = "PDO_SQLITE"
resources.db.params.dbname = APPLICATION_PATH "/../data/db/guestbook.db"

[staging : production]

[testing : production]
phpSettings.display_startup_errors = 1
phpSettings.display_errors = 1
resources.db.params.dbname = APPLICATION_PATH "/../data/db/guestbook-testing.db"

[development : production]
phpSettings.display_startup_errors = 1
phpSettings.display_errors = 1
resources.db.params.dbname = APPLICATION_PATH "/../data/db/guestbook-dev.db"

データベースはdata/db/ディレクトリ以下に保存される。ディレクトリを生成し、誰でも書き込み可能なように設定しておこう。UNIXライクなシステムでは以下のようにする。


% mkdir -p data/db; chmod -R a+rwX data

Windowsでは、エクスプローラディレクトリを作成し、ディレクトリに誰でも書き込めるような権限をセットする。

これでデータベース接続の準備ができた。今回は、application/data/ディレクトリ内に置かれたSQLiteデータベースを利用する。それでは、ゲストブックへの投稿を管理するシンプルなテーブルを設計していこう。

-- scripts/schema.sqlite.sql
--
-- このSQLを使ってデータベーススキーマをロードする

CREATE TABLE guestbook (
    id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
    email VARCHAR(32) NOT NULL DEFAULT 'noemail@test.com',
    comment TEXT NULL,
    created DATETIME NOT NULL
);

CREATE INDEX "id" ON "guestbook" ("id");

また、すぐに使えるようなデータがあったほうがアプリケーションが面白くなるので、いくつかの情報を追加しておこう。

-- scripts/data.sqlite.sql
--
-- 以下のSQL文でデータベースに値を投入する

INSERT INTO guestbook (email, comment, created) VALUES 
    ('ralph.schindler@zend.com', 
    'Hello! Hope you enjoy this sample zf application!', 
    DATETIME('NOW'));
INSERT INTO guestbook (email, comment, created) VALUES 
    ('foo@bar.com', 
    'Baz baz baz, baz baz Baz baz baz - baz baz baz.', 
    DATETIME('NOW'));

これで、スキーマとサンプルデータの両方がそろった。データベースを構築するためのスクリプトを用意してみよう。普通こういうものは製品には不要なのだが、こういうスクリプトがあると、開発者がデータベース環境をローカルに構築して、そこでアプリケーションを動かすのが楽になる。以下のようなscripts/load.sqlite.phpスクリプトを作成する。


getBootstrap();
$bootstrap->bootstrap('db');
$dbAdapter = $bootstrap->getResource('db');

// ユーザーに何を実行しているのか知らせる(ここで実際にデータベースを
// 作成する)
if ('testing' != APPLICATION_ENV) {
echo 'Writing Database Guestbook in (control-c to cancel): ' . PHP_EOL;
for ($x = 5; $x > 0; $x--) {
echo $x . "\r"; sleep(1);
}
}

// データベースファイルがすでに存在するか確認する
$options = $bootstrap->getOption('resources');
$dbFile = $options['db']['params']['dbname'];
if (file_exists($dbFile)) {
unlink($dbFile);
}

// このブロックでスキーマファイルから読み込んだ実際のSQL文を
// 実行する
try {
$schemaSql = file_get_contents(dirname(__FILE__) . '/schema.sqlite.sql');
// 読み込んだSQLを直接DBコネクションに渡す
$dbAdapter->getConnection()->exec($schemaSql);

if ('testing' != APPLICATION_ENV) {
echo PHP_EOL;
echo 'Database Created';
echo PHP_EOL;
}

if ($withData) {
$dataSql = file_get_contents(dirname(__FILE__) . '/data.sqlite.sql');
// 読み込んだSQLを直接DBコネクションに渡す
$dbAdapter->getConnection()->exec($dataSql);
if ('testing' != APPLICATION_ENV) {
echo 'Data Loaded.';
echo PHP_EOL;
}
}

} catch (Exception $e) {
echo 'AN ERROR HAS OCCURED:' . PHP_EOL;
echo $e->getMessage() . PHP_EOL;
return false;
}

// 通常このスクリプトコマンドラインから実行される
return true;

さてこのスクリプトを実行しよう。ターミナルやDOSコマンドラインから以下のように実行する。


% php scripts/load.sqlite.php

以下のような出力結果となるだろう。


path/to/ZendFrameworkQuickstart/scripts$ php load.sqlite.php --withdata
Writing Database Guestbook in (control-c to cancel):
1
Database Created

これでゲストブックアプリケーションのための、完全に動作するデータベースとテーブルの準備ができた。後は、アプリケーションのコードを書いていくだけだ。具体的には、データソース(今回はZend_Db_Tableを利用する)、データソースとドメインモデルを接続するデータマッパーなどの構築だ。最終的には、既存投稿の表示や新規投稿処理などをモデルを介して行うコントローラも作成する。

データソースに接続するためにTable Data Gatewayパターンを使う。Zend_Db_Tableがこの機能を担当する。まずはZend_Db_Tableを継承したテーブルクラスを作成しよう。application/models/DbTableディレクトリを作成し、以下のような内容を持つGuestbook.phpファイルを作成する。


クラスのプレフィックスはDefault_Model_DbTableだ。オートローダーから与えられるプレフィックス"Default"が最初のセグメントとなり、その後ろにコンポーネントプレフィックス"Model_DbTable"が続く。後者はmodels/DbTableディレクトリにマッピングされる。

Zend_Db_Tableクラスを継承する場合、テーブル名と、オプションとしてプライマリーキー名(もしも"id"でない場合)が必要となる。((Zend_Db_Tableはテーブル定義からプライマリキー名を推測するから、"id"じゃない場合もコードで指定する必要はないんじゃ? ここでの「オプションとして」はそういう意味まで含んでいるんじゃないよね))

続いてData Mapperを作る。Data Mapperはドメインオブジェクトをデータベースにマッピングする。今回の場合、Default_Model_GuestbookモデルをDefault_Model_DbTable_Guestbookデータソースにマッピングする。典型的なデータマッパーのAPIは以下のようになる。


// application/models/GuestbookMapper.php

class Default_Model_GuestbookMapper
{
public function save($model);
public function find($id, $model);
public function fetchAll();
}

これらのメソッドに加え、Table Data Gatewayを設定・取得するためのメソッドも追加する。最終的にapplication/models/GuestbookMapper.phpには以下のようなクラスが置かれる。

// application/models/GuestbookMapper.php

class Default_Model_GuestbookMapper
{
    protected $_dbTable;

    public function setDbTable($dbTable)
    {
        if (is_string($dbTable)) {
            $dbTable = new $dbTable();
        }
        if (!$dbTable instanceof Zend_Db_Table_Abstract) {
            throw new Exception('Invalid table data gateway provided');
        }
        $this->_dbTable = $dbTable;
        return $this;
    }

    public function getDbTable()
    {
        if (null === $this->_dbTable) {
            $this->setDbTable('Default_Model_DbTable_Guestbook');
        }
        return $this->_dbTable;
    }

    public function save(Default_Model_Guestbook $guestbook)
    {
        $data = array(
            'email'   => $guestbook->getEmail(),
            'comment' => $guestbook->getComment(),
            'created' => date('Y-m-d H:i:s'),
        );

        if (null === ($id = $guestbook->getId())) {
            unset($data['id']);
            $this->getDbTable()->insert($data);
        } else {
            $this->getDbTable()->update($data, array('id = ?' => $id));
        }
    }

    public function find($id, Default_Model_Guestbook $guestbook)
    {
        $result = $this->getDbTable()->find($id);
        if (0 == count($result)) {
            return;
        }
        $row = $result->current();
        $guestbook->setId($row->id)
                  ->setEmail($row->email)
                  ->setComment($row->comment)
                  ->setCreated($row->created);
    }

    public function fetchAll()
    {
        $resultSet = $this->getDbTable()->fetchAll();
        $entries   = array();
        foreach ($resultSet as $row) {
            $entry = new Default_Model_Guestbook();
            $entry->setId($row->id)
                  ->setEmail($row->email)
                  ->setComment($row->comment)
                  ->setCreated($row->created)
                  ->setMapper($this);
            $entries[] = $entry;
        }
        return $entries;
    }
}

データマッパーが用意できたので、モデルクラスをデータマッパーに対応させよう。データマッパーがデータソースを参照し、モデルクラスがデータマッパーを参照するようにすればいい。さらには、コンストラクタやsetOptions()メソッドに配列形式でデータを渡すことによって、モデルに簡単に値をセットできるようにしておく。最終的なapplication/models/Guestbook.phpに置かれるモデルクラスは以下のようになる。

// application/models/Guestbook.php

class Default_Model_Guestbook
{
    protected $_comment;
    protected $_created;
    protected $_email;
    protected $_id;
    protected $_mapper;

    public function __construct(array $options = null)
    {
        if (is_array($options)) {
            $this->setOptions($options);
        }
    }

    public function __set($name, $value)
    {
        $method = 'set' . $name;
        if (('mapper' == $name) || !method_exists($this, $method)) {
            throw new Exception('Invalid guestbook property');
        }
        $this->$method($value);
    }

    public function __get($name)
    {
        $method = 'get' . $name;
        if (('mapper' == $name) || !method_exists($this, $method)) {
            throw new Exception('Invalid guestbook property');
        }
        return $this->$method();
    }

    public function setOptions(array $options)
    {
        $methods = get_class_methods($this);
        foreach ($options as $key => $value) {
            $method = 'set' . ucfirst($key);
            if (in_array($method, $methods)) {
                $this->$method($value);
            }
        }
        return $this;
    }

    public function setComment($text)
    {
        $this->_comment = (string) $text;
        return $this;
    }

    public function getComment()
    {
        return $this->_comment;
    }

    public function setEmail($email)
    {
        $this->_email = (string) $email;
        return $this;
    }

    public function getEmail()
    {
        return $this->_email;
    }

    public function setCreated($ts)
    {
        $this->_created = $ts;
        return $this;
    }

    public function getCreated()
    {
        return $this->_created;
    }

    public function setId($id)
    {
        $this->_id = (int) $id;
        return $this;
    }

    public function getId()
    {
        return $this->_id;
    }

    public function setMapper($mapper)
    {
        $this->_mapper = $mapper;
        return $this;
    }

    public function getMapper()
    {
        if (null === $this->_mapper) {
            $this->setMapper(new Default_Model_GuestbookMapper());
        }
        return $this->_mapper;
    }

    public function save()
    {
        $this->getMapper()->save($this);
    }

    public function find($id)
    {
        $this->getMapper()->find($id, $this);
        return $this;
    }

    public function fetchAll()
    {
        return $this->getMapper()->fetchAll();
    }
}

最後に、今まで作ってきたクラス群を使って、データベースに保存されている投稿をリスト化するゲストブックコントローラーを作成しよう。*2

新しいコントローラを作成するには、ターミナルもしくはDOSコンソールでプロジェクトディレクトリに移動して、以下のようなコマンドを実行する。


# Unix-like systems:
% zf.sh create controller guestbook

# DOS/Windows:
C:> zf.bat create controller guestbook

このコマンドは、application/controllers/GuestbookController.phpに、indexAction()というアクションをもつGuestbookControllerコントローラーを作成する。また、application/views/scripts/guestbook/というビュースクリプト用のディレクトリとindexアクション用のビュースクリプトも作成される。

"index"アクションをゲストブックのすべての投稿を表示するページ用のアクションとしよう。

それでは、基本的なアプリケーションロジックを実装していこう。indexActionが呼ばれると、すべてのゲストブックへの投稿を表示する。そのコードは以下のようになる。


// application/controllers/GuestbookController.php

class GuestbookController extends Zend_Controller_Action
{
public function indexAction()
{
$guestbook = new Default_Model_Guestbook();
$this->view->entries = $guestbook->fetchAll();
}
}

もちろん対応するビュースクリプトも必要だ。application/views/scripts/guestbook/index.phtmlを以下のように編集しよう。

<!-- application/views/scripts/guestbook/index.phtml -->

<p><a href="<?php echo $this->url(
    array(
        'controller' => 'guestbook',
        'action'     => 'sign'
    ), 
    'default', 
    true) ?>">Sign Our Guestbook</a></p>

Guestbook Entries: <br />
<dl>
    <?php foreach ($this->entries as $entry): ?>
    <dt><?php echo $this->escape($entry->email) ?></dt>
    <dd><?php echo $this->escape($entry->comment) ?></dd>
    <?php endforeach ?>
</dl>

*1:ディレクトリ配置や命名規則

*2:意味がよくわからないな。bothがどこにかかるのかもよくわかってないし

Zend Frameworkリソースオートローダー

標準的なモデルの扱い方を調べていたら、Zend_Application_Module_Autoloaderなんてものを発見してしまった。Zend_Application配下ではなくZend_Loader配下のドキュメントとして書かれていたせいで見逃していた。というわけで、続いてはそこを読みながら翻訳していく。

Zend Frameworkリソースオートローダーの原文はこちら

30.3. リソースオートローダー

リソースオートローダーは、Zend Framework標準コーディング規約に基づきつつも、クラス名とディレクトリ構造が1対1で対応していないようなネームスペースを使ったライブラリコードを扱うためのものだ。その目的は、ある特定のアプリケーション向けに作られたモデル・フォーム・ACL(アクセス制御リスト)クラスのようなコードを、オートロードできるようにすることだ。

リソースオートローダーは、対象となるネームスペース設定とともに生成され、Zend_Loader_Autoloaderに登録される。これによって、特定のディレクトリとネームスペースを結びつけることが簡単にでき、それをオートロードすることもできる。

30.3.1. リソースオートローダーの使い方

以下のようなディレクトリ構造を想定して欲しい。


path/to/some/directory/
acls/
Site.php
forms/
Login.php
models/
User.php

このディレクトリのコードには、すべて"My_"というネームスペースがつけられている。その中の"acls"ディレクトリに置かれるコンポーネントは"Acl_"というプレフィックスがつけられ、最終的には"My_Acl_Site"というクラス名になる。同様に"forms"ディレクトリは"Form_"に対応し、"My_Form_Login"クラスとなる。"models"ディレクトリではコンポーネント用のネームスペースは使われず、"My_User"クラスとなる。

このようなクラスをオートロードするのに、リソースオートローダーが使える。リソースオートローダーを生成する際に、最低限リソースのベースとなるパスおよびネームスペースの情報を渡す必要がある。つまり、以下のようになる。


$resourceLoader = new Zend_Loader_Autoloader_Resource(array(
'basePath' => 'path/to/some/directory',
'namespace' => 'My',
));

ベースネームスペースについて

Zend_Loader_Autoloaderでは、ネームスペースの後ろにアンダースコアがつけられていることが期待されている。Zend_Loader_Autoloader_Resourceは、ネームスペース、コンポーネント、クラス名の間がアンダースコアでつなげられるようなコードがオートロードされることを前提としている。だから、リソースオートローダーに登録する際には(ベースネームスペースに)アンダースコアは必要はない。

ベースとなるリソースオートローダーの準備はできたので、続いてはaddResourceType()メソッドを使って、いくつかオートロードするコンポーネントを追加してみよう。このメソッドは3つのパラメータを持つ。内部での参照名となる"リソース種別(type)"。ベースパス配下で実際にリソースが置かれる"サブディレクトリパス"。ベースネームスペースの後ろに追加される"コンポーネントネームスペース"。それでは独自のリソース種別を追加してみよう。


$resourceLoader->addResourceType('acl', 'acls/', 'Acl')
->addResourceType('form', 'forms/', 'Form')
->addResourceType('model', 'models/');

このように書く代わりに、addResourceTypes()を使って配列を渡すこともできる。上記に相当するコードは以下になる。


$resourceLoader->addResourceTypes(array(
'acl' => array(
'path' => 'acls/',
'namespace' => 'Acl',
),
'form' => array(
'path' => 'forms/',
'namespace' => 'Form',
),
'model' => array(
'path' => 'models/',
),
));

最終的に、(Zend_Loader_Autoloader_Resource)オブジェクトを生成する際のオプションで"resourceTypes"キーとして指定することによって、これらすべての設定を指定してしまうこともできる。具体的には以下のような(オプション配列の)構造となる。


$resourceLoader = new Zend_Loader_Autoloader_Resource(array(
'basePath' => 'path/to/some/directory',
'namespace' => 'My',
'resourceTypes' => array(
'acl' => array(
'path' => 'acls/',
'namespace' => 'Acl',
),
'form' => array(
'path' => 'forms/',
'namespace' => 'Form',
),
'model' => array(
'path' => 'models/',
),
),
));

30.3.2. モジュールリソースオートローダー

Zend Frameworkには、そのMVCアプリケーションで推奨するディレクトリ構造があり、それに対応するマッピング設定を持つZend_Loader_Autoloader_Resourceクラスの具体的な実装が同梱されている。その具体的な実装であるZend_Application_Module_Autoloaderというローダーは、以下のようなマッピング情報を持っている。


api/ => Api
forms/ => Form
models/ => Model
DbTable/ => Model_DbTable
plugins/ => Plugin

たとえば、"Blog_"というプレフィックスを持つモジュールがあり、そこで"Blog_Form_Entry"クラスが使いたい場合、"forms/"ディレクトリ内に"Entry.php"というファイルが置かれることになる。

Zend_Applicationをモジュールブートストラップと一緒に使う場合、Zend_Application_Module_Autoloaderがそれぞれのモジュールごとに生成され、モジュールリソースをオートロードできるようにしてくれる。

30.3.3. リソースオートローダーをオブジェクトファクトリーとして使う場合*1
30.3.3. リソースオートローダーリファレンス*2

*1:$loader->load($resource, $type)の使い方だろうな。オブジェクトを使い回せるファクトリーになるみたいだ

*2:新しいクラスはマニュアル内にAPIリファレンスを置いている場合が多いな

[Zend Framework] Zend_Applicationの利用例

Zend_Applicationの利用例の原文。

4.4. 利用例

Bootstrapクラスは最小限のコードとなることが多く、しばしばブートストラップ基底クラスを継承するだけの空のスタブとなる。


class Bootstrap extends Zend_Application_Bootstrap_Bootstrap
{
}

(上記クラスと)対応する設定ファイルは以下。


; APPLICATION_PATH/configs/application.ini
[production]
bootstrap.path = APPLICATION_PATH "/Bootstrap.php"
bootstrap.class = "Bootstrap"
resources.frontController.controllerDirectory = APPLICATION_PATH "/controllers"

[development : testing]
[development : production]

しかし、カスタムの初期化コードが必要になることもあるだろう。その場合は二つの選択肢がある。一つは、_initから始まるメソッドとしてブートストラップコードを記述する方法。これらのメソッドはbootstrap()メソッドによって、まるでbootstrap()というpublicメソッドのように呼ばれ、配列を使った追加オプションを受け取る。

リソースメソッドが返した値は、ブートストラップ内のコンテナに保存される。これはリソース同士が相互連携する場合に便利だ(あるリソースオブジェクトが自分自身を他のリソースオブジェクトに注入(インジェクション)したり)。getResource()メソッドはそれらの(戻り値が保存された)値を取得するために使われる。

以下は、リクエストオブジェクトを(独自に)初期化するためのリソースメソッドの例だ。依存性の追跡機構(フロントコントローラリソースへの依存)、ブートストラップからのリソースオブジェクトの取得、ブートストラップへの値(リソースオブジェクト)の保存を行う例となる。


class Bootstrap extends Zend_Application_Bootstrap_Bootstrap
{
protected function _initRequest(array $options = array())
{
// フロントコントローラオブジェクトの存在を保証し、それを取得する
$this->bootstrap('FrontController');
$front = $this->getResource('FrontController');

// リクエストオブジェクトの初期化
$request = new Zend_Controller_Request_Http();
$request->setBaseUrl('/foo');

// フロントコントローラにセット(注入)
$front->setRequest($request);

// ブートストラップはコンテナに'request'キーの値として保存する
return $request;
}
}

この例におけるbootstrap()メソッドの('FrontController'を引数にした)呼び出しは、_initRequest()メソッドが実行されるよりも先にフロントコントローラリソースの初期化が行われていることを保証する。あの呼び出し(=$this->bootstrap('FrontController');)がリソース*1もしくはクラス内の他のメソッド*2の呼び出しのトリガーとなる。

他のオプションはリソースプラグインで使われるためのものだ。リソースプラグインは指定された初期化処理を実行するオブジェクトで、初期化方法は以下のタイミングで指定される。

  • Zend_Applicationオブジェクトが生成されるとき*3
  • ブートストラップオブジェクトの初期化処理中*4
  • ブートストラップオブジェクトへのメソッドコールを介して明示的に*5

リソースプラグインはZend_Application_Bootstrap_Resourceインターフェースを実装しており、呼び出し元(ブートストラップオブジェクト)の注入、オプションの設定、init()メソッドを持つように定義されている。たとえばカスタムの"View"ブートストラップリソースは以下のようになる。


class My_Bootstrap_Resource_View
extends Zend_Application_Resource_ResourceAbstract
{
public function init()
{
$view = new Zend_View($this->getOptions());
Zend_Dojo::enableView($view);

$view->doctype('XHTML1_STRICT');
$view->headTitle()->setSeparator(' - ')->append('My Site');
$view->headMeta()->appendHttpEquiv('Content-Type',
'text/html; charset=utf-8');

$view->dojo()->setDjConfigOption('parseOnLoad', true)
->setLocalPath('/js/dojo/dojo.js')
->registerModulePath('../spindle', 'spindle')
->addStylesheetModule('spindle.themes.spindle')
->requireModule('spindle.main')
->disable();

$viewRenderer = Zend_Controller_Action_HelperBroker::getStaticHelper(
'ViewRenderer'
);
$viewRenderer->setView($view);

return $view;
}
}

ブートストラップでこのリソースを使えるようにするには、リソースの(完全な)クラス名、もしくはプラグインローダー用の検索パスとリソースの短縮名(たとえば"view")の組み合わせを与える必要がある。


$application = new Zend_Application(
APPLICATION_ENV,
array(
'resources' => array(
'My_Bootstrap_Resource_View' => array(), // 完全クラス名もしくは
'view' => array(), // 短縮名

'FrontController' => array(
'controllerDirectory' => APPLICATION_PATH . '/controllers',
),
),

// 短縮名を使うにはプラグインのパスを定義しておく
'pluginPaths = array(
'My_Bootstrap_Resource' => 'My/Bootstrap/Resource',
)
)
);

リソースは呼び出し元となるブートストラップにアクセスすることで、他のリソースや初期化処理を呼び出すことができる。


class My_Bootstrap_Resource_Layout
extends Zend_Application_Resource_ResourceAbstract
{
public function init()
{
// ビューが初期化されていることを保証する
$this->getBootstrap()->bootstrap('view');

// ビューオブジェクトを取得する
$view = $this->getBootstrap()->getResource('view');

// ...
}
}

通常の使い方では、アプリケーションオブジェクトを生成し、ブートストラップし、実行する。


$application = new Zend_Application(...);
$application->bootstrap()
->run();

(通常のWebアプリケーション以外の)カスタムスクリプトを作る場合は、特定のリソースだけを初期化する必要があるかもしれない。*6


$application = new Zend_Application(...);
$application->getBootstrap()->bootstrap('db');

$service = new Zend_XmlRpc_Server();
$service->setClass('Foo'); // データベースを利用する
echo $service->handle();

内部メソッドやリソースを呼ぶためにbootstrap()メソッドを使う代わりに、メソッドオーバーロード(__call())を利用することもできる。*7


$application = new Zend_Application(...);
$application->getBootstrap()->bootstrapDb();

*1:=リソースプラグイン

*2:=リソースメソッド?

*3:Zend_Applicationのコンストラクタの引数として渡されるオプション

*4:ブートストラップのコンストラクタの引数として渡されるZend_Applicationオブジェクトの持つオプション

*5:BootstrapAbstract::setOptions()やBootstrapAbstract::registerPluginResource()

*6:下記では、通常のMVC Webアプリケーションを実行する代わりに、DBリソースの初期化処理だけを利用して、XMLRPC Webサービスを実行している

*7:$boostrap->boostrap('foo')は$bootstrap->boostrapFoo()と書くこともできる。