RedisEngine.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360
  1. <?php
  2. declare(strict_types=1);
  3. /**
  4. * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
  5. * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
  6. *
  7. * Licensed under The MIT License
  8. * For full copyright and license information, please see the LICENSE.txt
  9. * Redistributions of files must retain the above copyright notice.
  10. *
  11. * @copyright Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
  12. * @link https://cakephp.org CakePHP(tm) Project
  13. * @since 2.2.0
  14. * @license https://opensource.org/licenses/mit-license.php MIT License
  15. */
  16. namespace Cake\Cache\Engine;
  17. use Cake\Cache\CacheEngine;
  18. use Cake\Log\Log;
  19. use DateInterval;
  20. use Redis;
  21. use RedisException;
  22. use RuntimeException;
  23. /**
  24. * Redis storage engine for cache.
  25. */
  26. class RedisEngine extends CacheEngine
  27. {
  28. /**
  29. * Redis wrapper.
  30. *
  31. * @var \Redis
  32. */
  33. protected Redis $_Redis;
  34. /**
  35. * The default config used unless overridden by runtime configuration
  36. *
  37. * - `database` database number to use for connection.
  38. * - `duration` Specify how long items in this cache configuration last.
  39. * - `groups` List of groups or 'tags' associated to every key stored in this config.
  40. * handy for deleting a complete group from cache.
  41. * - `password` Redis server password.
  42. * - `persistent` Connect to the Redis server with a persistent connection
  43. * - `port` port number to the Redis server.
  44. * - `prefix` Prefix appended to all entries. Good for when you need to share a keyspace
  45. * with either another cache config or another application.
  46. * - `server` URL or IP to the Redis server host.
  47. * - `timeout` timeout in seconds (float).
  48. * - `unix_socket` Path to the unix socket file (default: false)
  49. *
  50. * @var array<string, mixed>
  51. */
  52. protected array $_defaultConfig = [
  53. 'database' => 0,
  54. 'duration' => 3600,
  55. 'groups' => [],
  56. 'password' => false,
  57. 'persistent' => true,
  58. 'port' => 6379,
  59. 'prefix' => 'cake_',
  60. 'host' => null,
  61. 'server' => '127.0.0.1',
  62. 'timeout' => 0,
  63. 'unix_socket' => false,
  64. ];
  65. /**
  66. * Initialize the Cache Engine
  67. *
  68. * Called automatically by the cache frontend
  69. *
  70. * @param array<string, mixed> $config array of setting for the engine
  71. * @return bool True if the engine has been successfully initialized, false if not
  72. */
  73. public function init(array $config = []): bool
  74. {
  75. if (!extension_loaded('redis')) {
  76. throw new RuntimeException('The `redis` extension must be enabled to use RedisEngine.');
  77. }
  78. if (!empty($config['host'])) {
  79. $config['server'] = $config['host'];
  80. }
  81. parent::init($config);
  82. return $this->_connect();
  83. }
  84. /**
  85. * Connects to a Redis server
  86. *
  87. * @return bool True if Redis server was connected
  88. */
  89. protected function _connect(): bool
  90. {
  91. try {
  92. $this->_Redis = new Redis();
  93. if (!empty($this->_config['unix_socket'])) {
  94. $return = $this->_Redis->connect($this->_config['unix_socket']);
  95. } elseif (empty($this->_config['persistent'])) {
  96. $return = $this->_Redis->connect(
  97. $this->_config['server'],
  98. (int)$this->_config['port'],
  99. (int)$this->_config['timeout']
  100. );
  101. } else {
  102. $persistentId = $this->_config['port'] . $this->_config['timeout'] . $this->_config['database'];
  103. $return = $this->_Redis->pconnect(
  104. $this->_config['server'],
  105. (int)$this->_config['port'],
  106. (int)$this->_config['timeout'],
  107. $persistentId
  108. );
  109. }
  110. } catch (RedisException $e) {
  111. if (class_exists(Log::class)) {
  112. Log::error('RedisEngine could not connect. Got error: ' . $e->getMessage());
  113. }
  114. return false;
  115. }
  116. if ($return && $this->_config['password']) {
  117. $return = $this->_Redis->auth($this->_config['password']);
  118. }
  119. if ($return) {
  120. $return = $this->_Redis->select((int)$this->_config['database']);
  121. }
  122. return $return;
  123. }
  124. /**
  125. * Write data for key into cache.
  126. *
  127. * @param string $key Identifier for the data
  128. * @param mixed $value Data to be cached
  129. * @param \DateInterval|int|null $ttl Optional. The TTL value of this item. If no value is sent and
  130. * the driver supports TTL then the library may set a default value
  131. * for it or let the driver take care of that.
  132. * @return bool True if the data was successfully cached, false on failure
  133. */
  134. public function set(string $key, mixed $value, DateInterval|int|null $ttl = null): bool
  135. {
  136. $key = $this->_key($key);
  137. $value = $this->serialize($value);
  138. $duration = $this->duration($ttl);
  139. if ($duration === 0) {
  140. return $this->_Redis->set($key, $value);
  141. }
  142. return $this->_Redis->setEx($key, $duration, $value);
  143. }
  144. /**
  145. * Read a key from the cache
  146. *
  147. * @param string $key Identifier for the data
  148. * @param mixed $default Default value to return if the key does not exist.
  149. * @return mixed The cached data, or the default if the data doesn't exist, has
  150. * expired, or if there was an error fetching it
  151. */
  152. public function get(string $key, mixed $default = null): mixed
  153. {
  154. $value = $this->_Redis->get($this->_key($key));
  155. /** @psalm-suppress DocblockTypeContradiction */
  156. if ($value === false) {
  157. return $default;
  158. }
  159. return $this->unserialize($value);
  160. }
  161. /**
  162. * Increments the value of an integer cached key & update the expiry time
  163. *
  164. * @param string $key Identifier for the data
  165. * @param int $offset How much to increment
  166. * @return int|false New incremented value, false otherwise
  167. */
  168. public function increment(string $key, int $offset = 1): int|false
  169. {
  170. $duration = $this->_config['duration'];
  171. $key = $this->_key($key);
  172. $value = $this->_Redis->incrBy($key, $offset);
  173. if ($duration > 0) {
  174. $this->_Redis->expire($key, $duration);
  175. }
  176. return $value;
  177. }
  178. /**
  179. * Decrements the value of an integer cached key & update the expiry time
  180. *
  181. * @param string $key Identifier for the data
  182. * @param int $offset How much to subtract
  183. * @return int|false New decremented value, false otherwise
  184. */
  185. public function decrement(string $key, int $offset = 1): int|false
  186. {
  187. $duration = $this->_config['duration'];
  188. $key = $this->_key($key);
  189. $value = $this->_Redis->decrBy($key, $offset);
  190. if ($duration > 0) {
  191. $this->_Redis->expire($key, $duration);
  192. }
  193. return $value;
  194. }
  195. /**
  196. * Delete a key from the cache
  197. *
  198. * @param string $key Identifier for the data
  199. * @return bool True if the value was successfully deleted, false if it didn't exist or couldn't be removed
  200. */
  201. public function delete(string $key): bool
  202. {
  203. $key = $this->_key($key);
  204. return $this->_Redis->del($key) > 0;
  205. }
  206. /**
  207. * Delete all keys from the cache
  208. *
  209. * @return bool True if the cache was successfully cleared, false otherwise
  210. */
  211. public function clear(): bool
  212. {
  213. $this->_Redis->setOption(Redis::OPT_SCAN, (string)Redis::SCAN_RETRY);
  214. $isAllDeleted = true;
  215. $iterator = null;
  216. $pattern = $this->_config['prefix'] . '*';
  217. while (true) {
  218. $keys = $this->_Redis->scan($iterator, $pattern);
  219. if ($keys === false) {
  220. break;
  221. }
  222. foreach ($keys as $key) {
  223. $isDeleted = ($this->_Redis->del($key) > 0);
  224. $isAllDeleted = $isAllDeleted && $isDeleted;
  225. }
  226. }
  227. return $isAllDeleted;
  228. }
  229. /**
  230. * Write data for key into cache if it doesn't exist already.
  231. * If it already exists, it fails and returns false.
  232. *
  233. * @param string $key Identifier for the data.
  234. * @param mixed $value Data to be cached.
  235. * @return bool True if the data was successfully cached, false on failure.
  236. * @link https://github.com/phpredis/phpredis#set
  237. */
  238. public function add(string $key, mixed $value): bool
  239. {
  240. $duration = $this->_config['duration'];
  241. $key = $this->_key($key);
  242. $value = $this->serialize($value);
  243. if ($this->_Redis->set($key, $value, ['nx', 'ex' => $duration])) {
  244. return true;
  245. }
  246. return false;
  247. }
  248. /**
  249. * Returns the `group value` for each of the configured groups
  250. * If the group initial value was not found, then it initializes
  251. * the group accordingly.
  252. *
  253. * @return array<string>
  254. */
  255. public function groups(): array
  256. {
  257. $result = [];
  258. foreach ($this->_config['groups'] as $group) {
  259. $value = $this->_Redis->get($this->_config['prefix'] . $group);
  260. if (!$value) {
  261. $value = $this->serialize(1);
  262. $this->_Redis->set($this->_config['prefix'] . $group, $value);
  263. }
  264. $result[] = $group . $value;
  265. }
  266. return $result;
  267. }
  268. /**
  269. * Increments the group value to simulate deletion of all keys under a group
  270. * old values will remain in storage until they expire.
  271. *
  272. * @param string $group name of the group to be cleared
  273. * @return bool success
  274. */
  275. public function clearGroup(string $group): bool
  276. {
  277. return (bool)$this->_Redis->incr($this->_config['prefix'] . $group);
  278. }
  279. /**
  280. * Serialize value for saving to Redis.
  281. *
  282. * This is needed instead of using Redis' in built serialization feature
  283. * as it creates problems incrementing/decrementing intially set integer value.
  284. *
  285. * @param mixed $value Value to serialize.
  286. * @return string
  287. * @link https://github.com/phpredis/phpredis/issues/81
  288. */
  289. protected function serialize(mixed $value): string
  290. {
  291. if (is_int($value)) {
  292. return (string)$value;
  293. }
  294. return serialize($value);
  295. }
  296. /**
  297. * Unserialize string value fetched from Redis.
  298. *
  299. * @param string $value Value to unserialize.
  300. * @return mixed
  301. */
  302. protected function unserialize(string $value): mixed
  303. {
  304. if (preg_match('/^[-]?\d+$/', $value)) {
  305. return (int)$value;
  306. }
  307. return unserialize($value);
  308. }
  309. /**
  310. * Disconnects from the redis server
  311. */
  312. public function __destruct()
  313. {
  314. if (empty($this->_config['persistent'])) {
  315. $this->_Redis->close();
  316. }
  317. }
  318. }