yiisoft /
cache-file
| 1 | <?php |
||
| 2 | |||
| 3 | declare(strict_types=1); |
||
| 4 | |||
| 5 | namespace Yiisoft\Cache\File; |
||
| 6 | |||
| 7 | use DateInterval; |
||
| 8 | use DateTime; |
||
| 9 | use Psr\SimpleCache\CacheInterface; |
||
| 10 | use Traversable; |
||
| 11 | |||
| 12 | use function array_keys; |
||
| 13 | use function array_map; |
||
| 14 | use function closedir; |
||
| 15 | use function dirname; |
||
| 16 | use function error_get_last; |
||
| 17 | use function filemtime; |
||
| 18 | use function fileowner; |
||
| 19 | use function fopen; |
||
| 20 | use function function_exists; |
||
| 21 | use function is_dir; |
||
| 22 | use function is_file; |
||
| 23 | use function iterator_to_array; |
||
| 24 | use function opendir; |
||
| 25 | use function posix_geteuid; |
||
| 26 | use function random_int; |
||
| 27 | use function readdir; |
||
| 28 | use function rmdir; |
||
| 29 | use function serialize; |
||
| 30 | use function strpbrk; |
||
| 31 | use function substr; |
||
| 32 | use function unlink; |
||
| 33 | use function unserialize; |
||
| 34 | |||
| 35 | use const LOCK_EX; |
||
| 36 | use const LOCK_SH; |
||
| 37 | use const LOCK_UN; |
||
| 38 | |||
| 39 | /** |
||
| 40 | * `FileCache` implements a cache handler using files. |
||
| 41 | * |
||
| 42 | * For each data value being cached, `FileCache` will store it in a separate file. The cache files are placed |
||
| 43 | * under {@see FileCache::$cachePath}. `FileCache` will perform garbage collection automatically to remove expired |
||
| 44 | * cache files. |
||
| 45 | * |
||
| 46 | * Please refer to {@see CacheInterface} for common cache operations that are supported by `FileCache`. |
||
| 47 | */ |
||
| 48 | final class FileCache implements CacheInterface |
||
| 49 | { |
||
| 50 | private const TTL_INFINITY = 31_536_000; // 1 year |
||
|
0 ignored issues
–
show
Bug
introduced
by
Loading history...
|
|||
| 51 | private const EXPIRATION_EXPIRED = -1; |
||
| 52 | |||
| 53 | /** |
||
| 54 | * @var string The cache file suffix. Defaults to '.bin'. |
||
| 55 | */ |
||
| 56 | private string $fileSuffix = '.bin'; |
||
| 57 | |||
| 58 | /** |
||
| 59 | * @var int|null The permission to be set for newly created cache files. |
||
| 60 | * This value will be used by PHP chmod() function. No umask will be applied. |
||
| 61 | * If not set, the permission will be determined by the current environment. |
||
| 62 | */ |
||
| 63 | private ?int $fileMode = null; |
||
| 64 | |||
| 65 | /** |
||
| 66 | * @var int The level of sub-directories to store cache files. Defaults to 1. |
||
| 67 | * If the system has huge number of cache files (e.g. one million), you may use a bigger value |
||
| 68 | * (usually no bigger than 3). Using sub-directories is mainly to ensure the file system |
||
| 69 | * is not over burdened with a single directory having too many files. |
||
| 70 | */ |
||
| 71 | private int $directoryLevel = 1; |
||
| 72 | |||
| 73 | /** |
||
| 74 | * @var int The probability (parts per million) that garbage collection (GC) should be performed |
||
| 75 | * when storing a piece of data in the cache. Defaults to 10, meaning 0.001% chance. |
||
| 76 | * This number should be between 0 and 1000000. A value 0 means no GC will be performed at all. |
||
| 77 | */ |
||
| 78 | private int $gcProbability = 10; |
||
| 79 | |||
| 80 | /** |
||
| 81 | * @param string $cachePath The directory to store cache files. |
||
| 82 | * @param int $directoryMode The permission to be set for newly created directories. This value will be used |
||
| 83 | * by PHP `chmod()` function. No umask will be applied. Defaults to 0775, meaning the directory is read-writable |
||
| 84 | * by owner and group, but read-only for other users. |
||
| 85 | * |
||
| 86 | * @see FileCache::$cachePath |
||
| 87 | * |
||
| 88 | * @throws CacheException If failed to create cache directory. |
||
| 89 | */ |
||
| 90 | 124 | public function __construct( |
|
| 91 | private string $cachePath, |
||
| 92 | private int $directoryMode = 0775, |
||
| 93 | ) { |
||
| 94 | 124 | if (!$this->createDirectoryIfNotExists($cachePath)) { |
|
| 95 | 1 | throw new CacheException("Failed to create cache directory \"$cachePath\"."); |
|
| 96 | } |
||
| 97 | } |
||
| 98 | |||
| 99 | 82 | public function get(string $key, mixed $default = null): mixed |
|
| 100 | { |
||
| 101 | 82 | $this->validateKey($key); |
|
| 102 | 80 | $file = $this->getCacheFile($key); |
|
| 103 | |||
| 104 | 80 | if (!$this->existsAndNotExpired($file) || ($filePointer = @fopen($file, 'rb')) === false) { |
|
| 105 | 29 | return $default; |
|
| 106 | } |
||
| 107 | |||
| 108 | 66 | flock($filePointer, LOCK_SH); |
|
| 109 | 66 | $value = stream_get_contents($filePointer); |
|
| 110 | 66 | flock($filePointer, LOCK_UN); |
|
| 111 | 66 | fclose($filePointer); |
|
| 112 | |||
| 113 | 66 | return unserialize($value); |
|
| 114 | } |
||
| 115 | |||
| 116 | 99 | public function set(string $key, mixed $value, null|int|DateInterval $ttl = null): bool |
|
| 117 | { |
||
| 118 | 99 | $this->validateKey($key); |
|
| 119 | 97 | $this->gc(); |
|
| 120 | 97 | $expiration = $this->ttlToExpiration($ttl); |
|
| 121 | |||
| 122 | 97 | if ($expiration <= self::EXPIRATION_EXPIRED) { |
|
| 123 | 1 | return $this->delete($key); |
|
| 124 | } |
||
| 125 | |||
| 126 | 97 | $file = $this->getCacheFile($key); |
|
| 127 | 97 | $cacheDirectory = dirname($file); |
|
| 128 | |||
| 129 | 97 | if (!is_dir($this->cachePath) |
|
| 130 | 97 | || $this->directoryLevel > 0 && !$this->createDirectoryIfNotExists($cacheDirectory) |
|
| 131 | ) { |
||
| 132 | 1 | throw new CacheException("Failed to create cache directory \"$cacheDirectory\"."); |
|
| 133 | } |
||
| 134 | |||
| 135 | // If ownership differs, the touch call will fail, so we try to |
||
| 136 | // rebuild the file from scratch by deleting it first |
||
| 137 | // https://github.com/yiisoft/yii2/pull/16120 |
||
| 138 | 96 | if (function_exists('posix_geteuid') && is_file($file) && fileowner($file) !== posix_geteuid()) { |
|
| 139 | @unlink($file); |
||
| 140 | } |
||
| 141 | |||
| 142 | 96 | if (file_put_contents($file, serialize($value), LOCK_EX) === false) { |
|
| 143 | return false; |
||
| 144 | } |
||
| 145 | |||
| 146 | 96 | if ($this->fileMode !== null) { |
|
| 147 | 1 | $result = @chmod($file, $this->fileMode); |
|
| 148 | 1 | if (!$this->isLastErrorSafe($result)) { |
|
| 149 | return false; |
||
| 150 | } |
||
| 151 | } |
||
| 152 | |||
| 153 | 96 | $result = false; |
|
| 154 | |||
| 155 | 96 | if (@touch($file, $expiration)) { |
|
| 156 | 96 | clearstatcache(); |
|
| 157 | 96 | $result = true; |
|
| 158 | } |
||
| 159 | |||
| 160 | 96 | return $this->isLastErrorSafe($result); |
|
| 161 | } |
||
| 162 | |||
| 163 | 17 | public function delete(string $key): bool |
|
| 164 | { |
||
| 165 | 17 | $this->validateKey($key); |
|
| 166 | 15 | $file = $this->getCacheFile($key); |
|
| 167 | |||
| 168 | 15 | if (!is_file($file)) { |
|
| 169 | 1 | return true; |
|
| 170 | } |
||
| 171 | |||
| 172 | 14 | $result = @unlink($file); |
|
| 173 | |||
| 174 | 14 | return $this->isLastErrorSafe($result); |
|
| 175 | } |
||
| 176 | |||
| 177 | 12 | public function clear(): bool |
|
| 178 | { |
||
| 179 | 12 | $this->removeCacheFiles($this->cachePath, false); |
|
| 180 | 12 | return true; |
|
| 181 | } |
||
| 182 | |||
| 183 | 9 | public function getMultiple(iterable $keys, mixed $default = null): iterable |
|
| 184 | { |
||
| 185 | 9 | $keys = $this->iterableToArray($keys); |
|
| 186 | 9 | $this->validateKeys($keys); |
|
| 187 | 7 | $results = []; |
|
| 188 | |||
| 189 | 7 | foreach ($keys as $key) { |
|
| 190 | 7 | $results[$key] = $this->get($key, $default); |
|
| 191 | } |
||
| 192 | |||
| 193 | 7 | return $results; |
|
| 194 | } |
||
| 195 | |||
| 196 | 10 | public function setMultiple(iterable $values, null|int|DateInterval $ttl = null): bool |
|
| 197 | { |
||
| 198 | 10 | $values = $this->iterableToArray($values); |
|
| 199 | 10 | $this->validateKeys(array_map('\strval', array_keys($values))); |
|
| 200 | |||
| 201 | 10 | foreach ($values as $key => $value) { |
|
| 202 | 10 | $this->set((string) $key, $value, $ttl); |
|
| 203 | } |
||
| 204 | |||
| 205 | 10 | return true; |
|
| 206 | } |
||
| 207 | |||
| 208 | 3 | public function deleteMultiple(iterable $keys): bool |
|
| 209 | { |
||
| 210 | 3 | $keys = $this->iterableToArray($keys); |
|
| 211 | 3 | $this->validateKeys($keys); |
|
| 212 | |||
| 213 | 1 | foreach ($keys as $key) { |
|
| 214 | 1 | $this->delete($key); |
|
| 215 | } |
||
| 216 | |||
| 217 | 1 | return true; |
|
| 218 | } |
||
| 219 | |||
| 220 | 15 | public function has(string $key): bool |
|
| 221 | { |
||
| 222 | 15 | $this->validateKey($key); |
|
| 223 | 13 | return $this->existsAndNotExpired($this->getCacheFile($key)); |
|
| 224 | } |
||
| 225 | |||
| 226 | /** |
||
| 227 | * @param string $fileSuffix The cache file suffix. Defaults to '.bin'. |
||
| 228 | */ |
||
| 229 | 1 | public function withFileSuffix(string $fileSuffix): self |
|
| 230 | { |
||
| 231 | 1 | $new = clone $this; |
|
| 232 | 1 | $new->fileSuffix = $fileSuffix; |
|
| 233 | 1 | return $new; |
|
| 234 | } |
||
| 235 | |||
| 236 | /** |
||
| 237 | * @param int $fileMode The permission to be set for newly created cache files. This value will be used |
||
| 238 | * by PHP `chmod()` function. No umask will be applied. If not set, the permission will be determined |
||
| 239 | * by the current environment. |
||
| 240 | */ |
||
| 241 | 1 | public function withFileMode(int $fileMode): self |
|
| 242 | { |
||
| 243 | 1 | $new = clone $this; |
|
| 244 | 1 | $new->fileMode = $fileMode; |
|
| 245 | 1 | return $new; |
|
| 246 | } |
||
| 247 | |||
| 248 | /** |
||
| 249 | * @param int $directoryMode The permission to be set for newly created directories. This value will be used |
||
| 250 | * by PHP `chmod()` function. No umask will be applied. Defaults to 0775, meaning the directory is read-writable |
||
| 251 | * by owner and group, but read-only for other users. |
||
| 252 | * |
||
| 253 | * @deprecated Use `$directoryMode` in the constructor instead |
||
| 254 | */ |
||
| 255 | 1 | public function withDirectoryMode(int $directoryMode): self |
|
| 256 | { |
||
| 257 | 1 | $new = clone $this; |
|
| 258 | 1 | $new->directoryMode = $directoryMode; |
|
| 259 | 1 | return $new; |
|
| 260 | } |
||
| 261 | |||
| 262 | /** |
||
| 263 | * @param int $directoryLevel The level of sub-directories to store cache files. Defaults to 1. |
||
| 264 | * If the system has huge number of cache files (e.g. one million), you may use a bigger value |
||
| 265 | * (usually no bigger than 3). Using sub-directories is mainly to ensure the file system |
||
| 266 | * is not over burdened with a single directory having too many files. |
||
| 267 | */ |
||
| 268 | 6 | public function withDirectoryLevel(int $directoryLevel): self |
|
| 269 | { |
||
| 270 | 6 | $new = clone $this; |
|
| 271 | 6 | $new->directoryLevel = $directoryLevel; |
|
| 272 | 6 | return $new; |
|
| 273 | } |
||
| 274 | |||
| 275 | /** |
||
| 276 | * @param int $gcProbability The probability (parts per million) that garbage collection (GC) should |
||
| 277 | * be performed when storing a piece of data in the cache. Defaults to 10, meaning 0.001% chance. |
||
| 278 | * This number should be between 0 and 1000000. A value 0 means no GC will be performed at all. |
||
| 279 | */ |
||
| 280 | 1 | public function withGcProbability(int $gcProbability): self |
|
| 281 | { |
||
| 282 | 1 | $new = clone $this; |
|
| 283 | 1 | $new->gcProbability = $gcProbability; |
|
| 284 | 1 | return $new; |
|
| 285 | } |
||
| 286 | |||
| 287 | /** |
||
| 288 | * Converts TTL to expiration. |
||
| 289 | */ |
||
| 290 | 102 | private function ttlToExpiration(null|int|string|DateInterval $ttl = null): int |
|
| 291 | { |
||
| 292 | 102 | $ttl = $this->normalizeTtl($ttl); |
|
| 293 | |||
| 294 | 102 | if ($ttl === null) { |
|
| 295 | 94 | return self::TTL_INFINITY + time(); |
|
| 296 | } |
||
| 297 | |||
| 298 | 11 | if ($ttl <= 0) { |
|
| 299 | 4 | return self::EXPIRATION_EXPIRED; |
|
| 300 | } |
||
| 301 | |||
| 302 | 7 | return $ttl + time(); |
|
| 303 | } |
||
| 304 | |||
| 305 | /** |
||
| 306 | * Normalizes cache TTL handling strings and {@see DateInterval} objects. |
||
| 307 | * |
||
| 308 | * @param DateInterval|int|string|null $ttl The raw TTL. |
||
| 309 | * |
||
| 310 | * @return int|null TTL value as UNIX timestamp or null meaning infinity |
||
| 311 | */ |
||
| 312 | 108 | private function normalizeTtl(null|int|string|DateInterval $ttl = null): ?int |
|
| 313 | { |
||
| 314 | 108 | if ($ttl === null) { |
|
| 315 | 95 | return null; |
|
| 316 | } |
||
| 317 | |||
| 318 | 16 | if ($ttl instanceof DateInterval) { |
|
| 319 | 3 | return (new DateTime('@0')) |
|
| 320 | 3 | ->add($ttl) |
|
| 321 | 3 | ->getTimestamp(); |
|
| 322 | } |
||
| 323 | |||
| 324 | 13 | return (int) $ttl; |
|
| 325 | } |
||
| 326 | |||
| 327 | /** |
||
| 328 | * Ensures that the directory is created. |
||
| 329 | * |
||
| 330 | * @param string $path The path to the directory. |
||
| 331 | * |
||
| 332 | * @return bool Whether the directory was created. |
||
| 333 | */ |
||
| 334 | 124 | private function createDirectoryIfNotExists(string $path): bool |
|
| 335 | { |
||
| 336 | 124 | if (is_dir($path)) { |
|
| 337 | 30 | return true; |
|
| 338 | } |
||
| 339 | |||
| 340 | 124 | $result = !is_file($path) && mkdir(directory: $path, recursive: true) && is_dir($path); |
|
| 341 | |||
| 342 | 124 | if ($result) { |
|
| 343 | 124 | chmod($path, $this->directoryMode); |
|
| 344 | } |
||
| 345 | |||
| 346 | 124 | return $result; |
|
| 347 | } |
||
| 348 | |||
| 349 | /** |
||
| 350 | * Returns the cache file path given the cache key. |
||
| 351 | * |
||
| 352 | * @param string $key The cache key. |
||
| 353 | * |
||
| 354 | * @return string The cache file path. |
||
| 355 | */ |
||
| 356 | 99 | private function getCacheFile(string $key): string |
|
| 357 | { |
||
| 358 | 99 | if ($this->directoryLevel < 1) { |
|
| 359 | 1 | return $this->cachePath . DIRECTORY_SEPARATOR . $key . $this->fileSuffix; |
|
| 360 | } |
||
| 361 | |||
| 362 | 98 | $base = $this->cachePath; |
|
| 363 | |||
| 364 | 98 | for ($i = 0; $i < $this->directoryLevel; ++$i) { |
|
| 365 | 98 | if (($prefix = substr($key, $i + $i, 2)) !== '') { |
|
| 366 | 98 | $base .= DIRECTORY_SEPARATOR . $prefix; |
|
| 367 | } |
||
| 368 | } |
||
| 369 | |||
| 370 | 98 | return $base . DIRECTORY_SEPARATOR . $key . $this->fileSuffix; |
|
| 371 | } |
||
| 372 | |||
| 373 | /** |
||
| 374 | * Recursively removing expired cache files under a directory. This method is mainly used by {@see gc()}. |
||
| 375 | * |
||
| 376 | * @param string $path The directory under which expired cache files are removed. |
||
| 377 | * @param bool $expiredOnly Whether to only remove expired cache files. |
||
| 378 | * If false, all files under `$path` will be removed. |
||
| 379 | */ |
||
| 380 | 13 | private function removeCacheFiles(string $path, bool $expiredOnly): void |
|
| 381 | { |
||
| 382 | 13 | if (($handle = @opendir($path)) === false) { |
|
| 383 | return; |
||
| 384 | } |
||
| 385 | |||
| 386 | 13 | while (($file = readdir($handle)) !== false) { |
|
| 387 | 13 | if (str_starts_with($file, '.')) { |
|
| 388 | 13 | continue; |
|
| 389 | } |
||
| 390 | |||
| 391 | 13 | $fullPath = $path . DIRECTORY_SEPARATOR . $file; |
|
| 392 | |||
| 393 | 13 | if (is_dir($fullPath)) { |
|
| 394 | 13 | $this->removeCacheFiles($fullPath, $expiredOnly); |
|
| 395 | |||
| 396 | 13 | if (!$expiredOnly && !@rmdir($fullPath)) { |
|
| 397 | $errorMessage = error_get_last()['message'] ?? ''; |
||
| 398 | 13 | throw new CacheException("Unable to remove directory '{$fullPath}': {$errorMessage}"); |
|
| 399 | } |
||
| 400 | 13 | } elseif ((!$expiredOnly || @filemtime($fullPath) < time()) && !@unlink($fullPath)) { |
|
| 401 | $errorMessage = error_get_last()['message'] ?? ''; |
||
| 402 | throw new CacheException("Unable to remove file '{$fullPath}': {$errorMessage}"); |
||
| 403 | } |
||
| 404 | } |
||
| 405 | |||
| 406 | 13 | closedir($handle); |
|
| 407 | } |
||
| 408 | |||
| 409 | /** |
||
| 410 | * Removes expired cache files. |
||
| 411 | */ |
||
| 412 | 97 | private function gc(): void |
|
| 413 | { |
||
| 414 | 97 | if (random_int(0, 1_000_000) < $this->gcProbability) { |
|
| 415 | 1 | $this->removeCacheFiles($this->cachePath, true); |
|
| 416 | } |
||
| 417 | } |
||
| 418 | |||
| 419 | 111 | private function validateKey(string $key): void |
|
| 420 | { |
||
| 421 | 111 | if ($key === '' || strpbrk($key, '{}()/\@:')) { |
|
| 422 | 12 | throw new InvalidArgumentException('Invalid key value.'); |
|
| 423 | } |
||
| 424 | } |
||
| 425 | |||
| 426 | /** |
||
| 427 | * @param string[] $keys |
||
| 428 | */ |
||
| 429 | 14 | private function validateKeys(array $keys): void |
|
| 430 | { |
||
| 431 | 14 | foreach ($keys as $key) { |
|
| 432 | 14 | $this->validateKey($key); |
|
| 433 | } |
||
| 434 | } |
||
| 435 | |||
| 436 | 81 | private function existsAndNotExpired(string $file): bool |
|
| 437 | { |
||
| 438 | 81 | return is_file($file) && @filemtime($file) > time(); |
|
| 439 | } |
||
| 440 | |||
| 441 | /** |
||
| 442 | * Converts iterable to array. |
||
| 443 | * |
||
| 444 | * @psalm-template TKey |
||
| 445 | * @psalm-template TValue |
||
| 446 | * @psalm-param iterable<TKey, TValue> $iterable |
||
| 447 | * @psalm-return array<TKey, TValue> |
||
| 448 | */ |
||
| 449 | 14 | private function iterableToArray(iterable $iterable): array |
|
| 450 | { |
||
| 451 | 14 | return $iterable instanceof Traversable ? iterator_to_array($iterable) : $iterable; |
|
| 452 | } |
||
| 453 | |||
| 454 | /** |
||
| 455 | * Check if error was because of file was already deleted by another process on high load. |
||
| 456 | */ |
||
| 457 | 96 | private function isLastErrorSafe(bool $result): bool |
|
| 458 | { |
||
| 459 | 96 | if ($result !== false) { |
|
| 460 | 96 | return true; |
|
| 461 | } |
||
| 462 | |||
| 463 | $lastError = error_get_last(); |
||
| 464 | |||
| 465 | if ($lastError === null) { |
||
| 466 | return true; |
||
| 467 | } |
||
| 468 | |||
| 469 | if (str_ends_with($lastError['message'] ?? '', 'No such file or directory')) { |
||
| 470 | error_clear_last(); |
||
| 471 | return true; |
||
| 472 | } |
||
| 473 | |||
| 474 | return false; |
||
| 475 | } |
||
| 476 | } |
||
| 477 |