diff --git a/Cache.php b/Cache.php index 0f39985..c2ebd5c 100644 --- a/Cache.php +++ b/Cache.php @@ -2,7 +2,137 @@ namespace dcb9\redis; +use yii\di\Instance; + class Cache extends \yii\caching\Cache { + /** + * @var Connection|string|array the Redis [[Connection]] object or the application component ID of the Redis [[Connection]]. + * This can also be an array that is used to create a redis [[Connection]] instance in case you do not want do configure + * redis connection as an application component. + * After the Cache object is created, if you want to change this property, you should only assign it + * with a Redis [[Connection]] object. + */ + public $redis = 'redis'; + + /** + * Initializes the redis Cache component. + * This method will initialize the [[redis]] property to make sure it refers to a valid redis connection. + * @throws \yii\base\InvalidConfigException if [[redis]] is invalid. + */ + public function init() + { + parent::init(); + $this->redis = Instance::ensure($this->redis, Connection::className()); + $this->redis->open(); + } + + + /** + * @inheritdoc + */ + public function exists($key) + { + $key = $this->buildKey($key); + + return (bool)$this->redis->exists($key); + } + + /** + * @inheritdoc + */ + protected function getValue($key) + { + return $this->redis->get($key); + } + + /** + * @inheritdoc + */ + protected function getValues($keys) + { + $response = $this->redis->mget($keys); + $result = []; + $i = 0; + foreach ($keys as $key) { + $result[$key] = $response[$i++]; + } + + return $result; + } + + /** + * @inheritdoc + */ + protected function setValue($key, $value, $expire) + { + if ($expire == 0) { + return (bool)$this->redis->set($key, $value); + } else { + return (bool)$this->redis->setex($key, $expire, $value); + } + } + + /** + * @inheritdoc + */ + protected function setValues($data, $expire) + { + $args = []; + foreach ($data as $key => $value) { + $args[] = $key; + $args[] = $value; + } + $failedKeys = []; + if ($expire == 0) { + $this->redis->mset($args); + } else { + $expire = (int)($expire * 1000); + $this->redis->multi(); + $this->redis->mset($args); + $index = []; + foreach ($data as $key => $value) { + $this->redis->expire($key, $expire); + $index[] = $key; + } + $result = $this->redis->exec(); + array_shift($result); + foreach ($result as $i => $r) { + if ($r != 1) { + $failedKeys[] = $index[$i]; + } + } + } + + return $failedKeys; + } + + + /** + * @inheritdoc + */ + protected function addValue($key, $value, $expire) + { + if ($expire == 0) { + return (bool)$this->redis->set($key, $value); + } + + return (bool)$this->redis->setex($key, $expire, $value); + } + + /** + * @inheritdoc + */ + protected function deleteValue($key) + { + return (bool)$this->redis->del($key); + } + /** + * @inheritdoc + */ + protected function flushValues() + { + return $this->redis->flushdb(); + } } diff --git a/Connection.php b/Connection.php index 0637ea9..2d2b243 100644 --- a/Connection.php +++ b/Connection.php @@ -2,9 +2,113 @@ namespace dcb9\redis; -use yii\base\Component; +use Redis; +use Yii; +use yii\base\Configurable; +use RedisException; -class Connection extends Component +/** + * Class Connection + * @package dcb9\redis + */ +class Connection extends Redis implements Configurable { + /** + * @var string the hostname or ip address to use for connecting to the redis server. Defaults to 'localhost'. + * If [[unixSocket]] is specified, hostname and port will be ignored. + */ + public $hostname = 'localhost'; + /** + * @var integer the port to use for connecting to the redis server. Default port is 6379. + * If [[unixSocket]] is specified, hostname and port will be ignored. + */ + public $port = 6379; + /** + * @var string the unix socket path (e.g. `/var/run/redis/redis.sock`) to use for connecting to the redis server. + * This can be used instead of [[hostname]] and [[port]] to connect to the server using a unix socket. + * If a unix socket path is specified, [[hostname]] and [[port]] will be ignored. + */ + public $unixSocket; + /** + * @var string the password for establishing DB connection. Defaults to null meaning no AUTH command is send. + * See http://redis.io/commands/auth + */ + public $password; + /** + * @var integer the redis database to use. This is an integer value starting from 0. Defaults to 0. + */ + public $database = 0; + /** + * @var float value in seconds (optional, default is 0.0 meaning unlimited) + */ + public $connectionTimeout = 0.0; + /** + * Constructor. + * The default implementation does two things: + * + * - Initializes the object with the given configuration `$config`. + * - Call [[init()]]. + * + * If this method is overridden in a child class, it is recommended that + * + * - the last parameter of the constructor is a configuration array, like `$config` here. + * - call the parent implementation at the end of the constructor. + * + * @param array $config name-value pairs that will be used to initialize the object properties + */ + public function __construct($config = []) + { + if (!empty($config)) { + Yii::configure($this, $config); + } + } + + /** + * Returns the fully qualified name of this class. + * @return string the fully qualified name of this class. + */ + public static function className() + { + return get_called_class(); + } + + /** + * Establishes a DB connection. + * It does nothing if a DB connection has already been established. + * @throws RedisException if connection fails + */ + public function open() + { + if ($this->unixSocket !== null) { + $isConnected = $this->connect($this->unixSocket); + } else { + $isConnected = $this->connect($this->hostname, $this->port, $this->connectionTimeout); + } + + if ($isConnected === false) { + throw new RedisException('Connect to redis server error.'); + } + + if ($this->password !== null) { + $this->auth($this->password); + } + + if ($this->database !== null) { + $this->select($this->database); + } + } + + /** + * @return bool + */ + public function ping() + { + return parent::ping() === '+PONG'; + } + + public function flushdb() + { + return parent::flushDB(); + } } diff --git a/Session.php b/Session.php index c236b3e..5425b61 100644 --- a/Session.php +++ b/Session.php @@ -2,7 +2,149 @@ namespace dcb9\redis; +use yii\base\InvalidConfigException; +use Yii; + +/** + * Redis Session implements a session component using [redis](http://redis.io/) as the storage medium. + * + * Redis Session requires redis version 2.6.12 or higher to work properly. + * + * It needs to be configured with a redis [[Connection]] that is also configured as an application component. + * By default it will use the `redis` application component. + * + * To use redis Session as the session application component, configure the application as follows, + * + * ~~~ + * [ + * 'components' => [ + * 'session' => [ + * 'class' => 'dcb9\redis\Session', + * 'redis' => [ + * 'hostname' => 'localhost', + * 'port' => 6379, + * 'database' => 0, + * ] + * ], + * ], + * ] + * ~~~ + * + * Or if you have configured the redis [[Connection]] as an application component, the following is sufficient: + * + * ~~~ + * [ + * 'components' => [ + * 'session' => [ + * 'class' => 'dcb9\redis\Session', + * // 'redis' => 'redis' // id of the connection application component + * ], + * ], + * ] + * ~~~ + * + * @property boolean $useCustomStorage Whether to use custom storage. This property is read-only. + * + * @author Bob chengbin + */ class Session extends \yii\web\Session { + /** + * @var Connection|string|array the Redis [[Connection]] object or the application component ID of the Redis [[Connection]]. + * This can also be an array that is used to create a redis [[Connection]] instance in case you do not want do configure + * redis connection as an application component. + * After the Session object is created, if you want to change this property, you should only assign it + * with a Redis [[Connection]] object. + */ + public $redis = 'redis'; + + /** + * @var string a string prefixed to every cache key so that it is unique. If not set, + * it will use a prefix generated from [[Application::id]]. You may set this property to be an empty string + * if you don't want to use key prefix. It is recommended that you explicitly set this property to some + * static value if the cached data needs to be shared among multiple applications. + */ + public $keyPrefix; + + /** + * Initializes the redis Session component. + * This method will initialize the [[redis]] property to make sure it refers to a valid redis connection. + * @throws InvalidConfigException if [[redis]] is invalid. + */ + public function init() + { + if (is_string($this->redis)) { + $this->redis = Yii::$app->get($this->redis); + } elseif (is_array($this->redis)) { + if (!isset($this->redis['class'])) { + $this->redis['class'] = Connection::className(); + } + $this->redis = Yii::createObject($this->redis); + } + if (!$this->redis instanceof Connection) { + throw new InvalidConfigException("Session::redis must be either a Redis connection instance or the application component ID of a Redis connection."); + } + if ($this->keyPrefix === null) { + $this->keyPrefix = substr(md5(Yii::$app->id), 0, 5); + } + $this->redis->open(); + + parent::init(); + } + + /** + * Returns a value indicating whether to use custom session storage. + * This method overrides the parent implementation and always returns true. + * @return boolean whether to use custom storage. + */ + public function getUseCustomStorage() + { + return true; + } + + /** + * Session read handler. + * Do not call this method directly. + * @param string $id session ID + * @return string the session data + */ + public function readSession($id) + { + $data = $this->redis->get($this->calculateKey($id)); + + return $data === false || $data === null ? '' : $data; + } + + /** + * Session write handler. + * Do not call this method directly. + * @param string $id session ID + * @param string $data session data + * @return boolean whether session write is successful + */ + public function writeSession($id, $data) + { + return (bool)$this->redis->setex($this->calculateKey($id), $this->getTimeout(), $data); + } + + /** + * Session destroy handler. + * Do not call this method directly. + * @param string $id session ID + * @return boolean whether session is destroyed successfully + */ + public function destroySession($id) + { + return (bool)$this->redis->del($this->calculateKey($id)); + } + /** + * Generates a unique key used for storing session data in cache. + * @param string $id session variable name + * @return string a safe cache key associated with the session variable name + */ + protected function calculateKey($id) + { + return $this->keyPrefix . md5(json_encode([__CLASS__, $id])); + } } diff --git a/tests/CacheTest.php b/tests/CacheTest.php index 0253e76..2387274 100644 --- a/tests/CacheTest.php +++ b/tests/CacheTest.php @@ -2,7 +2,77 @@ namespace dcb9\redis\tests; +use dcb9\redis\Cache; +use dcb9\redis\Connection; + class CacheTest extends TestCase { + private $_cacheInstance = null; + + /** + * @return Cache + */ + protected function getCacheInstance() + { + $params = self::getParam(); + $connection = new Connection($params); + $this->mockApplication(['components' => ['redis' => $connection]]); + if ($this->_cacheInstance === null) { + $this->_cacheInstance = new Cache(); + } + + return $this->_cacheInstance; + } + + /** + * Store a value that is 2 times buffer size big + * https://github.com/yiisoft/yii2/issues/743 + */ + public function testLargeData() + { + $cache = $this->getCacheInstance(); + $data = str_repeat('XX', 8192); // http://www.php.net/manual/en/function.fread.php + $key = 'bigdata1'; + $this->assertFalse($cache->get($key)); + $cache->set($key, $data); + $this->assertTrue($cache->get($key) === $data); + // try with multibyte string + $data = str_repeat('ЖЫ', 8192); // http://www.php.net/manual/en/function.fread.php + $key = 'bigdata2'; + $this->assertFalse($cache->get($key)); + $cache->set($key, $data); + $this->assertTrue($cache->get($key) === $data); + } + + /** + * Store a megabyte and see how it goes + * https://github.com/yiisoft/yii2/issues/6547 + */ + public function testReallyLargeData() + { + $cache = $this->getCacheInstance(); + $keys = []; + for ($i = 1; $i < 16; $i++) { + $key = 'realbigdata' . $i; + $data = str_repeat('X', 100 * 1024); // 100 KB + $keys[$key] = $data; +// $this->assertTrue($cache->get($key) === false); // do not display 100KB in terminal if this fails :) + $cache->set($key, $data); + } + $values = $cache->mget(array_keys($keys)); + foreach ($keys as $key => $value) { + $this->assertArrayHasKey($key, $values); + $this->assertTrue($values[$key] === $value); + } + } + public function testMultiByteGetAndSet() + { + $cache = $this->getCacheInstance(); + $data = ['abc' => 'ежик', 2 => 'def']; + $key = 'data1'; + $this->assertFalse($cache->get($key)); + $cache->set($key, $data); + $this->assertTrue($cache->get($key) === $data); + } } diff --git a/tests/ConnectionTest.php b/tests/ConnectionTest.php index cb68c3b..03411e9 100644 --- a/tests/ConnectionTest.php +++ b/tests/ConnectionTest.php @@ -4,4 +4,64 @@ class ConnectionTest extends TestCase { + /** + * test connection to redis and selection of db + */ + public function testConnect() + { + $db = $this->getConnection(false); + $database = $db->database; + $db->open(); + $this->assertTrue($db->ping()); + $db->set('YIITESTKEY', 'YIITESTVALUE'); + $db->close(); + + $db = $this->getConnection(false); + $db->database = $database; + $db->open(); + $this->assertEquals('YIITESTVALUE', $db->get('YIITESTKEY')); + $db->close(); + + $db = $this->getConnection(false); + $db->database = 1; + $db->open(); + $this->assertFalse($db->get('YIITESTKEY')); + $db->close(); + } + + /** + * tests whether close cleans up correctly so that a new connect works + */ + public function testReConnect() + { + $db = $this->getConnection(false); + $db->open(); + $this->assertTrue($db->ping()); + $db->close(); + $db->open(); + $this->assertTrue($db->ping()); + $db->close(); + } + + public function keyValueData() + { + return [ + [123], + [-123], + [0], + ['test'], + ["test\r\ntest"], + [''], + ]; + } + + /** + * @dataProvider keyValueData + */ + public function testStoreGet($data) + { + $db = $this->getConnection(true); + $db->set('hi', $data); + $this->assertEquals($data, $db->get('hi')); + } } diff --git a/tests/SessionTest.php b/tests/SessionTest.php new file mode 100644 index 0000000..237f641 --- /dev/null +++ b/tests/SessionTest.php @@ -0,0 +1,33 @@ +mockApplication([ + 'components' => [ + 'redis' => $params, + 'session' => 'dcb9\\redis\\Session', + ] + ]); + + $sessionId = 'sessionId'; + $session = Yii::$app->session; + $session->setTimeout(1); + $sessionData = json_encode([ + 'sessionId' => $sessionId, + 'username' => 'bob', + ]); + $session->writeSession($sessionId, $sessionData); + $this->assertEquals($sessionData, $session->readSession($sessionId)); + $this->assertTrue($session->destroySession($sessionId)); + $this->assertEquals('', $session->readSession($sessionId)); + } +}