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