From 3b91801a353d70dfa58a0a089e19ae19d3f42138 Mon Sep 17 00:00:00 2001 From: Carsten Brandt Date: Tue, 27 Aug 2013 17:29:38 +0200 Subject: [PATCH] cherry picked RedisCache and redis\Connection from redis WIP branch commit in redis branch was: 0cd65e7496befb3c4aed86e47257103fea63d329 --- framework/yii/caching/RedisCache.php | 198 +++++++++++ framework/yii/db/redis/Connection.php | 425 ++++++++++++++++++++++++ framework/yii/db/redis/Transaction.php | 91 +++++ tests/unit/framework/caching/RedisCacheTest.php | 83 +++++ 4 files changed, 797 insertions(+) create mode 100644 framework/yii/caching/RedisCache.php create mode 100644 framework/yii/db/redis/Connection.php create mode 100644 framework/yii/db/redis/Transaction.php create mode 100644 tests/unit/framework/caching/RedisCacheTest.php diff --git a/framework/yii/caching/RedisCache.php b/framework/yii/caching/RedisCache.php new file mode 100644 index 0000000..0c8bf15 --- /dev/null +++ b/framework/yii/caching/RedisCache.php @@ -0,0 +1,198 @@ +array( + * 'cache'=>array( + * 'class'=>'RedisCache', + * 'hostname'=>'localhost', + * 'port'=>6379, + * 'database'=>0, + * ), + * ), + * ) + * ~~~ + * + * @author Carsten Brandt + * @since 2.0 + */ +class RedisCache extends Cache +{ + /** + * @var string hostname to use for connecting to the redis server. Defaults to 'localhost'. + */ + public $hostname = 'localhost'; + /** + * @var int the port to use for connecting to the redis server. Default port is 6379. + */ + public $port = 6379; + /** + * @var string the password to use to authenticate with the redis server. If not set, no AUTH command will be sent. + */ + public $password; + /** + * @var int the redis database to use. This is an integer value starting from 0. Defaults to 0. + */ + public $database = 0; + /** + * @var float timeout to use for connection to redis. If not set the timeout set in php.ini will be used: ini_get("default_socket_timeout") + */ + public $connectionTimeout = null; + /** + * @var float timeout to use for redis socket when reading and writing data. If not set the php default value will be used. + */ + public $dataTimeout = null; + /** + * @var \yii\db\redis\Connection the redis connection + */ + private $_connection; + + + /** + * Initializes the cache component by establishing a connection to the redis server. + */ + public function init() + { + parent::init(); + $this->getConnection(); + } + + /** + * Returns the redis connection object. + * Establishes a connection to the redis server if it does not already exists. + * + * TODO throw exception on error + * @return \yii\db\redis\Connection + */ + public function getConnection() + { + if ($this->_connection === null) { + $this->_connection = new Connection(array( + 'dsn' => 'redis://' . $this->hostname . ':' . $this->port . '/' . $this->database, + 'password' => $this->password, + 'connectionTimeout' => $this->connectionTimeout, + 'dataTimeout' => $this->dataTimeout, + )); + } + return $this->_connection; + } + + /** + * Retrieves a value from cache with a specified key. + * This is the implementation of the method declared in the parent class. + * @param string $key a unique key identifying the cached value + * @return string|boolean the value stored in cache, false if the value is not in the cache or expired. + */ + protected function getValue($key) + { + return $this->_connection->executeCommand('GET', array($key)); + } + + /** + * Retrieves multiple values from cache with the specified keys. + * @param array $keys a list of keys identifying the cached values + * @return array a list of cached values indexed by the keys + */ + protected function getValues($keys) + { + $response = $this->_connection->executeCommand('MGET', $keys); + $result = array(); + $i = 0; + foreach($keys as $key) { + $result[$key] = $response[$i++]; + } + return $result; + } + + /** + * Stores a value identified by a key in cache. + * This is the implementation of the method declared in the parent class. + * + * @param string $key the key identifying the value to be cached + * @param string $value the value to be cached + * @param float $expire the number of seconds in which the cached value will expire. 0 means never expire. + * This can be a floating point number to specify the time in milliseconds. + * @return boolean true if the value is successfully stored into cache, false otherwise + */ + protected function setValue($key,$value,$expire) + { + if ($expire == 0) { + return (bool) $this->_connection->executeCommand('SET', array($key, $value)); + } else { + $expire = (int) ($expire * 1000); + return (bool) $this->_connection->executeCommand('PSETEX', array($key, $expire, $value)); + } + } + + /** + * Stores a value identified by a key into cache if the cache does not contain this key. + * This is the implementation of the method declared in the parent class. + * + * @param string $key the key identifying the value to be cached + * @param string $value the value to be cached + * @param float $expire the number of seconds in which the cached value will expire. 0 means never expire. + * This can be a floating point number to specify the time in milliseconds. + * @return boolean true if the value is successfully stored into cache, false otherwise + */ + protected function addValue($key,$value,$expire) + { + if ($expire == 0) { + return (bool) $this->_connection->executeCommand('SETNX', array($key, $value)); + } else { + // TODO consider requiring redis version >= 2.6.12 that supports this in one command + $expire = (int) ($expire * 1000); + $this->_connection->executeCommand('MULTI'); + $this->_connection->executeCommand('SETNX', array($key, $value)); + $this->_connection->executeCommand('PEXPIRE', array($key, $expire)); + $response = $this->_connection->executeCommand('EXEC'); + return (bool) $response[0]; + } + } + + /** + * Deletes a value with the specified key from cache + * This is the implementation of the method declared in the parent class. + * @param string $key the key of the value to be deleted + * @return boolean if no error happens during deletion + */ + protected function deleteValue($key) + { + return (bool) $this->_connection->executeCommand('DEL', array($key)); + } + + /** + * Deletes all values from cache. + * This is the implementation of the method declared in the parent class. + * @return boolean whether the flush operation was successful. + */ + protected function flushValues() + { + return $this->_connection->executeCommand('FLUSHDB'); + } +} diff --git a/framework/yii/db/redis/Connection.php b/framework/yii/db/redis/Connection.php new file mode 100644 index 0000000..46db575 --- /dev/null +++ b/framework/yii/db/redis/Connection.php @@ -0,0 +1,425 @@ +close(); + return array_keys(get_object_vars($this)); + } + + /** + * Returns a value indicating whether the DB connection is established. + * @return boolean whether the DB connection is established + */ + public function getIsActive() + { + return $this->_socket !== null; + } + + /** + * Establishes a DB connection. + * It does nothing if a DB connection has already been established. + * @throws Exception if connection fails + */ + public function open() + { + if ($this->_socket === null) { + if (empty($this->dsn)) { + throw new InvalidConfigException('Connection.dsn cannot be empty.'); + } + $dsn = explode('/', $this->dsn); + $host = $dsn[2]; + if (strpos($host, ':')===false) { + $host .= ':6379'; + } + $db = isset($dsn[3]) ? $dsn[3] : 0; + + \Yii::trace('Opening DB connection: ' . $this->dsn, __CLASS__); + $this->_socket = @stream_socket_client( + $host, + $errorNumber, + $errorDescription, + $this->connectionTimeout ? $this->connectionTimeout : ini_get("default_socket_timeout") + ); + if ($this->_socket) { + if ($this->dataTimeout !== null) { + stream_set_timeout($this->_socket, $timeout=(int)$this->dataTimeout, (int) (($this->dataTimeout - $timeout) * 1000000)); + } + if ($this->password !== null) { + $this->executeCommand('AUTH', array($this->password)); + } + $this->executeCommand('SELECT', array($db)); + $this->initConnection(); + } else { + \Yii::error("Failed to open DB connection ({$this->dsn}): " . $errorNumber . ' - ' . $errorDescription, __CLASS__); + $message = YII_DEBUG ? 'Failed to open DB connection: ' . $errorNumber . ' - ' . $errorDescription : 'Failed to open DB connection.'; + throw new Exception($message, $errorDescription, (int)$errorNumber); + } + } + } + + /** + * Closes the currently active DB connection. + * It does nothing if the connection is already closed. + */ + public function close() + { + if ($this->_socket !== null) { + \Yii::trace('Closing DB connection: ' . $this->dsn, __CLASS__); + $this->executeCommand('QUIT'); + stream_socket_shutdown($this->_socket, STREAM_SHUT_RDWR); + $this->_socket = null; + $this->_transaction = null; + } + } + + /** + * Initializes the DB connection. + * This method is invoked right after the DB connection is established. + * The default implementation triggers an [[EVENT_AFTER_OPEN]] event. + */ + protected function initConnection() + { + $this->trigger(self::EVENT_AFTER_OPEN); + } + + /** + * Returns the currently active transaction. + * @return Transaction the currently active transaction. Null if no active transaction. + */ + public function getTransaction() + { + return $this->_transaction && $this->_transaction->isActive ? $this->_transaction : null; + } + + /** + * Starts a transaction. + * @return Transaction the transaction initiated + */ + public function beginTransaction() + { + $this->open(); + $this->_transaction = new Transaction(array( + 'db' => $this, + )); + $this->_transaction->begin(); + return $this->_transaction; + } + + /** + * Returns the name of the DB driver for the current [[dsn]]. + * @return string name of the DB driver + */ + public function getDriverName() + { + if (($pos = strpos($this->dsn, ':')) !== false) { + return strtolower(substr($this->dsn, 0, $pos)); + } else { + return 'redis'; + } + } + + /** + * + * @param string $name + * @param array $params + * @return mixed + */ + public function __call($name, $params) + { + $redisCommand = strtoupper(Inflector::camel2words($name, false)); + if (in_array($redisCommand, $this->redisCommands)) { + return $this->executeCommand($name, $params); + } else { + return parent::__call($name, $params); + } + } + + /** + * Executes a redis command. + * For a list of available commands and their parameters see http://redis.io/commands. + * + * @param string $name the name of the command + * @param array $params list of parameters for the command + * @return array|bool|null|string Dependend on the executed command this method + * will return different data types: + * + * - `true` for commands that return "status reply". + * - `string` for commands that return "integer reply" + * as the value is in the range of a signed 64 bit integer. + * - `string` or `null` for commands that return "bulk reply". + * - `array` for commands that return "Multi-bulk replies". + * + * See [redis protocol description](http://redis.io/topics/protocol) + * for details on the mentioned reply types. + * @trows Exception for commands that return [error reply](http://redis.io/topics/protocol#error-reply). + */ + public function executeCommand($name, $params=array()) + { + $this->open(); + + array_unshift($params, $name); + $command = '*' . count($params) . "\r\n"; + foreach($params as $arg) { + $command .= '$' . mb_strlen($arg, '8bit') . "\r\n" . $arg . "\r\n"; + } + + \Yii::trace("Executing Redis Command: {$name}", __CLASS__); + fwrite($this->_socket, $command); + + return $this->parseResponse(implode(' ', $params)); + } + + private function parseResponse($command) + { + if(($line = fgets($this->_socket)) === false) { + throw new Exception("Failed to read from socket.\nRedis command was: " . $command); + } + $type = $line[0]; + $line = mb_substr($line, 1, -2, '8bit'); + switch($type) + { + case '+': // Status reply + return true; + case '-': // Error reply + throw new Exception("Redis error: " . $line . "\nRedis command was: " . $command); + case ':': // Integer reply + // no cast to int as it is in the range of a signed 64 bit integer + return $line; + case '$': // Bulk replies + if ($line == '-1') { + return null; + } + $length = $line + 2; + $data = ''; + while ($length > 0) { + if(($block = fread($this->_socket, $line + 2)) === false) { + throw new Exception("Failed to read from socket.\nRedis command was: " . $command); + } + $data .= $block; + $length -= mb_strlen($block, '8bit'); + } + return mb_substr($data, 0, -2, '8bit'); + case '*': // Multi-bulk replies + $count = (int) $line; + $data = array(); + for($i = 0; $i < $count; $i++) { + $data[] = $this->parseResponse($command); + } + return $data; + default: + throw new Exception('Received illegal data from redis: ' . $line . "\nRedis command was: " . $command); + } + } +} diff --git a/framework/yii/db/redis/Transaction.php b/framework/yii/db/redis/Transaction.php new file mode 100644 index 0000000..721a7be --- /dev/null +++ b/framework/yii/db/redis/Transaction.php @@ -0,0 +1,91 @@ +_active; + } + + /** + * Begins a transaction. + * @throws InvalidConfigException if [[connection]] is null + */ + public function begin() + { + if (!$this->_active) { + if ($this->db === null) { + throw new InvalidConfigException('Transaction::db must be set.'); + } + \Yii::trace('Starting transaction', __CLASS__); + $this->db->open(); + $this->db->createCommand('MULTI')->execute(); + $this->_active = true; + } + } + + /** + * Commits a transaction. + * @throws Exception if the transaction or the DB connection is not active. + */ + public function commit() + { + if ($this->_active && $this->db && $this->db->isActive) { + \Yii::trace('Committing transaction', __CLASS__); + $this->db->createCommand('EXEC')->execute(); + // TODO handle result of EXEC + $this->_active = false; + } else { + throw new Exception('Failed to commit transaction: transaction was inactive.'); + } + } + + /** + * Rolls back a transaction. + * @throws Exception if the transaction or the DB connection is not active. + */ + public function rollback() + { + if ($this->_active && $this->db && $this->db->isActive) { + \Yii::trace('Rolling back transaction', __CLASS__); + $this->db->pdo->commit(); + $this->_active = false; + } else { + throw new Exception('Failed to roll back transaction: transaction was inactive.'); + } + } +} diff --git a/tests/unit/framework/caching/RedisCacheTest.php b/tests/unit/framework/caching/RedisCacheTest.php new file mode 100644 index 0000000..0924d0f --- /dev/null +++ b/tests/unit/framework/caching/RedisCacheTest.php @@ -0,0 +1,83 @@ + 'localhost', + 'port' => 6379, + 'database' => 0, + 'dataTimeout' => 0.1, + ); + $dsn = $config['hostname'] . ':' .$config['port']; + if(!@stream_socket_client($dsn, $errorNumber, $errorDescription, 0.5)) { + $this->markTestSkipped('No redis server running at ' . $dsn .' : ' . $errorNumber . ' - ' . $errorDescription); + } + + if($this->_cacheInstance === null) { + $this->_cacheInstance = new RedisCache($config); + } + return $this->_cacheInstance; + } + + public function testExpireMilliseconds() + { + $cache = $this->getCacheInstance(); + + $this->assertTrue($cache->set('expire_test_ms', 'expire_test_ms', 0.2)); + usleep(100000); + $this->assertEquals('expire_test_ms', $cache->get('expire_test_ms')); + usleep(300000); + $this->assertFalse($cache->get('expire_test_ms')); + } + + /** + * 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); + } + + public function testMultiByteGetAndSet() + { + $cache = $this->getCacheInstance(); + + $data=array('abc'=>'ежик',2=>'def'); + $key='data1'; + + $this->assertFalse($cache->get($key)); + $cache->set($key,$data); + $this->assertTrue($cache->get($key)===$data); + } + +} \ No newline at end of file