[TASK] Streamline imports in PHP cache classes
[Packages/TYPO3.CMS.git] / typo3 / sysext / core / Classes / Cache / Backend / FileBackend.php
1 <?php
2 namespace TYPO3\CMS\Core\Cache\Backend;
3
4 /*
5 * This file is part of the TYPO3 CMS project.
6 *
7 * It is free software; you can redistribute it and/or modify it under
8 * the terms of the GNU General Public License, either version 2
9 * of the License, or any later version.
10 *
11 * For the full copyright and license information, please read the
12 * LICENSE.txt file that was distributed with this source code.
13 *
14 * The TYPO3 project - inspiring people to share!
15 */
16
17 use TYPO3\CMS\Core\Cache\Exception;
18 use TYPO3\CMS\Core\Cache\Exception\InvalidDataException;
19 use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
20 use TYPO3\CMS\Core\Service\OpcodeCacheService;
21 use TYPO3\CMS\Core\Utility\GeneralUtility;
22 use TYPO3\CMS\Core\Utility\PathUtility;
23 use TYPO3\CMS\Core\Utility\StringUtility;
24
25 /**
26 * A caching backend which stores cache entries in files
27 *
28 * @api
29 */
30 class FileBackend extends SimpleFileBackend implements FreezableBackendInterface, TaggableBackendInterface
31 {
32 const SEPARATOR = '^';
33 const EXPIRYTIME_FORMAT = 'YmdHis';
34 const EXPIRYTIME_LENGTH = 14;
35 const DATASIZE_DIGITS = 10;
36 /**
37 * A file extension to use for each cache entry.
38 *
39 * @var string
40 */
41 protected $cacheEntryFileExtension = '';
42
43 /**
44 * @var array
45 */
46 protected $cacheEntryIdentifiers = [];
47
48 /**
49 * @var bool
50 */
51 protected $frozen = false;
52
53 /**
54 * Freezes this cache backend.
55 *
56 * All data in a frozen backend remains unchanged and methods which try to add
57 * or modify data result in an exception thrown. Possible expiry times of
58 * individual cache entries are ignored.
59 *
60 * On the positive side, a frozen cache backend is much faster on read access.
61 * A frozen backend can only be thawed by calling the flush() method.
62 *
63 * @throws \RuntimeException
64 */
65 public function freeze()
66 {
67 if ($this->frozen === true) {
68 throw new \RuntimeException(sprintf('The cache "%s" is already frozen.', $this->cacheIdentifier), 1323353176);
69 }
70 $cacheEntryFileExtensionLength = strlen($this->cacheEntryFileExtension);
71 for ($directoryIterator = new \DirectoryIterator($this->cacheDirectory); $directoryIterator->valid(); $directoryIterator->next()) {
72 if ($directoryIterator->isDot()) {
73 continue;
74 }
75 if ($cacheEntryFileExtensionLength > 0) {
76 $entryIdentifier = substr($directoryIterator->getFilename(), 0, -$cacheEntryFileExtensionLength);
77 } else {
78 $entryIdentifier = $directoryIterator->getFilename();
79 }
80 $this->cacheEntryIdentifiers[$entryIdentifier] = true;
81 file_put_contents($this->cacheDirectory . $entryIdentifier . $this->cacheEntryFileExtension, $this->get($entryIdentifier));
82 }
83 file_put_contents($this->cacheDirectory . 'FrozenCache.data', serialize($this->cacheEntryIdentifiers));
84 $this->frozen = true;
85 }
86
87 /**
88 * Tells if this backend is frozen.
89 *
90 * @return bool
91 */
92 public function isFrozen()
93 {
94 return $this->frozen;
95 }
96
97 /**
98 * Sets a reference to the cache frontend which uses this backend and
99 * initializes the default cache directory.
100 *
101 * This method also detects if this backend is frozen and sets the internal
102 * flag accordingly.
103 *
104 * @param FrontendInterface $cache The cache frontend
105 */
106 public function setCache(FrontendInterface $cache)
107 {
108 parent::setCache($cache);
109 if (file_exists($this->cacheDirectory . 'FrozenCache.data')) {
110 $this->frozen = true;
111 $this->cacheEntryIdentifiers = unserialize(file_get_contents($this->cacheDirectory . 'FrozenCache.data'));
112 }
113 }
114
115 /**
116 * Saves data in a cache file.
117 *
118 * @param string $entryIdentifier An identifier for this specific cache entry
119 * @param string $data The data to be stored
120 * @param array $tags Tags to associate with this cache entry
121 * @param int $lifetime Lifetime of this cache entry in seconds. If NULL is specified, the default lifetime is used. "0" means unlimited lifetime.
122 * @throws \RuntimeException
123 * @throws InvalidDataException if the directory does not exist or is not writable or exceeds the maximum allowed path length, or if no cache frontend has been set.
124 * @throws Exception if the directory does not exist or is not writable or exceeds the maximum allowed path length, or if no cache frontend has been set.
125 * @throws \InvalidArgumentException
126 * @api
127 */
128 public function set($entryIdentifier, $data, array $tags = [], $lifetime = null)
129 {
130 if (!is_string($data)) {
131 throw new InvalidDataException('The specified data is of type "' . gettype($data) . '" but a string is expected.', 1204481674);
132 }
133 if ($entryIdentifier !== PathUtility::basename($entryIdentifier)) {
134 throw new \InvalidArgumentException('The specified entry identifier must not contain a path segment.', 1282073032);
135 }
136 if ($entryIdentifier === '') {
137 throw new \InvalidArgumentException('The specified entry identifier must not be empty.', 1298114280);
138 }
139 if ($this->frozen === true) {
140 throw new \RuntimeException(sprintf('Cannot add or modify cache entry because the backend of cache "%s" is frozen.', $this->cacheIdentifier), 1323344192);
141 }
142 $this->remove($entryIdentifier);
143 $temporaryCacheEntryPathAndFilename = $this->cacheDirectory . StringUtility::getUniqueId() . '.temp';
144 $lifetime = $lifetime ?? $this->defaultLifetime;
145 $expiryTime = $lifetime === 0 ? 0 : $GLOBALS['EXEC_TIME'] + $lifetime;
146 $metaData = str_pad($expiryTime, self::EXPIRYTIME_LENGTH) . implode(' ', $tags) . str_pad(strlen($data), self::DATASIZE_DIGITS);
147 $result = file_put_contents($temporaryCacheEntryPathAndFilename, $data . $metaData);
148 GeneralUtility::fixPermissions($temporaryCacheEntryPathAndFilename);
149 if ($result === false) {
150 throw new Exception('The temporary cache file "' . $temporaryCacheEntryPathAndFilename . '" could not be written.', 1204026251);
151 }
152 $i = 0;
153 $cacheEntryPathAndFilename = $this->cacheDirectory . $entryIdentifier . $this->cacheEntryFileExtension;
154 while (($result = rename($temporaryCacheEntryPathAndFilename, $cacheEntryPathAndFilename)) === false && $i < 5) {
155 $i++;
156 }
157 if ($result === false) {
158 throw new Exception('The cache file "' . $cacheEntryPathAndFilename . '" could not be written.', 1222361632);
159 }
160 if ($this->cacheEntryFileExtension === '.php') {
161 GeneralUtility::makeInstance(OpcodeCacheService::class)->clearAllActive($cacheEntryPathAndFilename);
162 }
163 }
164
165 /**
166 * Loads data from a cache file.
167 *
168 * @param string $entryIdentifier An identifier which describes the cache entry to load
169 * @return mixed The cache entry's content as a string or FALSE if the cache entry could not be loaded
170 * @throws \InvalidArgumentException If identifier is invalid
171 * @api
172 */
173 public function get($entryIdentifier)
174 {
175 if ($this->frozen === true) {
176 return isset($this->cacheEntryIdentifiers[$entryIdentifier]) ? file_get_contents($this->cacheDirectory . $entryIdentifier . $this->cacheEntryFileExtension) : false;
177 }
178 if ($entryIdentifier !== PathUtility::basename($entryIdentifier)) {
179 throw new \InvalidArgumentException('The specified entry identifier must not contain a path segment.', 1282073033);
180 }
181 $pathAndFilename = $this->cacheDirectory . $entryIdentifier . $this->cacheEntryFileExtension;
182 if ($this->isCacheFileExpired($pathAndFilename)) {
183 return false;
184 }
185 $dataSize = (int)file_get_contents(
186 $pathAndFilename,
187 null,
188 null,
189 filesize($pathAndFilename) - self::DATASIZE_DIGITS,
190 self::DATASIZE_DIGITS
191 );
192 return file_get_contents($pathAndFilename, null, null, 0, $dataSize);
193 }
194
195 /**
196 * Checks if a cache entry with the specified identifier exists.
197 *
198 * @param string $entryIdentifier
199 * @return bool TRUE if such an entry exists, FALSE if not
200 * @throws \InvalidArgumentException
201 * @api
202 */
203 public function has($entryIdentifier)
204 {
205 if ($this->frozen === true) {
206 return isset($this->cacheEntryIdentifiers[$entryIdentifier]);
207 }
208 if ($entryIdentifier !== PathUtility::basename($entryIdentifier)) {
209 throw new \InvalidArgumentException('The specified entry identifier must not contain a path segment.', 1282073034);
210 }
211 return !$this->isCacheFileExpired($this->cacheDirectory . $entryIdentifier . $this->cacheEntryFileExtension);
212 }
213
214 /**
215 * Removes all cache entries matching the specified identifier.
216 * Usually this only affects one entry.
217 *
218 * @param string $entryIdentifier Specifies the cache entry to remove
219 * @return bool TRUE if (at least) an entry could be removed or FALSE if no entry was found
220 * @throws \RuntimeException
221 * @throws \InvalidArgumentException
222 * @api
223 */
224 public function remove($entryIdentifier)
225 {
226 if ($entryIdentifier !== PathUtility::basename($entryIdentifier)) {
227 throw new \InvalidArgumentException('The specified entry identifier must not contain a path segment.', 1282073035);
228 }
229 if ($entryIdentifier === '') {
230 throw new \InvalidArgumentException('The specified entry identifier must not be empty.', 1298114279);
231 }
232 if ($this->frozen === true) {
233 throw new \RuntimeException(sprintf('Cannot remove cache entry because the backend of cache "%s" is frozen.', $this->cacheIdentifier), 1323344193);
234 }
235 $pathAndFilename = $this->cacheDirectory . $entryIdentifier . $this->cacheEntryFileExtension;
236 if (file_exists($pathAndFilename) === false) {
237 return false;
238 }
239 if (@unlink($pathAndFilename) === false) {
240 return false;
241 }
242 return true;
243 }
244
245 /**
246 * Finds and returns all cache entry identifiers which are tagged by the
247 * specified tag.
248 *
249 * @param string $searchedTag The tag to search for
250 * @return array An array with identifiers of all matching entries. An empty array if no entries matched
251 * @api
252 */
253 public function findIdentifiersByTag($searchedTag)
254 {
255 $entryIdentifiers = [];
256 $now = $GLOBALS['EXEC_TIME'];
257 $cacheEntryFileExtensionLength = strlen($this->cacheEntryFileExtension);
258 for ($directoryIterator = GeneralUtility::makeInstance(\DirectoryIterator::class, $this->cacheDirectory); $directoryIterator->valid(); $directoryIterator->next()) {
259 if ($directoryIterator->isDot()) {
260 continue;
261 }
262 $cacheEntryPathAndFilename = $directoryIterator->getPathname();
263 $index = (int)file_get_contents(
264 $cacheEntryPathAndFilename,
265 null,
266 null,
267 filesize($cacheEntryPathAndFilename) - self::DATASIZE_DIGITS,
268 self::DATASIZE_DIGITS
269 );
270 $metaData = file_get_contents($cacheEntryPathAndFilename, null, null, $index);
271 $expiryTime = (int)substr($metaData, 0, self::EXPIRYTIME_LENGTH);
272 if ($expiryTime !== 0 && $expiryTime < $now) {
273 continue;
274 }
275 if (in_array($searchedTag, explode(' ', substr($metaData, self::EXPIRYTIME_LENGTH, -self::DATASIZE_DIGITS)))) {
276 if ($cacheEntryFileExtensionLength > 0) {
277 $entryIdentifiers[] = substr($directoryIterator->getFilename(), 0, -$cacheEntryFileExtensionLength);
278 } else {
279 $entryIdentifiers[] = $directoryIterator->getFilename();
280 }
281 }
282 }
283 return $entryIdentifiers;
284 }
285
286 /**
287 * Removes all cache entries of this cache and sets the frozen flag to FALSE.
288 *
289 * @api
290 */
291 public function flush()
292 {
293 parent::flush();
294 if ($this->frozen === true) {
295 $this->frozen = false;
296 }
297 }
298
299 /**
300 * Removes all cache entries of this cache which are tagged by the specified tag.
301 *
302 * @param string $tag The tag the entries must have
303 * @api
304 */
305 public function flushByTag($tag)
306 {
307 $identifiers = $this->findIdentifiersByTag($tag);
308 if (empty($identifiers)) {
309 return;
310 }
311 foreach ($identifiers as $entryIdentifier) {
312 $this->remove($entryIdentifier);
313 }
314 }
315
316 /**
317 * Checks if the given cache entry files are still valid or if their
318 * lifetime has exceeded.
319 *
320 * @param string $cacheEntryPathAndFilename
321 * @return bool
322 * @api
323 */
324 protected function isCacheFileExpired($cacheEntryPathAndFilename)
325 {
326 if (file_exists($cacheEntryPathAndFilename) === false) {
327 return true;
328 }
329 $index = (int)file_get_contents(
330 $cacheEntryPathAndFilename,
331 null,
332 null,
333 filesize($cacheEntryPathAndFilename) - self::DATASIZE_DIGITS,
334 self::DATASIZE_DIGITS
335 );
336 $expiryTime = (int)file_get_contents($cacheEntryPathAndFilename, null, null, $index, self::EXPIRYTIME_LENGTH);
337 return $expiryTime !== 0 && $expiryTime < $GLOBALS['EXEC_TIME'];
338 }
339
340 /**
341 * Does garbage collection
342 *
343 * @api
344 */
345 public function collectGarbage()
346 {
347 if ($this->frozen === true) {
348 return;
349 }
350 for ($directoryIterator = new \DirectoryIterator($this->cacheDirectory); $directoryIterator->valid(); $directoryIterator->next()) {
351 if ($directoryIterator->isDot()) {
352 continue;
353 }
354 if ($this->isCacheFileExpired($directoryIterator->getPathname())) {
355 $cacheEntryFileExtensionLength = strlen($this->cacheEntryFileExtension);
356 if ($cacheEntryFileExtensionLength > 0) {
357 $this->remove(substr($directoryIterator->getFilename(), 0, -$cacheEntryFileExtensionLength));
358 } else {
359 $this->remove($directoryIterator->getFilename());
360 }
361 }
362 }
363 }
364
365 /**
366 * Tries to find the cache entry for the specified identifier.
367 * Usually only one cache entry should be found - if more than one exist, this
368 * is due to some error or crash.
369 *
370 * @param string $entryIdentifier The cache entry identifier
371 * @return mixed The filenames (including path) as an array if one or more entries could be found, otherwise FALSE
372 */
373 protected function findCacheFilesByIdentifier($entryIdentifier)
374 {
375 $pattern = $this->cacheDirectory . $entryIdentifier;
376 $filesFound = glob($pattern);
377 if ($filesFound === false || empty($filesFound)) {
378 return false;
379 }
380 return $filesFound;
381 }
382
383 /**
384 * Loads PHP code from the cache and require_onces it right away.
385 *
386 * @param string $entryIdentifier An identifier which describes the cache entry to load
387 * @throws \InvalidArgumentException
388 * @return mixed Potential return value from the include operation
389 * @api
390 */
391 public function requireOnce($entryIdentifier)
392 {
393 if ($this->frozen === true) {
394 if (isset($this->cacheEntryIdentifiers[$entryIdentifier])) {
395 return require_once $this->cacheDirectory . $entryIdentifier . $this->cacheEntryFileExtension;
396 }
397 return false;
398 }
399 if ($entryIdentifier !== PathUtility::basename($entryIdentifier)) {
400 throw new \InvalidArgumentException('The specified entry identifier must not contain a path segment.', 1282073036);
401 }
402 $pathAndFilename = $this->cacheDirectory . $entryIdentifier . $this->cacheEntryFileExtension;
403 return $this->isCacheFileExpired($pathAndFilename) ? false : require_once $pathAndFilename;
404 }
405 }