今日も適当ダイアリー

PHP や Javascript や Symfony、BEAR.Sunday などのWeb周りのことを中心に。それ以外のことも気まぐれに投稿します。

PHPとMongoDBでセッション管理してみる

補足(2010-08-28追記):
下記 mongoSession クラスは一部のPHPバージョンでAPCを使用している場合、正常に動作しない場合があるようです。(静的メソッドがキャッシュに乗らない場合がある?) PHPAPCでバグ報告がされており、現行バージョンでは解決済み、とのレスもありますが、その解決済みバージョンを使用しても直らない、という人もいるようです。

前回、MongoDBとPHP mongoモジュールのインストールをしたので、PHPからmongoを操作してみたいと思います。
CentOS5.5にMongoDBをインストールしてみる - 今日も適当ダイアリー

何がいいかなぁ、と考えてみたのですが、セッションハンドラを書いてみる事にしてみます。
PHPのセッションは、デフォルトではファイル管理となりますが、それだと、冗長化構成時にネットワークディスクを使ったりしなければならなかったり問題になる場合があります。
やっぱ、同一の情報に複数のサーバーからアクセスするんなら、DBだよね。ってわけで、DBで、セッション管理をすることになるのですが、今回はMongoDBでセッション情報を保存するようなハンドラを作りたいと思います。

PHP Mongoエクステンションの詳細については、http://jp.php.net/mongoで確認してください。

MongoDBへのアクセス方法

では、基本的なMongoDBの操作方法を見ていきます。

接続する
<?php
    $mongo = new Mongo('mongodb://localhost:27017');

オプションを指定する場合は、下記のように。
ここでは、コンストラクタで接続を行い、持続的な接続を有効にしてみます。
'persist' => 'foobar' で初期化した Mongo のインスタンスがふたつあれば、それらは同じデータベース接続になります。

<?php
    $mongo = new Mongo('mongodb://localhost:27017',
        array('connect' => true, 'persist' => 'foobar'));

接続を確認したい場合は、こんな感じ。

<?php
    if ($mongo->connected) {
        echo '接続されてるよ!';
    }

ちなみに、特に理由がない限り、切断は必要ありません。

データの保存

こんな感じ。

<?php
    $data = array(
        'text' => 'テストデータです。',
        'insertdate' => new MongoDate() // 日時は MongoDate を使う
    );

    $col = $mongo->selectDB('testDb')->selectCollection('testCollection');
    $col->save($data);

    var_dump($data);
    echo $data['_id'];

saveメソッドで渡した$dataには挿入されたデータが入ります。
(_idは自動割り振りですが、_idにMongoId情報も入ります。)

出力は下記のような感じ。

array(3) {
  ["text"]=>
  string(27) "テストデータです。"
  ["insertdate"]=>
  object(MongoDate)#2 (2) {
    ["sec"]=>
    int(1278667026)
    ["usec"]=>
    int(160485)
  }
  ["_id"]=>
  object(MongoId)#5 (0) {
  }
}
4c36e9129d5c882710000000

同じIDを指定すれば、上書きされます。
※arrayの内容で上書きされます。

<?php
    $data = array(
        // MongoID を使います
        '_id' => new MongoId('4c36e9129d5c882710000000'),
        'text' => '上書きされます。'
    );

    $col = $mongo->selectDB('testDb')->selectCollection('testCollection');
    $col->save($data);

上記の場合、以前に入れたinsertdateは消えてしまいます。
insertdateを残したい場合は、updateするのがよさそうです。

<?php
    $col = $mongo->selectDB('testDb')->selectCollection('testCollection');
    $col->update(
        array('_id' => new MongoId('4c36e9129d5c882710000000')),
        array('$set' => array('text' => '更新します。'))
    );

もちろん、_idを指定しての挿入も可能です。

<?php
    $col = $mongo->selectDB('testDb')->selectCollection('testCollection');
    $col->save(
        array(
            '_id' => 1,
            'text' => 'テストデータです。',
            'insertdate' => new MongoDate()
        )
    );
データの読み込み

一件のみ見つける。

<?php
    $col = $mongo->selectDB('testDb')->selectCollection('testCollection');
    $item = $col->findOne(array('_id' => new MongoId('4c36e9129d5c882710000000')));

複数件見つける。

<?php
    $col = $mongo->selectDB('testDb')->selectCollection('testCollection');
    $cur = $col->find(array());

    $items = array();
    while ($cur->hasNext()) {
        $items[] = $cur->getNext();
    }


findOne/findともに、返される結果のフィールドを指定出来ます。
下記では、textフィールドのみを取得します。
(フィールド指定の有無を問わず、_idは必ず返ります。)

<?php
    $col = $mongo->selectDB('testDb')->selectCollection('testCollection');
    $item = $col->findOne(
        array('_id' => new MongoId('4c36e9129d5c882710000000')),
        array('text' => true)
    );
データの削除

特筆すべきことも無いですね。

<?php
    $col = $mongo->selectDB('testDb')->selectCollection('testCollection');
    $col->remove(array('_id' => new MongoId('4c36e9129d5c882710000000')));

1件だけ削除を明示したい場合は、オプションとしてjustOneを指定します。

<?php
    $col = $mongo->selectDB('testDb')->selectCollection('testCollection');
    $col->remove(
        array('_id' => new MongoId('4c36e9129d5c882710000000')),
        array('justOne' => true)
    );

詳細は、PHPマニュアル、MongoDBマニュアルで確認してください。
(かなりはしょりました。)

セッションハンドラ

で、上記程度の知識で書いてみたセッションハンドラはこんな感じです。

<?php
/**
 * MongoDB 用セッションハンドラ
 */
class mongoSession
{
    /**
     * @var string Mongoサーバー名
     */
    private static $server;

    /**
     * @var string 持続的な接続を行うかどうか
     */
    private static $persist;

    /**
     * @var string DB名
     */
    private static $db;

    /**
     * @var string セッション保存用コレクション名
     */
    private static $collection;

    /**
     * @var object  Mongoクラスのインスタンス
     */
    private static $mongo = null;

    /**
     * @var object  MongoCollectionクラスのインスタンス
     */
    private static $col = null;

    /**
     * mongoSessionの初期化とカスタムセッションハンドラの登録
     *
     * @param   string  $db         セッション保存用DB名
     * @param   string  $collection セッション保存用コレクション名
     * @param   string  $server     サーバ名
     * @param   string  $persist    持続的な接続を行うかどうか
     * @return  bool    セッションハンドラの登録に成功した場合に TRUE を、
                        失敗した場合に FALSE を返す
     */
    public static function init($db = 'php', $collection = 'sessions',
        $server = 'mongodb://localhost:27017', $persist = null)
    {
        // それぞれのオプションを設定
        self::$db = $db;
        self::$collection = $collection;
        self::$server = $server;
        self::$persist = $persist;

        // カスタムセッションハンドラとして登録
        return session_set_save_handler(
            array(__CLASS__, 'open'),
            array(__CLASS__, 'close'),
            array(__CLASS__, 'read'),
            array(__CLASS__, 'write'),
            array(__CLASS__, 'destroy'),
            array(__CLASS__, 'gc')
        );
    }

    /**
     * セッションがオープンした際に実行されます
     *
     * @param   string  $save_path      保存パス
     * @param   string  $session_name   セッション名
     * @return  bool    成功した場合に TRUE を、失敗した場合に FALSE を返す
     */
    public static function open($save_path, $session_name)
    {
        // すでにインスタンスがあれば何もしない
        if (!is_null(self::$mongo)) {
            return;
        }

        // MongoDB接続オプションの設定
        $options = array('connect' => true);
        if (self::$persist) $options['persist'] = self::$persist;

        // MongoDBへ接続
        self::$mongo = new Mongo(self::$server, $options);
        if (self::$mongo->connected) {
            self::$col = self::$mongo->selectDB(self::$db)->selectCollection(self::$collection);
        }

        return self::$mongo->connected;
    }

    /**
     * セッションの操作が終了した際に実行されます
     *
     * @return  bool    成功した場合に TRUE を、失敗した場合に FALSE を返す
     */
    public static function close()
    {
        return true;
    }

    /**
     * 保存されたセッションデータを読み込みます
     *
     * @param   string  $id     セッションID
     * @return  string  セッションデータを返します
     */
    public static function read($id)
    {
        if (!self::$col) return '';
        $item = self::$col->findOne(
            array('_id' => $id), array('data' => true)
        );

        return (string) $item['data'];
    }

    /**
     * セッションデータを保存します
     *
     * @param   string  $id         セッションID
     * @param   string  $sess_data  セッションデータ
     * @return  bool    成功した場合に TRUE を、失敗した場合に FALSE を返す
     */
    public static function write($id, $sess_data)
    {
        if (!self::$col) return false;
        return self::$col->save(array(
                '_id' => $id,
                'data' => $sess_data,
                'ts' => new MongoDate() // タイムスタンプ
            ), array('safe' => true));
    }

    /**
     * セッションが session_destroy()  で破棄された際に実行されます。
     *
     * @param   string  $id         セッションID
     * @return  bool    成功した場合に TRUE を、失敗した場合に FALSE を返す
     */
    public static function destroy($id)
    {
        return self::$col->remove(
            array('_id' => $id), array('justOne' => true)
        );
    }

    /**
     * ガベージコレクタが実行されたときに実行されます。
     *
     * @param   int  $maxlifetime   最大有効期間
     * @return  bool    成功した場合に TRUE を、失敗した場合に FALSE を返す
     */
    public static function gc($maxlifetime)
    {
        return self::$col->remove(
            array(
                // 現在日時より $maxlifetime 以上古いデータは無効
                'ts' => array('$lt' => new MongoDate(time() - $maxlifetime))
            )
        );
    }
}

次のスクリプトでmongoSessionが正常に動作するか試して見ます。

<?php
    mongoSession::init('test', 'phpsess', 'mongodb://localhost:27017', 'cPHPSess');

    session_start();
    $backsess = $_SESSION;

    if (isset($_GET['m']) && $_GET['m'] == 'logout') {
        // セッション変数を全て解除する
        $_SESSION = array();

        $params = session_get_cookie_params();
        setcookie(session_name(), '', time() - 42000,
            $params["path"], $params["domain"],
            $params["secure"]);

        // 最終的に、セッションを破壊する
        session_destroy();
    } else {
        $_SESSION['count'] = (isset($_SESSION['count']) && $_SESSION['count'])
            ? ($_SESSION['count']) + 1
            : 1;
        $_SESSION['time'] = date('Y-m-d H:i:s', time());
        $_SESSION['rand'] = mt_rand();
    }
    ?>
    <h2>古い</h2>
    <pre>
    <?php
        var_dump($backsess);
    ?></pre>
    <h2>新しい</h2>
    <pre>
    <?php
        var_dump($_SESSION);
    ?></pre>
    <a href="?m=<?=$_SESSION['rand']?>">インクリメント</a>
    <a href="?m=logout">ログアウト</a>

うん。ちゃんと動いているみたいです。
あらかじめDB、テーブルを作っておかなくていいのは、とっても楽ちんです。

間違いなどありましたら、ご指摘ください。m(_ _)m