[Cache] Added PhpFilesAdapter

This commit is contained in:
Piotr Stankowski 2016-05-24 15:06:45 +02:00 committed by Nicolas Grekas
parent c71868d06f
commit 14bcd799c7
6 changed files with 661 additions and 60 deletions

View File

@ -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 <p@tchwork.com>
*/
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);
}
}

View File

@ -0,0 +1,137 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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;
}
}

View File

@ -0,0 +1,157 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 '<?php return '.$exportedValue.';';
}
}

View File

@ -0,0 +1,260 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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);
}
}

View File

@ -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');
}
}

View File

@ -0,0 +1,70 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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);
}
}