【CodeIgniter】CodeIgniterでSession Driver作ってみる。【Redis】

CodeIgniterでSession Driverを作ってみる。

こんばんは、マークアップエンジニアの です。

CodeIgniter Advent Calendar 2015 - Qiita 24日目の記事です。

この記事はCodeIgniter 3.1.0-devにて検証しています。

前回の composer_autoloadの記事の続きまして
predisでRedis Session Driverを作ってみた記事です。

predisを使ってSession Driverを作ってみる

Session Driverって?

PHPのSession保存したいな」って時に
DBに保存するよ!
とかfileに保存するよ!
とかの処理が記述されているファイルです。

実はCodeIgniterってPHPRedisを使えば、CodeIgniter純正のSession Driverが使えます。
https://www.codeigniter.com/user_guide/libraries/sessions.html#redis-driver

せっかくなのでpredisを使ってSession Driverを実装してみます。
predisにはSession Handlerも用意されていますが、今回はそれも使いません。


Session Driverの書く準備

Session Driverを書きたい時は
「CodeIgniter/system/libraries/Session/SessionHandlerInterface.php
を見ると
interfaceが定義されているのでどんなメソッドを用意したら良いかがすぐにわかります。

Interfaceで定義されているメソッドは下記のメソッドです。

  1. open
  2. close
  3. read
  4. write
  5. destroy
  6. gc

session_set_save_handlerで必要な物なのでわかりやすいです。
http://php.net/manual/ja/function.session-set-save-handler.php

また、applicationディレクトリでSession Driverを作りたいときは

「CodeIgniter/application/libraries/Session/drivers/[sub class prefix]_Session_[driver名]_driver.php

にファイルを置くとdriverとして読み込めます。



実際に書いてみた。

今回は元々Session Driverに「Session_redis_driver.php」があるので流用しました。

「system/libraries/Session/drivers/Session_redis_driver.php
をコピーして
「application/libraries/Session/drivers/MY_Session_predis_driver.php
の作成

やった事。

・configにredis driverの設定みたいに記述
・configからとってくる情報をPHPRedisでもpredisでも使える形で整える。
・predis用にsetTimeoutメソッドやdeleteメソッドを「expire」や「del」メソッドに書き換え

configにredis driverの設定みたいに記述

application/config/config.php

<?php
$config['sess_driver'] = 'predis';
$config['sess_cookie_name'] = 'ci_session';
$config['sess_expiration'] = 7200;
$config['sess_save_path'] = 'tcp://localhost:6379?timeout=300.0&database=1&auth=hoge';
$config['sess_match_ip'] = FALSE;
$config['sess_time_to_update'] = 300;
$config['sess_regenerate_destroy'] = FALSE;

こんな感じで記述します。
デフォルトからの変更点は


・sess_driver
file => predis

・sess_save_path
'' => 'tcp://localhost:6379?timeout=300.0&database=1&auth=hoge'

です。

CodeIgniter純正のRedisドライバーでも「sess_save_path」は同じ設定で動きます。

configからとってくる情報をPHPRedisでもpredisでも使える形で整える

MY_Session_predis_driver.php : constructor

<?php
	public function __construct(&$params)
	{
		parent::__construct($params);

		if (empty($this->_config['save_path']))
		{
			log_message('error', 'Session: No Redis save path configured.');
		}
		elseif (preg_match('#^unix://([^¥?]+)(?<options>¥?.+)?$#', $this->_config['save_path'], $matches))
		{
                         //redis driverと同じ形式で、predisに形式を揃える。
			//$save_path = array('path' => $matches[1]);
			$save_path = array(
				'scheme' => 'unix',
				'host'      => $matches[1]
			);
		}
		elseif (preg_match('#(?:tcp://)?([^:?]+)(?:¥:(¥d+))?(?<options>¥?.+)?#', $this->_config['save_path'], $matches))
		{
                        //redis driverと同じ形式で、predisに形式を揃える。
			$save_path = array(
                                'scheme' => 'tcp',
				'host'      => $matches[1],
				'port'       => empty($matches[2]) ? NULL : $matches[2]
			);
		}
		else
		{
			log_message('error', 'Session: Invalid Redis save path format: '.$this->_config['save_path']);
		}


MY_Session_predis_driver.php : open

<?php
	public function open($save_path, $name)
	{
		if (empty($this->_config['save_path']))
		{
			return $this->_failure;
		}

		//save_pathの中身が全部predisっぽくなってたのでそのまま流用
		/*$redis = new Redis();
		$connected = isset($this->_config['save_path']['path'])
			? $redis->connect($this->_config['save_path']['path'])
			: $redis->connect(
				$this->_config['save_path']['host'],
				$this->_config['save_path']['port'],
				$this->_config['save_path']['timeout']
			);*/
		foreach ($this->_config['save_path'] as $key => $val) if ( ! isset($val) ) unset($this->_config['save_path'][$key]);
		$connected = new Predis\Client($this->_config['save_path']);


		if ($connected)
		{
                        //このへんインスタンス化時に渡してるので不要
			/*if (isset($this->_config['save_path']['password']) && ! $redis->auth($this->_config['save_path']['password']))
			{
				log_message('error', 'Session: Unable to authenticate to Redis instance.');
			}
			elseif (isset($this->_config['save_path']['database']) && ! $redis->select($this->_config['save_path']['database']))
			{
				log_message('error', 'Session: Unable to select Redis database with index '.$this->_config['save_path']['database']);
			}
			else
			{
				$this->_redis = $redis;*/
				$this->_redis = $connected;
				return $this->_success;
			//}
		}
		else
		{
			log_message('error', 'Session: Unable to connect to Redis with the configured settings.');
		}

		return $this->_failure;
	}

こんな感じで、config.phpからの値をpredisに渡しても大丈夫なように修正。
openメソッドでpredisのインスタンス化してPredis\Clientインスタンスを_redisメンバ変数に代入。


predis用にsetTimeoutメソッドやdeleteメソッドを「expire」や「del」メソッドに書き換え
<?php
public function write($session_id, $session_data)
	{
		if ( ! isset($this->_redis))
		{
			return $this->_failure;
		}
		// Was the ID regenerated?
		elseif ($session_id !== $this->_session_id)
		{
			if ( ! $this->_release_lock() OR ! $this->_get_lock($session_id))
			{
				return $this->_failure;
			}

			$this->_fingerprint = md5('');
			$this->_session_id = $session_id;
		}

		if (isset($this->_lock_key))
		{
			//predis用に書き換え。
                        //PHPRedisのsetTimeoutの第三引数ってmilisecじゃなくてsecなのでexpireで代替。
			//$this->_redis->setTimeout($this->_lock_key, 300);
			$this->_redis->expire($this->_lock_key, 300);
			if ($this->_fingerprint !== ($fingerprint = md5($session_data)))
			{
				//predisではsetの第三引数で有効期限を指定できないので
				//pipeline(パイプライン)で代用
				//if ($this->_redis->set($this->_key_prefix.$session_id, $session_data, $this->_config['expiration']))
				$resp = $this->_redis
					->pipeline()
					->set($this->_key_prefix.$session_id, $session_data)
					->expire($this->_key_prefix.$session_id, $this->_config['expiration'])
					->execute();

				if ( ! empty($resp) )
				{
					$this->_fingerprint = $fingerprint;
					return $this->_success;
				}

				return $this->_failure;
			}

			//setTimeoutはつかえないよ!
			//return ($this->_redis->setTimeout($this->_key_prefix.$session_id, $this->_config['expiration']))
			return ($this->_redis->expire($this->_key_prefix.$session_id, $this->_config['expiration']))
				? $this->_success
				: $this->_failure;
		}

		return $this->_failure;
	}

setTimeout => expire
delete => del
predisっぽい感じで修正して完成です。


できたやつ

MY_Session_predis_driver.php

<?php
defined('BASEPATH') OR exit('No direct script access allowed');

class MY_Session_predis_driver extends CI_Session_driver implements SessionHandlerInterface {

	protected $_redis;

	protected $_key_prefix = 'ci_session:';

	protected $_lock_key;

	public function __construct(&$params)
	{
		parent::__construct($params);

		if (empty($this->_config['save_path']))
		{
			log_message('error', 'Session: No Redis save path configured.');
		}
		elseif (preg_match('#^unix://([^¥?]+)(?<options>¥?.+)?$#', $this->_config['save_path'], $matches))
		{
			$save_path = array(
				'scheme' => 'unix',
				'host'   => $matches[1]
			);
		}
		elseif (preg_match('#(?:tcp://)?([^:?]+)(?:¥:(¥d+))?(?<options>¥?.+)?#', $this->_config['save_path'], $matches))
		{
			$save_path = array(
				'scheme' => 'tcp',
				'host'   => $matches[1],
				'port'   => empty($matches[2]) ? NULL : $matches[2]
			);
		}
		else
		{
			log_message('error', 'Session: Invalid Redis save path format: '.$this->_config['save_path']);
		}

		if (isset($save_path))
		{
			if (isset($matches['options']))
			{
				$save_path['password'] = preg_match('#auth=([^¥s&]+)#', $matches['options'], $match) ? $match[1] : NULL;
				$save_path['database'] = preg_match('#database=(¥d+)#', $matches['options'], $match) ? (int) $match[1] : NULL;
				$save_path['timeout']  = preg_match('#timeout=(¥d+¥.¥d+)#', $matches['options'], $match) ? (float) $match[1] : NULL;

				preg_match('#prefix=([^¥s&]+)#', $matches['options'], $match) && $this->_key_prefix = $match[1];
			}
			$this->_config['save_path'] = $save_path;

			if ($this->_config['match_ip'] === TRUE)
			{
				$this->_key_prefix .= $_SERVER['REMOTE_ADDR'].':';
			}
		}
	}

	public function open($save_path, $name)
	{
		if (empty($this->_config['save_path']))
		{
			return $this->_failure;
		}

		foreach ($this->_config['save_path'] as $key => $val) if ( ! isset($val) ) unset($this->_config['save_path'][$key]);
		$connected = new Predis\Client($this->_config['save_path']);
		if ($connected)
		{
			$this->_redis = $connected;
			return $this->_success;
		}
		else
		{
			log_message('error', 'Session: Unable to connect to Redis with the configured settings.');
		}

		return $this->_failure;
	}

	public function read($session_id)
	{
		if (isset($this->_redis) && $this->_get_lock($session_id))
		{
			// Needed by write() to detect session_regenerate_id() calls
			$this->_session_id = $session_id;

			$session_data = (string) $this->_redis->get($this->_key_prefix.$session_id);
			$this->_fingerprint = md5($session_data);
			return $session_data;
		}

		return $this->_failure;
	}


	public function write($session_id, $session_data)
	{
		if ( ! isset($this->_redis))
		{
			return $this->_failure;
		}
		// Was the ID regenerated?
		elseif ($session_id !== $this->_session_id)
		{
			if ( ! $this->_release_lock() OR ! $this->_get_lock($session_id))
			{
				return $this->_failure;
			}

			$this->_fingerprint = md5('');
			$this->_session_id = $session_id;
		}

		if (isset($this->_lock_key))
		{
			$this->_redis->expire($this->_lock_key, 300);
			if ($this->_fingerprint !== ($fingerprint = md5($session_data)))
			{
				$resp = $this->_redis
					->pipeline()
					->set($this->_key_prefix.$session_id, $session_data)
					->expire($this->_key_prefix.$session_id, $this->_config['expiration'])
					->execute();

				if ( ! empty($resp) )
				{
					$this->_fingerprint = $fingerprint;
					return $this->_success;
				}

				return $this->_failure;
			}

			return ($this->_redis->expire($this->_key_prefix.$session_id, $this->_config['expiration']))
				? $this->_success
				: $this->_failure;
		}

		return $this->_failure;
	}

	public function close()
	{
		if (isset($this->_redis))
		{
			try {
				$ping = $this->_redis->ping();
				if ( $ping->getPayload() === 'PONG')
				{
					isset($this->_lock_key) && $this->_redis->del($this->_lock_key);
					if ( ! $this->_redis->quit())
					{
						return $this->_failure;
					}
				}
			}
			catch (RedisException $e)
			{
				log_message('error', 'Session: Got RedisException on close(): '.$e->getMessage());
			}

			$this->_redis = NULL;
			return $this->_success;
		}

		return $this->_success;
	}

	public function destroy($session_id)
	{
		if (isset($this->_redis, $this->_lock_key))
		{
			if (($result = $this->_redis->del($this->_key_prefix.$session_id)) !== 1)
			{
				log_message('debug', 'Session: Redis::delete() expected to return 1, got '.var_export($result, TRUE).' instead.');
			}

			$this->_cookie_destroy();
			return $this->_success;
		}

		return $this->_failure;
	}

	public function gc($maxlifetime)
	{
		// Not necessary, Redis takes care of that.
		return $this->_success;
	}

	protected function _get_lock($session_id)
	{
		if (isset($this->_lock_key))
		{
			return $this->_redis->expire($this->_lock_key, 300);
		}

		// 30 attempts to obtain a lock, in case another request already has it
		$lock_key = $this->_key_prefix.$session_id.':lock';
		$attempt = 0;
		do
		{
			if (($ttl = $this->_redis->ttl($lock_key)) > 0)
			{
				sleep(1);
				continue;
			}

			if ( ! $this->_redis->setex($lock_key, 300, time()))
			{
				log_message('error', 'Session: Error while trying to obtain lock for '.$this->_key_prefix.$session_id);
				return FALSE;
			}

			$this->_lock_key = $lock_key;
			break;
		}
		while (++$attempt < 30);

		if ($attempt === 30)
		{
			log_message('error', 'Session: Unable to obtain lock for '.$this->_key_prefix.$session_id.' after 30 attempts, aborting.');
			return FALSE;
		}
		elseif ($ttl === -1)
		{
			log_message('debug', 'Session: Lock for '.$this->_key_prefix.$session_id.' had no TTL, overriding.');
		}

		$this->_lock = TRUE;
		return TRUE;
	}

	protected function _release_lock()
	{
		if (isset($this->_redis, $this->_lock_key) && $this->_lock)
		{
			if ( ! $this->_redis->del($this->_lock_key))
			{
				log_message('error', 'Session: Error while trying to free lock for '.$this->_lock_key);
				return FALSE;
			}

			$this->_lock_key = NULL;
			$this->_lock = FALSE;
		}

		return TRUE;
	}

}

ちょっと長くて申し訳ないですが、Session_redis_driver.phpとdiffを取ってみると30行以下の変更で実装できました。
もし、「環境がPHPRedisは入れれないけどcomposerは使えるよ」
という場合に参考にして頂けると幸いです。


まとめ

PHPのsession_set_save_handler部文のドキュメント読めば、どんな風に書けば良いかわかるのでSession Driver作りたいなって時はご参考に。
・Session Driver作るときはlibraryとか拡張する時みたいにsubclass prefixが必要ですよ!
・ぶっちゃけCodeIgniterの純正Session Driverで殆ど困らない。
・predisにはprefixとかが機能としてあったり、レプリケーションとかあったりするけどその辺の処理してない。
・前回つくったlibraryを全然使ってないのでconfigの取り回しが思いついたら使う様にしたい。


まだCodeIgniter Advent Calendar 最終日に空きがありますので、年納めにいかがでしょうか?
それでは。