Passed
Push — master ( aeec26...e5ad74 )
by Alexander
01:23
created

FileCache   A

Complexity

Total Complexity 41

Size/Duplication

Total Lines 229
Duplicated Lines 0 %

Test Coverage

Coverage 75.9%

Importance

Changes 1
Bugs 0 Features 0
Metric Value
wmc 41
eloc 77
dl 0
loc 229
ccs 63
cts 83
cp 0.759
rs 9.1199
c 1
b 0
f 0

15 Methods

Rating   Name   Duplication   Size   Complexity  
A setCacheFileSuffix() 0 3 1
A setCachePath() 0 6 2
A setGcProbability() 0 3 1
A __construct() 0 5 1
A hasValue() 0 5 1
A getValue() 0 16 3
A setDirMode() 0 3 1
A setFileMode() 0 3 1
A gc() 0 4 2
A createDirectory() 0 3 3
B removeCacheFiles() 0 22 11
A getCacheFile() 0 14 4
B setValue() 0 29 8
A deleteValue() 0 4 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
namespace Yiisoft\Cache;
3
4
use Psr\Log\LoggerInterface;
5
use Yiisoft\Cache\Exceptions\CacheException;
6
use Yiisoft\Cache\Serializer\SerializerInterface;
7
8
/**
9
 * FileCache implements a cache handler using files.
10
 *
11
 * For each data value being cached, FileCache will store it in a separate file.
12
 * The cache files are placed under {@see cachePath}. FileCache will perform garbage collection
13
 * automatically to remove expired cache files.
14
 *
15
 * Application configuration example:
16
 *
17
 * ```php
18
 * return [
19
 *     'components' => [
20
 *         'cache' => [
21
 *             '__class' => Yiisoft\Cache\Cache::class,
22
 *             'handler' => [
23
 *                 '__class' => Yiisoft\Cache\FileCache::class,
24
 *                 'cachePath' => Yiisoft\Aliases\Aliases::get('@runtime/cache'),
25
 *             ],
26
 *         ],
27
 *         // ...
28
 *     ],
29
 *     // ...
30
 * ];
31
 * ```
32
 *
33
 * Please refer to {@see \Psr\SimpleCache\CacheInterface} for common cache operations that are supported by FileCache.
34
 *
35
 * For more details and usage information on Cache, see the [guide article on caching](guide:caching-overview).
36
 */
37
final class FileCache extends SimpleCache
38
{
39
    private const TTL_INFINITY = 31536000; // 1 year
40
41
    /**
42
     * @var string the directory to store cache files. You may use [path alias](guide:concept-aliases) here.
43
     */
44
    private $cachePath;
45
    /**
46
     * @var string cache file suffix. Defaults to '.bin'.
47
     */
48
    private $cacheFileSuffix = '.bin';
49
    /**
50
     * @var int the level of sub-directories to store cache files. Defaults to 1.
51
     * If the system has huge number of cache files (e.g. one million), you may use a bigger value
52
     * (usually no bigger than 3). Using sub-directories is mainly to ensure the file system
53
     * is not over burdened with a single directory having too many files.
54
     */
55
    private $directoryLevel = 1;
56
    /**
57
     * @var int the probability (parts per million) that garbage collection (GC) should be performed
58
     * when storing a piece of data in the cache. Defaults to 10, meaning 0.001% chance.
59
     * This number should be between 0 and 1000000. A value 0 means no GC will be performed at all.
60
     */
61
    private $gcProbability = 10;
62
    /**
63
     * @var int the permission to be set for newly created cache files.
64
     * This value will be used by PHP chmod() function. No umask will be applied.
65
     * If not set, the permission will be determined by the current environment.
66
     */
67
    private $fileMode;
68
    /**
69
     * @var int the permission to be set for newly created directories.
70
     * This value will be used by PHP chmod() function. No umask will be applied.
71
     * Defaults to 0775, meaning the directory is read-writable by owner and group,
72
     * but read-only for other users.
73
     */
74
    private $dirMode = 0775;
75
76
    private $logger;
77
78 18
    public function __construct(string $cachePath, LoggerInterface $logger, SerializerInterface $serializer = null)
79
    {
80 18
        $this->logger = $logger;
81 18
        $this->setCachePath($cachePath);
82 18
        parent::__construct($serializer);
83
    }
84
85
    /**
86
     * Sets cache path and ensures it exists.
87
     * @param string $cachePath
88
     */
89 18
    public function setCachePath(string $cachePath): void
90
    {
91 18
        $this->cachePath = $cachePath;
92
93 18
        if (!$this->createDirectory($this->cachePath, $this->dirMode)) {
94
            throw new CacheException('Failed to create cache directory "' . $this->cachePath . '"');
95
        }
96
    }
97
98
99 3
    public function hasValue(string $key): bool
100
    {
101 3
        $cacheFile = $this->getCacheFile($key);
102
103 3
        return @filemtime($cacheFile) > time();
104
    }
105
106
    /**
107
     * @param string $cacheFileSuffix
108
     */
109
    public function setCacheFileSuffix(string $cacheFileSuffix): void
110
    {
111
        $this->cacheFileSuffix = $cacheFileSuffix;
112
    }
113
114
    /**
115
     * @param int $gcProbability
116
     */
117
    public function setGcProbability(int $gcProbability): void
118
    {
119
        $this->gcProbability = $gcProbability;
120
    }
121
122
    /**
123
     * @param int $fileMode
124
     */
125
    public function setFileMode(int $fileMode): void
126
    {
127
        $this->fileMode = $fileMode;
128
    }
129
130
    /**
131
     * @param int $dirMode
132
     */
133
    public function setDirMode(int $dirMode): void
134
    {
135
        $this->dirMode = $dirMode;
136
    }
137
138 17
    protected function getValue(string $key, $default = null)
139
    {
140 17
        $cacheFile = $this->getCacheFile($key);
141
142 17
        if (@filemtime($cacheFile) > time()) {
143 15
            $fp = @fopen($cacheFile, 'rb');
144 15
            if ($fp !== false) {
145 15
                @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

145
                /** @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...
146 15
                $cacheValue = @stream_get_contents($fp);
147 15
                @flock($fp, LOCK_UN);
148 15
                @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

148
                /** @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...
149 15
                return $cacheValue;
150
            }
151
        }
152
153 11
        return $default;
154
    }
155
156 17
    protected function setValue(string $key, $value, ?int $ttl): bool
157
    {
158 17
        $this->gc();
159 17
        $cacheFile = $this->getCacheFile($key);
160 17
        if ($this->directoryLevel > 0) {
161 17
            $directoryName = \dirname($cacheFile);
162 17
            if (!$this->createDirectory($directoryName, $this->dirMode)) {
163
                $this->logger->warning('Failed to create cache directory "' . $directoryName . '"');
164
                return false;
165
            }
166
        }
167
        // If ownership differs the touch call will fail, so we try to
168
        // rebuild the file from scratch by deleting it first
169
        // https://github.com/yiisoft/yii2/pull/16120
170 17
        if (\function_exists('posix_geteuid') && is_file($cacheFile) && fileowner($cacheFile) !== posix_geteuid()) {
171 1
            @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

171
            /** @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...
172
        }
173
174 17
        if (@file_put_contents($cacheFile, $value, LOCK_EX) !== false) {
175 17
            if ($this->fileMode !== null) {
176
                @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

176
                /** @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...
177
            }
178 17
            $ttl = $ttl ?? self::TTL_INFINITY;
179 17
            return @touch($cacheFile, $ttl + time());
180
        }
181
182
        $error = error_get_last();
183
        $this->logger->warning("Failed to write cache data to \"$cacheFile\": " . $error['message']);
184
        return false;
185
    }
186
187 1
    protected function deleteValue(string $key): bool
188
    {
189 1
        $cacheFile = $this->getCacheFile($key);
190 1
        return @unlink($cacheFile);
191
    }
192
193
    /**
194
     * Returns the cache file path given the cache key.
195
     * @param string $key cache key
196
     * @return string the cache file path
197
     */
198 18
    private function getCacheFile(string $key): string
199
    {
200 18
        if ($this->directoryLevel > 0) {
201 18
            $base = $this->cachePath;
202 18
            for ($i = 0; $i < $this->directoryLevel; ++$i) {
203 18
                if (($prefix = substr($key, $i + $i, 2)) !== false) {
204 18
                    $base .= DIRECTORY_SEPARATOR . $prefix;
205
                }
206
            }
207
208 18
            return $base . DIRECTORY_SEPARATOR . $key . $this->cacheFileSuffix;
209
        }
210
211
        return $this->cachePath . DIRECTORY_SEPARATOR . $key . $this->cacheFileSuffix;
212
    }
213
214 11
    public function clear(): bool
215
    {
216 11
        $this->removeCacheFiles($this->cachePath, false);
217 11
        return true;
218
    }
219
220
    /**
221
     * Removes expired cache files
222
     * @throws \Exception
223
     */
224 17
    public function gc(): void
225
    {
226 17
        if (\random_int(0, 1000000) < $this->gcProbability) {
227
            $this->removeCacheFiles($this->cachePath, true);
228
        }
229
    }
230
231
    /**
232
     * Recursively removing expired cache files under a directory.
233
     * This method is mainly used by {@see gc()}.
234
     * @param string $path the directory under which expired cache files are removed.
235
     * @param bool $expiredOnly whether to only remove expired cache files. If false, all files
236
     * under `$path` will be removed.
237
     */
238 11
    private function removeCacheFiles(string $path, bool $expiredOnly): void
239
    {
240 11
        if (($handle = opendir($path)) !== false) {
241 11
            while (($file = readdir($handle)) !== false) {
242 11
                if (strncmp($file, '.', 1) === 0) {
243 11
                    continue;
244
                }
245 10
                $fullPath = $path . DIRECTORY_SEPARATOR . $file;
246 10
                if (is_dir($fullPath)) {
247 10
                    $this->removeCacheFiles($fullPath, $expiredOnly);
248 10
                    if (!$expiredOnly && !@rmdir($fullPath)) {
249
                        $error = error_get_last();
250 10
                        throw new CacheException("Unable to remove directory '{$fullPath}': {$error['message']}");
251
                    }
252 10
                } elseif (!$expiredOnly || ($expiredOnly && @filemtime($fullPath) < time())) {
253 10
                    if (!@unlink($fullPath)) {
254
                        $error = error_get_last();
255
                        throw new CacheException("Unable to remove file '{$fullPath}': {$error['message']}");
256
                    }
257
                }
258
            }
259 11
            closedir($handle);
260
        }
261
    }
262
263 18
    private function createDirectory(string $cachePath, int $mode, bool $recursive = true): bool
264
    {
265 18
        return is_dir($cachePath) || (mkdir($cachePath, $mode, $recursive) && is_dir($cachePath));
266
    }
267
}
268