├── LICENSE ├── README.md ├── composer.json └── src ├── Cache.php ├── Debug ├── CacheCollection.php ├── CacheCollector.php └── icons │ └── cache.svg ├── FilesCache.php ├── MemcachedCache.php ├── RedisCache.php └── Serializer.php /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Natan Felles 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Aplus Framework Cache Library 2 | 3 | # Aplus Framework Cache Library 4 | 5 | - [Home](https://aplus-framework.com/packages/cache) 6 | - [User Guide](https://docs.aplus-framework.com/guides/libraries/cache/index.html) 7 | - [API Documentation](https://docs.aplus-framework.com/packages/cache.html) 8 | 9 | [![tests](https://github.com/aplus-framework/cache/actions/workflows/tests.yml/badge.svg)](https://github.com/aplus-framework/cache/actions/workflows/tests.yml) 10 | [![coverage](https://coveralls.io/repos/github/aplus-framework/cache/badge.svg?branch=master)](https://coveralls.io/github/aplus-framework/cache?branch=master) 11 | [![packagist](https://img.shields.io/packagist/v/aplus/cache)](https://packagist.org/packages/aplus/cache) 12 | [![open-source](https://img.shields.io/badge/open--source-sponsor-magenta)](https://aplus-framework.com/sponsor) 13 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "aplus/cache", 3 | "description": "Aplus Framework Cache Library", 4 | "license": "MIT", 5 | "type": "library", 6 | "keywords": [ 7 | "cache", 8 | "files", 9 | "memcached", 10 | "redis", 11 | "igbinary", 12 | "json", 13 | "json-array", 14 | "msgpack" 15 | ], 16 | "authors": [ 17 | { 18 | "name": "Natan Felles", 19 | "email": "natanfelles@gmail.com", 20 | "homepage": "https://natanfelles.github.io" 21 | } 22 | ], 23 | "homepage": "https://aplus-framework.com/packages/cache", 24 | "support": { 25 | "email": "support@aplus-framework.com", 26 | "issues": "https://github.com/aplus-framework/cache/issues", 27 | "forum": "https://aplus-framework.com/forum", 28 | "source": "https://github.com/aplus-framework/cache", 29 | "docs": "https://docs.aplus-framework.com/guides/libraries/cache/" 30 | }, 31 | "funding": [ 32 | { 33 | "type": "Aplus Sponsor", 34 | "url": "https://aplus-framework.com/sponsor" 35 | } 36 | ], 37 | "require": { 38 | "php": ">=8.3", 39 | "ext-igbinary": "*", 40 | "ext-json": "*", 41 | "ext-memcached": "*", 42 | "ext-msgpack": "*", 43 | "ext-redis": "*", 44 | "aplus/debug": "^4.3", 45 | "aplus/log": "^4.0" 46 | }, 47 | "require-dev": { 48 | "ext-xdebug": "*", 49 | "aplus/coding-standard": "^2.8", 50 | "ergebnis/composer-normalize": "^2.25", 51 | "jetbrains/phpstorm-attributes": "^1.0", 52 | "phpmd/phpmd": "^2.13", 53 | "phpstan/phpstan": "^1.9", 54 | "phpunit/phpunit": "^10.5" 55 | }, 56 | "minimum-stability": "dev", 57 | "prefer-stable": true, 58 | "autoload": { 59 | "psr-4": { 60 | "Framework\\Cache\\": "src/" 61 | } 62 | }, 63 | "autoload-dev": { 64 | "psr-4": { 65 | "Tests\\Cache\\": "tests/" 66 | } 67 | }, 68 | "config": { 69 | "allow-plugins": { 70 | "ergebnis/composer-normalize": true 71 | }, 72 | "optimize-autoloader": true, 73 | "preferred-install": "dist", 74 | "sort-packages": true 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/Cache.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | namespace Framework\Cache; 11 | 12 | use Framework\Cache\Debug\CacheCollector; 13 | use Framework\Debug\Debugger; 14 | use Framework\Log\Logger; 15 | use Framework\Log\LogLevel; 16 | use InvalidArgumentException; 17 | use JetBrains\PhpStorm\Pure; 18 | use SensitiveParameter; 19 | 20 | /** 21 | * Class Cache. 22 | * 23 | * @package cache 24 | */ 25 | abstract class Cache 26 | { 27 | /** 28 | * Driver specific configurations. 29 | * 30 | * @var array 31 | */ 32 | protected array $configs = []; 33 | /** 34 | * Keys prefix. 35 | * 36 | * @var string|null 37 | */ 38 | protected ?string $prefix = null; 39 | /** 40 | * Data serializer. 41 | * 42 | * @var Serializer 43 | */ 44 | protected Serializer $serializer; 45 | /** 46 | * The Logger instance if is set. 47 | * 48 | * @var Logger|null 49 | */ 50 | protected ?Logger $logger; 51 | /** 52 | * The default Time To Live value. 53 | * 54 | * Used when set methods has the $ttl param as null. 55 | * 56 | * @var int 57 | */ 58 | protected int $defaultTtl = 60; 59 | protected CacheCollector $debugCollector; 60 | protected bool $autoClose = true; 61 | 62 | /** 63 | * Cache constructor. 64 | * 65 | * @param mixed $configs Driver specific configurations. Set 66 | * null to not initialize and set a custom object. 67 | * @param string|null $prefix Keys prefix 68 | * @param Serializer|string $serializer Data serializer 69 | * @param Logger|null $logger Logger instance 70 | */ 71 | public function __construct( 72 | #[SensitiveParameter] 73 | mixed $configs = [], 74 | ?string $prefix = null, 75 | Serializer | string $serializer = Serializer::PHP, 76 | ?Logger $logger = null 77 | ) { 78 | $this->prefix = $prefix; 79 | $this->setSerializer($serializer); 80 | $this->logger = $logger; 81 | if (\is_array($configs)) { 82 | if ($configs) { 83 | $this->setConfigs($configs); 84 | } 85 | $this->initialize(); 86 | } 87 | } 88 | 89 | public function __destruct() 90 | { 91 | if ($this->isAutoClose()) { 92 | $this->close(); 93 | } 94 | } 95 | 96 | /** 97 | * @since 4.1 98 | * 99 | * @return bool 100 | */ 101 | public function isAutoClose() : bool 102 | { 103 | return $this->autoClose; 104 | } 105 | 106 | /** 107 | * @since 4.1 108 | * 109 | * @param bool $autoClose True to enable auto close, false to disable 110 | * 111 | * @return static 112 | */ 113 | public function setAutoClose(bool $autoClose) : static 114 | { 115 | $this->autoClose = $autoClose; 116 | return $this; 117 | } 118 | 119 | /** 120 | * @since 4.1 121 | * 122 | * @param array $configs 123 | * 124 | * @return static 125 | */ 126 | protected function setConfigs(array $configs) : static 127 | { 128 | $this->configs = \array_replace_recursive($this->configs, $configs); 129 | return $this; 130 | } 131 | 132 | /** 133 | * @since 4.1 134 | * 135 | * @param Serializer|string $serializer 136 | * 137 | * @return static 138 | */ 139 | protected function setSerializer(Serializer | string $serializer) : static 140 | { 141 | if (\is_string($serializer)) { 142 | $serializer = Serializer::from($serializer); 143 | } 144 | $this->serializer = $serializer; 145 | return $this; 146 | } 147 | 148 | /** 149 | * Initialize Cache handlers and configurations. 150 | */ 151 | protected function initialize() : void 152 | { 153 | } 154 | 155 | protected function log( 156 | string $message, 157 | LogLevel $level = LogLevel::ERROR 158 | ) : void { 159 | if (isset($this->logger)) { 160 | $this->logger->log($level, $message); 161 | } 162 | } 163 | 164 | /** 165 | * Get the default Time To Live value in seconds. 166 | * 167 | * @return int 168 | */ 169 | #[Pure] 170 | public function getDefaultTtl() : int 171 | { 172 | return $this->defaultTtl; 173 | } 174 | 175 | /** 176 | * Set the default Time To Live value in seconds. 177 | * 178 | * @param int $seconds An integer greater than zero 179 | * 180 | * @return static 181 | */ 182 | public function setDefaultTtl(int $seconds) : static 183 | { 184 | if ($seconds < 1) { 185 | throw new InvalidArgumentException( 186 | 'Default TTL must be greater than 0. ' . $seconds . ' given' 187 | ); 188 | } 189 | $this->defaultTtl = $seconds; 190 | return $this; 191 | } 192 | 193 | /** 194 | * Make the Time To Live value. 195 | * 196 | * @param int|null $seconds TTL value or null to use the default 197 | * 198 | * @return int The input $seconds or the $defaultTtl as integer 199 | */ 200 | #[Pure] 201 | protected function makeTtl(?int $seconds) : int 202 | { 203 | return $seconds ?? $this->getDefaultTtl(); 204 | } 205 | 206 | /** 207 | * Gets one item from the cache storage. 208 | * 209 | * @param string $key The item name 210 | * 211 | * @return mixed The item value or null if not found 212 | */ 213 | abstract public function get(string $key) : mixed; 214 | 215 | /** 216 | * Gets multi items from the cache storage. 217 | * 218 | * @param array $keys List of items names to get 219 | * 220 | * @return array associative array with key names and respective values 221 | */ 222 | public function getMulti(array $keys) : array 223 | { 224 | $values = []; 225 | foreach ($keys as $key) { 226 | $values[$key] = $this->get($key); 227 | } 228 | return $values; 229 | } 230 | 231 | /** 232 | * Sets one item to the cache storage. 233 | * 234 | * @param string $key The item name 235 | * @param mixed $value The item value 236 | * @param int|null $ttl The Time To Live for the item or null to use the default 237 | * 238 | * @return bool TRUE if the item was set, FALSE if fail to set 239 | */ 240 | abstract public function set(string $key, mixed $value, ?int $ttl = null) : bool; 241 | 242 | /** 243 | * Sets multi items to the cache storage. 244 | * 245 | * @param array $data Associative array with key names and respective values 246 | * @param int|null $ttl The Time To Live for all the items or null to use the default 247 | * 248 | * @return array associative array with key names and respective set status 249 | */ 250 | public function setMulti(array $data, ?int $ttl = null) : array 251 | { 252 | foreach ($data as $key => &$value) { 253 | $value = $this->set($key, $value, $ttl); 254 | } 255 | return $data; 256 | } 257 | 258 | /** 259 | * Deletes one item from the cache storage. 260 | * 261 | * @param string $key the item name 262 | * 263 | * @return bool TRUE if the item was deleted, FALSE if fail to delete 264 | */ 265 | abstract public function delete(string $key) : bool; 266 | 267 | /** 268 | * Deletes multi items from the cache storage. 269 | * 270 | * @param array $keys List of items names to be deleted 271 | * 272 | * @return array associative array with key names and respective delete status 273 | */ 274 | public function deleteMulti(array $keys) : array 275 | { 276 | $values = []; 277 | foreach ($keys as $key) { 278 | $values[$key] = $this->delete($key); 279 | } 280 | return $values; 281 | } 282 | 283 | /** 284 | * Flush the cache storage. 285 | * 286 | * @return bool TRUE if all items are deleted, otherwise FALSE 287 | */ 288 | abstract public function flush() : bool; 289 | 290 | /** 291 | * Increments the value of one item. 292 | * 293 | * @param string $key The item name 294 | * @param int $offset The value to increment 295 | * @param int|null $ttl The Time To Live for the item or null to use the default 296 | * 297 | * @return int The current item value 298 | */ 299 | public function increment(string $key, int $offset = 1, ?int $ttl = null) : int 300 | { 301 | $offset = (int) \abs($offset); 302 | $value = (int) $this->get($key); 303 | $value = $value ? $value + $offset : $offset; 304 | $this->set($key, $value, $ttl); 305 | return $value; 306 | } 307 | 308 | /** 309 | * Decrements the value of one item. 310 | * 311 | * @param string $key The item name 312 | * @param int $offset The value to decrement 313 | * @param int|null $ttl The Time To Live for the item or null to use the default 314 | * 315 | * @return int The current item value 316 | */ 317 | public function decrement(string $key, int $offset = 1, ?int $ttl = null) : int 318 | { 319 | $offset = (int) \abs($offset); 320 | $value = (int) $this->get($key); 321 | $value = $value ? $value - $offset : -$offset; 322 | $this->set($key, $value, $ttl); 323 | return $value; 324 | } 325 | 326 | /** 327 | * Close the cache storage. 328 | * 329 | * @since 4.1 330 | * 331 | * @return bool TRUE on success, otherwise FALSE 332 | */ 333 | public function close() : bool 334 | { 335 | return true; 336 | } 337 | 338 | #[Pure] 339 | protected function renderKey(string $key) : string 340 | { 341 | return $this->prefix . $key; 342 | } 343 | 344 | /** 345 | * @param mixed $value 346 | * 347 | * @throws \JsonException 348 | * 349 | * @return string 350 | */ 351 | protected function serialize(mixed $value) : string 352 | { 353 | if ($this->serializer === Serializer::IGBINARY) { 354 | return \igbinary_serialize($value); 355 | } 356 | if ($this->serializer === Serializer::JSON 357 | || $this->serializer === Serializer::JSON_ARRAY 358 | ) { 359 | return \json_encode($value, \JSON_THROW_ON_ERROR); 360 | } 361 | if ($this->serializer === Serializer::MSGPACK) { 362 | return \msgpack_serialize($value); 363 | } 364 | return \serialize($value); 365 | } 366 | 367 | /** 368 | * @param string $value 369 | * 370 | * @return mixed 371 | */ 372 | protected function unserialize(string $value) : mixed 373 | { 374 | if ($this->serializer === Serializer::IGBINARY) { 375 | return @\igbinary_unserialize($value); 376 | } 377 | if ($this->serializer === Serializer::JSON) { 378 | return \json_decode($value); 379 | } 380 | if ($this->serializer === Serializer::JSON_ARRAY) { 381 | return \json_decode($value, true); 382 | } 383 | if ($this->serializer === Serializer::MSGPACK) { 384 | return \msgpack_unserialize($value); 385 | } 386 | return \unserialize($value, ['allowed_classes' => true]); 387 | } 388 | 389 | public function setDebugCollector(CacheCollector $debugCollector) : static 390 | { 391 | $this->debugCollector = $debugCollector; 392 | $this->debugCollector->setInfo([ 393 | 'class' => static::class, 394 | 'prefix' => $this->prefix, 395 | 'serializer' => $this->serializer->value, 396 | ]); 397 | return $this; 398 | } 399 | 400 | protected function addDebugGet(string $key, float $start, mixed $value) : mixed 401 | { 402 | $end = \microtime(true); 403 | $this->debugCollector->addData([ 404 | 'start' => $start, 405 | 'end' => $end, 406 | 'command' => 'GET', 407 | 'status' => $value === null ? 'FAIL' : 'OK', 408 | 'key' => $key, 409 | 'value' => Debugger::makeDebugValue($value), 410 | ]); 411 | return $value; 412 | } 413 | 414 | protected function addDebugSet(string $key, ?int $ttl, float $start, mixed $value, bool $status) : bool 415 | { 416 | $end = \microtime(true); 417 | $this->debugCollector->addData([ 418 | 'start' => $start, 419 | 'end' => $end, 420 | 'command' => 'SET', 421 | 'status' => $status ? 'OK' : 'FAIL', 422 | 'key' => $key, 423 | 'value' => Debugger::makeDebugValue($value), 424 | 'ttl' => $this->makeTtl($ttl), 425 | ]); 426 | return $status; 427 | } 428 | 429 | protected function addDebugDelete(string $key, float $start, bool $status) : bool 430 | { 431 | $end = \microtime(true); 432 | $this->debugCollector->addData([ 433 | 'start' => $start, 434 | 'end' => $end, 435 | 'command' => 'DELETE', 436 | 'status' => $status ? 'OK' : 'FAIL', 437 | 'key' => $key, 438 | ]); 439 | return $status; 440 | } 441 | 442 | protected function addDebugFlush(float $start, bool $status) : bool 443 | { 444 | $end = \microtime(true); 445 | $this->debugCollector->addData([ 446 | 'start' => $start, 447 | 'end' => $end, 448 | 'command' => 'FLUSH', 449 | 'status' => $status ? 'OK' : 'FAIL', 450 | ]); 451 | return $status; 452 | } 453 | } 454 | -------------------------------------------------------------------------------- /src/Debug/CacheCollection.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | namespace Framework\Cache\Debug; 11 | 12 | use Framework\Debug\Collection; 13 | 14 | /** 15 | * Class CacheCollection. 16 | * 17 | * @package cache 18 | */ 19 | class CacheCollection extends Collection 20 | { 21 | protected string $iconPath = __DIR__ . '/icons/cache.svg'; 22 | } 23 | -------------------------------------------------------------------------------- /src/Debug/CacheCollector.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | namespace Framework\Cache\Debug; 11 | 12 | use Framework\Cache\FilesCache; 13 | use Framework\Cache\MemcachedCache; 14 | use Framework\Cache\RedisCache; 15 | use Framework\Debug\Collector; 16 | use Framework\Debug\Debugger; 17 | 18 | /** 19 | * Class CacheCollector. 20 | * 21 | * @package cache 22 | */ 23 | class CacheCollector extends Collector 24 | { 25 | /** 26 | * @var array 27 | */ 28 | protected array $info; 29 | 30 | /** 31 | * @param array $info 32 | * 33 | * @return static 34 | */ 35 | public function setInfo(array $info) : static 36 | { 37 | $this->info = $info; 38 | return $this; 39 | } 40 | 41 | public function getActivities() : array 42 | { 43 | $activities = []; 44 | foreach ($this->getData() as $index => $data) { 45 | $activities[] = [ 46 | 'collector' => $this->getName(), 47 | 'class' => static::class, 48 | 'description' => 'Run command ' . ($index + 1), 49 | 'start' => $data['start'], 50 | 'end' => $data['end'], 51 | ]; 52 | } 53 | return $activities; 54 | } 55 | 56 | public function getContents() : string 57 | { 58 | if (empty($this->info)) { 59 | return '

This collector has not been added to a Cache instance.

'; 60 | } 61 | \ob_start(); ?> 62 |

Handler: 63 | getHandler()) ?> 64 |

65 | info['prefix'])) : ?> 67 |

Keys Prefix: 68 | info['prefix']) ?> 69 |

70 | 72 |

Serializer: 73 | info['serializer']) ?> 74 |

75 |

Commands

76 | renderCommands(); 78 | return \ob_get_clean(); // @phpstan-ignore-line 79 | } 80 | 81 | protected function renderCommands() : string 82 | { 83 | if (!$this->hasData()) { 84 | return '

No command was run.

'; 85 | } 86 | $count = \count($this->getData()); 87 | \ob_start(); ?> 88 |

Ran command:

89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | getData() as $index => $data): ?> 104 | 105 | 106 | 107 | 108 | 109 | 114 | 115 | 120 | 121 | 122 | 123 | 124 |
#CommandStatusKeyValueTTLExpires AtTime
110 | 111 |
112 | 113 |
125 | FilesCache::class, 133 | 'memcached' => MemcachedCache::class, 134 | 'redis' => RedisCache::class, 135 | ] as $name => $class) { 136 | if ($this->info['class'] === $class) { 137 | return $name; 138 | } 139 | } 140 | return $this->info['class']; 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/Debug/icons/cache.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/FilesCache.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | namespace Framework\Cache; 11 | 12 | use Framework\Log\Logger; 13 | use InvalidArgumentException; 14 | use JetBrains\PhpStorm\Pure; 15 | use RuntimeException; 16 | use SensitiveParameter; 17 | 18 | /** 19 | * Class FilesCache. 20 | * 21 | * @package cache 22 | */ 23 | class FilesCache extends Cache 24 | { 25 | /** 26 | * Files Cache handler configurations. 27 | * 28 | * @var array 29 | */ 30 | protected array $configs = [ 31 | 'directory' => null, 32 | 'files_permission' => 0644, 33 | 'gc' => 1, 34 | ]; 35 | /** 36 | * @var string|null 37 | */ 38 | protected ?string $baseDirectory; 39 | 40 | /** 41 | * FilesCache constructor. 42 | * 43 | * @param array|null $configs Driver specific configurations 44 | * @param string|null $prefix Keys prefix 45 | * @param Serializer|string $serializer Data serializer 46 | * @param Logger|null $logger Logger instance 47 | */ 48 | public function __construct( 49 | #[SensitiveParameter] 50 | ?array $configs = [], 51 | ?string $prefix = null, 52 | Serializer | string $serializer = Serializer::PHP, 53 | ?Logger $logger = null 54 | ) { 55 | parent::__construct($configs, $prefix, $serializer, $logger); 56 | } 57 | 58 | public function __destruct() 59 | { 60 | if (\rand(1, 100) <= $this->configs['gc']) { 61 | $this->gc(); 62 | } 63 | parent::__destruct(); 64 | } 65 | 66 | protected function initialize() : void 67 | { 68 | $this->setBaseDirectory(); 69 | $this->setGC($this->configs['gc']); 70 | } 71 | 72 | protected function setGC(int $gc) : void 73 | { 74 | if ($gc < 1 || $gc > 100) { 75 | throw new InvalidArgumentException( 76 | "Invalid cache GC: {$gc}" 77 | ); 78 | } 79 | } 80 | 81 | protected function setBaseDirectory() : void 82 | { 83 | $path = $this->configs['directory']; 84 | if ($path === null) { 85 | $path = \sys_get_temp_dir(); 86 | } 87 | $real = \realpath($path); 88 | if ($real === false) { 89 | throw new RuntimeException("Invalid cache directory: {$path}"); 90 | } 91 | $real = \rtrim($path, \DIRECTORY_SEPARATOR) . \DIRECTORY_SEPARATOR; 92 | if (isset($this->prefix[0])) { 93 | $real .= $this->prefix; 94 | } 95 | if (!\is_dir($real)) { 96 | throw new RuntimeException( 97 | "Invalid cache directory path: {$real}" 98 | ); 99 | } 100 | if (!\is_writable($real)) { 101 | throw new RuntimeException( 102 | "Cache directory is not writable: {$real}" 103 | ); 104 | } 105 | $this->baseDirectory = $real . \DIRECTORY_SEPARATOR; 106 | } 107 | 108 | public function get(string $key) : mixed 109 | { 110 | if (isset($this->debugCollector)) { 111 | $start = \microtime(true); 112 | return $this->addDebugGet( 113 | $key, 114 | $start, 115 | $this->getContents($this->renderFilepath($key)) 116 | ); 117 | } 118 | return $this->getContents($this->renderFilepath($key)); 119 | } 120 | 121 | /** 122 | * @param string $filepath 123 | * 124 | * @return mixed 125 | */ 126 | protected function getContents(string $filepath) : mixed 127 | { 128 | if (!\is_file($filepath)) { 129 | return null; 130 | } 131 | $value = @\file_get_contents($filepath); 132 | if ($value === false) { 133 | $this->log("Cache (files): File '{$filepath}' could not be read"); 134 | return null; 135 | } 136 | $value = (array) $this->unserialize($value); 137 | if (!isset($value['ttl'], $value['data']) || $value['ttl'] <= \time()) { 138 | $this->deleteFile($filepath); 139 | return null; 140 | } 141 | return $value['data']; 142 | } 143 | 144 | protected function createSubDirectory(string $filepath) : void 145 | { 146 | $dirname = \dirname($filepath); 147 | if (\is_dir($dirname)) { 148 | return; 149 | } 150 | if (!\mkdir($dirname, 0777, true) || !\is_dir($dirname)) { 151 | throw new RuntimeException( 152 | "Directory key was not created: {$filepath}" 153 | ); 154 | } 155 | } 156 | 157 | public function set(string $key, mixed $value, ?int $ttl = null) : bool 158 | { 159 | if (isset($this->debugCollector)) { 160 | $start = \microtime(true); 161 | return $this->addDebugSet( 162 | $key, 163 | $ttl, 164 | $start, 165 | $value, 166 | $this->setValue($key, $value, $ttl) 167 | ); 168 | } 169 | return $this->setValue($key, $value, $ttl); 170 | } 171 | 172 | public function setValue(string $key, mixed $value, ?int $ttl = null) : bool 173 | { 174 | $filepath = $this->renderFilepath($key); 175 | $this->createSubDirectory($filepath); 176 | $value = [ 177 | 'ttl' => \time() + $this->makeTtl($ttl), 178 | 'data' => $value, 179 | ]; 180 | $value = $this->serialize($value); 181 | $isFile = \is_file($filepath); 182 | $written = @\file_put_contents($filepath, $value, \LOCK_EX); 183 | if ($written !== false && $isFile === false) { 184 | \chmod($filepath, $this->configs['files_permission']); 185 | } 186 | if ($written === false) { 187 | $this->log("Cache (files): File '{$filepath}' could not be written"); 188 | return false; 189 | } 190 | return true; 191 | } 192 | 193 | public function delete(string $key) : bool 194 | { 195 | if (isset($this->debugCollector)) { 196 | $start = \microtime(true); 197 | return $this->addDebugDelete( 198 | $key, 199 | $start, 200 | $this->deleteFile($this->renderFilepath($key)) 201 | ); 202 | } 203 | return $this->deleteFile($this->renderFilepath($key)); 204 | } 205 | 206 | public function flush() : bool 207 | { 208 | if (isset($this->debugCollector)) { 209 | $start = \microtime(true); 210 | return $this->addDebugFlush( 211 | $start, 212 | $this->deleteAll($this->baseDirectory) 213 | ); 214 | } 215 | return $this->deleteAll($this->baseDirectory); 216 | } 217 | 218 | /** 219 | * Garbage collector. 220 | * 221 | * Deletes all expired items. 222 | * 223 | * @return bool TRUE if all expired items was deleted, FALSE if a fail occurs 224 | */ 225 | public function gc() : bool 226 | { 227 | return $this->deleteExpired($this->baseDirectory); 228 | } 229 | 230 | protected function deleteExpired(string $baseDirectory) : bool 231 | { 232 | $handle = $this->openDir($baseDirectory); 233 | if ($handle === false) { 234 | return false; 235 | } 236 | $baseDirectory = \rtrim($baseDirectory, \DIRECTORY_SEPARATOR) . \DIRECTORY_SEPARATOR; 237 | $status = true; 238 | while (($path = \readdir($handle)) !== false) { 239 | if ($path[0] === '.') { 240 | continue; 241 | } 242 | $path = $baseDirectory . $path; 243 | if (\is_file($path)) { 244 | $this->getContents($path); 245 | continue; 246 | } 247 | if (!$this->deleteExpired($path)) { 248 | $status = false; 249 | break; 250 | } 251 | if (\scandir($path, \SCANDIR_SORT_ASCENDING) === ['.', '..'] && !\rmdir($path)) { 252 | $status = false; 253 | break; 254 | } 255 | } 256 | $this->closeDir($handle); 257 | return $status; 258 | } 259 | 260 | protected function deleteAll(string $baseDirectory) : bool 261 | { 262 | $handle = $this->openDir($baseDirectory); 263 | if ($handle === false) { 264 | return false; 265 | } 266 | $baseDirectory = \rtrim($baseDirectory, \DIRECTORY_SEPARATOR) . \DIRECTORY_SEPARATOR; 267 | $status = true; 268 | while (($path = \readdir($handle)) !== false) { 269 | if ($path[0] === '.') { 270 | continue; 271 | } 272 | $path = $baseDirectory . $path; 273 | if (\is_file($path)) { 274 | if (\unlink($path)) { 275 | continue; 276 | } 277 | $this->log("Cache (files): File '{$path}' could not be deleted"); 278 | $status = false; 279 | break; 280 | } 281 | if (!$this->deleteAll($path)) { 282 | $status = false; 283 | break; 284 | } 285 | if (\scandir($path, \SCANDIR_SORT_ASCENDING) === ['.', '..'] && !\rmdir($path)) { 286 | $status = false; 287 | break; 288 | } 289 | } 290 | $this->closeDir($handle); 291 | return $status; 292 | } 293 | 294 | protected function deleteFile(string $filepath) : bool 295 | { 296 | if (\is_file($filepath)) { 297 | $deleted = \unlink($filepath); 298 | if ($deleted === false) { 299 | $this->log("Cache (files): File '{$filepath}' could not be deleted"); 300 | return false; 301 | } 302 | } 303 | return true; 304 | } 305 | 306 | /** 307 | * @param string $dirpath 308 | * 309 | * @return false|resource 310 | */ 311 | protected function openDir(string $dirpath) 312 | { 313 | $real = \realpath($dirpath); 314 | if ($real === false) { 315 | return false; 316 | } 317 | if (!\is_dir($real)) { 318 | return false; 319 | } 320 | $real = \rtrim($real, \DIRECTORY_SEPARATOR) . \DIRECTORY_SEPARATOR; 321 | if (!\str_starts_with($real, $this->configs['directory'])) { 322 | return false; 323 | } 324 | return \opendir($real); 325 | } 326 | 327 | /** 328 | * @param resource $resource 329 | */ 330 | protected function closeDir($resource) : void 331 | { 332 | if (\is_resource($resource)) { 333 | \closedir($resource); 334 | } 335 | } 336 | 337 | #[Pure] 338 | protected function renderFilepath(string $key) : string 339 | { 340 | $key = \md5($key); 341 | return $this->baseDirectory . 342 | $key[0] . $key[1] . \DIRECTORY_SEPARATOR . 343 | $key; 344 | } 345 | } 346 | -------------------------------------------------------------------------------- /src/MemcachedCache.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | namespace Framework\Cache; 11 | 12 | use Framework\Log\Logger; 13 | use Framework\Log\LogLevel; 14 | use Memcached; 15 | use OutOfBoundsException; 16 | use Override; 17 | use RuntimeException; 18 | use SensitiveParameter; 19 | 20 | /** 21 | * Class MemcachedCache. 22 | * 23 | * @package cache 24 | */ 25 | class MemcachedCache extends Cache 26 | { 27 | protected Memcached $memcached; 28 | /** 29 | * Memcached Cache handler configurations. 30 | * 31 | * @var array 32 | */ 33 | protected array $configs = [ 34 | 'servers' => [ 35 | [ 36 | 'host' => '127.0.0.1', 37 | 'port' => 11211, 38 | 'weight' => 0, 39 | ], 40 | ], 41 | 'options' => [ 42 | Memcached::OPT_BINARY_PROTOCOL => true, 43 | ], 44 | ]; 45 | 46 | /** 47 | * MemcachedCache constructor. 48 | * 49 | * @param Memcached|array|null $configs Driver specific 50 | * configurations. Set null to not initialize or a custom Memcached object. 51 | * @param string|null $prefix Keys prefix 52 | * @param Serializer|string $serializer Data serializer 53 | * @param Logger|null $logger Logger instance 54 | */ 55 | public function __construct( 56 | #[SensitiveParameter] 57 | Memcached | array | null $configs = [], 58 | ?string $prefix = null, 59 | Serializer | string $serializer = Serializer::PHP, 60 | ?Logger $logger = null 61 | ) { 62 | parent::__construct($configs, $prefix, $serializer, $logger); 63 | if ($configs instanceof Memcached) { 64 | $this->setMemcached($configs); 65 | $this->setAutoClose(false); 66 | } 67 | } 68 | 69 | protected function initialize() : void 70 | { 71 | $this->validateConfigs(); 72 | $this->connect(); 73 | } 74 | 75 | protected function validateConfigs() : void 76 | { 77 | foreach ($this->configs['servers'] as $index => $config) { 78 | if (empty($config['host'])) { 79 | throw new OutOfBoundsException( 80 | "Memcached host config empty on server '{$index}'" 81 | ); 82 | } 83 | } 84 | } 85 | 86 | /** 87 | * Set custom Memcached instance. 88 | * 89 | * @since 3.2 90 | * 91 | * @param Memcached $memcached 92 | * 93 | * @return static 94 | */ 95 | public function setMemcached(Memcached $memcached) : static 96 | { 97 | $this->memcached = $memcached; 98 | return $this; 99 | } 100 | 101 | /** 102 | * Get Memcached instance or null. 103 | * 104 | * @since 3.2 105 | * 106 | * @return Memcached|null 107 | */ 108 | public function getMemcached() : ?Memcached 109 | { 110 | return $this->memcached ?? null; 111 | } 112 | 113 | public function get(string $key) : mixed 114 | { 115 | if (isset($this->debugCollector)) { 116 | $start = \microtime(true); 117 | return $this->addDebugGet( 118 | $key, 119 | $start, 120 | $this->getValue($key) 121 | ); 122 | } 123 | return $this->getValue($key); 124 | } 125 | 126 | protected function getValue(string $key) : mixed 127 | { 128 | $key = $this->memcached->get($this->renderKey($key)); 129 | return $key === false && $this->memcached->getResultCode() === Memcached::RES_NOTFOUND 130 | ? null 131 | : $key; 132 | } 133 | 134 | public function set(string $key, mixed $value, ?int $ttl = null) : bool 135 | { 136 | if (isset($this->debugCollector)) { 137 | $start = \microtime(true); 138 | return $this->addDebugSet( 139 | $key, 140 | $ttl, 141 | $start, 142 | $value, 143 | $this->memcached->set($this->renderKey($key), $value, $this->makeTtl($ttl)) 144 | ); 145 | } 146 | return $this->memcached->set($this->renderKey($key), $value, $this->makeTtl($ttl)); 147 | } 148 | 149 | public function delete(string $key) : bool 150 | { 151 | if (isset($this->debugCollector)) { 152 | $start = \microtime(true); 153 | return $this->addDebugDelete( 154 | $key, 155 | $start, 156 | $this->memcached->delete($this->renderKey($key)) 157 | ); 158 | } 159 | return $this->memcached->delete($this->renderKey($key)); 160 | } 161 | 162 | public function flush() : bool 163 | { 164 | if (isset($this->debugCollector)) { 165 | $start = \microtime(true); 166 | return $this->addDebugFlush( 167 | $start, 168 | $this->memcached->flush() 169 | ); 170 | } 171 | return $this->memcached->flush(); 172 | } 173 | 174 | #[Override] 175 | public function close() : bool 176 | { 177 | return $this->memcached->quit(); 178 | } 179 | 180 | protected function connect() : void 181 | { 182 | $this->configs['options'][Memcached::OPT_SERIALIZER] = match ($this->serializer) { 183 | Serializer::IGBINARY => Memcached::SERIALIZER_IGBINARY, 184 | Serializer::JSON => Memcached::SERIALIZER_JSON, 185 | Serializer::JSON_ARRAY => Memcached::SERIALIZER_JSON_ARRAY, 186 | Serializer::MSGPACK => Memcached::SERIALIZER_MSGPACK, 187 | default => Memcached::SERIALIZER_PHP, 188 | }; 189 | $this->memcached = new Memcached(); 190 | $pool = []; 191 | foreach ($this->configs['servers'] as $server) { 192 | $host = $server['host'] . ':' . ($server['port'] ?? 11211); 193 | if (\in_array($host, $pool, true)) { 194 | $this->log( 195 | 'Cache (memcached): Server pool already has ' . $host, 196 | LogLevel::DEBUG 197 | ); 198 | continue; 199 | } 200 | $result = $this->memcached->addServer( 201 | $server['host'], 202 | $server['port'] ?? 11211, 203 | $server['weight'] ?? 0 204 | ); 205 | if ($result === false) { 206 | $this->log("Cache (memcached): Could not add {$host} to server pool"); 207 | } 208 | $pool[] = $host; 209 | } 210 | $result = $this->memcached->setOptions($this->configs['options']); 211 | if ($result === false) { 212 | $this->log('Cache (memcached): ' . $this->memcached->getLastErrorMessage()); 213 | } 214 | if (!$this->memcached->getStats()) { 215 | throw new RuntimeException('Cache (memcached): Could not connect to any server'); 216 | } 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /src/RedisCache.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | namespace Framework\Cache; 11 | 12 | use Framework\Log\Logger; 13 | use Override; 14 | use Redis; 15 | use SensitiveParameter; 16 | 17 | /** 18 | * Class RedisCache. 19 | * 20 | * @package cache 21 | */ 22 | class RedisCache extends Cache 23 | { 24 | protected Redis $redis; 25 | /** 26 | * Redis Cache handler configurations. 27 | * 28 | * @var array 29 | */ 30 | protected array $configs = [ 31 | 'host' => '127.0.0.1', 32 | 'port' => 6379, 33 | 'timeout' => 0.0, 34 | 'password' => null, 35 | 'database' => null, 36 | ]; 37 | 38 | /** 39 | * RedisCache constructor. 40 | * 41 | * @param Redis|array|null $configs Driver specific 42 | * configurations. Set null to not initialize or a custom Redis object. 43 | * @param string|null $prefix Keys prefix 44 | * @param Serializer|string $serializer Data serializer 45 | * @param Logger|null $logger Logger instance 46 | */ 47 | public function __construct( 48 | #[SensitiveParameter] 49 | Redis | array | null $configs = [], 50 | ?string $prefix = null, 51 | Serializer | string $serializer = Serializer::PHP, 52 | ?Logger $logger = null 53 | ) { 54 | parent::__construct($configs, $prefix, $serializer, $logger); 55 | if ($configs instanceof Redis) { 56 | $this->setRedis($configs); 57 | $this->setAutoClose(false); 58 | } 59 | } 60 | 61 | protected function initialize() : void 62 | { 63 | $this->connect(); 64 | } 65 | 66 | protected function connect() : void 67 | { 68 | $this->redis = new Redis(); 69 | $this->redis->connect( 70 | $this->configs['host'], 71 | $this->configs['port'], 72 | $this->configs['timeout'] 73 | ); 74 | if (isset($this->configs['password'])) { 75 | $this->redis->auth($this->configs['password']); 76 | } 77 | if (isset($this->configs['database'])) { 78 | $this->redis->select($this->configs['database']); 79 | } 80 | } 81 | 82 | /** 83 | * Set custom Redis instance. 84 | * 85 | * @since 3.2 86 | * 87 | * @param Redis $redis 88 | * 89 | * @return static 90 | */ 91 | public function setRedis(Redis $redis) : static 92 | { 93 | $this->redis = $redis; 94 | return $this; 95 | } 96 | 97 | /** 98 | * Get Redis instance or null. 99 | * 100 | * @since 3.2 101 | * 102 | * @return Redis|null 103 | */ 104 | public function getRedis() : ?Redis 105 | { 106 | return $this->redis ?? null; 107 | } 108 | 109 | public function get(string $key) : mixed 110 | { 111 | if (isset($this->debugCollector)) { 112 | $start = \microtime(true); 113 | return $this->addDebugGet( 114 | $key, 115 | $start, 116 | $this->getValue($key) 117 | ); 118 | } 119 | return $this->getValue($key); 120 | } 121 | 122 | protected function getValue(string $key) : mixed 123 | { 124 | $value = $this->redis->get($this->renderKey($key)); 125 | if ($value === false) { 126 | return null; 127 | } 128 | return $this->unserialize($value); 129 | } 130 | 131 | public function set(string $key, mixed $value, ?int $ttl = null) : bool 132 | { 133 | if (isset($this->debugCollector)) { 134 | $start = \microtime(true); 135 | return $this->addDebugSet( 136 | $key, 137 | $ttl, 138 | $start, 139 | $value, 140 | $this->redis->set( 141 | $this->renderKey($key), 142 | $this->serialize($value), 143 | $this->makeTtl($ttl) 144 | ) 145 | ); 146 | } 147 | return $this->redis->set( 148 | $this->renderKey($key), 149 | $this->serialize($value), 150 | $this->makeTtl($ttl) 151 | ); 152 | } 153 | 154 | public function delete(string $key) : bool 155 | { 156 | if (isset($this->debugCollector)) { 157 | $start = \microtime(true); 158 | return $this->addDebugDelete( 159 | $key, 160 | $start, 161 | (bool) $this->redis->del($this->renderKey($key)) 162 | ); 163 | } 164 | return (bool) $this->redis->del($this->renderKey($key)); 165 | } 166 | 167 | public function flush() : bool 168 | { 169 | if (isset($this->debugCollector)) { 170 | $start = \microtime(true); 171 | return $this->addDebugFlush( 172 | $start, 173 | $this->redis->flushAll() 174 | ); 175 | } 176 | return $this->redis->flushAll(); 177 | } 178 | 179 | #[Override] 180 | public function close() : bool 181 | { 182 | return $this->redis->close(); 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /src/Serializer.php: -------------------------------------------------------------------------------- 1 | 6 | * 7 | * For the full copyright and license information, please view the LICENSE 8 | * file that was distributed with this source code. 9 | */ 10 | namespace Framework\Cache; 11 | 12 | /** 13 | * Enum Serializer. 14 | * 15 | * @package cache 16 | */ 17 | enum Serializer : string 18 | { 19 | /** 20 | * The Igbinary serializer. 21 | */ 22 | case IGBINARY = 'igbinary'; 23 | /** 24 | * The JSON serializer. 25 | */ 26 | case JSON = 'json'; 27 | /** 28 | * The JSON Array serializer. 29 | */ 30 | case JSON_ARRAY = 'json-array'; 31 | /** 32 | * The MessagePack serializer. 33 | */ 34 | case MSGPACK = 'msgpack'; 35 | /** 36 | * The PHP serializer. 37 | */ 38 | case PHP = 'php'; 39 | } 40 | --------------------------------------------------------------------------------