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
![]() |
|||
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 |