Passed
Pull Request — master (#47)
by Sergei
02:03
created

FileCache   F

Complexity

Total Complexity 66

Size/Duplication

Total Lines 411
Duplicated Lines 0 %

Test Coverage

Coverage 95.74%

Importance

Changes 0
Metric Value
wmc 66
eloc 125
dl 0
loc 411
ccs 135
cts 141
cp 0.9574
rs 3.12
c 0
b 0
f 0

24 Methods

Rating   Name   Duplication   Size   Complexity  
A setMultiple() 0 10 2
A withFileMode() 0 5 1
A getMultiple() 0 11 2
A withDirectoryMode() 0 5 1
A withGcProbability() 0 5 1
A withFileSuffix() 0 5 1
A deleteMultiple() 0 10 2
A clear() 0 4 1
A ttlToExpiration() 0 13 3
A get() 0 15 3
A __construct() 0 7 2
A delete() 0 10 2
A normalizeTtl() 0 13 3
A withDirectoryLevel() 0 5 1
A has() 0 4 1
B set() 0 33 10
A iterableToArray() 0 4 2
A existsAndNotExpired() 0 3 2
A getCacheFile() 0 15 4
A gc() 0 4 2
A validateKey() 0 4 3
B removeCacheFiles() 0 27 10
A validateKeys() 0 4 2
A createDirectoryIfNotExists() 0 13 5

How to fix   Complexity   

Complex Class

Complex classes like FileCache often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use FileCache, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Cache\File;
6
7
use DateInterval;
8
use DateTime;
9
use Exception;
10
use Psr\SimpleCache\CacheInterface;
11
use Traversable;
12
13
use function array_keys;
14
use function array_map;
15
use function closedir;
16
use function dirname;
17
use function error_get_last;
18
use function filemtime;
19
use function fileowner;
20
use function fopen;
21
use function function_exists;
22
use function is_dir;
23
use function is_file;
24
use function iterator_to_array;
25
use function opendir;
26
use function posix_geteuid;
27
use function random_int;
28
use function readdir;
29
use function rmdir;
30
use function serialize;
31
use function strncmp;
32
use function strpbrk;
33
use function substr;
34
use function unlink;
35
use function unserialize;
36
37
use const LOCK_EX;
38
use const LOCK_SH;
39
use const LOCK_UN;
40
41
/**
42
 * FileCache implements a cache handler using files.
43
 *
44
 * For each data value being cached, FileCache will store it in a separate file.
45
 * The cache files are placed under {@see FileCache::$cachePath}.
46
 * FileCache will perform garbage collection automatically to remove expired cache files.
47
 *
48
 * Please refer to {@see \Psr\SimpleCache\CacheInterface} for common cache operations that are supported by FileCache.
49
 */
50
final class FileCache implements CacheInterface
51
{
52
    private const TTL_INFINITY = 31536000; // 1 year
53
    private const EXPIRATION_EXPIRED = -1;
54
55
    /**
56
     * @var string The directory to store cache files.
57
     */
58
    private string $cachePath;
59
60
    /**
61
     * @var string The cache file suffix. Defaults to '.bin'.
62
     */
63
    private string $fileSuffix = '.bin';
64
65
    /**
66
     * @var int|null The permission to be set for newly created cache files.
67
     * This value will be used by PHP chmod() function. No umask will be applied.
68
     * If not set, the permission will be determined by the current environment.
69
     */
70
    private ?int $fileMode = null;
71
72
    /**
73
     * @var int The permission to be set for newly created directories.
74
     * This value will be used by PHP chmod() function. No umask will be applied.
75
     * Defaults to 0775, meaning the directory is read-writable by owner and group,
76
     * but read-only for other users.
77
     */
78
    private int $directoryMode = 0775;
79
80
    /**
81
     * @var int The level of sub-directories to store cache files. Defaults to 1.
82
     * If the system has huge number of cache files (e.g. one million), you may use a bigger value
83
     * (usually no bigger than 3). Using sub-directories is mainly to ensure the file system
84
     * is not over burdened with a single directory having too many files.
85
     */
86
    private int $directoryLevel = 1;
87
88
    /**
89
     * @var int The probability (parts per million) that garbage collection (GC) should be performed
90
     * when storing a piece of data in the cache. Defaults to 10, meaning 0.001% chance.
91
     * This number should be between 0 and 1000000. A value 0 means no GC will be performed at all.
92
     */
93
    private int $gcProbability = 10;
94
95
    /**
96
     * @param string $cachePath The directory to store cache files.
97
     *
98
     * @see FileCache::$cachePath
99
     *
100
     * @throws CacheException If failed to create cache directory.
101
     */
102 116
    public function __construct(string $cachePath)
103
    {
104 116
        if (!$this->createDirectoryIfNotExists($cachePath)) {
105 1
            throw new CacheException("Failed to create cache directory \"{$cachePath}\".");
106
        }
107
108 116
        $this->cachePath = $cachePath;
109
    }
110
111 80
    public function get(string $key, mixed $default = null): mixed
112
    {
113 80
        $this->validateKey($key);
114 78
        $file = $this->getCacheFile($key);
115
116 78
        if (!$this->existsAndNotExpired($file) || ($filePointer = @fopen($file, 'rb')) === false) {
117 28
            return $default;
118
        }
119
120 64
        flock($filePointer, LOCK_SH);
121 64
        $value = stream_get_contents($filePointer);
122 64
        flock($filePointer, LOCK_UN);
123 64
        fclose($filePointer);
124
125 64
        return unserialize($value);
126
    }
127
128 92
    public function set(string $key, mixed $value, null|int|DateInterval $ttl = null): bool
129
    {
130 92
        $this->validateKey($key);
131 90
        $this->gc();
132 90
        $expiration = $this->ttlToExpiration($ttl);
133
134 90
        if ($expiration <= self::EXPIRATION_EXPIRED) {
135 1
            return $this->delete($key);
136
        }
137
138 90
        $file = $this->getCacheFile($key);
139 90
        $cacheDirectory = dirname($file);
140
141 90
        if (!is_dir($this->cachePath) || ($this->directoryLevel > 0 && !$this->createDirectoryIfNotExists($cacheDirectory))) {
142 1
            throw new CacheException("Failed to create cache directory \"{$cacheDirectory}\".");
143
        }
144
145
        // If ownership differs the touch call will fail, so we try to
146
        // rebuild the file from scratch by deleting it first
147
        // https://github.com/yiisoft/yii2/pull/16120
148 89
        if (function_exists('posix_geteuid') && is_file($file) && fileowner($file) !== posix_geteuid()) {
149
            @unlink($file);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for unlink(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

149
            /** @scrutinizer ignore-unhandled */ @unlink($file);

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
150
        }
151
152 89
        if (file_put_contents($file, serialize($value), LOCK_EX) === false) {
153
            return false;
154
        }
155
156 89
        if ($this->fileMode !== null) {
157 1
            chmod($file, $this->fileMode);
158
        }
159
160 89
        return touch($file, $expiration);
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
        return @unlink($file);
173
    }
174
175 12
    public function clear(): bool
176
    {
177 12
        $this->removeCacheFiles($this->cachePath, false);
178 12
        return true;
179
    }
180
181 9
    public function getMultiple(iterable $keys, mixed $default = null): iterable
182
    {
183 9
        $keys = $this->iterableToArray($keys);
184 9
        $this->validateKeys($keys);
185 7
        $results = [];
186
187 7
        foreach ($keys as $key) {
188 7
            $results[$key] = $this->get($key, $default);
189
        }
190
191 7
        return $results;
192
    }
193
194 10
    public function setMultiple(iterable $values, null|int|DateInterval $ttl = null): bool
195
    {
196 10
        $values = $this->iterableToArray($values);
197 10
        $this->validateKeys(array_map('\strval', array_keys($values)));
198
199 10
        foreach ($values as $key => $value) {
200 10
            $this->set((string) $key, $value, $ttl);
201
        }
202
203 10
        return true;
204
    }
205
206 3
    public function deleteMultiple(iterable $keys): bool
207
    {
208 3
        $keys = $this->iterableToArray($keys);
209 3
        $this->validateKeys($keys);
210
211 1
        foreach ($keys as $key) {
212 1
            $this->delete($key);
213
        }
214
215 1
        return true;
216
    }
217
218 15
    public function has(string $key): bool
219
    {
220 15
        $this->validateKey($key);
221 13
        return $this->existsAndNotExpired($this->getCacheFile($key));
222
    }
223
224
    /**
225
     * @param string $fileSuffix The cache file suffix. Defaults to '.bin'.
226
     *
227
     * @return self
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.
238
     * This value will be used by PHP chmod() function. No umask will be applied.
239
     * If not set, the permission will be determined by the current environment.
240
     *
241
     * @return self
242
     */
243 1
    public function withFileMode(int $fileMode): self
244
    {
245 1
        $new = clone $this;
246 1
        $new->fileMode = $fileMode;
247 1
        return $new;
248
    }
249
250
    /**
251
     * @param int $directoryMode The permission to be set for newly created directories.
252
     * This value will be used by PHP chmod() function. No umask will be applied.
253
     * Defaults to 0775, meaning the directory is read-writable by owner and group, but read-only for other users.
254
     *
255
     * @return self
256
     */
257 1
    public function withDirectoryMode(int $directoryMode): self
258
    {
259 1
        $new = clone $this;
260 1
        $new->directoryMode = $directoryMode;
261 1
        return $new;
262
    }
263
264
    /**
265
     * @param int $directoryLevel The level of sub-directories to store cache files. Defaults to 1.
266
     * If the system has huge number of cache files (e.g. one million), you may use a bigger value
267
     * (usually no bigger than 3). Using sub-directories is mainly to ensure the file system
268
     * is not over burdened with a single directory having too many files.
269
     *
270
     * @return self
271
     */
272 1
    public function withDirectoryLevel(int $directoryLevel): self
273
    {
274 1
        $new = clone $this;
275 1
        $new->directoryLevel = $directoryLevel;
276 1
        return $new;
277
    }
278
279
    /**
280
     * @param int $gcProbability The probability (parts per million) that garbage collection (GC) should
281
     * be performed when storing a piece of data in the cache. Defaults to 10, meaning 0.001% chance.
282
     * This number should be between 0 and 1000000. A value 0 means no GC will be performed at all.
283
     *
284
     * @return self
285
     */
286 1
    public function withGcProbability(int $gcProbability): self
287
    {
288 1
        $new = clone $this;
289 1
        $new->gcProbability = $gcProbability;
290 1
        return $new;
291
    }
292
293
    /**
294
     * Converts TTL to expiration.
295
     *
296
     * @param DateInterval|int|string|null $ttl
297
     *
298
     * @return int
299
     */
300 95
    private function ttlToExpiration(null|int|string|DateInterval $ttl = null): int
301
    {
302 95
        $ttl = $this->normalizeTtl($ttl);
303
304 95
        if ($ttl === null) {
305 88
            return self::TTL_INFINITY + time();
306
        }
307
308 10
        if ($ttl <= 0) {
309 4
            return self::EXPIRATION_EXPIRED;
310
        }
311
312 6
        return $ttl + time();
313
    }
314
315
    /**
316
     * Normalizes cache TTL handling strings and {@see DateInterval} objects.
317
     *
318
     * @param DateInterval|int|string|null $ttl The raw TTL.
319
     *
320
     * @return int|null TTL value as UNIX timestamp or null meaning infinity
321
     */
322 101
    private function normalizeTtl(null|int|string|DateInterval $ttl = null): ?int
323
    {
324 101
        if ($ttl === null) {
325 89
            return null;
326
        }
327
328 15
        if ($ttl instanceof DateInterval) {
329 3
            return (new DateTime('@0'))
330 3
                ->add($ttl)
331 3
                ->getTimestamp();
332
        }
333
334 12
        return (int) $ttl;
335
    }
336
337
    /**
338
     * Ensures that the directory is created.
339
     *
340
     * @param string $path The path to the directory.
341
     *
342
     * @return bool Whether the directory was created.
343
     */
344 116
    private function createDirectoryIfNotExists(string $path): bool
345
    {
346 116
        if (is_dir($path)) {
347 29
            return true;
348
        }
349
350 116
        $result = !is_file($path) && mkdir(directory: $path, recursive: true) && is_dir($path);
351
352 116
        if ($result) {
353 116
            chmod($path, $this->directoryMode);
354
        }
355
356 116
        return $result;
357
    }
358
359
    /**
360
     * Returns the cache file path given the cache key.
361
     *
362
     * @param string $key The cache key.
363
     *
364
     * @return string The cache file path.
365
     */
366 92
    private function getCacheFile(string $key): string
367
    {
368 92
        if ($this->directoryLevel < 1) {
369 1
            return $this->cachePath . DIRECTORY_SEPARATOR . $key . $this->fileSuffix;
370
        }
371
372 91
        $base = $this->cachePath;
373
374 91
        for ($i = 0; $i < $this->directoryLevel; ++$i) {
375 91
            if (($prefix = substr($key, $i + $i, 2)) !== '') {
376 91
                $base .= DIRECTORY_SEPARATOR . $prefix;
377
            }
378
        }
379
380 91
        return $base . DIRECTORY_SEPARATOR . $key . $this->fileSuffix;
381
    }
382
383
    /**
384
     * Recursively removing expired cache files under a directory. This method is mainly used by {@see gc()}.
385
     *
386
     * @param string $path The directory under which expired cache files are removed.
387
     * @param bool $expiredOnly Whether to only remove expired cache files.
388
     * If false, all files under `$path` will be removed.
389
     */
390 13
    private function removeCacheFiles(string $path, bool $expiredOnly): void
391
    {
392 13
        if (($handle = @opendir($path)) === false) {
393
            return;
394
        }
395
396 13
        while (($file = readdir($handle)) !== false) {
397 13
            if (strncmp($file, '.', 1) === 0) {
398 13
                continue;
399
            }
400
401 13
            $fullPath = $path . DIRECTORY_SEPARATOR . $file;
402
403 13
            if (is_dir($fullPath)) {
404 13
                $this->removeCacheFiles($fullPath, $expiredOnly);
405
406 13
                if (!$expiredOnly && !@rmdir($fullPath)) {
407
                    $errorMessage = error_get_last()['message'] ?? '';
408 13
                    throw new CacheException("Unable to remove directory '{$fullPath}': {$errorMessage}");
409
                }
410 13
            } elseif ((!$expiredOnly || @filemtime($fullPath) < time()) && !@unlink($fullPath)) {
411
                $errorMessage = error_get_last()['message'] ?? '';
412
                throw new CacheException("Unable to remove file '{$fullPath}': {$errorMessage}");
413
            }
414
        }
415
416 13
        closedir($handle);
417
    }
418
419
    /**
420
     * Removes expired cache files.
421
     *
422
     * @throws Exception
423
     */
424 90
    private function gc(): void
425
    {
426 90
        if (random_int(0, 1000000) < $this->gcProbability) {
427 1
            $this->removeCacheFiles($this->cachePath, true);
428
        }
429
    }
430
431 104
    private function validateKey(string $key): void
432
    {
433 104
        if ($key === '' || strpbrk($key, '{}()/\@:')) {
434 12
            throw new InvalidArgumentException('Invalid key value.');
435
        }
436
    }
437
438 14
    private function validateKeys(array $keys): void
439
    {
440 14
        foreach ($keys as $key) {
441 14
            $this->validateKey($key);
442
        }
443
    }
444
445 79
    private function existsAndNotExpired(string $file): bool
446
    {
447 79
        return is_file($file) && @filemtime($file) > time();
448
    }
449
450
    /**
451
     * Converts iterable to array. If provided value is not iterable it throws an InvalidArgumentException.
452
     *
453
     * @param iterable $iterable
454
     *
455
     * @return array
456
     */
457 14
    private function iterableToArray(iterable $iterable): array
458
    {
459
        /** @psalm-suppress RedundantCast */
460 14
        return $iterable instanceof Traversable ? iterator_to_array($iterable) : (array) $iterable;
461
    }
462
}
463