Completed
Pull Request — master (#30)
by Alexander
09:17 queued 07:44
created

FileCache::removeCacheFiles()   B

Complexity

Conditions 11
Paths 8

Size

Total Lines 22
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 13
CRAP Score 11.7975

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 11
eloc 15
c 1
b 0
f 0
nc 8
nop 2
dl 0
loc 22
ccs 13
cts 16
cp 0.8125
crap 11.7975
rs 7.3166

How to fix   Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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
    private const TTL_EXPIRED = -1;
27
28
    /**
29
     * @var string the directory to store cache files. You may use [path alias](guide:concept-aliases) here.
30
     */
31
    private $cachePath;
32
    /**
33
     * @var string cache file suffix. Defaults to '.bin'.
34
     */
35
    private $cacheFileSuffix = '.bin';
36
    /**
37
     * @var int the level of sub-directories to store cache files. Defaults to 1.
38
     * If the system has huge number of cache files (e.g. one million), you may use a bigger value
39
     * (usually no bigger than 3). Using sub-directories is mainly to ensure the file system
40
     * is not over burdened with a single directory having too many files.
41
     */
42
    private $directoryLevel = 1;
43
44
    /**
45
     * @var int the probability (parts per million) that garbage collection (GC) should be performed
46
     * when storing a piece of data in the cache. Defaults to 10, meaning 0.001% chance.
47
     * This number should be between 0 and 1000000. A value 0 means no GC will be performed at all.
48
     */
49
    private $gcProbability = 10;
50
    /**
51
     * @var int the permission to be set for newly created cache files.
52
     * This value will be used by PHP chmod() function. No umask will be applied.
53
     * If not set, the permission will be determined by the current environment.
54
     */
55
    private $fileMode;
56
    /**
57
     * @var int the permission to be set for newly created directories.
58
     * This value will be used by PHP chmod() function. No umask will be applied.
59
     * Defaults to 0775, meaning the directory is read-writable by owner and group,
60
     * but read-only for other users.
61
     */
62
    private $dirMode = 0775;
63
64
    /**
65
     * @var SerializerInterface the serializer to be used for serializing and unserializing of the cached data.
66
     */
67
    private $serializer;
68
69 179
    public function __construct(string $cachePath, ?SerializerInterface $serializer = null)
70
    {
71 179
        $this->cachePath = $cachePath;
72 179
        $this->serializer = $serializer ?? new PhpSerializer();
73 179
        $this->initCacheDirectory();
74
    }
75
76 137
    public function get($key, $default = null)
77
    {
78 137
        if ($this->existsAndNotExpired($key)) {
79 115
            $fp = @fopen($this->getCacheFile($key), 'rb');
80 115
            if ($fp !== false) {
81 115
                @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

81
                /** @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...
82 115
                $cacheValue = @stream_get_contents($fp);
83 115
                @flock($fp, LOCK_UN);
84 115
                @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

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

113
            /** @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...
114
        }
115
116 157
        $value = $this->serializer->serialize($value);
117
118 157
        if (@file_put_contents($cacheFile, $value, LOCK_EX) !== false) {
119 157
            if ($this->fileMode !== null) {
120
                @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

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