エトセトラ

補足

データベースを使わないモデル

バリデーションだけを使う場合などは、通常の手順で任意の名前のモデルを作成します。ただしデータベースを扱う命令を呼び出すとエラーになります。

ファイルアップロードの実装

専用の機能は無いので、通常の方法でアップロードします。

ユーザー認証の実装

専用の機能は無いので、セッションを使うなどして実装します。

レイアウト

専用の機能は無いので、import() で共通テンプレートを読み込むなどします。

フィルター

専用の機能は無いので、共通関数を定義するなどします。

ORM・アソシエーション

専用の機能は無いので、SQLの発行には db_select()select_xxx() などを使います。

アソシエーションのように関連データを取得したい場合、モデル内で db_select()db_query() などによってSQLを発行するなどします。

予約語一覧

以下の語句からはじまる定数。

  • MAIN_
  • DATABASE_
  • SESSION_
  • TOKEN_
  • REGEXP_
  • PAGE_
  • TEST_
  • DEBUG_
  • LOGGING_
  • VERSION_

以下の変数。

  • $_params
  • $_db
  • $_view

以下のリクエスト変数。

  • _mode
  • _work
  • _type
  • _token
  • _test

以下のグローバル変数。

  • _target
  • _routing
  • _language
  • _time

以下のセッション変数。

  • _token
  • _auth
  • _language

以下の関数。

  • import()
  • bootstrap()
  • session()
  • database()
  • normalize()
  • routing()
  • service()
  • model()
  • controller()
  • view()
  • unescape()
  • sanitize()
  • unify()
  • convert()
  • alt()
  • truncate()
  • e()
  • t()
  • h()
  • language()
  • localdate()
  • clientip()
  • ssl()
  • token()
  • redirect()
  • forward()
  • debug()
  • logging()
  • auth()
  • ok()
  • warning()
  • error()
  • password()
  • about()
  • style()

以下の語句からはじまる関数。

  • db_
  • info_
  • rand_
  • regexp_
  • test_
  • cookie_
  • directory_
  • file_
  • hash_
  • mail_
  • string_
  • ui_

外部JSファイルでプログラムのパスを取得する

一例ですが、PHPプログラム側で

$GLOBALS['config']['http_path'] = '/path/to/app/';

このようにパスを定義しておき、共通のビューで常に

<script>
var HTTP_PATH = '<?php t($GLOBALS['config']['http_path']) ?>';
</script>

このような変数もしくは定数を定義し、その後外部JSファイルを読み込むことでパスを参照できます。

本番環境・検収環境・開発環境の切り替え

Gitなどでソースコードを管理している場合、本番環境・検収環境・開発環境・作業環境(ローカル)の切り替えは一例ですが以下の手順で対応できます。

  1. 本番環境か否かでプログラムを分岐させたい場合、​PRODUCTION という定数が定義済みか否かで判定する。同様に、検収環境か否かは ​STAGING という定数で判定する。同様に、開発環境か否かは ​DEVELOP という定数で判定する。どれも定義されていなければ作業環境とする。
  2. フレームワークの config.phpconfig.default.php という名前で保存しておき、環境に依存しない内容(作業環境用)のみ設定しておく。config.php はリポジトリの管理対象外にしておく。
  3. 本番環境、検収環境、開発環境は通常1つなので、それらの設定を config.production.phpconfig.staging.phpconfig.develop.php として作成済みにしておき、それらの変更内容もgitで管理する。(git内に本番環境の情報を含めていい場合のみ。)
  4. 作業環境でプログラムを動作させる場合、各々の環境で config.default.php を複製して config.php を作成し、環境に応じた設定を行う。
  5. git内に本番環境の情報を含めていい場合、本番環境や検収環境や開発環境では config.production.phpconfig.staging.phpconfig.develop.php を複製して config.php を作成する。config.php は直接編集せず、config.production.phpconfig.staging.php に変更があった場合に複製し直す。
    git内に本番環境の情報を含めてはいけない場合、本番環境や検収環境や開発環境でも config.default.php を複製して config.php を作成し、環境に応じた設定を行う。
    いずれの場合でも、本番環境なら ​PRODUCTION という定数が、検収環境なら ​STAGING という定数が、開発環境なら ​DEVELOP という定数が config.php 内で定義済みになるようにしておく。

具体例を挙げます。本番環境では config.php に以下を記述しておきます。

define('PRODUCTION', true);

検収環境では config.php に以下を記述しておきます。

define('STAGING', true);

開発環境では何も記述しません。これで準備は完了です。

これでもし、プログラム内で「本番環境もしくは検収環境なら」という分岐を作りたければ、以下のような条件分岐を記述します。

if (defined('PRODUCTION') || defined('STAGING')) {
}

この要領で、環境によって処理を切り替えることができます。

ワンタイムトークンの実装

CSRF対策に、ワンタイムトークンを実装する方法です。ワンタイムトークンを扱うための命令は標準で用意されているので、これを呼び出すことで対応できます。

まずはコントローラで

$_view['token'] = token('create')

このようにすると、トークンの発行ができます。これをビューで

<input type="hidden" name="_token" value="<?php t($_view['token']) ?>">

このようにしてフォームタグに埋め込み、トークンを送信します。トークンを送信する際の名前は _token で固定されています。送られたトークンはコントローラで

if (!token('check')) {
    error('不正なアクセスです。');
}

このようにして確認します。

同時に複数のトークンを扱う

一度トークンを発行しても、再度トークンを発行すると以前のトークンは失われてしまいます。同時に複数のトークンを扱いたい場合、

$_view['token'] = token('create', '任意の名前')

このようにトークンを発行し、

if (!token('check', '任意の名前')) {
    error('不正なアクセスです。');
}

このようにトークンを確認します。トークンを送信するための、HTMLのフォームタグは同じで大丈夫です。

ファイルの読み込み先を変更する

グローバル変数 $GLOBALS['_target'] に値を代入すると、import() でファイルを読み込む際の場所として認識されます。これを利用すると「会社単位に提供するASPを作成するが、一部の処理は会社ごとにしたい」のような場合に対応できます。

例えば app/routing.php に以下のように書いておくと、

// 先頭のパラメータを会社IDとみなす
if (isset($_params[0]) && preg_match('/^[\w\-]+$/', $_params[0])) {
    $GLOBALS['_target'] = 'companies/' . $_params[0];

    $_REQUEST['_mode'] = empty($_params[1]) ? 'home'  : $_params[1];
    $_REQUEST['_work'] = empty($_params[2]) ? 'index' : $_params[2];
}

index.php/test/home/index にアクセスしたときは /companies/test/app/ 内のMVCが使われるようになり、index.php/sample/home/index にアクセスしたときは /companies/sample/app/ 内のMVCが使われるようになります。
つまり、/app/controllers/home/index.php の代わりに /companies/test/app/controllers/home/index.php などが読み込まれるようになります。

ただし、/companies/test/app/controllers/home/index.php などを認識させるには、大元のファイルである /app/controllers/home/index.php が存在している必要があるので注意が必要です。(内容は何でも大丈夫です。)

なおこのとき、/companies/test/app/ 内のMVCで

import('app/views/header.php');

と書くと

/companies/test/app/views/header.php

が読み込まれるようになります。このファイルが存在しなければ、通常通り /app/views/header.php が読み込まれます。

adminルーティングとサブドメインルーティング

グローバル変数 $GLOBALS['_routing'] に値を代入すると、コントローラとビューのファイル読み込み先を変更できます。(モデルの呼び出しには影響しません。)

これを利用して app/routing.php に以下の処理を書くと、URLルーティングのルールを変更できます。これで index.php/admin/param2/param3 でアクセスした時、param2param3 の値によって admin 内のコントローラとビューが呼び出されるようになります。

<?php

if (isset($_params[0]) && preg_match('/^admin$/', $_params[0])) {
    $GLOBALS['_routing'] = $_params[0];

    $_REQUEST['_mode'] = empty($_params[1]) ? 'home'  : $_params[1];
    $_REQUEST['_work'] = empty($_params[2]) ? 'index' : $_params[2];
}

具体的には、

  • index.php/param1/param2 にアクセスすると app/controllers/param1/param2.php が呼び出される
  • index.php/admin/param2/param3 にアクセスすると app/controllers/admin/param2/param3.php が呼び出される

となります。管理ページが非常に多機能な場合など、処理をフォルダごとに分けることができるようになります。admin の部分などを変更すれば、ルーティングのルールは自由に変更できます。app/routing.php の詳細については、フレームワーク本体の処理内容に手を加えるを参照してください。

サブドメインの値をもとにルーティングのルールを変更する例も紹介します。

if (isset($_SERVER['SERVER_NAME']) && preg_match('/^(test|sample)\./', $_SERVER['SERVER_NAME'], $matches)) {
    $GLOBALS['_routing'] = $matches[1];
} else {
    $GLOBALS['_routing'] = 'www';
}

この場合、

  • http://example.com/index.php/param1/param2 にアクセスすると app/controllers/www/param1/param2.php が呼び出される
  • http://test.example.com/index.php/param1/param2 にアクセスすると app/controllers/test/param1/param2.php が呼び出される
  • http://sample.example.com/index.php/param1/param2 にアクセスすると app/controllers/sample/param1/param2.php が呼び出される

となります。サブドメインをまたいだプログラムを作る場合でも、同じフレームワークで一元管理できるようになります。

PHPのセッションをデータベースで管理する

PHPのセッションは、デフォルトでは /var/lib/php/session 内にファイルとして保存されます。基本的にはこれで問題ないですが、サーバが複数台構成の場合など何らかの理由でデータベースに保存したいことがあります。

通常のPHPプログラムと同じ手法で対応することができますが、MySQLを例に具体的な内容を紹介します。(「How do I store sessions in a database?」という海外のサイトを参考にしましたが、元ページが無くなっていたので内容のみ残しておきます。)

まずはデータベースに、セッション保存用のテーブルを作成します。

CREATE TABLE levis_sessions(
    id       VARCHAR(255),
    created  DATETIME,
    modified DATETIME,
    data     LONGBLOB,
    PRIMARY KEY(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT 'session';

libs/vendors/SessionDatabase.php を作成して以下を記述します。

<?php

class SessionDatabase
{
    private $db_dns;
    private $db_username;
    private $db_password;
    private $db_table;
    private $db;

    public function __construct($db_dns, $db_username, $db_password, $db_table)
    {
        $this->db_dns      = $db_dns;
        $this->db_username = $db_username;
        $this->db_password = $db_password;
        $this->db_table    = $db_table;
    }

    public function open($path, $name)
    {
        try {
            $this->db = new PDO($this->db_dns, $this->db_username, $this->db_password, array(
                PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
                PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => true
            ));
        } catch (PDOException $e) {
            echo 'Session Open Error: ' . $e->getMessage();

            return false;
        }

        return true;
    }

    public function close()
    {
        $this->db = null;

        return true;
    }

    public function read($id)
    {
        try {
            $stmt = $this->db->prepare('SELECT data FROM ' . $this->db_table . ' WHERE id = :id');
            $stmt->bindValue(':id', $id);
            $stmt->execute();
            $result = $stmt->fetchAll(PDO::FETCH_ASSOC);
        } catch (PDOException $e) {
            echo 'Session Read Error: ' . $e->getMessage();

            return '';
        }

        if (count($result) > 0 && isset($result[0]['data'])) {
            return $result[0]['data'];
        } else {
            return '';
        }
    }

    public function write($id, $data)
    {
        try {
            $stmt = $this->db->prepare('SELECT data FROM ' . $this->db_table . ' WHERE id = :id');
            $stmt->bindValue(':id', $id);
            $stmt->execute();
            $result = $stmt->fetchAll(PDO::FETCH_ASSOC);
        } catch (PDOException $e) {
            echo 'Session Write Error: ' . $e->getMessage();

            return false;
        }

        try {
            if (count($result) > 0) {
                $stmt = $this->db->prepare('UPDATE ' . $this->db_table . ' SET modified = NOW(), data = :data WHERE id = :id');
                $stmt->bindValue(':data', $data);
                $stmt->bindValue(':id', $id);
            } else {
                $stmt = $this->db->prepare('INSERT INTO ' . $this->db_table . ' VALUES(:id, NOW(), NOW(), :data)');
                $stmt->bindValue(':id', $id);
                $stmt->bindValue(':data', $data);
            }
            $stmt->execute();
        } catch (PDOException $e) {
            echo 'Session Write Error: '.$e->getMessage();

            return false;
        }

        return true;
    }

    public function destroy($id)
    {
        try {
            $stmt = $this->db->prepare('DELETE FROM ' . $this->db_table . ' WHERE id = :id');
            $stmt->bindValue(':id', $id);
            $stmt->execute();
        } catch (PDOException $e) {
            echo 'Session Destroy Error: ' . $e->errorMessage();

            return false;
        }

        return true;
    }

    public function gc($lifetime)
    {
        try {
            $stmt = $this->db->prepare('DELETE FROM ' . $this->db_table . ' WHERE modified < :end');
            $stmt->bindValue(':end', date('Y-m-d H:i:s', time() - $lifetime));
            $stmt->execute();
        } catch (PDOException $e) {
            echo 'Session Garbage Collector Error: '. $e->getMessage();

            return false;
        }

        return true;
    }

    public function __destruct()
    {
        session_write_close();
    }
}

app/bootstrap.php を作成して以下を記述します。(すでにファイルが存在している場合、以下の処理を追加します。)

<?php

// セッションをデータベースで管理
import('libs/vendors/SessionDatabase.php');
$session = new SessionDatabase('mysql:host=' . DATABASE_HOST . ';dbname=' . DATABASE_NAME, DATABASE_USERNAME, DATABASE_PASSWORD, DATABASE_PREFIX . 'levis_sessions');
session_set_save_handler(
    array($session, 'open'),
    array($session, 'close'),
    array($session, 'read'),
    array($session, 'write'),
    array($session, 'destroy'),
    array($session, 'gc')
);

これでセッションがデータベースに保存されます。

デフォルトモデルの動作を指定する

この機能はVer8.8以降で対応しています。

app/model.php があると、デフォルトモデルの動作を指定できます。

一例ですが、以下のように記述すると各モデルのひととおりのデフォルト動作を指定できます。(実際は、デフォルト動作を指定したい関数のみ記述すれば十分です。)コード内の APP_MODEL は、モデルの名前に置換されます。

<?php

/**
 * データの取得
 *
 * @param array $queries
 *
 * @return array
 */
if (!function_exists('select_APP_MODEL')) {
    function select_APP_MODEL($queries)
    {
        $queries = db_placeholder($queries);
        $queries['from'] = DATABASE_PREFIX . 'APP_MODEL';
        $results = db_select($queries);

        return $results;
    }
}

/**
 * データの登録
 *
 * @param array $queries
 *
 * @return resource
 */
if (!function_exists('insert_APP_MODEL')) {
    function insert_APP_MODEL($queries)
    {
        $queries = db_placeholder($queries);
        $queries['insert_into'] = DATABASE_PREFIX . 'APP_MODEL';
        $resource = db_insert($queries);

        return $resource;
    }
}

/**
 * データの編集
 *
 * @param array $queries
 *
 * @return resource
 */
if (!function_exists('update_APP_MODEL')) {
    function update_APP_MODEL($queries)
    {
        $queries = db_placeholder($queries);
        $queries['update'] = DATABASE_PREFIX . 'APP_MODEL';
        $resource = db_update($queries);

        return $resource;
    }
}

/**
 * データの削除
 *
 * @param array $queries
 *
 * @return resource
 */
if (!function_exists('delete_APP_MODEL')) {
    function delete_APP_MODEL($queries)
    {
        $queries = db_placeholder($queries);
        $queries['delete_from'] = DATABASE_PREFIX . 'APP_MODEL';
        $resource = db_delete($queries);

        return $resource;
    }
}

/**
 * データの正規化
 *
 * @param array $queries
 *
 * @return array
 */
if (!function_exists('normalize_APP_MODEL')) {
    function normalize_APP_MODEL($queries)
    {
        return $queries;
    }
}

/**
 * データの検証
 *
 * @param array $queries
 *
 * @return array
 */
if (!function_exists('validate_APP_MODEL')) {
    function validate_APP_MODEL($queries)
    {
        return array();
    }
}

参考までの一例ですが、以下のように記述すると「論理削除や登録編集削除日時の自動記録」「作業者IDの自動記録」に対応したデフォルトモデルになります。(_sets で終わるテーブルは紐付け用のテーブルとみなし、自動記録の対象外にしています。)

<?php

/**
 * データの取得
 *
 * @param array $queries
 *
 * @return array
 */
if (!function_exists('select_APP_MODEL')) {
    function select_APP_MODEL($queries)
    {
        $queries = db_placeholder($queries);

        // データを取得
        $queries['from'] = DATABASE_PREFIX . 'APP_MODEL';

        // 削除済みデータは取得しない
        if (!preg_match('/_sets$/', 'APP_MODEL')) {
            if (!isset($queries['where'])) {
                $queries['where'] = 'TRUE';
            }
            $queries['where'] = 'deleted IS NULL AND (' . $queries['where'] . ')';
        }

        // データを取得
        $results = db_select($queries);

        return $results;
    }
}

/**
 * データの登録
 *
 * @param array $queries
 *
 * @return resource
 */
if (!function_exists('insert_APP_MODEL')) {
    function insert_APP_MODEL($queries)
    {
        $queries = db_placeholder($queries);

        // 初期値を取得
        $defaults = default_APP_MODEL();

        if (!preg_match('/_sets$/', 'APP_MODEL')) {
            if (isset($queries['values']['created'])) {
                if ($queries['values']['created'] === false) {
                    unset($queries['values']['created']);
                }
            } else {
                $queries['values']['created'] = $defaults['created'];
            }
            if (isset($queries['values']['modified'])) {
                if ($queries['values']['modified'] === false) {
                    unset($queries['values']['modified']);
                }
            } else {
                $queries['values']['modified'] = $defaults['modified'];
            }
            if (isset($queries['values']['deleted'])) {
                if ($queries['values']['deleted'] === false) {
                    unset($queries['values']['deleted']);
                }
            } else {
                $queries['values']['deleted'] = $defaults['deleted'];
            }
        }

        // ユーザのIDを自動記録
        if (!preg_match('/_sets$/', 'APP_MODEL')) {
            if (!empty($_SESSION['auth']['user']['id'])) {
                $queries['values']['created_by_user_id']  = $_SESSION['auth']['user']['id'];
                $queries['values']['modified_by_user_id'] = $_SESSION['auth']['user']['id'];
            }
        }

        // データを登録
        $queries['insert_into'] = DATABASE_PREFIX . 'APP_MODEL';

        $resource = db_insert($queries);

        return $resource;
    }
}

/**
 * データの編集
 *
 * @param array $queries
 *
 * @return resource
 */
if (!function_exists('update_APP_MODEL')) {
    function update_APP_MODEL($queries)
    {
        $queries = db_placeholder($queries);

        // 初期値を取得
        $defaults = default_APP_MODEL();

        if (!preg_match('/_sets$/', 'APP_MODEL')) {
            if (isset($queries['set']['modified'])) {
                if ($queries['set']['modified'] === false) {
                    unset($queries['set']['modified']);
                }
            } else {
                $queries['set']['modified'] = $defaults['modified'];
            }
            if (isset($queries['set']['deleted'])) {
                if ($queries['set']['deleted'] === false) {
                    unset($queries['set']['deleted']);
                }
            } else {
                $queries['set']['deleted'] = $defaults['deleted'];
            }
        }

        // ユーザのIDを自動記録
        if (!preg_match('/_sets$/', 'APP_MODEL')) {
            if (!empty($_SESSION['auth']['user']['id'])) {
                $queries['set']['modified_by_user_id'] = $_SESSION['auth']['user']['id'];
            }
        }

        // データを編集
        $queries['update'] = DATABASE_PREFIX . 'APP_MODEL';

        $resource = db_update($queries);

        return $resource;
    }
}

/**
 * データの削除
 *
 * @param array $queries
 *
 * @return resource
 */
if (!function_exists('delete_APP_MODEL')) {
    function delete_APP_MODEL($queries)
    {
        // ユーザのIDを自動記録
        if (!preg_match('/_sets$/', 'APP_MODEL')) {
            if (empty($_SESSION['auth']['user']['id'])) {
                $deleted_by_user_id = null;
            } else {
                $deleted_by_user_id = $_SESSION['auth']['user']['id'];
            }
        }

        // データを削除
        if (preg_match('/_sets$/', 'APP_MODEL')) {
            $queries = db_placeholder($queries);

            // データを削除
            $queries['delete_from'] = DATABASE_PREFIX . 'APP_MODEL';

            $resource = db_delete($queries);
        } else {
            $resource = db_update(array(
                'update' => 'APP_MODEL',
                'set'    => array(
                    'deleted'            => localdate('Y-m-d H:i:s'),
                    'deleted_by_user_id' => $deleted_by_user_id,
                ),
                'where'  => isset($queries['where']) ? $queries['where'] : '',
                'limit'  => isset($queries['limit']) ? $queries['limit'] : ''
            ));
            if (!$resource) {
                error('データを削除できません。');
            }
        }

        return $resource;
    }
}

/**
 * データの初期値
 *
 * @return array
 */
if (!function_exists('default_APP_MODEL')) {
    function default_APP_MODEL()
    {
        return array();
    }
}

データベースのテーブルロック

モデルでデータベースのテーブルを操作する際、option を渡すとクエリの最後に任意の文字列を指定できます。これにより、例えば以下のように書くことでテーブルロックを行うことができます。

$_view['posts'] = select_posts(array(
    'where'  => 'id >= 1 AND id <= 10',
    'option' => 'FOR UPDATE',
));

テーブルロックのために指定できる文字列や、テーブルロックが使えるかどうかは、使用するデータベースの仕様に依存します。

データベースのトランザクション分離レベルを変更する

MySQLのトランザクション分離レベルはデフォルトで REPEATABLE READ ですが、これを READ COMMITTED に変更する方法です。(設定を変更する権限は与えられているものとします。)

REPEATABLE READ はトランザクション開始後にテーブルの値を変更しても、SELECT で参照できるのは変更前の値です。READ COMMITTED は変更後の値を参照でき、Oracle、PostgreSQL、SQL Server などではデフォルト設定となっています。そちらの方が直感的なので、変更しておくと余計なトラブルを防ぐことができます。

具体的には、app/database.php を作成して以下のコードを記述します。app/database.php の詳細については、フレームワーク本体の処理内容に手を加えるを参照してください。

db_query('SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;');

データベースのプレースホルダ

$_view['posts'] = select_posts(array(
    'where' => 'id >= 1 AND id <= 10',
));

このSQLを扱う場合、プレースホルダを使って以下のように書くことができます。(配列で指定した値が、先頭から順に :? へ代入されます。)

$_view['posts'] = select_posts(array(
    'where' => array('id >= :? AND id <= :?', array(1, 10)),
));

以下のように、プレースホルダに文字列を使うこともできます。

$_view['posts'] = select_posts(array(
    'where' => array(
        'id >= :from AND id <= :to',
        array(
            'from' => 1,
            'to'   => 10,
        ),
    ),
));

プレースホルダは、値に数値と判断できる値を渡すと数値として扱われ、文字列と判断できる値を渡すと文字列として扱われます。例えば

$classes = select_posts(array(
    'where' ==> array(
        'id IS NOT NULL AND code = :code',
        array(
            'code' ==> '102',
        ),
    ),
));

このようなコードがあった場合、

id IS NOT NULL AND code = 102

という比較が行われます。このとき、MySQLの場合はデータベースの仕様でcodeが「102」のものだけでなく、「102B」「102C」の値も検索にヒットします。(codeには文字列を登録できる前提とします。)

この現象は

id IS NOT NULL AND code = '102'

のように文字列として比較を行うことで回避できますが、このようにデータの型を明示したければ

$classes = select_posts(array(
    'where' ==> array(
        'id IS NOT NULL AND code = :code',
        array(
            'code:string' ==> '102',
        ),
    ),
));

このように code:string と指定します。(コロンに続いて型を指定。)
型には string(文字列)、number(数値)、bool(真偽値)のいずれかを指定することができます。(型の指定は機能はVer8.14以降で対応しています。)

なお、これは擬似プレースホルダです。同じSQLで値だけ変えて複数実行することはできません。大量のSQLを実行する際に速度アップを図りたい場合、一例ですが以下のようにトランザクションを使用してください。

// トランザクションを開始
db_transaction();

while (/* データ順次取り出し */) {
    // データ内容確認&登録処理
}

if (/* エラーがなければ */) {
    // トランザクションを終了
    db_commit();
} else {
    // エラーがあればロールバック
    db_rollback();
}

登録・編集の際は以下のような特別な書き方を利用できます。(valuesset の内容が、自動的にエスケープされます。)特に理由がなければ、この書き方を推奨します。

$resource = insert_posts(array(
    'values' => array(
        'id'       => $_POST['id'],
        'created'  => $_POST['created'],
        'modified' => $_POST['modified'],
        'title'    => $_POST['title'],
        'body'     => $_POST['body'],
    ),
));
if (!$resource) {
    error('insert error.');
}
$resource = update_posts(array(
    'set' => array(
        'created'  => $_POST['created'],
        'modified' => $_POST['modified'],
        'title'    => $_POST['title'],
        'body'     => $_POST['body'],
    ),
    'where' => array(
        'id = :id',
        array(
            'id' => $_POST['id']
        ),
    ),
));
if (!$resource) {
    error('update error.');
}

上の書き方であえて式を渡したい場合、以下のように配列で渡します。

$resource = update_posts(array(
    'set'   => array(
        'sort' => array('sort + 1'),
    ),
    'where' => array(
        'id = :id',
        array(
            'id' => 2,
        ),
    ),
));

複数のデータベースに接続

db_connect に接続情報を渡すと、別のデータベースに接続できます。

db_connect(array(
    'master' => array(
        'host'     => 'localhost',
        'username' => 'root',
        'password' => '1234',
        'name'     => 'master_db',
    ),
));

$test = db_result(db_query('SELECT * FROM members'));

このように接続した以降は、データベースを扱う命令を呼び出すと master_db データベースが呼ばれるようになります。再度本来のデータベースを呼び出したい場合、

db_connect('default');

を呼び出します。その上で再度 master_db データベースを呼び出したい場合、

db_connect('master');

とすれば呼び出せます。(連想配列のキーの値を指定。)

接続情報として、以下の値を渡せます。値を省略した場合、config.php で指定した値が使われます。

type
接続方法
host
ホスト
port
ポート番号
username
ユーザー名
password
パスワード
name
データベース名
prefix
テーブル名のプレフィックス
charset
データベースの文字コード
charset_input_from
データベースへ入力するときの変換前文字コード
charset_input_to
データベースへ入力するときの変換後文字コード
charset_output_from
データベースから出力するときの変換前文字コード
charset_output_to
データベースから出力するときの変換後文字コード

データベースのクエリエラーを独自に処理する

データベースに渡すクエリに文法ミスがあったりUNIQUEキーの重複があったりする場合、フレームワーク内部でエラー関数が自動的に呼ばれてエラーが表示されます。

これにより処理が強制的に止まりますが、独自のエラー処理を行ったりエラーメッセージを表示したい場合、以下のように処理します。

$resource = db_insert(array(
    'insert_into' => 'categories',
    'values'      => array(
        'id' => 2,  // 重複しているとする
    ),
), false, false);  // 第2・第3引数を false にすると自動でのエラー表示が無効になる

if (!$resource) {
    error('データを登録できません : ' . db_error());  // 任意のエラー処理
}

db_query()db_select()db_insert()db_update()db_delete() で同様の処理を行うことができます。

例えばモデルの insert_categories() などからも同じようにエラー処理を独自に行いたい場合、モデル内で上の関数を呼び出す際に引数を渡すようにします。具体的には

/**
 * 分類の登録
 *
 * @param array $queries
 * @param array $options
 * @param bool  $return
 * @param bool  $error
 *
 * @return resource
 */
function insert_categories($queries, $options = array(), $return = false, $error = true)
{

このようにモデルで引数を受け取れるようにし、

    $resource = db_insert($queries, $return, $error);
    if (!$resource) {
        return $resource;
    }

モデル内部から db_insert() などに引数をそのまま渡すようにします。これで

$resource = insert_categories(array(
    'values' => array(
        'id' => 2,  // 重複しているとする
    ),
), array(), false, false);  // 第3・第4引数を false にすると自動でのエラー表示が無効になる

if (!$resource) {
    error('データを登録できません : ' . db_error());  // 任意のエラー処理
}

このように処理できるようになります。

データベースのバージョン管理

複数人でプログラムを作成したり複数箇所でプログラムを実行したりする場合のために、データベースのバージョン管理(マイグレーション)を行えます。これにより、各環境でデータベースの定義内容を同じ状態に保つことができます。

この機能を使用する場合、まずはデータベースに接続できるようにしておきます。

次に index.php と同じ場所(config.phpDATABASE_MIGRATE_PATH の値を変更した場合はその場所)に migrate ディレクトリを作成し、http://www.example.com/index.php/?_mode=db_migrate にアクセスすると migrate/ 内に置いたSQLファイルが実行されます。

SQLファイルは YYYYMMDDHHIISS-任意の半角英数字.sql という名前にします。YYYYMMDDHHIISS はバージョン番号として扱われるため、重複しないようにします。具体的には以下のような名前にします。

20151128213500-create_table_classes.sql
20151128214000-create_table_members.sql

初回実行時、バージョン管理用のテーブルが levis_migrations という名前で作成されます。実行したファイルはこのテーブルに記録されるため、何度も同じSQLファイルが実行されることはありません。

データベースのバージョン管理ではテーブルの構造のみ管理し、初期データは含めないことを推奨します。一例ですが初期データの登録のような管理を推奨します。

初期データの登録

データベースのバージョン管理を行う場合、初期データはバージョン管理に含めないことを推奨します。初期データを管理対象にすると、

  • データの管理が複雑になる
  • 代理キーを使用している場合、関連するデータの登録が複雑になる
  • 巨大なマスターデータがあると、マイグレーションに時間がかかりすぎて失敗する可能性がある

などの問題があるため、別途SQLファイルを用意したり以下のようにデータ登録プログラムを用意することを推奨します。

一例ですが /app/controllers/seed/companies.php という名前でコントローラを作成し、以下の内容を記述します。

<?php

import('libs/plugins/hash.php');

// トランザクションを開始
db_transaction();

// 企業を確認
$companies = select_companies(array(
    'select' => 'id',
    'where'  => array(
        'code = :code',
        array(
            'code' => 'sample',
        ),
    ),
));
if (count($companies) >= 1) {
    error('すでに登録されています。');
}

// 企業を登録
$resource = insert_companies(array(
    'values' => array(
        'code' => 'sample',           // 企業コード
        'name' => '株式会社サンプル', // 企業名
        'tel'  => '0120-0000-0000',   // 電話番号
    ),
));
if (!$resource) {
    error('企業を登録できません。');
}

$company_id = db_last_insert_id();

// 従業員を登録
$password_salt = hash_salt();
$password      = hash_crypt('Hi2EbNPaeS', $password_salt . ':' . $GLOBALS['config']['hash_salt']);

$resource = insert_employees(array(
    'values' => array(
        'username'      => 'yamada',       // ユーザ名
        'password'      => $password,      // パスワード
        'password_salt' => $password_salt, // パスワードのソルト
        'name'          => '山田太郎',     // 名前
        'company_id'    => $company_id,    // 外部キー 企業
        'loggedin'      => null,           // 最終ログイン日時
    ),
));
if (!$resource) {
    error('従業員を登録できません。');
}

// トランザクションを終了
db_commit();

ok();

これで /seed/companies にアクセスすると、必要な初期データが登録されます。

このコントローラは非公開が望ましいため、一例ですが /app/controllers/before_seed.php を作成して

<?php

// アクセス元を確認
if (clientip() !== '203.0.113.1') {
    exit('不正なアクセスです。');
}

このように記述することにより、アクセス制限を行っておきます。

データベースのバックアップ

データベースのバックアップをサーバ上に作成できます。

この機能を使用する場合、まずはデータベースに接続できるようにしておきます。

次に index.php と同じ場所(config.phpDATABASE_BACKUP_PATH の値を変更した場合はその場所)に backup ディレクトリを作成し、http://www.example.com/index.php/?_mode=db_backup にアクセスするとバックアップ画面が表示されます。「backup」ボタンを押すと backup/ 内にSQLファイルが保存されます。

バックアップが有効になっていると、データベースのバージョン管理でデータベースに変更を加える直前に自動でバックアップが作成されます。

データベース操作命令を独自に定義する

この機能はVer8.11以降で対応しています。

my_db_migrate 関数を定義すると、db_migrate 関数より優先して実行されます。これにより、マイグレーションの挙動を変更することができます。

この仕組みは、以下の関数に対して使用できます。実際にどのように挙動を定義すべきかは、関数の内容を確認して判断してください。

  • db_migrate ... my_db_migrate で挙動を変更
  • db_scaffold ... my_db_scaffold で挙動を変更
  • db_scaffold_output ... my_db_scaffold_output で挙動を変更
  • db_import ... my_db_import で挙動を変更
  • db_export ... my_db_export で挙動を変更
  • db_sql ... my_db_sql で挙動を変更

一例として、インポートとエクスポート処理を独自に定義する方法を紹介します。app/bootstrap.php を作成し、以下の内容を記述すると、「PHPで独自にインポート、エクスポート」という標準の動作が「mysqlコマンドでインポート、mysqldumpコマンドでエクスポート」に変更されます。

<?php

/**
 * Import SQL from the file.
 *
 * @param string $file
 *
 * @return int
 */
function my_db_import($file)
{
    // 実行するコマンドを決定
    $command  = 'mysql';
    $command .= ' -u ' . DATABASE_USERNAME;
    $command .= ' -p"' . DATABASE_PASSWORD . '"';
    $command .= ' ' . DATABASE_NAME;
    $command .= ' --default-character-set=binary';

    if ($file !== null) {
        $command .= ' < ' . $file;
    }

    // コマンドを実行
    exec($command, $output, $return_var);

    // 結果を取得
    if ($return_var === 1) {
        error('db: Import error');
    }

    return 1;
}

/**
 * Export SQL to the file.
 *
 * @param string|null $file
 * @param string|null $target
 * @param bool        $combined
 */
function my_db_export($file = null, $target = null, $combined = true)
{
    // 実行するコマンドを決定
    $command  = 'mysqldump';
    $command .= ' -u ' . DATABASE_USERNAME;
    $command .= ' -p"' . DATABASE_PASSWORD . '"';
    $command .= ' ' . DATABASE_NAME;

    if ($target !== null) {
        $command .= ' ' . $target;
    }

    $command .= ' --default-character-set=binary';

    if ($combined === false) {
        $command .= ' --skip-extended-insert';
    }
    if ($file !== null) {
        $command .= ' > ' . $file;
    }

    // コマンドを実行
    exec($command, $output, $return_var);

    // 結果を取得
    if ($return_var === 1) {
        error('db: Export error');
    } elseif ($file === null) {
        if ($target === null) {
            $filename = DATABASE_NAME . '.sql';
        } else {
            $filename = DATABASE_NAME . '-' . $target . '.sql';
        }

        header('Content-Type: text/plain');
        header('Content-Disposition: attachment; filename="' . $filename . '"');
        echo implode("\n", $output);
        exit;
    }
}

フレームワーク本体の処理内容に手を加える

app/ 直下に特定の名前でファイルを置いておくと、フレームワーク本体から読み込まれます。これを利用して、フレームワークの挙動を調整できます。

  • app/bootstrap.php があると、最初に自動で読み込まれます。
  • app/session.php があると、セッション初期化処理の後に自動で読み込まれます。
  • app/database.php があると、データベース接続処理の後に自動で読み込まれます。
  • app/normalize.php があると、データ正規化処理の後に自動で読み込まれます。
  • app/routing.php があると、ルーティング処理の後に自動で読み込まれます。
  • app/model.php があると、デフォルトモデルの動作を指定できます。

例えば app/database.php に以下の処理を書くと、他データベースへの接続準備になります。これで、モデルやコントローラから db_connect('master'); と書くだけで他のデータベースに接続できます。

<?php

db_connect(array(
    'master' => array(
        'host'     => 'localhost',
        'username' => 'root',
        'password' => '1234',
        'name'     => 'master_db',
    ),
));

db_connect('default');

例えば app/routing.php に以下の処理を書くと、URLルーティングのルールを変更できます。これで index.php/test/param2/param3 でアクセスした時、param2param3 の値によってコントローラなどが呼び出されるようになります。

<?php

if (isset($_params[0]) && $_params[0] === 'test') {
    if (isset($_params[1])) {
        $_REQUEST['_mode'] = empty($_params[1]) ? 'home' : $_params[1];
    }
    if (isset($_params[2])) {
        $_REQUEST['_work'] = empty($_params[2]) ? 'index' : $_params[2];
    }
}

フレームワーク外からフレームワークの命令を利用

定数 MAIN_PATH にlevisの配置場所を設定し、libs/cores/loader.php を読み込むとフレームワーク外からフレームワークの命令を利用できます。

loader.php を読み込むことによりフレームワークの基本的な命令は使えるようになりますが、モデル・ビュー・コントローラ・サービスは明示的に読み込む必要があります。model() と書くとすべてのモデルを、service() と書くとすべてのサービスを、一括で読み込みます。

<?php

if (!defined('MAIN_PATH')) {
    define('MAIN_PATH', '/var/www/html/test/levis/');
}
require_once MAIN_PATH . 'libs/cores/loader.php';

// 教室を取得
model('classes.php');
$classes = select_classes(array(
    'order_by' => 'id',
));

// 名簿を取得
model('members.php');
$members = select_members(array(
    'order_by' => 'id',
));

?>
<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8" />
        <title>テスト</title>
    </head>
    <body>
        <p>テスト。</p>
        <ul>
            <?php foreach ($classes as $class) : ?>
            <li><?php h($class['name']) ?></li>
            <?php endforeach ?>
        </ul>
        <ul>
            <?php foreach ($members as $member) : ?>
            <li><?php h($member['name']) ?></li>
            <?php endforeach ?>
        </ul>
    </body>
</html>
<?php

if (!defined('MAIN_PATH')) {
    define('MAIN_PATH', '/var/www/html/test/levis/');
}
require_once MAIN_PATH . 'libs/cores/loader.php';

// ページを表示
model('classes.php');
controller('home/index.php');
view('home/index.php');
<?php

if (!defined('MAIN_PATH')) {
    define('MAIN_PATH', '/var/www/html/test/levis/');
}
require_once MAIN_PATH . 'libs/cores/loader.php';

// 引数付きでページを表示
model();
$_params = array('class', 'class1');
controller('class/index.php');
view('class/index.php');
<?php

if (!defined('MAIN_PATH')) {
    define('MAIN_PATH', '/var/www/html/test/levis/');
}
require_once MAIN_PATH . 'libs/cores/loader.php';

// ページを取得
model('classes.php');
controller('home/index.php');
echo view('home/index.php', true);

なお、以下のコードでlevisの配置場所を指定していますが、

define('MAIN_PATH', '/var/www/html/test/levis/');

一例ですが以下のように __FILE__ を使うことで環境に依存しない指定が可能です。

define('MAIN_PATH', dirname(__FILE__) . '/../');

通常ページを作成

index.php と同じ場所(config.phpPAGE_PATH の値を変更した場合はその場所)に page ディレクトリを作成し、その中にPHPファイルを作成すると通常ページとして扱われます。
つまり、例えば page/test.php を作成すると、/index.php/test(もしくは /test) にアクセスしたとき、フレームワークを経由してこのページが表示されます。フレームワークとURL規則を統一した通常ページを作成したり、プログラムへのログイン状態に応じて通常ページの表示内容を切り替えたりする場合に利用できます。

ページには階層を持たせることができます。
つまり page/aaa/bbb.php には index.php/aaa/bbb で、page/aaa/bbb/ccc.php には index.php/aaa/bbb/ccc で、それぞれアクセスできます。

このとき app/controllers/page.php があると、通常ページ共通のコントローラとして扱われます。

また、URLのルールに対応したコントローラやビューが存在する場合、優先してそちらが使われます。

多言語に対応させる

language() 関数を使えば、多言語化させた文言を取得できます。(この機能はVer8.4以降で対応しています。Ver8.10で関数名が現在のものに変更されました。)

日本語と英語を切り替える具定例を紹介します。まずは言語ファイルを用意します。言語ファイルは /app/languages/ 内に作成します。言語コードをファイル名としますが、デフォルトの言語は default をファイル名とします。

/app/languages/default.php を作成し、以下のコードを記述します。$GLOBALS['_language']['言語コード'] に、キーと値をセットで設定します。

<?php

$GLOBALS['_language']['ja'] = array(
    'title'   => 'タイトル',
    'message' => 'メッセージ',
);

/app/languages/en.php を作成し、以下のコードを記述します。

<?php

$GLOBALS['_language']['en'] = array(
    'title'   => 'Title',
    'message' => 'Message',
);

これで例えば任意のビューファイルに

<h1><?php h(language('title')) ?></h1>
<p><?php h(language('message')) ?></p>

このように記載すると、ブラウザの言語設定に従って日本語もしくは英語が表示されます。日本語と英語以外の設定の場合、デフォルトである日本語が表示されます。コントローラ内やモデル内でも、同様に language('キー') で値を取得できます。キーに対応する値が見つからない場合、キーがそのまま返されます。

以下のように第二引数で言語コードを指定すると、ブラウザの言語設定に関わらず任意の言語で表示できます。

<h1><?php h(language('title')) ?></h1>
<p><?php h(language('message', 'en')) ?></p>

また、$_SESSION['_language'] に任意の言語コードを設定すると、ブラウザの言語設定に関わらず任意の言語とみなされます。

cronで実行する

フレームワーク外からフレームワークの命令を利用の方法でプログラムを作成し、そのプログラムをcronで呼び出します。

例えば check_update.php を作成した場合、一例ですがcronでは以下のように指定します。

*/5 * * * * apache /usr/bin/php /var/www/levis/task/check_update.php

雛形作成(Scaffold)

データベースにテーブルを作成し、index.php と同じ場所(config.phpDATABASE_SCAFFOLD_PATH の値を変更した場合はその場所)に scaffold ディレクトリを作成し、http://www.example.com/index.php/?_mode=db_scaffold にアクセスすると scaffold/app/ ディレクトリ内にプログラムの雛形が作成されます。

scaffold/app/app/ に移動させると、そのままプログラムのモデル・ビュー・コントローラとして利用できます。(ただし即座に公開できるプログラムというより、プログラム作成の補助に使うためのコードという位置づけで作成しています。)

scaffold/test/ には単体テスト用のプログラムも自動作成されます。

単体テスト(UnitTest)

index.php と同じ場所(config.phpTEST_PATH の値を変更した場合はその場所)に test ディレクトリを作成し、その中にテスト用プログラム(calculate.php など、ファイル名は任意。)を作成し、http://www.example.com/index.php/?_mode=test_index にアクセスすると単体テストを行えます。ページ内に表示される All Test. リンクから、一括テストも行えます。

テスト用プログラムは、具体的には以下のような内容になります。

<?php

// 掛け算の結果をテスト(「multiplication 1」のみ成功する)
test_equals('multiplication 1', multiplication(4, 2), 8);
test_equals('multiplication 2', multiplication(4, 2), 6);
test_equals('multiplication 3', multiplication(4, 2), 4);

// 割り算の結果をテスト(「division 3」のみ成功する)
test_equals('division 1', division(4, 2), 6);
test_equals('division 2', division(4, 2), 4);
test_equals('division 3', division(4, 2), 2);

// テスト用の関数
function multiplication($x, $y) {
    return $x * $y;
}
function division($x, $y) {
    return $x / $y;
}
<?php

// 返り値に特定の文字列が含まれるかテスト(「message 1」のみ成功する)
test_contains('message 1', message('test'), 'test');
test_contains('message 2', message('test'), 'sample');

// テスト用の関数
function message($message) {
    return 'This is a \'' . $message . '\'!';
}
<?php

// 返り値に特定の形式の文字列が含まれるかテスト
test_regexp('check_date', check_date(), '\d\d\d\d-\d\d-\d\d');

// テスト用の関数
function check_date() {
    return date('Y-m-d');
}
<?php

// トップページに「テスト」という文字が含まれているかテスト
model('classes.php');
controller('home/index.php');
$html = view('home/index.php', true);
test_contains('home/index', $html, 'テスト');

// index.php/class/class1に「テスト」という文字が含まれているかテスト
model();
$_params = array('class', 'class1');
controller('class/index.php');
$html = view('class/index.php', true);
test_contains('class/index', $html, 'テスト');

テスト用の関数は libs/cores/test.php 内で定義されており、以下を利用できます。

test_equals('テストの説明', '実際の値', '期待する値')  // 「実際の値」と「期待する値」が等しければテスト成功とみなします。
test_not_equals('テストの説明', '実際の値', '期待する値')  // 「実際の値」と「期待する値」が等しくなければテスト成功とみなします。
test_greaterthan('テストの説明', '実際の値', '期待する値')  // 「実際の値」が「期待する値」より大きければテスト成功とみなします。
test_greaterthanorequal('テストの説明', '実際の値', '期待する値')  // 「実際の値」が「期待する値」以上ならばテスト成功とみなします。
test_lessthan('テストの説明', '実際の値', '期待する値')  // 「実際の値」が「期待する値」より小さければテスト成功とみなします。
test_lessthanorequal('テストの説明', '実際の値', '期待する値')  // 「実際の値」が「期待する値」以下ならばテスト成功とみなします。
test_contains('テストの説明', '実際の値', '期待する値')  // 「実際の値」に「期待する値」が含まれていればテスト成功とみなします。
test_not_contains('テストの説明', '実際の値', '期待する値')  // 「実際の値」に「期待する値」が含まれていなければテスト成功とみなします。
test_regexp('テストの説明', '実際の値', '期待する値')  // 「実際の値」と「期待する値」の正規表現にマッチすればテスト成功とみなします。
test_not_regexp('テストの説明', '実際の値', '期待する値')  // 「実際の値」と「期待する値」の正規表現にマッチしなければテスト成功とみなします。
test_array_haskey('テストの説明', '配列', '期待する配列のキー')  // 「配列」に「期待する配列のキー」が存在すればテスト成功とみなします。
test_array_not_haskey('テストの説明', '配列', '期待する配列のキー')  // 「配列」に「期待する配列のキー」が存在しなければテスト成功とみなします。
test_array_subset('テストの説明', '配列', '期待する配列の値')  // 「配列」と「期待する配列の値」が存在すればテスト成功とみなします。
test_array_not_subset('テストの説明', '配列', '期待する配列の値')  // 「配列」と「期待する配列の値」が存在しなければテスト成功とみなします。