1041fd04ce8ff62f654ee6062189842ea54dbc70
[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
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 if (!is_string($data)) {
238 throw new Exception\InvalidDataException('The specified data is of type "' . gettype($data) . '" but a string is expected.', 1207149231);
239 }
240 $tags[] = '%MEMCACHEBE%' . $this->cacheIdentifier;
241 $expiration = $lifetime !== null ? $lifetime : $this->defaultLifetime;
242 $memcacheIsUsed = $this->usedPeclModule === 'memcache';
243 // Memcached consideres values over 2592000 sec (30 days) as UNIX timestamp
244 // thus $expiration should be converted from lifetime to UNIX timestamp
245 if ($expiration > 2592000) {
246 $expiration += $GLOBALS['EXEC_TIME'];
247 }
248 try {
249 if (strlen($data) > self::MAX_BUCKET_SIZE) {
250 $data = str_split($data, 1024 * 1000);
251 $success = true;
252 $chunkNumber = 1;
253 foreach ($data as $chunk) {
254 if ($memcacheIsUsed) {
255 $success = $success && $this->memcache->set($this->identifierPrefix . $entryIdentifier . '_chunk_' . $chunkNumber, $chunk, $this->flags, $expiration);
256 } else {
257 $success = $success && $this->memcache->set($this->identifierPrefix . $entryIdentifier . '_chunk_' . $chunkNumber, $chunk, $expiration);
258 }
259
260 $chunkNumber++;
261 }
262 if ($memcacheIsUsed) {
263 $success = $success && $this->memcache->set($this->identifierPrefix . $entryIdentifier, 'TYPO3*chunked:' . $chunkNumber, $this->flags, $expiration);
264 } else {
265 $success = $success && $this->memcache->set($this->identifierPrefix . $entryIdentifier, 'TYPO3*chunked:' . $chunkNumber, $expiration);
266 }
267 } else {
268 if ($memcacheIsUsed) {
269 $success = $this->memcache->set($this->identifierPrefix . $entryIdentifier, $data, $this->flags, $expiration);
270 } else {
271 $success = $this->memcache->set($this->identifierPrefix . $entryIdentifier, $data, $expiration);
272 }
273 }
274 if ($success === true) {
275 $this->removeIdentifierFromAllTags($entryIdentifier);
276 $this->addIdentifierToTags($entryIdentifier, $tags);
277 } else {
278 throw new Exception('Could not set data to memcache server.', 1275830266);
279 }
280 } catch (\Exception $exception) {
281 GeneralUtility::sysLog('Memcache: could not set value. Reason: ' . $exception->getMessage(), 'core', GeneralUtility::SYSLOG_SEVERITY_WARNING);
282 }
283 }
284
285 /**
286 * Loads data from the cache.
287 *
288 * @param string $entryIdentifier An identifier which describes the cache entry to load
289 * @return mixed The cache entry's content as a string or FALSE if the cache entry could not be loaded
290 * @api
291 */
292 public function get($entryIdentifier)
293 {
294 $value = $this->memcache->get($this->identifierPrefix . $entryIdentifier);
295 if (substr($value, 0, 14) === 'TYPO3*chunked:') {
296 list(, $chunkCount) = explode(':', $value);
297 $value = '';
298 for ($chunkNumber = 1; $chunkNumber < $chunkCount; $chunkNumber++) {
299 $value .= $this->memcache->get($this->identifierPrefix . $entryIdentifier . '_chunk_' . $chunkNumber);
300 }
301 }
302 return $value;
303 }
304
305 /**
306 * Checks if a cache entry with the specified identifier exists.
307 *
308 * @param string $entryIdentifier An identifier specifying the cache entry
309 * @return bool TRUE if such an entry exists, FALSE if not
310 * @api
311 */
312 public function has($entryIdentifier)
313 {
314 if ($this->usedPeclModule === 'memcache') {
315 return $this->memcache->get($this->identifierPrefix . $entryIdentifier) !== false;
316 }
317
318 // pecl-memcached supports storing literal FALSE
319 $this->memcache->get($this->identifierPrefix . $entryIdentifier);
320 return $this->memcache->getResultCode() !== \Memcached::RES_NOTFOUND;
321 }
322
323 /**
324 * Removes all cache entries matching the specified identifier.
325 * Usually this only affects one entry but if - for what reason ever -
326 * old entries for the identifier still exist, they are removed as well.
327 *
328 * @param string $entryIdentifier Specifies the cache entry to remove
329 * @return bool TRUE if (at least) an entry could be removed or FALSE if no entry was found
330 * @api
331 */
332 public function remove($entryIdentifier)
333 {
334 $this->removeIdentifierFromAllTags($entryIdentifier);
335 return $this->memcache->delete($this->identifierPrefix . $entryIdentifier, 0);
336 }
337
338 /**
339 * Finds and returns all cache entry identifiers which are tagged by the
340 * specified tag.
341 *
342 * @param string $tag The tag to search for
343 * @return array An array of entries with all matching entries. An empty array if no entries matched
344 * @api
345 */
346 public function findIdentifiersByTag($tag)
347 {
348 $identifiers = $this->memcache->get($this->identifierPrefix . 'tag_' . $tag);
349 if ($identifiers !== false) {
350 return (array)$identifiers;
351 } else {
352 return [];
353 }
354 }
355
356 /**
357 * Removes all cache entries of this cache.
358 *
359 * @throws Exception
360 * @api
361 */
362 public function flush()
363 {
364 if (!$this->cache instanceof FrontendInterface) {
365 throw new Exception('No cache frontend has been set via setCache() yet.', 1204111376);
366 }
367 $this->flushByTag('%MEMCACHEBE%' . $this->cacheIdentifier);
368 }
369
370 /**
371 * Removes all cache entries of this cache which are tagged by the specified tag.
372 *
373 * @param string $tag The tag the entries must have
374 * @api
375 */
376 public function flushByTag($tag)
377 {
378 $identifiers = $this->findIdentifiersByTag($tag);
379 foreach ($identifiers as $identifier) {
380 $this->remove($identifier);
381 }
382 }
383
384 /**
385 * Associates the identifier with the given tags
386 *
387 * @param string $entryIdentifier
388 * @param array $tags
389 */
390 protected function addIdentifierToTags($entryIdentifier, array $tags)
391 {
392 // Get identifier-to-tag index to look for updates
393 $existingTags = $this->findTagsByIdentifier($entryIdentifier);
394 $existingTagsUpdated = false;
395
396 foreach ($tags as $tag) {
397 // Update tag-to-identifier index
398 $identifiers = $this->findIdentifiersByTag($tag);
399 if (!in_array($entryIdentifier, $identifiers, true)) {
400 $identifiers[] = $entryIdentifier;
401 $this->memcache->set($this->identifierPrefix . 'tag_' . $tag, $identifiers);
402 }
403 // Test if identifier-to-tag index needs update
404 if (!in_array($tag, $existingTags, true)) {
405 $existingTags[] = $tag;
406 $existingTagsUpdated = true;
407 }
408 }
409
410 // Update identifier-to-tag index if needed
411 if ($existingTagsUpdated) {
412 $this->memcache->set($this->identifierPrefix . 'ident_' . $entryIdentifier, $existingTags);
413 }
414 }
415
416 /**
417 * Removes association of the identifier with the given tags
418 *
419 * @param string $entryIdentifier
420 */
421 protected function removeIdentifierFromAllTags($entryIdentifier)
422 {
423 // Get tags for this identifier
424 $tags = $this->findTagsByIdentifier($entryIdentifier);
425 // De-associate tags with this identifier
426 foreach ($tags as $tag) {
427 $identifiers = $this->findIdentifiersByTag($tag);
428 // Formally array_search() below should never return FALSE due to
429 // the behavior of findTagsByIdentifier(). But if reverse index is
430 // corrupted, we still can get 'FALSE' from array_search(). This is
431 // not a problem because we are removing this identifier from
432 // anywhere.
433 if (($key = array_search($entryIdentifier, $identifiers)) !== false) {
434 unset($identifiers[$key]);
435 if (!empty($identifiers)) {
436 $this->memcache->set($this->identifierPrefix . 'tag_' . $tag, $identifiers);
437 } else {
438 $this->memcache->delete($this->identifierPrefix . 'tag_' . $tag, 0);
439 }
440 }
441 }
442 // Clear reverse tag index for this identifier
443 $this->memcache->delete($this->identifierPrefix . 'ident_' . $entryIdentifier, 0);
444 }
445
446 /**
447 * Finds all tags for the given identifier. This function uses reverse tag
448 * index to search for tags.
449 *
450 * @param string $identifier Identifier to find tags by
451 * @return array
452 * @api
453 */
454 protected function findTagsByIdentifier($identifier)
455 {
456 $tags = $this->memcache->get($this->identifierPrefix . 'ident_' . $identifier);
457 return $tags === false ? [] : (array)$tags;
458 }
459
460 /**
461 * Does nothing, as memcached does GC itself
462 *
463 * @api
464 */
465 public function collectGarbage()
466 {
467 }
468 }