diff --git a/src/Symfony/Component/Cache/Adapter/FilesystemAdapter.php b/src/Symfony/Component/Cache/Adapter/FilesystemAdapter.php index bad7c5eb3e..cf617c3b0e 100644 --- a/src/Symfony/Component/Cache/Adapter/FilesystemAdapter.php +++ b/src/Symfony/Component/Cache/Adapter/FilesystemAdapter.php @@ -11,43 +11,27 @@ namespace Symfony\Component\Cache\Adapter; -use Symfony\Component\Cache\Exception\InvalidArgumentException; +use Symfony\Component\Cache\Adapter\Helper\FilesCacheHelper; /** * @author Nicolas Grekas */ class FilesystemAdapter extends AbstractAdapter { - private $directory; + /** + * @var FilesCacheHelper + */ + protected $filesCacheHelper; + /** + * @param string $namespace Cache namespace + * @param int $defaultLifetime Default lifetime for cache items + * @param null $directory Path where cache items should be stored, defaults to sys_get_temp_dir().'/symfony-cache' + */ public function __construct($namespace = '', $defaultLifetime = 0, $directory = null) { - parent::__construct('', $defaultLifetime); - - if (!isset($directory[0])) { - $directory = sys_get_temp_dir().'/symfony-cache'; - } - if (isset($namespace[0])) { - if (preg_match('#[^-+_.A-Za-z0-9]#', $namespace, $match)) { - throw new InvalidArgumentException(sprintf('FilesystemAdapter namespace contains "%s" but only characters in [-+_.A-Za-z0-9] are allowed.', $match[0])); - } - $directory .= '/'.$namespace; - } - if (!file_exists($dir = $directory.'/.')) { - @mkdir($directory, 0777, true); - } - if (false === $dir = realpath($dir)) { - throw new InvalidArgumentException(sprintf('Cache directory does not exist (%s)', $directory)); - } - if (!is_writable($dir .= DIRECTORY_SEPARATOR)) { - throw new InvalidArgumentException(sprintf('Cache directory is not writable (%s)', $directory)); - } - // On Windows the whole path is limited to 258 chars - if ('\\' === DIRECTORY_SEPARATOR && strlen($dir) > 234) { - throw new InvalidArgumentException(sprintf('Cache directory too long (%s)', $directory)); - } - - $this->directory = $dir; + parent::__construct($namespace, $defaultLifetime); + $this->filesCacheHelper = new FilesCacheHelper($directory, $namespace); } /** @@ -59,7 +43,7 @@ class FilesystemAdapter extends AbstractAdapter $now = time(); foreach ($ids as $id) { - $file = $this->getFile($id); + $file = $this->filesCacheHelper->getFilePath($id); if (!$h = @fopen($file, 'rb')) { continue; } @@ -86,7 +70,7 @@ class FilesystemAdapter extends AbstractAdapter */ protected function doHave($id) { - $file = $this->getFile($id); + $file = $this->filesCacheHelper->getFilePath($id); return file_exists($file) && (@filemtime($file) > time() || $this->doFetch(array($id))); } @@ -97,8 +81,9 @@ class FilesystemAdapter extends AbstractAdapter protected function doClear($namespace) { $ok = true; + $directory = $this->filesCacheHelper->getDirectory(); - foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($this->directory, \FilesystemIterator::SKIP_DOTS)) as $file) { + foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS)) as $file) { $ok = ($file->isDir() || @unlink($file) || !file_exists($file)) && $ok; } @@ -113,7 +98,7 @@ class FilesystemAdapter extends AbstractAdapter $ok = true; foreach ($ids as $id) { - $file = $this->getFile($id); + $file = $this->filesCacheHelper->getFilePath($id); $ok = (!file_exists($file) || @unlink($file) || !file_exists($file)) && $ok; } @@ -127,32 +112,24 @@ class FilesystemAdapter extends AbstractAdapter { $ok = true; $expiresAt = $lifetime ? time() + $lifetime : PHP_INT_MAX; - $tmp = $this->directory.uniqid('', true); foreach ($values as $id => $value) { - $file = $this->getFile($id, true); - - $value = $expiresAt."\n".rawurlencode($id)."\n".serialize($value); - if (false !== @file_put_contents($tmp, $value)) { - @touch($tmp, $expiresAt); - $ok = @rename($tmp, $file) && $ok; - } else { - $ok = false; - } + $fileContent = $this->createCacheFileContent($id, $value, $expiresAt); + $ok = $this->filesCacheHelper->saveFileForId($id, $fileContent, $expiresAt) && $ok; } return $ok; } - private function getFile($id, $mkdir = false) + /** + * @param string $id + * @param mixed $value + * @param int $expiresAt + * + * @return string + */ + protected function createCacheFileContent($id, $value, $expiresAt) { - $hash = str_replace('/', '-', base64_encode(md5($id, true))); - $dir = $this->directory.$hash[0].DIRECTORY_SEPARATOR.$hash[1].DIRECTORY_SEPARATOR; - - if ($mkdir && !file_exists($dir)) { - @mkdir($dir, 0777, true); - } - - return $dir.substr($hash, 2, -2); + return $expiresAt."\n".rawurlencode($id)."\n".serialize($value); } } diff --git a/src/Symfony/Component/Cache/Adapter/Helper/FilesCacheHelper.php b/src/Symfony/Component/Cache/Adapter/Helper/FilesCacheHelper.php new file mode 100644 index 0000000000..305046c0fb --- /dev/null +++ b/src/Symfony/Component/Cache/Adapter/Helper/FilesCacheHelper.php @@ -0,0 +1,137 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Adapter\Helper; + +use Symfony\Component\Cache\Exception\InvalidArgumentException; + +class FilesCacheHelper +{ + /** + * @var string + */ + private $fileSuffix; + + /** + * @var string + */ + private $directory; + + /** + * @param string $directory Path where cache items should be stored, defaults to sys_get_temp_dir().'/symfony-cache' + * @param string $namespace Cache namespace + * @param string $version Version (works the same way as namespace) + * @param string $fileSuffix Suffix that will be appended to all file names + */ + public function __construct($directory = null, $namespace = null, $version = null, $fileSuffix = '') + { + if (!isset($directory[0])) { + $directory = sys_get_temp_dir().'/symfony-cache'; + } + if (isset($namespace[0])) { + if (preg_match('#[^-+_.A-Za-z0-9]#', $namespace, $match)) { + throw new InvalidArgumentException(sprintf('Cache namespace for filesystem cache contains "%s" but only characters in [-+_.A-Za-z0-9] are allowed.', $match[0])); + } + $directory .= '/'.$namespace; + } + if (isset($version[0])) { + if (preg_match('#[^-+_.A-Za-z0-9]#', $version, $match)) { + throw new InvalidArgumentException(sprintf('Cache version contains "%s" but only characters in [-+_.A-Za-z0-9] are allowed.', $match[0])); + } + $directory .= '/'.$version; + } + if (!file_exists($dir = $directory.'/.')) { + @mkdir($directory, 0777, true); + } + if (false === $dir = realpath($dir)) { + throw new InvalidArgumentException(sprintf('Cache directory does not exist (%s)', $directory)); + } + if (!is_writable($dir .= DIRECTORY_SEPARATOR)) { + throw new InvalidArgumentException(sprintf('Cache directory is not writable (%s)', $directory)); + } + // On Windows the whole path is limited to 258 chars + if ('\\' === DIRECTORY_SEPARATOR && strlen($dir) + strlen($fileSuffix) > 234) { + throw new InvalidArgumentException(sprintf('Cache directory too long (%s)', $directory)); + } + + $this->fileSuffix = $fileSuffix; + $this->directory = $dir; + } + + /** + * Returns root cache directory. + * + * @return string + */ + public function getDirectory() + { + return $this->directory; + } + + /** + * Saves entry in cache. + * + * @param string $id Id of the cache entry (used for obtaining file path to write to). + * @param string $fileContent Content to write to cache file + * @param int|null $modificationTime If this is not-null it will be passed to touch() + * + * @return bool + */ + public function saveFileForId($id, $fileContent, $modificationTime = null) + { + $file = $this->getFilePath($id, true); + + return $this->saveFile($file, $fileContent, $modificationTime); + } + + /** + * Saves entry in cache. + * + * @param string $file File path to cache entry. + * @param string $fileContent Content to write to cache file + * @param int|null $modificationTime If this is not-null it will be passed to touch() + * + * @return bool + */ + public function saveFile($file, $fileContent, $modificationTime = null) + { + $temporaryFile = $this->directory.uniqid('', true); + if (false === @file_put_contents($temporaryFile, $fileContent)) { + return false; + } + + if (null !== $modificationTime) { + @touch($temporaryFile, $modificationTime); + } + + return @rename($temporaryFile, $file); + } + + /** + * Returns file path to cache entry. + * + * @param string $id Cache entry id. + * @param bool $mkdir Whether to create necessary directories before returning file path. + * + * @return string + */ + public function getFilePath($id, $mkdir = false) + { + $hash = str_replace('/', '-', base64_encode(md5($id, true))); + $dir = $this->directory.$hash[0].DIRECTORY_SEPARATOR.$hash[1].DIRECTORY_SEPARATOR; + + if ($mkdir && !file_exists($dir)) { + @mkdir($dir, 0777, true); + } + + return $dir.substr($hash, 2, -2).$this->fileSuffix; + } +} diff --git a/src/Symfony/Component/Cache/Adapter/PhpFilesAdapter.php b/src/Symfony/Component/Cache/Adapter/PhpFilesAdapter.php new file mode 100644 index 0000000000..2984762945 --- /dev/null +++ b/src/Symfony/Component/Cache/Adapter/PhpFilesAdapter.php @@ -0,0 +1,157 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Adapter; + +use Symfony\Component\Cache\Adapter\Helper\FilesCacheHelper; + +class PhpFilesAdapter extends AbstractAdapter +{ + /** + * @var FilesCacheHelper + */ + protected $filesCacheHelper; + + /** + * @param string $namespace Cache namespace + * @param int $defaultLifetime Default lifetime for cache items + * @param null $directory Path where cache items should be stored, defaults to sys_get_temp_dir().'/symfony-cache' + * @param string $version Version (works the same way as namespace) + */ + public function __construct($namespace = '', $defaultLifetime = 0, $directory = null, $version = null) + { + parent::__construct($namespace, $defaultLifetime); + $this->filesCacheHelper = new FilesCacheHelper($directory, $namespace, $version, '.php'); + } + + /** + * {@inheritdoc} + */ + protected function doFetch(array $ids) + { + $values = array(); + + foreach ($ids as $id) { + $valueArray = $this->includeCacheFile($this->filesCacheHelper->getFilePath($id)); + if (!is_array($valueArray)) { + continue; + } + $values[$id] = $valueArray[0]; + } + + return $values; + } + + /** + * {@inheritdoc} + */ + protected function doHave($id) + { + return 0 !== count($this->doFetch(array($id))); + } + + /** + * {@inheritdoc} + */ + protected function doClear($namespace) + { + $directory = $this->filesCacheHelper->getDirectory(); + + return !(new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS))->valid(); + } + + /** + * {@inheritdoc} + */ + protected function doDelete(array $ids) + { + foreach ($ids as $id) { + $file = $this->filesCacheHelper->getFilePath($id); + if (@file_exists($file)) { + return false; + } + } + + return true; + } + + /** + * {@inheritdoc} + */ + protected function doSave(array $values, $lifetime) + { + $ok = true; + $expiresAt = $lifetime ? time() + $lifetime : PHP_INT_MAX; + + foreach ($values as $id => $value) { + $file = $this->filesCacheHelper->getFilePath($id, true); + if (file_exists($file)) { + $ok = false; + } else { + $ok = $this->saveCacheFile($file, $value, $expiresAt) && $ok; + } + } + + return $ok; + } + + /** + * @param string $file + * @param mixed $value + * @param int $expiresAt + * + * @return bool + */ + private function saveCacheFile($file, $value, $expiresAt) + { + $fileContent = $this->createCacheFileContent($value, $expiresAt); + + return $this->filesCacheHelper->saveFile($file, $fileContent); + } + + /** + * @param string $file File path + * + * @return array|null unserialized value wrapped in array or null + */ + private function includeCacheFile($file) + { + $valueArray = @include $file; + if (!is_array($valueArray) || 2 !== count($valueArray)) { + return; + } + + list($serializedValue, $expiresAt) = $valueArray; + if (time() > (int) $expiresAt) { + return; + } + + $unserializedValueInArray = unserialize($serializedValue); + if (!is_array($unserializedValueInArray)) { + return; + } + + return $unserializedValueInArray; + } + + /** + * @param mixed $value + * @param int $expiresAt + * + * @return string + */ + private function createCacheFileContent($value, $expiresAt) + { + $exportedValue = var_export(array(serialize([$value]), $expiresAt), true); + + return ' + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Tests\Adapter; + +use Cache\IntegrationTests\CachePoolTest; +use Psr\Cache\CacheItemInterface; +use Psr\Cache\CacheItemPoolInterface; + +abstract class AbstractAppendOnlyAdapterTest extends CachePoolTest +{ + /** + * @var mixed + */ + private $cacheVersion; + + /** + * @var CacheItemPoolInterface + */ + private $cache; + + protected function setUp() + { + parent::setUp(); + $this->cacheVersion = $this->createRandomCachePoolVersion(); + $this->cache = $this->createVersionedCachePool($this->cacheVersion); + } + + public function createCachePool() + { + $cacheVersion = $this->createRandomCachePoolVersion(); + + return $this->createVersionedCachePool($cacheVersion); + } + + /** + * @return mixed cache version that will be used by this adapter + */ + abstract public function createRandomCachePoolVersion(); + + /** + * @return CacheItemPoolInterface that is used in the tests that need to recreate the same cache pool + */ + abstract public function createVersionedCachePool($cacheVersion); + + public function testBasicUsage() + { + if (isset($this->skippedTests[__FUNCTION__])) { + $this->markTestSkipped($this->skippedTests[__FUNCTION__]); + + return; + } + + $item = $this->cache->getItem('key'); + $item->set('4711'); + $this->cache->save($item); + + $item = $this->cache->getItem('key2'); + $item->set('4712'); + $this->cache->save($item); + + $fooItem = $this->cache->getItem('key'); + $this->assertTrue($fooItem->isHit()); + $this->assertEquals('4711', $fooItem->get()); + + $barItem = $this->cache->getItem('key2'); + $this->assertTrue($barItem->isHit()); + $this->assertEquals('4712', $barItem->get()); + + // Removing must always return false + $this->assertFalse($this->cache->deleteItem('key')); + $this->assertTrue($this->cache->getItem('key')->isHit()); + $this->assertTrue($this->cache->getItem('key2')->isHit()); + + // Remove everything + $this->assertFalse($this->cache->clear()); + $this->assertTrue($this->cache->getItem('key')->isHit()); + $this->assertTrue($this->cache->getItem('key2')->isHit()); + } + + public function testClear() + { + if (isset($this->skippedTests[__FUNCTION__])) { + $this->markTestSkipped($this->skippedTests[__FUNCTION__]); + + return; + } + + $return = $this->cache->clear(); + $this->assertTrue($return, 'clear() should return true when no items are in a cache'); + + $item = $this->cache->getItem('key'); + $item->set('value'); + $this->cache->save($item); + + $return = $this->cache->clear(); + + $this->assertFalse($return, 'clear() must return false for append-only cache when not empty.'); + $this->assertTrue($this->cache->getItem('key')->isHit(), 'Item should still be in an append-only cache, even after clear.'); + } + + public function testDeleteItem() + { + if (isset($this->skippedTests[__FUNCTION__])) { + $this->markTestSkipped($this->skippedTests[__FUNCTION__]); + + return; + } + + $item = $this->cache->getItem('key'); + $item->set('value'); + $this->cache->save($item); + + $this->assertFalse($this->cache->deleteItem('key')); + $this->assertTrue($this->cache->getItem('key')->isHit(), 'A deleted item should still be a hit in an append-only cache.'); + $this->assertTrue($this->cache->hasItem('key'), 'A deleted item should still be a hit in an append-only cache.'); + + $this->assertTrue($this->cache->deleteItem('key2'), 'Deleting an item that does not exist should return true.'); + } + + public function testDeleteItems() + { + if (isset($this->skippedTests[__FUNCTION__])) { + $this->markTestSkipped($this->skippedTests[__FUNCTION__]); + + return; + } + + $items = $this->cache->getItems(['foo', 'bar', 'baz']); + + /** @var CacheItemInterface $item */ + foreach ($items as $idx => $item) { + $item->set($idx); + $this->cache->save($item); + } + + // All should be a hit but 'biz' + $this->assertTrue($this->cache->getItem('foo')->isHit()); + $this->assertTrue($this->cache->getItem('bar')->isHit()); + $this->assertTrue($this->cache->getItem('baz')->isHit()); + $this->assertFalse($this->cache->getItem('biz')->isHit()); + + $return = $this->cache->deleteItems(['foo', 'bar', 'biz']); + $this->assertFalse($return, 'Deleting should return false in append-only cache'); + + $this->assertTrue($this->cache->getItem('foo')->isHit(), 'Deleting shouldn\'t work for append-only cache'); + $this->assertTrue($this->cache->getItem('bar')->isHit(), 'Deleting shouldn\'t work for append-only cache'); + $this->assertTrue($this->cache->getItem('baz')->isHit(), 'Deleting shouldn\'t work for append-only cache'); + $this->assertFalse($this->cache->getItem('biz')->isHit()); + } + + public function testSaveExpired() + { + if (isset($this->skippedTests[__FUNCTION__])) { + $this->markTestSkipped($this->skippedTests[__FUNCTION__]); + + return; + } + + $item = $this->cache->getItem('key'); + $item->set('value'); + $item->expiresAt(\DateTime::createFromFormat('U', time() - 1)); + $this->cache->save($item); + $item = $this->cache->getItem('key'); + $this->assertFalse($item->isHit(), 'Cache should not return expired items'); + } + + public function testSaveWithoutExpire() + { + if (isset($this->skippedTests[__FUNCTION__])) { + $this->markTestSkipped($this->skippedTests[__FUNCTION__]); + + return; + } + + $item = $this->cache->getItem('test_ttl_null'); + $item->set('data'); + $this->cache->save($item); + + // Use a new pool instance to ensure that we don't it any caches + $pool = $this->createVersionedCachePool($this->cacheVersion); + $item = $pool->getItem('test_ttl_null'); + + $this->assertTrue($item->isHit(), 'Cache should have retrieved the items'); + $this->assertEquals('data', $item->get()); + } + + public function testDeleteDeferredItem() + { + if (isset($this->skippedTests[__FUNCTION__])) { + $this->markTestSkipped($this->skippedTests[__FUNCTION__]); + + return; + } + + $item = $this->cache->getItem('key'); + $item->set('4711'); + $this->cache->saveDeferred($item); + + $this->cache->deleteItem('key'); + $this->assertFalse($this->cache->hasItem('key'), 'You must be able to delete a deferred item before committed. '); + $this->assertFalse($this->cache->getItem('key')->isHit(), 'You must be able to delete a deferred item before committed. '); + + $this->cache->commit(); + $this->assertFalse($this->cache->hasItem('key'), 'A deleted item should not reappear after commit. '); + $this->assertFalse($this->cache->getItem('key')->isHit(), 'A deleted item should not reappear after commit. '); + } + + public function testDeferredSaveWithoutCommit() + { + if (isset($this->skippedTests[__FUNCTION__])) { + $this->markTestSkipped($this->skippedTests[__FUNCTION__]); + + return; + } + + $this->prepareDeferredSaveWithoutCommit(); + gc_collect_cycles(); + + $cache = $this->createVersionedCachePool($this->cacheVersion); + $this->assertTrue($cache->getItem('key')->isHit(), 'A deferred item should automatically be committed on CachePool::__destruct().'); + } + + public function testSaveDeferredOverwrite() + { + if (isset($this->skippedTests[__FUNCTION__])) { + $this->markTestSkipped($this->skippedTests[__FUNCTION__]); + + return; + } + + $item = $this->cache->getItem('key'); + $item->set('value'); + $this->cache->saveDeferred($item); + $item->set('new value'); + $this->cache->saveDeferred($item); + + $this->cache->commit(); + $item = $this->cache->getItem('key'); + $this->assertEquals('new value', $item->get()); + } + + private function prepareDeferredSaveWithoutCommit() + { + $cache = $this->cache; + $this->cache = null; + + $item = $cache->getItem('key'); + $item->set('4711'); + $cache->saveDeferred($item); + } +} diff --git a/src/Symfony/Component/Cache/Tests/Adapter/FilesystemAdapterTest.php b/src/Symfony/Component/Cache/Tests/Adapter/FilesystemAdapterTest.php index 418cbf9ebc..66da9c9078 100644 --- a/src/Symfony/Component/Cache/Tests/Adapter/FilesystemAdapterTest.php +++ b/src/Symfony/Component/Cache/Tests/Adapter/FilesystemAdapterTest.php @@ -19,15 +19,6 @@ use Symfony\Component\Cache\Adapter\FilesystemAdapter; */ class FilesystemAdapterTest extends CachePoolTest { - public function createCachePool() - { - if (defined('HHVM_VERSION')) { - $this->skippedTests['testDeferredSaveWithoutCommit'] = 'Fails on HHVM'; - } - - return new FilesystemAdapter('sf-cache'); - } - public static function tearDownAfterClass() { self::rmdir(sys_get_temp_dir().'/symfony-cache'); @@ -54,4 +45,13 @@ class FilesystemAdapterTest extends CachePoolTest } rmdir($dir); } + + public function createCachePool() + { + if (defined('HHVM_VERSION')) { + $this->skippedTests['testDeferredSaveWithoutCommit'] = 'Fails on HHVM'; + } + + return new FilesystemAdapter('sf-cache'); + } } diff --git a/src/Symfony/Component/Cache/Tests/Adapter/PhpFilesAdapterTest.php b/src/Symfony/Component/Cache/Tests/Adapter/PhpFilesAdapterTest.php new file mode 100644 index 0000000000..eaeba8a5e6 --- /dev/null +++ b/src/Symfony/Component/Cache/Tests/Adapter/PhpFilesAdapterTest.php @@ -0,0 +1,70 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Tests\Adapter; + +use Psr\Cache\CacheItemPoolInterface; +use Symfony\Component\Cache\Adapter\PhpFilesAdapter; + +/** + * @group time-sensitive + */ +class PhpFilesAdapterTest extends AbstractAppendOnlyAdapterTest +{ + public static function tearDownAfterClass() + { + self::rmdir(sys_get_temp_dir().'/symfony-cache'); + } + + public static function rmdir($dir) + { + if (!file_exists($dir)) { + return; + } + if (!$dir || 0 !== strpos(dirname($dir), sys_get_temp_dir())) { + throw new \Exception(__METHOD__."() operates only on subdirs of system's temp dir"); + } + $children = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ($children as $child) { + if ($child->isDir()) { + rmdir($child); + } else { + unlink($child); + } + } + rmdir($dir); + } + + /** + * @param string $cacheVersion + * + * @return CacheItemPoolInterface that is used in the tests that need to recreate the same cache pool + */ + public function createVersionedCachePool($cacheVersion) + { + if (defined('HHVM_VERSION')) { + $this->skippedTests['testDeferredSaveWithoutCommit'] = 'Fails on HHVM'; + } + + return new PhpFilesAdapter('sf-cache', 0, null, $cacheVersion); + } + + /** + * @return mixed cache version that will be used by this adapter + */ + public function createRandomCachePoolVersion() + { + return substr(str_replace('/', '-', base64_encode(md5(uniqid(mt_rand(), true), true))), 0, -2); + } +}