Completed
Pull Request — master (#16)
by Alexander
01:24
created

FileCache   A

Complexity

Total Complexity 42

Size/Duplication

Total Lines 231
Duplicated Lines 0 %

Test Coverage

Coverage 76.19%

Importance

Changes 1
Bugs 0 Features 0
Metric Value
wmc 42
eloc 78
c 1
b 0
f 0
dl 0
loc 231
ccs 64
cts 84
cp 0.7619
rs 9.0399

15 Methods

Rating   Name   Duplication   Size   Complexity  
A gc() 0 4 2
A createDirectory() 0 3 3
B removeCacheFiles() 0 22 11
A setCacheFileSuffix() 0 3 1
A getCacheFile() 0 14 4
B setValue() 0 31 9
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 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 [[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 [[\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
    /**
40
     * @var string the directory to store cache files. You may use [path alias](guide:concept-aliases) here.
41
     */
42
    private $cachePath;
43
    /**
44
     * @var string cache file suffix. Defaults to '.bin'.
45
     */
46
    private $cacheFileSuffix = '.bin';
47
    /**
48
     * @var int the level of sub-directories to store cache files. Defaults to 1.
49
     * If the system has huge number of cache files (e.g. one million), you may use a bigger value
50
     * (usually no bigger than 3). Using sub-directories is mainly to ensure the file system
51
     * is not over burdened with a single directory having too many files.
52
     */
53
    private $directoryLevel = 1;
54
    /**
55
     * @var int the probability (parts per million) that garbage collection (GC) should be performed
56
     * when storing a piece of data in the cache. Defaults to 10, meaning 0.001% chance.
57
     * This number should be between 0 and 1000000. A value 0 means no GC will be performed at all.
58
     */
59
    private $gcProbability = 10;
60
    /**
61
     * @var int the permission to be set for newly created cache files.
62
     * This value will be used by PHP chmod() function. No umask will be applied.
63
     * If not set, the permission will be determined by the current environment.
64
     */
65
    private $fileMode;
66
    /**
67
     * @var int the permission to be set for newly created directories.
68
     * This value will be used by PHP chmod() function. No umask will be applied.
69
     * Defaults to 0775, meaning the directory is read-writable by owner and group,
70
     * but read-only for other users.
71
     */
72
    private $dirMode = 0775;
73
74
    private const NEGATIVE_TTL_REPLACEMENT = 31536000; // 1 year
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
            if ($ttl <= 0) {
179 13
                $ttl = self::NEGATIVE_TTL_REPLACEMENT;
180
            }
181 17
            return @touch($cacheFile, $ttl + time());
182
        }
183
184
        $error = error_get_last();
185
        $this->logger->warning("Failed to write cache data to \"$cacheFile\": " . $error['message']);
186
        return false;
187
    }
188
189 1
    protected function deleteValue(string $key): bool
190
    {
191 1
        $cacheFile = $this->getCacheFile($key);
192 1
        return @unlink($cacheFile);
193
    }
194
195
    /**
196
     * Returns the cache file path given the cache key.
197
     * @param string $key cache key
198
     * @return string the cache file path
199
     */
200 18
    private function getCacheFile(string $key): string
201
    {
202 18
        if ($this->directoryLevel > 0) {
203 18
            $base = $this->cachePath;
204 18
            for ($i = 0; $i < $this->directoryLevel; ++$i) {
205 18
                if (($prefix = substr($key, $i + $i, 2)) !== false) {
206 18
                    $base .= DIRECTORY_SEPARATOR . $prefix;
207
                }
208
            }
209
210 18
            return $base . DIRECTORY_SEPARATOR . $key . $this->cacheFileSuffix;
211
        }
212
213
        return $this->cachePath . DIRECTORY_SEPARATOR . $key . $this->cacheFileSuffix;
214
    }
215
216 11
    public function clear(): bool
217
    {
218 11
        $this->removeCacheFiles($this->cachePath, false);
219 11
        return true;
220
    }
221
222
    /**
223
     * Removes expired cache files
224
     * @throws \Exception
225
     */
226 17
    public function gc(): void
227
    {
228 17
        if (\random_int(0, 1000000) < $this->gcProbability) {
229
            $this->removeCacheFiles($this->cachePath, true);
230
        }
231
    }
232
233
    /**
234
     * Recursively removing expired cache files under a directory.
235
     * This method is mainly used by [[gc()]].
236
     * @param string $path the directory under which expired cache files are removed.
237
     * @param bool $expiredOnly whether to only remove expired cache files. If false, all files
238
     * under `$path` will be removed.
239
     */
240 11
    private function removeCacheFiles(string $path, bool $expiredOnly): void
241
    {
242 11
        if (($handle = opendir($path)) !== false) {
243 11
            while (($file = readdir($handle)) !== false) {
244 11
                if (strncmp($file, '.', 1) === 0) {
245 11
                    continue;
246
                }
247 10
                $fullPath = $path . DIRECTORY_SEPARATOR . $file;
248 10
                if (is_dir($fullPath)) {
249 10
                    $this->removeCacheFiles($fullPath, $expiredOnly);
250 10
                    if (!$expiredOnly && !@rmdir($fullPath)) {
251
                        $error = error_get_last();
252 10
                        throw new CacheException("Unable to remove directory '{$fullPath}': {$error['message']}");
253
                    }
254 10
                } elseif (!$expiredOnly || ($expiredOnly && @filemtime($fullPath) < time())) {
255 10
                    if (!@unlink($fullPath)) {
256
                        $error = error_get_last();
257
                        throw new CacheException("Unable to remove file '{$fullPath}': {$error['message']}");
258
                    }
259
                }
260
            }
261 11
            closedir($handle);
262
        }
263
    }
264
265 18
    private function createDirectory(string $cachePath, int $mode, bool $recursive = true): bool
266
    {
267 18
        return is_dir($cachePath) || (mkdir($cachePath, $mode, $recursive) && is_dir($cachePath));
268
    }
269
}
270