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がどこにかかるのかもよくわかってないし