From f54531dad88addc4639f09b4cc37228524156d0c Mon Sep 17 00:00:00 2001 From: Max Kellermann <max.kellermann@ionos.com> Date: Mon, 6 Feb 2023 16:43:55 +0000 Subject: [PATCH] [TASK] Eliminate double serialization in ApcuBackend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit APCu can store arbitrary PHP data; it serializes all values when they are stored, and it has a pluggable serializer interface which can use serializers that are better than serialize(), such as "igbinary", see [1] and [2]. By not implementing TransientBackendInterface, the ApcuBackend forces class VariableFrontend to serialize all values into a string, but APCu serializes the string again. This adds TransientBackendInterface and removes the is_string() check. Double serialization is eliminated by this change. Additionally, the unit tests are turned into functionals, the backend is declared final, gets some more type hints and uses the quicker xxh3 hash. [1] https://www.php.net/manual/en/book.igbinary.php [2] https://pecl.php.net/package/igbinary Releases: main Resolves: #99851 Change-Id: I8663deefd1ffeb249376119287191bcec9ef2420 Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/77717 Tested-by: Christian Kuhn <lolli@schwarzbu.ch> Tested-by: Stefan Bürk <stefan@buerk.tech> Reviewed-by: Christian Kuhn <lolli@schwarzbu.ch> Tested-by: core-ci <typo3@b13.com> Reviewed-by: Stefan Bürk <stefan@buerk.tech> --- composer.json | 1 + .../Classes/Cache/Backend/ApcuBackend.php | 131 +++++++----------- .../Cache/Backend/ApcuBackendTest.php | 122 +++++++++------- .../Cache/Backend/RedisBackendTest.php | 3 +- typo3/sysext/core/composer.json | 1 + 5 files changed, 122 insertions(+), 136 deletions(-) rename typo3/sysext/core/Tests/{Unit => Functional}/Cache/Backend/ApcuBackendTest.php (71%) diff --git a/composer.json b/composer.json index e3bef379af03..1bf994954bf7 100644 --- a/composer.json +++ b/composer.json @@ -123,6 +123,7 @@ "webmozart/assert": "^1.11.0" }, "suggest": { + "ext-apcu": "Needed when non-default APCU based cache backends are used", "ext-gd": "GDlib/Freetype is required for building images with text (GIFBUILDER) and can also be used to scale images", "ext-fileinfo": "Used for proper file type detection in the file abstraction layer", "ext-zlib": "TYPO3 uses zlib for amongst others output compression and un/packing t3x extension files", diff --git a/typo3/sysext/core/Classes/Cache/Backend/ApcuBackend.php b/typo3/sysext/core/Classes/Cache/Backend/ApcuBackend.php index a9a8b36ad37b..04a91243c8d5 100644 --- a/typo3/sysext/core/Classes/Cache/Backend/ApcuBackend.php +++ b/typo3/sysext/core/Classes/Cache/Backend/ApcuBackend.php @@ -16,58 +16,37 @@ namespace TYPO3\CMS\Core\Cache\Backend; use TYPO3\CMS\Core\Cache\Exception; -use TYPO3\CMS\Core\Cache\Exception\InvalidDataException; use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface; use TYPO3\CMS\Core\Core\Environment; /** * A caching backend which stores cache entries by using APCu. * + * The APCu backend is not very good with tagging and scales O(2n) with the + * number of tags. Do not use this backend if the data to be cached has many tags! + * * This backend uses the following types of keys: * - tag_xxx - * xxx is tag name, value is array of associated identifiers identifier. This - * is "forward" tag index. It is mainly used for obtaining content by tag - * (get identifier by tag -> get content by identifier) + * xxx is tag name, value is array of associated identifiers identifier. This + * is "forward" tag index. It is mainly used for obtaining content by tag + * (get identifier by tag -> get content by identifier) * - ident_xxx - * xxx is identifier, value is array of associated tags. This is "reverse" tag - * index. It provides quick access for all tags associated with this identifier - * and used when removing the identifier + * xxx is identifier, value is array of associated tags. This is "reverse" tag + * index. It provides quick access for all tags associated with this identifier + * and used when removing the identifier * - * Each key is prepended with a prefix. By default prefix consists from two parts + * Each key is prepended with a prefix. The prefix makes sure keys from the different + * installations do not conflict. By default, prefix consists from two parts * separated by underscore character and ends in yet another underscore character: * - "TYPO3" - * - MD5 of path to TYPO3 and user running TYPO3 - * This prefix makes sure that keys from the different installations do not - * conflict. + * - Hash of path to TYPO3 and user running TYPO3 */ -class ApcuBackend extends AbstractBackend implements TaggableBackendInterface +final class ApcuBackend extends AbstractBackend implements TaggableBackendInterface, TransientBackendInterface { /** - * A prefix to separate stored data from other data possible stored in the APC - * - * @var string + * A prefix to separate stored data from other data possible stored in the APC. */ - protected $identifierPrefix; - - /** - * Set the cache identifier prefix. - * - * @param string $identifierPrefix - */ - protected function setIdentifierPrefix($identifierPrefix) - { - $this->identifierPrefix = $identifierPrefix; - } - - /** - * Retrieves the cache identifier prefix. - * - * @return string - */ - protected function getIdentifierPrefix() - { - return $this->identifierPrefix; - } + private string $identifierPrefix = ''; /** * Constructs this backend @@ -90,34 +69,29 @@ class ApcuBackend extends AbstractBackend implements TaggableBackendInterface /** * Initializes the identifier prefix when setting the cache. */ - public function setCache(FrontendInterface $cache) + public function setCache(FrontendInterface $cache): void { parent::setCache($cache); - $pathHash = md5(Environment::getProjectPath() . $this->context . $cache->getIdentifier()); - $this->setIdentifierPrefix('TYPO3_' . $pathHash); + $this->identifierPrefix = 'TYPO3_' . hash('xxh3', Environment::getProjectPath() . $this->context . $cache->getIdentifier()) . '_'; } /** * Saves data in the cache. * * @param string $entryIdentifier An identifier for this specific cache entry - * @param string $data The data to be stored + * @param mixed $data The data to be stored * @param array $tags Tags to associate with this cache entry * @param int $lifetime Lifetime of this cache entry in seconds. If NULL is specified, the default lifetime is used. "0" means unlimited lifetime. * @throws Exception if no cache frontend has been set. - * @throws InvalidDataException if $data is not a string */ - public function set($entryIdentifier, $data, array $tags = [], $lifetime = null) + public function set($entryIdentifier, $data, array $tags = [], $lifetime = null): void { if (!$this->cache instanceof FrontendInterface) { throw new Exception('No cache frontend has been set yet via setCache().', 1232986118); } - if (!is_string($data)) { - throw new InvalidDataException('The specified data is of type "' . gettype($data) . '" but a string is expected.', 1232986125); - } $tags[] = '%APCBE%' . $this->cacheIdentifier; $expiration = $lifetime ?? $this->defaultLifetime; - $success = apcu_store($this->getIdentifierPrefix() . $entryIdentifier, $data, $expiration); + $success = apcu_store($this->identifierPrefix . $entryIdentifier, $data, $expiration); if ($success === true) { $this->removeIdentifierFromAllTags($entryIdentifier); $this->addIdentifierToTags($entryIdentifier, $tags); @@ -132,10 +106,10 @@ class ApcuBackend extends AbstractBackend implements TaggableBackendInterface * @param string $entryIdentifier An identifier which describes the cache entry to load * @return mixed The cache entry's content as a string or FALSE if the cache entry could not be loaded */ - public function get($entryIdentifier) + public function get($entryIdentifier): mixed { $success = false; - $value = apcu_fetch($this->getIdentifierPrefix() . $entryIdentifier, $success); + $value = apcu_fetch($this->identifierPrefix . $entryIdentifier, $success); return $success ? $value : $success; } @@ -145,10 +119,10 @@ class ApcuBackend extends AbstractBackend implements TaggableBackendInterface * @param string $entryIdentifier An identifier specifying the cache entry * @return bool TRUE if such an entry exists, FALSE if not */ - public function has($entryIdentifier) + public function has($entryIdentifier): bool { $success = false; - apcu_fetch($this->getIdentifierPrefix() . $entryIdentifier, $success); + apcu_fetch($this->identifierPrefix . $entryIdentifier, $success); return $success; } @@ -160,10 +134,10 @@ class ApcuBackend extends AbstractBackend implements TaggableBackendInterface * @param string $entryIdentifier Specifies the cache entry to remove * @return bool TRUE if (at least) an entry could be removed or FALSE if no entry was found */ - public function remove($entryIdentifier) + public function remove($entryIdentifier): bool { $this->removeIdentifierFromAllTags($entryIdentifier); - return apcu_delete($this->getIdentifierPrefix() . $entryIdentifier); + return apcu_delete($this->identifierPrefix . $entryIdentifier); } /** @@ -173,36 +147,22 @@ class ApcuBackend extends AbstractBackend implements TaggableBackendInterface * @param string $tag The tag to search for * @return array An array with identifiers of all matching entries. An empty array if no entries matched */ - public function findIdentifiersByTag($tag) + public function findIdentifiersByTag($tag): array { $success = false; - $identifiers = apcu_fetch($this->getIdentifierPrefix() . 'tag_' . $tag, $success); + $identifiers = apcu_fetch($this->identifierPrefix . 'tag_' . $tag, $success); if ($success === false) { return []; } return (array)$identifiers; } - /** - * Finds all tags for the given identifier. This function uses reverse tag - * index to search for tags. - * - * @param string $identifier Identifier to find tags by - * @return array Array with tags - */ - protected function findTagsByIdentifier($identifier) - { - $success = false; - $tags = apcu_fetch($this->getIdentifierPrefix() . 'ident_' . $identifier, $success); - return $success ? (array)$tags : []; - } - /** * Removes all cache entries of this cache. * * @throws Exception */ - public function flush() + public function flush(): void { if (!$this->cache instanceof FrontendInterface) { throw new Exception('Yet no cache frontend has been set via setCache().', 1232986571); @@ -215,7 +175,7 @@ class ApcuBackend extends AbstractBackend implements TaggableBackendInterface * * @param string $tag The tag the entries must have */ - public function flushByTag($tag) + public function flushByTag($tag): void { $identifiers = $this->findIdentifiersByTag($tag); foreach ($identifiers as $identifier) { @@ -225,10 +185,8 @@ class ApcuBackend extends AbstractBackend implements TaggableBackendInterface /** * Associates the identifier with the given tags - * - * @param string $entryIdentifier */ - protected function addIdentifierToTags($entryIdentifier, array $tags) + private function addIdentifierToTags(string $entryIdentifier, array $tags): void { // Get identifier-to-tag index to look for updates $existingTags = $this->findTagsByIdentifier($entryIdentifier); @@ -239,7 +197,7 @@ class ApcuBackend extends AbstractBackend implements TaggableBackendInterface $identifiers = $this->findIdentifiersByTag($tag); if (!in_array($entryIdentifier, $identifiers, true)) { $identifiers[] = $entryIdentifier; - apcu_store($this->getIdentifierPrefix() . 'tag_' . $tag, $identifiers); + apcu_store($this->identifierPrefix . 'tag_' . $tag, $identifiers); } // Test if identifier-to-tag index needs update if (!in_array($tag, $existingTags, true)) { @@ -250,16 +208,14 @@ class ApcuBackend extends AbstractBackend implements TaggableBackendInterface // Update identifier-to-tag index if needed if ($existingTagsUpdated) { - apcu_store($this->getIdentifierPrefix() . 'ident_' . $entryIdentifier, $existingTags); + apcu_store($this->identifierPrefix . 'ident_' . $entryIdentifier, $existingTags); } } /** * Removes association of the identifier with the given tags - * - * @param string $entryIdentifier */ - protected function removeIdentifierFromAllTags($entryIdentifier) + private function removeIdentifierFromAllTags(string $entryIdentifier): void { // Get tags for this identifier $tags = $this->findTagsByIdentifier($entryIdentifier); @@ -274,20 +230,29 @@ class ApcuBackend extends AbstractBackend implements TaggableBackendInterface if (($key = array_search($entryIdentifier, $identifiers)) !== false) { unset($identifiers[$key]); if (!empty($identifiers)) { - apcu_store($this->getIdentifierPrefix() . 'tag_' . $tag, $identifiers); + apcu_store($this->identifierPrefix . 'tag_' . $tag, $identifiers); } else { - apcu_delete($this->getIdentifierPrefix() . 'tag_' . $tag); + apcu_delete($this->identifierPrefix . 'tag_' . $tag); } } } // Clear reverse tag index for this identifier - apcu_delete($this->getIdentifierPrefix() . 'ident_' . $entryIdentifier); + apcu_delete($this->identifierPrefix . 'ident_' . $entryIdentifier); } /** - * Does nothing, as APCu does GC itself + * Finds all tags for the given identifier. This function uses reverse tag + * index to search for tags. */ - public function collectGarbage() + private function findTagsByIdentifier(string $identifier): array + { + $success = false; + $tags = apcu_fetch($this->identifierPrefix . 'ident_' . $identifier, $success); + return $success ? (array)$tags : []; + } + + public function collectGarbage(): void { + // Noop, APCu has internal GC } } diff --git a/typo3/sysext/core/Tests/Unit/Cache/Backend/ApcuBackendTest.php b/typo3/sysext/core/Tests/Functional/Cache/Backend/ApcuBackendTest.php similarity index 71% rename from typo3/sysext/core/Tests/Unit/Cache/Backend/ApcuBackendTest.php rename to typo3/sysext/core/Tests/Functional/Cache/Backend/ApcuBackendTest.php index 7dfe77af01e6..543d830eb4d5 100644 --- a/typo3/sysext/core/Tests/Unit/Cache/Backend/ApcuBackendTest.php +++ b/typo3/sysext/core/Tests/Functional/Cache/Backend/ApcuBackendTest.php @@ -15,33 +15,38 @@ declare(strict_types=1); * The TYPO3 project - inspiring people to share! */ -namespace TYPO3\CMS\Core\Tests\Unit\Cache\Backend; +namespace TYPO3\CMS\Core\Tests\Functional\Cache\Backend; use TYPO3\CMS\Core\Cache\Backend\ApcuBackend; use TYPO3\CMS\Core\Cache\Exception; use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface; use TYPO3\CMS\Core\Utility\StringUtility; -use TYPO3\TestingFramework\Core\AccessibleObjectInterface; -use TYPO3\TestingFramework\Core\Unit\UnitTestCase; +use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase; /** * NOTE: If you want to execute these tests you need to enable apc in * cli context (apc.enable_cli = 1) and disable slam defense (apc.slam_defense = 0) */ -class ApcuBackendTest extends UnitTestCase +class ApcuBackendTest extends FunctionalTestCase { - protected bool $resetSingletonInstances = true; + protected bool $initializeDatabase = false; protected function setUp(): void { - parent::setUp(); // APCu module is called apcu, but options are prefixed with apc if (!extension_loaded('apcu') || !(bool)ini_get('apc.enabled') || !(bool)ini_get('apc.enable_cli')) { self::markTestSkipped('APCu extension was not available, or it was disabled for CLI.'); } - if ((bool)ini_get('apc.slam_defense')) { + if (ini_get('apc.slam_defense')) { self::markTestSkipped('This testcase can only be executed with apc.slam_defense = 0'); } + parent::setUp(); + } + + protected function tearDown(): void + { + apcu_clear_cache(); + parent::tearDown(); } /** @@ -62,24 +67,51 @@ class ApcuBackendTest extends UnitTestCase */ public function itIsPossibleToSetAndCheckExistenceInCache(): void { - $backend = $this->setUpBackend(); + $backend = new ApcuBackend('Testing'); + $backend->setCache($this->createMock(FrontendInterface::class)); $data = 'Some data'; $identifier = StringUtility::getUniqueId('MyIdentifier'); $backend->set($identifier, $data); self::assertTrue($backend->has($identifier)); } + public function itIsPossibleToSetAndGetEntryDataProvider(): iterable + { + yield [ 5 ]; + yield [ 5.23 ]; + yield [ 'foo' ]; + yield [ false ]; + yield [ true ]; + yield [ null ]; + yield [ ['foo', 'bar'] ]; + } + /** * @test + * @dataProvider itIsPossibleToSetAndGetEntryDataProvider */ - public function itIsPossibleToSetAndGetEntry(): void + public function itIsPossibleToSetAndGetEntry(mixed $data): void { - $backend = $this->setUpBackend(); - $data = 'Some data'; + $backend = new ApcuBackend('Testing'); + $backend->setCache($this->createMock(FrontendInterface::class)); $identifier = StringUtility::getUniqueId('MyIdentifier'); $backend->set($identifier, $data); + self::assertSame($data, $backend->get($identifier)); + } + + /** + * @test + */ + public function itIsPossibleToSetAndGetObject(): void + { + $backend = new ApcuBackend('Testing'); + $backend->setCache($this->createMock(FrontendInterface::class)); + $identifier = StringUtility::getUniqueId('MyIdentifier'); + $object = new \stdClass(); + $object->foo = 'foo'; + $backend->set($identifier, $object); $fetchedData = $backend->get($identifier); - self::assertEquals($data, $fetchedData); + self::assertEquals($object, $fetchedData); } /** @@ -87,7 +119,8 @@ class ApcuBackendTest extends UnitTestCase */ public function itIsPossibleToRemoveEntryFromCache(): void { - $backend = $this->setUpBackend(); + $backend = new ApcuBackend('Testing'); + $backend->setCache($this->createMock(FrontendInterface::class)); $data = 'Some data'; $identifier = StringUtility::getUniqueId('MyIdentifier'); $backend->set($identifier, $data); @@ -100,7 +133,8 @@ class ApcuBackendTest extends UnitTestCase */ public function itIsPossibleToOverwriteAnEntryInTheCache(): void { - $backend = $this->setUpBackend(); + $backend = new ApcuBackend('Testing'); + $backend->setCache($this->createMock(FrontendInterface::class)); $data = 'Some data'; $identifier = StringUtility::getUniqueId('MyIdentifier'); $backend->set($identifier, $data); @@ -115,7 +149,8 @@ class ApcuBackendTest extends UnitTestCase */ public function findIdentifiersByTagFindsSetEntries(): void { - $backend = $this->setUpBackend(); + $backend = new ApcuBackend('Testing'); + $backend->setCache($this->createMock(FrontendInterface::class)); $data = 'Some data'; $identifier = StringUtility::getUniqueId('MyIdentifier'); $backend->set($identifier, $data, ['UnitTestTag%tag1', 'UnitTestTag%tag2']); @@ -130,7 +165,8 @@ class ApcuBackendTest extends UnitTestCase */ public function setRemovesTagsFromPreviousSet(): void { - $backend = $this->setUpBackend(); + $backend = new ApcuBackend('Testing'); + $backend->setCache($this->createMock(FrontendInterface::class)); $data = 'Some data'; $identifier = StringUtility::getUniqueId('MyIdentifier'); $backend->set($identifier, $data, ['UnitTestTag%tag1', 'UnitTestTag%tagX']); @@ -144,7 +180,8 @@ class ApcuBackendTest extends UnitTestCase */ public function hasReturnsFalseIfTheEntryDoesNotExist(): void { - $backend = $this->setUpBackend(); + $backend = new ApcuBackend('Testing'); + $backend->setCache($this->createMock(FrontendInterface::class)); $identifier = StringUtility::getUniqueId('NonExistingIdentifier'); self::assertFalse($backend->has($identifier)); } @@ -154,7 +191,8 @@ class ApcuBackendTest extends UnitTestCase */ public function removeReturnsFalseIfTheEntryDoesntExist(): void { - $backend = $this->setUpBackend(); + $backend = new ApcuBackend('Testing'); + $backend->setCache($this->createMock(FrontendInterface::class)); $identifier = StringUtility::getUniqueId('NonExistingIdentifier'); self::assertFalse($backend->remove($identifier)); } @@ -164,7 +202,8 @@ class ApcuBackendTest extends UnitTestCase */ public function flushByTagRemovesCacheEntriesWithSpecifiedTag(): void { - $backend = $this->setUpBackend(); + $backend = new ApcuBackend('Testing'); + $backend->setCache($this->createMock(FrontendInterface::class)); $data = 'some data' . microtime(); $backend->set('BackendAPCUTest1', $data, ['UnitTestTag%test', 'UnitTestTag%boring']); $backend->set('BackendAPCUTest2', $data, ['UnitTestTag%test', 'UnitTestTag%special']); @@ -180,7 +219,8 @@ class ApcuBackendTest extends UnitTestCase */ public function flushByTagsRemovesCacheEntriesWithSpecifiedTags(): void { - $backend = $this->setUpBackend(); + $backend = new ApcuBackend('Testing'); + $backend->setCache($this->createMock(FrontendInterface::class)); $data = 'some data' . microtime(); $backend->set('BackendAPCUTest1', $data, ['UnitTestTag%test', 'UnitTestTag%boring']); $backend->set('BackendAPCUTest2', $data, ['UnitTestTag%test', 'UnitTestTag%special']); @@ -196,7 +236,8 @@ class ApcuBackendTest extends UnitTestCase */ public function flushRemovesAllCacheEntries(): void { - $backend = $this->setUpBackend(); + $backend = new ApcuBackend('Testing'); + $backend->setCache($this->createMock(FrontendInterface::class)); $data = 'some data' . microtime(); $backend->set('BackendAPCUTest1', $data); $backend->set('BackendAPCUTest2', $data); @@ -235,7 +276,8 @@ class ApcuBackendTest extends UnitTestCase */ public function largeDataIsStored(): void { - $backend = $this->setUpBackend(); + $backend = new ApcuBackend('Testing'); + $backend->setCache($this->createMock(FrontendInterface::class)); $data = str_repeat('abcde', 1024 * 1024); $identifier = StringUtility::getUniqueId('tooLargeData'); $backend->set($identifier, $data); @@ -251,35 +293,13 @@ class ApcuBackendTest extends UnitTestCase $identifier = StringUtility::getUniqueId('MyIdentifier'); $tags = ['UnitTestTag%test', 'UnitTestTag%boring']; - $backend = $this->setUpBackend(true); - $backend->_call('addIdentifierToTags', $identifier, $tags); - self::assertSame( - $tags, - $backend->_call('findTagsByIdentifier', $identifier) - ); - - $backend->_call('addIdentifierToTags', $identifier, $tags); - self::assertSame( - $tags, - $backend->_call('findTagsByIdentifier', $identifier) - ); - } + $backend = new ApcuBackend('Testing'); + $backend->setCache($this->createMock(FrontendInterface::class)); + $backend->set($identifier, 'testData', $tags); + $backend->set($identifier, 'testData', $tags); - /** - * Sets up the APCu backend used for testing - * - * @param bool $accessible TRUE if backend should be encapsulated in accessible proxy otherwise FALSE. - * @return AccessibleObjectInterface|ApcuBackend - */ - protected function setUpBackend(bool $accessible = false) - { - $cache = $this->createMock(FrontendInterface::class); - if ($accessible) { - $backend = $this->getAccessibleMock(ApcuBackend::class, null, ['Testing']); - } else { - $backend = new ApcuBackend('Testing'); - } - $backend->setCache($cache); - return $backend; + // Expect exactly 5 entries: + // 1 for data, 3 for tag->identifier, 1 for identifier->tags + self::assertSame(5, count(apcu_cache_info()['cache_list'])); } } diff --git a/typo3/sysext/core/Tests/Functional/Cache/Backend/RedisBackendTest.php b/typo3/sysext/core/Tests/Functional/Cache/Backend/RedisBackendTest.php index 16bbd7fd65aa..f0dc7af67316 100644 --- a/typo3/sysext/core/Tests/Functional/Cache/Backend/RedisBackendTest.php +++ b/typo3/sysext/core/Tests/Functional/Cache/Backend/RedisBackendTest.php @@ -42,10 +42,9 @@ class RedisBackendTest extends FunctionalTestCase if (!getenv('typo3TestingRedisHost')) { self::markTestSkipped('environment variable "typo3TestingRedisHost" must be set to run this test'); } - // Note we assume that if that typo3TestingRedisHost env is set, we can use that for testing, + // Note we assume that if typo3TestingRedisHost env is set, we can use that for testing, // there is no test to see if the daemon is actually up and running. Tests will fail if env // is set but daemon is down. - parent::setUp(); } diff --git a/typo3/sysext/core/composer.json b/typo3/sysext/core/composer.json index 46110237765d..6127790262e7 100644 --- a/typo3/sysext/core/composer.json +++ b/typo3/sysext/core/composer.json @@ -76,6 +76,7 @@ "typo3fluid/fluid": "^2.7.2" }, "suggest": { + "ext-apcu": "Needed when non-default APCU based cache backends are used", "ext-fileinfo": "Used for proper file type detection in the file abstraction layer", "ext-gd": "GDlib/Freetype is required for building images with text (GIFBUILDER) and can also be used to scale images", "ext-mysqli": "", -- GitLab