[TASK] Use Environment API instead of PATH_site for Cache Backends
[Packages/TYPO3.CMS.git] / typo3 / sysext / core / Classes / Cache / Backend / MemcachedBackend.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\Frontend\FrontendInterface;
19 use TYPO3\CMS\Core\Core\Environment;
20
21 /**
22 * A caching backend which stores cache entries by using Memcached.
23 *
24 * This backend uses the following types of Memcache keys:
25 * - tag_xxx
26 * xxx is tag name, value is array of associated identifiers identifier. This
27 * is "forward" tag index. It is mainly used for obtaining content by tag
28 * (get identifier by tag -> get content by identifier)
29 * - ident_xxx
30 * xxx is identifier, value is array of associated tags. This is "reverse" tag
31 * index. It provides quick access for all tags associated with this identifier
32 * and used when removing the identifier
33 *
34 * Each key is prepended with a prefix. By default prefix consists from two parts
35 * separated by underscore character and ends in yet another underscore character:
36 * - "TYPO3"
37 * - Current site path obtained from Environment::getProjectPath()
38 * This prefix makes sure that keys from the different installations do not
39 * conflict.
40 *
41 * Note: When using the Memcached backend to store values of more than ~1 MB,
42 * the data will be split into chunks to make them fit into the memcached limits.
43 *
44 * @api
45 */
46 class MemcachedBackend extends AbstractBackend implements TaggableBackendInterface, TransientBackendInterface
47 {
48 /**
49 * Max bucket size, (1024*1024)-42 bytes
50 *
51 * @var int
52 */
53 const MAX_BUCKET_SIZE = 1048534;
54
55 /**
56 * Instance of the PHP Memcache class
57 *
58 * @var \Memcache|\Memcached
59 */
60 protected $memcache;
61
62 /**
63 * Used PECL module for memcached
64 *
65 * @var string
66 */
67 protected $usedPeclModule = '';
68
69 /**
70 * Array of Memcache server configurations
71 *
72 * @var array
73 */
74 protected $servers = [];
75
76 /**
77 * Indicates whether the memcache uses compression or not (requires zlib),
78 * either 0 or \Memcached::OPT_COMPRESSION / MEMCACHE_COMPRESSED
79 *
80 * @var int
81 */
82 protected $flags;
83
84 /**
85 * A prefix to separate stored data from other data possibly stored in the memcache
86 *
87 * @var string
88 */
89 protected $identifierPrefix;
90
91 /**
92 * Constructs this backend
93 *
94 * @param string $context Unused, for backward compatibility only
95 * @param array $options Configuration options - depends on the actual backend
96 * @throws Exception if memcache is not installed
97 */
98 public function __construct($context, array $options = [])
99 {
100 if (!extension_loaded('memcache') && !extension_loaded('memcached')) {
101 throw new Exception('The PHP extension "memcache" or "memcached" must be installed and loaded in ' . 'order to use the Memcached backend.', 1213987706);
102 }
103
104 parent::__construct($context, $options);
105
106 if ($this->usedPeclModule === '') {
107 if (extension_loaded('memcache')) {
108 $this->usedPeclModule = 'memcache';
109 } elseif (extension_loaded('memcached')) {
110 $this->usedPeclModule = 'memcached';
111 }
112 }
113 }
114
115 /**
116 * Setter for servers to be used. Expects an array, the values are expected
117 * to be formatted like "<host>[:<port>]" or "unix://<path>"
118 *
119 * @param array $servers An array of servers to add.
120 * @api
121 */
122 protected function setServers(array $servers)
123 {
124 $this->servers = $servers;
125 }
126
127 /**
128 * Setter for compression flags bit
129 *
130 * @param bool $useCompression
131 * @api
132 */
133 protected function setCompression($useCompression)
134 {
135 $compressionFlag = $this->usedPeclModule === 'memcache' ? MEMCACHE_COMPRESSED : \Memcached::OPT_COMPRESSION;
136 if ($useCompression === true) {
137 $this->flags ^= $compressionFlag;
138 } else {
139 $this->flags &= ~$compressionFlag;
140 }
141 }
142
143 /**
144 * Getter for compression flag
145 *
146 * @return bool
147 * @api
148 */
149 protected function getCompression()
150 {
151 return $this->flags !== 0;
152 }
153
154 /**
155 * Initializes the identifier prefix
156 *
157 * @throws Exception
158 */
159 public function initializeObject()
160 {
161 if (empty($this->servers)) {
162 throw new Exception('No servers were given to Memcache', 1213115903);
163 }
164 $memcachedPlugin = '\\' . ucfirst($this->usedPeclModule);
165 $this->memcache = new $memcachedPlugin;
166 $defaultPort = $this->usedPeclModule === 'memcache' ? ini_get('memcache.default_port') : 11211;
167 foreach ($this->servers as $server) {
168 if (substr($server, 0, 7) === 'unix://') {
169 $host = $server;
170 $port = 0;
171 } else {
172 if (substr($server, 0, 6) === 'tcp://') {
173 $server = substr($server, 6);
174 }
175 if (strpos($server, ':') !== false) {
176 list($host, $port) = explode(':', $server, 2);
177 } else {
178 $host = $server;
179 $port = $defaultPort;
180 }
181 }
182 $this->memcache->addserver($host, $port);
183 }
184 if ($this->usedPeclModule === 'memcached') {
185 $this->memcache->setOption(\Memcached::OPT_COMPRESSION, $this->getCompression());
186 }
187 }
188
189 /**
190 * Sets the preferred PECL module
191 *
192 * @param string $peclModule
193 * @throws Exception
194 */
195 public function setPeclModule($peclModule)
196 {
197 if ($peclModule !== 'memcache' && $peclModule !== 'memcached') {
198 throw new Exception('PECL module must be either "memcache" or "memcached".', 1442239768);
199 }
200
201 $this->usedPeclModule = $peclModule;
202 }
203
204 /**
205 * Initializes the identifier prefix when setting the cache.
206 *
207 * @param FrontendInterface $cache The frontend for this backend
208 */
209 public function setCache(FrontendInterface $cache)
210 {
211 parent::setCache($cache);
212 $identifierHash = substr(md5(Environment::getProjectPath() . $this->context . $this->cacheIdentifier), 0, 12);
213 $this->identifierPrefix = 'TYPO3_' . $identifierHash . '_';
214 }
215
216 /**
217 * Saves data in the cache.
218 *
219 * @param string $entryIdentifier An identifier for this specific cache entry
220 * @param string $data The data to be stored
221 * @param array $tags Tags to associate with this cache entry
222 * @param int $lifetime Lifetime of this cache entry in seconds. If NULL is specified, the default lifetime is used. "0" means unlimited lifetime.
223 * @throws Exception if no cache frontend has been set.
224 * @throws \InvalidArgumentException if the identifier is not valid or the final memcached key is longer than 250 characters
225 * @api
226 */
227 public function set($entryIdentifier, $data, array $tags = [], $lifetime = null)
228 {
229 if (strlen($this->identifierPrefix . $entryIdentifier) > 250) {
230 throw new \InvalidArgumentException('Could not set value. Key more than 250 characters (' . $this->identifierPrefix . $entryIdentifier . ').', 1232969508);
231 }
232 if (!$this->cache instanceof FrontendInterface) {
233 throw new Exception('No cache frontend has been set yet via setCache().', 1207149215);
234 }
235 $tags[] = '%MEMCACHEBE%' . $this->cacheIdentifier;
236 $expiration = $lifetime ?? $this->defaultLifetime;
237
238 // Memcached consideres values over 2592000 sec (30 days) as UNIX timestamp
239 // thus $expiration should be converted from lifetime to UNIX timestamp
240 if ($expiration > 2592000) {
241 $expiration += $GLOBALS['EXEC_TIME'];
242 }
243 try {
244 if (is_string($data) && strlen($data) > self::MAX_BUCKET_SIZE) {
245 $data = str_split($data, 1024 * 1000);
246 $success = true;
247 $chunkNumber = 1;
248 foreach ($data as $chunk) {
249 $success = $success && $this->setInternal($entryIdentifier . '_chunk_' . $chunkNumber, $chunk, $expiration);
250 $chunkNumber++;
251 }
252 $success = $success && $this->setInternal($entryIdentifier, 'TYPO3*chunked:' . $chunkNumber, $expiration);
253 } else {
254 $success = $this->setInternal($entryIdentifier, $data, $expiration);
255 }
256 if ($success === true) {
257 $this->removeIdentifierFromAllTags($entryIdentifier);
258 $this->addIdentifierToTags($entryIdentifier, $tags);
259 } else {
260 throw new Exception('Could not set data to memcache server.', 1275830266);
261 }
262 } catch (\Exception $exception) {
263 $this->logger->alert('Memcache: could not set value.', ['exception' => $exception]);
264 }
265 }
266
267 /**
268 * Stores the actual data inside memcache/memcached
269 *
270 * @param string $entryIdentifier
271 * @param mixed $data
272 * @param int $expiration
273 * @return bool
274 */
275 protected function setInternal($entryIdentifier, $data, $expiration)
276 {
277 if ($this->usedPeclModule === 'memcache') {
278 return $this->memcache->set($this->identifierPrefix . $entryIdentifier, $data, $this->flags, $expiration);
279 }
280 return $this->memcache->set($this->identifierPrefix . $entryIdentifier, $data, $expiration);
281 }
282
283 /**
284 * Loads data from the cache.
285 *
286 * @param string $entryIdentifier An identifier which describes the cache entry to load
287 * @return mixed The cache entry's content as a string or FALSE if the cache entry could not be loaded
288 * @api
289 */
290 public function get($entryIdentifier)
291 {
292 $value = $this->memcache->get($this->identifierPrefix . $entryIdentifier);
293 if (is_string($value) && substr($value, 0, 14) === 'TYPO3*chunked:') {
294 list(, $chunkCount) = explode(':', $value);
295 $value = '';
296 for ($chunkNumber = 1; $chunkNumber < $chunkCount; $chunkNumber++) {
297 $value .= $this->memcache->get($this->identifierPrefix . $entryIdentifier . '_chunk_' . $chunkNumber);
298 }
299 }
300 return $value;
301 }
302
303 /**
304 * Checks if a cache entry with the specified identifier exists.
305 *
306 * @param string $entryIdentifier An identifier specifying the cache entry
307 * @return bool TRUE if such an entry exists, FALSE if not
308 * @api
309 */
310 public function has($entryIdentifier)
311 {
312 if ($this->usedPeclModule === 'memcache') {
313 return $this->memcache->get($this->identifierPrefix . $entryIdentifier) !== false;
314 }
315
316 // pecl-memcached supports storing literal FALSE
317 $this->memcache->get($this->identifierPrefix . $entryIdentifier);
318 return $this->memcache->getResultCode() !== \Memcached::RES_NOTFOUND;
319 }
320
321 /**
322 * Removes all cache entries matching the specified identifier.
323 * Usually this only affects one entry but if - for what reason ever -
324 * old entries for the identifier still exist, they are removed as well.
325 *
326 * @param string $entryIdentifier Specifies the cache entry to remove
327 * @return bool TRUE if (at least) an entry could be removed or FALSE if no entry was found
328 * @api
329 */
330 public function remove($entryIdentifier)
331 {
332 $this->removeIdentifierFromAllTags($entryIdentifier);
333 return $this->memcache->delete($this->identifierPrefix . $entryIdentifier, 0);
334 }
335
336 /**
337 * Finds and returns all cache entry identifiers which are tagged by the
338 * specified tag.
339 *
340 * @param string $tag The tag to search for
341 * @return array An array of entries with all matching entries. An empty array if no entries matched
342 * @api
343 */
344 public function findIdentifiersByTag($tag)
345 {
346 $identifiers = $this->memcache->get($this->identifierPrefix . 'tag_' . $tag);
347 if ($identifiers !== false) {
348 return (array)$identifiers;
349 }
350 return [];
351 }
352
353 /**
354 * Removes all cache entries of this cache.
355 *
356 * @throws Exception
357 * @api
358 */
359 public function flush()
360 {
361 if (!$this->cache instanceof FrontendInterface) {
362 throw new Exception('No cache frontend has been set via setCache() yet.', 1204111376);
363 }
364 $this->flushByTag('%MEMCACHEBE%' . $this->cacheIdentifier);
365 }
366
367 /**
368 * Removes all cache entries of this cache which are tagged by the specified tag.
369 *
370 * @param string $tag The tag the entries must have
371 * @api
372 */
373 public function flushByTag($tag)
374 {
375 $identifiers = $this->findIdentifiersByTag($tag);
376 foreach ($identifiers as $identifier) {
377 $this->remove($identifier);
378 }
379 }
380
381 /**
382 * Associates the identifier with the given tags
383 *
384 * @param string $entryIdentifier
385 * @param array $tags
386 */
387 protected function addIdentifierToTags($entryIdentifier, array $tags)
388 {
389 // Get identifier-to-tag index to look for updates
390 $existingTags = $this->findTagsByIdentifier($entryIdentifier);
391 $existingTagsUpdated = false;
392
393 foreach ($tags as $tag) {
394 // Update tag-to-identifier index
395 $identifiers = $this->findIdentifiersByTag($tag);
396 if (!in_array($entryIdentifier, $identifiers, true)) {
397 $identifiers[] = $entryIdentifier;
398 $this->memcache->set($this->identifierPrefix . 'tag_' . $tag, $identifiers);
399 }
400 // Test if identifier-to-tag index needs update
401 if (!in_array($tag, $existingTags, true)) {
402 $existingTags[] = $tag;
403 $existingTagsUpdated = true;
404 }
405 }
406
407 // Update identifier-to-tag index if needed
408 if ($existingTagsUpdated) {
409 $this->memcache->set($this->identifierPrefix . 'ident_' . $entryIdentifier, $existingTags);
410 }
411 }
412
413 /**
414 * Removes association of the identifier with the given tags
415 *
416 * @param string $entryIdentifier
417 */
418 protected function removeIdentifierFromAllTags($entryIdentifier)
419 {
420 // Get tags for this identifier
421 $tags = $this->findTagsByIdentifier($entryIdentifier);
422 // De-associate tags with this identifier
423 foreach ($tags as $tag) {
424 $identifiers = $this->findIdentifiersByTag($tag);
425 // Formally array_search() below should never return FALSE due to
426 // the behavior of findTagsByIdentifier(). But if reverse index is
427 // corrupted, we still can get 'FALSE' from array_search(). This is
428 // not a problem because we are removing this identifier from
429 // anywhere.
430 if (($key = array_search($entryIdentifier, $identifiers)) !== false) {
431 unset($identifiers[$key]);
432 if (!empty($identifiers)) {
433 $this->memcache->set($this->identifierPrefix . 'tag_' . $tag, $identifiers);
434 } else {
435 $this->memcache->delete($this->identifierPrefix . 'tag_' . $tag, 0);
436 }
437 }
438 }
439 // Clear reverse tag index for this identifier
440 $this->memcache->delete($this->identifierPrefix . 'ident_' . $entryIdentifier, 0);
441 }
442
443 /**
444 * Finds all tags for the given identifier. This function uses reverse tag
445 * index to search for tags.
446 *
447 * @param string $identifier Identifier to find tags by
448 * @return array
449 * @api
450 */
451 protected function findTagsByIdentifier($identifier)
452 {
453 $tags = $this->memcache->get($this->identifierPrefix . 'ident_' . $identifier);
454 return $tags === false ? [] : (array)$tags;
455 }
456
457 /**
458 * Does nothing, as memcached does GC itself
459 *
460 * @api
461 */
462 public function collectGarbage()
463 {
464 }
465 }