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

FileCache::get()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 15
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 11
CRAP Score 3

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 3
eloc 10
c 2
b 0
f 0
nc 3
nop 2
dl 0
loc 15
ccs 11
cts 11
cp 1
crap 3
rs 9.9332
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