Passed
Pull Request — master (#30)
by Alexander
01:27
created

FileCache   C

Complexity

Total Complexity 55

Size/Duplication

Total Lines 318
Duplicated Lines 0 %

Test Coverage

Coverage 74.12%

Importance

Changes 4
Bugs 0 Features 0
Metric Value
eloc 103
c 4
b 0
f 0
dl 0
loc 318
rs 6
ccs 63
cts 85
cp 0.7412
wmc 55

21 Methods

Rating   Name   Duplication   Size   Complexity  
A gc() 0 4 2
A ttlToExpiration() 0 13 3
A createDirectory() 0 3 3
B removeCacheFiles() 0 22 11
A setCacheFileSuffix() 0 3 1
A has() 0 6 1
A setDirectoryLevel() 0 3 1
A getCacheFile() 0 14 4
A setMultiple() 0 6 2
A initCacheDirectory() 0 4 2
A delete() 0 3 1
A normalizeTtl() 0 11 3
B set() 0 33 9
A deleteMultiple() 0 6 2
A setGcProbability() 0 3 1
A getMultiple() 0 8 2
A __construct() 0 5 1
A get() 0 17 3
A setFileMode() 0 3 1
A setDirMode() 0 3 1
A clear() 0 4 1

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
namespace Yiisoft\Cache;
4
5
use DateInterval;
6
use DateTime;
7
use Exception;
8
use Psr\SimpleCache\CacheInterface;
0 ignored issues
show
Bug introduced by
This use statement conflicts with another class in this namespace, Yiisoft\Cache\CacheInterface. Consider defining an alias.

Let?s assume that you have a directory layout like this:

.
|-- OtherDir
|   |-- Bar.php
|   `-- Foo.php
`-- SomeDir
    `-- Foo.php

and let?s assume the following content of Bar.php:

// Bar.php
namespace OtherDir;

use SomeDir\Foo; // This now conflicts the class OtherDir\Foo

If both files OtherDir/Foo.php and SomeDir/Foo.php are loaded in the same runtime, you will see a PHP error such as the following:

PHP Fatal error:  Cannot use SomeDir\Foo as Foo because the name is already in use in OtherDir/Foo.php

However, as OtherDir/Foo.php does not necessarily have to be loaded and the error is only triggered if it is loaded before OtherDir/Bar.php, this problem might go unnoticed for a while. In order to prevent this error from surfacing, you must import the namespace with a different alias:

// Bar.php
namespace OtherDir;

use SomeDir\Foo as SomeDirFoo; // There is no conflict anymore.
Loading history...
9
use Yiisoft\Cache\Exception\CacheException;
10
use Yiisoft\Cache\Serializer\PhpSerializer;
11
use Yiisoft\Cache\Serializer\SerializerInterface;
12
13
/**
14
 * FileCache implements a cache handler using files.
15
 *
16
 * For each data value being cached, FileCache will store it in a separate file.
17
 * The cache files are placed under {@see FileCache::$cachePath}. FileCache will perform garbage collection
18
 * automatically to remove expired cache files.
19
 *
20
 * Please refer to {@see \Psr\SimpleCache\CacheInterface} for common cache operations that are supported by FileCache.
21
 */
22
final class FileCache implements CacheInterface
23
{
24
    private const TTL_INFINITY = 31536000; // 1 year
25
    private const EXPIRATION_EXPIRED = -1;
26
27
    /**
28
     * @var string the directory to store cache files. You may use [path alias](guide:concept-aliases) here.
29
     */
30
    private $cachePath;
31
    /**
32
     * @var string cache file suffix. Defaults to '.bin'.
33
     */
34
    private $cacheFileSuffix = '.bin';
35
    /**
36
     * @var int the level of sub-directories to store cache files. Defaults to 1.
37
     * If the system has huge number of cache files (e.g. one million), you may use a bigger value
38
     * (usually no bigger than 3). Using sub-directories is mainly to ensure the file system
39
     * is not over burdened with a single directory having too many files.
40
     */
41
    private $directoryLevel = 1;
42
43
    /**
44
     * @var int the probability (parts per million) that garbage collection (GC) should be performed
45
     * when storing a piece of data in the cache. Defaults to 10, meaning 0.001% chance.
46
     * This number should be between 0 and 1000000. A value 0 means no GC will be performed at all.
47
     */
48
    private $gcProbability = 10;
49
    /**
50
     * @var int the permission to be set for newly created cache files.
51
     * This value will be used by PHP chmod() function. No umask will be applied.
52
     * If not set, the permission will be determined by the current environment.
53
     */
54
    private $fileMode;
55
    /**
56
     * @var int the permission to be set for newly created directories.
57
     * This value will be used by PHP chmod() function. No umask will be applied.
58
     * Defaults to 0775, meaning the directory is read-writable by owner and group,
59 20
     * but read-only for other users.
60
     */
61 20
    private $dirMode = 0775;
62 20
63 20
    /**
64
     * @var SerializerInterface the serializer to be used for serializing and unserializing of the cached data.
65
     */
66
    private $serializer;
67
68
    public function __construct(string $cachePath, ?SerializerInterface $serializer = null)
69
    {
70 20
        $this->cachePath = $cachePath;
71
        $this->serializer = $serializer ?? new PhpSerializer();
72 20
        $this->initCacheDirectory();
73
    }
74 20
75
    public function get($key, $default = null)
76
    {
77
        $cacheFile = $this->getCacheFile($key);
78
79 4
        if (@filemtime($cacheFile) > time()) {
80
            $fp = @fopen($cacheFile, 'rb');
81 4
            if ($fp !== false) {
82
                @flock($fp, LOCK_SH);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for flock(). 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

82
                /** @scrutinizer ignore-unhandled */ @flock($fp, LOCK_SH);

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...
83 4
                $cacheValue = @stream_get_contents($fp);
84
                @flock($fp, LOCK_UN);
85
                @fclose($fp);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for fclose(). 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

85
                /** @scrutinizer ignore-unhandled */ @fclose($fp);

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...
86
                $cacheValue = $this->serializer->unserialize($cacheValue);
87
                return $cacheValue;
88
            }
89
        }
90
91
        return $default;
92
    }
93
94
    public function set($key, $value, $ttl = null)
95
    {
96
        $this->gc();
97
98
        $expiration = $this->ttlToExpiration($ttl);
99
        if ($expiration < 0) {
100
            return $this->delete($key);
101
        }
102
103
        $cacheFile = $this->getCacheFile($key);
104
        if ($this->directoryLevel > 0) {
105
            $directoryName = \dirname($cacheFile);
106
            if (!$this->createDirectory($directoryName, $this->dirMode)) {
107
                return false;
108
            }
109
        }
110
        // If ownership differs the touch call will fail, so we try to
111
        // rebuild the file from scratch by deleting it first
112
        // https://github.com/yiisoft/yii2/pull/16120
113
        if (\function_exists('posix_geteuid') && is_file($cacheFile) && fileowner($cacheFile) !== posix_geteuid()) {
114
            @unlink($cacheFile);
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

114
            /** @scrutinizer ignore-unhandled */ @unlink($cacheFile);

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...
115
        }
116
117
        $value = $this->serializer->serialize($value);
118
119
        if (@file_put_contents($cacheFile, $value, LOCK_EX) !== false) {
120
            if ($this->fileMode !== null) {
121
                @chmod($cacheFile, $this->fileMode);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for chmod(). 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

121
                /** @scrutinizer ignore-unhandled */ @chmod($cacheFile, $this->fileMode);

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...
122
            }
123
            return @touch($cacheFile, $expiration);
124
        }
125 19
126
        return false;
127 19
    }
128
129 19
    public function delete($key)
130 17
    {
131 17
        return @unlink($this->getCacheFile($key));
132 17
    }
133 17
134 17
    public function clear()
135 17
    {
136 17
        $this->removeCacheFiles($this->cachePath, false);
137
        return true;
138
    }
139
140 12
    public function getMultiple($keys, $default = null)
141
    {
142
        $results = [];
143 19
        foreach ($keys as $key) {
144
            $value = $this->get($key, $default);
145 19
            $results[$key] = $value;
146 19
        }
147 19
        return $results;
148 19
    }
149 19
150
    public function setMultiple($values, $ttl = null)
151
    {
152
        foreach ($values as $key => $value) {
153
            $this->set($key, $value, $ttl);
154
        }
155
        return true;
156
    }
157 19
158 1
    public function deleteMultiple($keys)
159
    {
160
        foreach ($keys as $key) {
161 19
            $this->delete($key);
162 19
        }
163
        return true;
164
    }
165 19
166 19
    public function has($key)
167
    {
168
        // TODO extract expired
169
        $cacheFile = $this->getCacheFile($key);
170
171
        return @filemtime($cacheFile) > time();
172
    }
173
174 2
    /**
175
     * Converts TTL to expiration
176 2
     * @param $ttl
177 2
     * @return int
178
     */
179
    private function ttlToExpiration($ttl): int
180
    {
181
        $ttl = $this->normalizeTtl($ttl);
182
183
        if ($ttl === null) {
184
            $expiration = static::TTL_INFINITY + time();
185 20
        } elseif ($ttl <= 0) {
186
            $expiration = static::EXPIRATION_EXPIRED;
187 20
        } else {
188 20
            $expiration = $ttl + time();
189 20
        }
190 20
191 20
        return $expiration;
192
    }
193
194
    /**
195 20
     * Normalizes cache TTL handling `null` value and {@see DateInterval} objects.
196
     * @param int|DateInterval|null $ttl raw TTL.
197
     * @return int|null TTL value as UNIX timestamp or null meaning infinity
198
     */
199
    private function normalizeTtl($ttl): ?int
200
    {
201 20
        if ($ttl instanceof DateInterval) {
202
            try {
203 20
                return (new DateTime('@0'))->add($ttl)->getTimestamp();
204 20
            } catch (Exception $e) {
205
                return null;
206
            }
207
        }
208
209
        return $ttl;
210
    }
211 19
212
    /**
213 19
     * Ensures that cache directory exists.
214
     */
215
    private function initCacheDirectory(): void
216
    {
217
        if (!$this->createDirectory($this->cachePath, $this->dirMode)) {
218
            throw new CacheException('Failed to create cache directory "' . $this->cachePath . '"');
219
        }
220
    }
221
222
    private function createDirectory(string $path, int $mode): bool
223
    {
224
        return is_dir($path) || (mkdir($path, $mode, true) && is_dir($path));
225 20
    }
226
227 20
    /**
228 20
     * Returns the cache file path given the cache key.
229 20
     * @param string $key cache key
230 20
     * @return string the cache file path
231
     */
232 17
    private function getCacheFile(string $key): string
233 17
    {
234 17
        if ($this->directoryLevel > 0) {
235 17
            $base = $this->cachePath;
236
            for ($i = 0; $i < $this->directoryLevel; ++$i) {
237 17
                if (($prefix = substr($key, $i + $i, 2)) !== false) {
238
                    $base .= DIRECTORY_SEPARATOR . $prefix;
239 17
                }
240 17
            }
241
242
            return $base . DIRECTORY_SEPARATOR . $key . $this->cacheFileSuffix;
243
        }
244
245
        return $this->cachePath . DIRECTORY_SEPARATOR . $key . $this->cacheFileSuffix;
246 20
    }
247
248
    /**
249
     * Removes expired cache files
250 20
     * @throws \Exception
251
     */
252 20
    public function gc(): void
253
    {
254
        if (\random_int(0, 1000000) < $this->gcProbability) {
255
            $this->removeCacheFiles($this->cachePath, true);
256
        }
257
    }
258
259
    /**
260
     * Recursively removing expired cache files under a directory.
261
     * This method is mainly used by {@see gc()}.
262
     * @param string $path the directory under which expired cache files are removed.
263
     * @param bool $expiredOnly whether to only remove expired cache files. If false, all files
264
     * under `$path` will be removed.
265
     */
266
    private function removeCacheFiles(string $path, bool $expiredOnly): void
267
    {
268
        if (($handle = opendir($path)) !== false) {
269
            while (($file = readdir($handle)) !== false) {
270
                if (strncmp($file, '.', 1) === 0) {
271
                    continue;
272
                }
273
                $fullPath = $path . DIRECTORY_SEPARATOR . $file;
274
                if (is_dir($fullPath)) {
275
                    $this->removeCacheFiles($fullPath, $expiredOnly);
276
                    if (!$expiredOnly && !@rmdir($fullPath)) {
277
                        $error = error_get_last();
278
                        throw new CacheException("Unable to remove directory '{$fullPath}': {$error['message']}");
279
                    }
280
                } elseif (!$expiredOnly || ($expiredOnly && @filemtime($fullPath) < time())) {
281
                    if (!@unlink($fullPath)) {
282
                        $error = error_get_last();
283
                        throw new CacheException("Unable to remove file '{$fullPath}': {$error['message']}");
284
                    }
285
                }
286
            }
287
            closedir($handle);
288
        }
289
    }
290
291
    /**
292
     * @param string $cacheFileSuffix cache file suffix. Defaults to '.bin'.
293
     */
294
    public function setCacheFileSuffix(string $cacheFileSuffix): void
295
    {
296
        $this->cacheFileSuffix = $cacheFileSuffix;
297
    }
298
299
    /**
300
     * @param int $gcProbability the probability (parts per million) that garbage collection (GC) should be performed
301
     * when storing a piece of data in the cache. Defaults to 10, meaning 0.001% chance.
302
     * This number should be between 0 and 1000000. A value 0 means no GC will be performed at all.
303
     */
304
    public function setGcProbability(int $gcProbability): void
305
    {
306
        $this->gcProbability = $gcProbability;
307
    }
308
309
    /**
310
     * @param int $fileMode the permission to be set for newly created cache files.
311
     * This value will be used by PHP chmod() function. No umask will be applied.
312
     * If not set, the permission will be determined by the current environment.
313
     */
314
    public function setFileMode(int $fileMode): void
315
    {
316
        $this->fileMode = $fileMode;
317
    }
318
319
    /**
320
     * @param int $dirMode the permission to be set for newly created directories.
321
     * This value will be used by PHP chmod() function. No umask will be applied.
322
     * Defaults to 0775, meaning the directory is read-writable by owner and group,
323
     * but read-only for other users.
324
     */
325
    public function setDirMode(int $dirMode): void
326
    {
327
        $this->dirMode = $dirMode;
328
    }
329
330
    /**
331
     * @param int $directoryLevel the level of sub-directories to store cache files. Defaults to 1.
332
     * If the system has huge number of cache files (e.g. one million), you may use a bigger value
333
     * (usually no bigger than 3). Using sub-directories is mainly to ensure the file system
334
     * is not over burdened with a single directory having too many files.
335
     *
336
     */
337
    public function setDirectoryLevel(int $directoryLevel): void
338
    {
339
        $this->directoryLevel = $directoryLevel;
340
    }
341
}
342