feature #26929 [Cache] Add [Taggable]CacheInterface, the easiest way to use a cache (nicolas-grekas)

This PR was merged into the 4.2-dev branch.

Discussion
----------

[Cache] Add [Taggable]CacheInterface, the easiest way to use a cache

| Q             | A
| ------------- | ---
| Branch?       | master
| Bug fix?      | no
| New feature?  | yes
| BC breaks?    | no
| Deprecations? | no
| Tests pass?   | yes
| Fixed tickets | #25903
| License       | MIT
| Doc PR        | -

This feature is a no-brainer, yet it provides a wonderful DX when using a cache:

by type-hinting the new `CacheInterface` or  `TaggableCacheInterface`, you get access to:

```php
public function get(string $key, callable $callback);
```

`$callback` is called when `$key` is not found in the cache pool.
It is given one arguments: a `CacheItemInterface $item` (a `CacheItem` for a `TaggableCacheInterface`), and should return the corresponding value.

```php
$value = $cache->get($key, function (CacheItemInterface $item) {
    $item->expiresAfter(3600);
    return $this->computeValue();
});
```

or for tags, on a `TaggableCacheInterface $cache`:
```php
$value = $cache->get($key, function (CacheItem $item) {
    $item->tag('foo_tag');
    return $this->computeValue();
});
```

Plain simple, I love it, why didn't we have the idea earlier, isn't it ?! :)

Commits
-------

589ff697f4 [Cache] Add [Taggable]CacheInterface, the easiest way to use a cache
This commit is contained in:
Nicolas Grekas 2018-05-21 19:51:44 +02:00
commit bd6769e391
20 changed files with 341 additions and 11 deletions

View File

@ -15,6 +15,10 @@
<argument type="service" id="cache.app" />
</service>
<service id="cache.app.taggable" class="Symfony\Component\Cache\Adapter\TagAwareAdapter">
<argument type="service" id="cache.app" />
</service>
<service id="cache.system" parent="cache.adapter.system" public="true">
<tag name="cache.pool" />
</service>
@ -122,7 +126,9 @@
<service id="cache.global_clearer" parent="cache.default_clearer" public="true" />
<service id="cache.app_clearer" alias="cache.default_clearer" public="true" />
<service id="Psr\Cache\CacheItemPoolInterface" alias="cache.app" />
<service id="Symfony\Component\Cache\TaggableCacheInterface" alias="cache.app.taggable" />
<service id="Psr\SimpleCache\CacheInterface" alias="cache.app.simple" />
<service id="Symfony\Component\Cache\Adapter\AdapterInterface" alias="cache.app" />
<service id="Symfony\Component\Cache\CacheInterface" alias="cache.app" />
</services>
</container>

View File

@ -15,17 +15,20 @@ use Psr\Cache\CacheItemInterface;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Symfony\Component\Cache\CacheInterface;
use Symfony\Component\Cache\CacheItem;
use Symfony\Component\Cache\Exception\InvalidArgumentException;
use Symfony\Component\Cache\ResettableInterface;
use Symfony\Component\Cache\Traits\AbstractTrait;
use Symfony\Component\Cache\Traits\GetTrait;
/**
* @author Nicolas Grekas <p@tchwork.com>
*/
abstract class AbstractAdapter implements AdapterInterface, LoggerAwareInterface, ResettableInterface
abstract class AbstractAdapter implements AdapterInterface, CacheInterface, LoggerAwareInterface, ResettableInterface
{
use AbstractTrait;
use GetTrait;
private static $apcuSupported;
private static $phpFilesSupported;

View File

@ -13,16 +13,19 @@ namespace Symfony\Component\Cache\Adapter;
use Psr\Cache\CacheItemInterface;
use Psr\Log\LoggerAwareInterface;
use Symfony\Component\Cache\CacheInterface;
use Symfony\Component\Cache\CacheItem;
use Symfony\Component\Cache\ResettableInterface;
use Symfony\Component\Cache\Traits\ArrayTrait;
use Symfony\Component\Cache\Traits\GetTrait;
/**
* @author Nicolas Grekas <p@tchwork.com>
*/
class ArrayAdapter implements AdapterInterface, LoggerAwareInterface, ResettableInterface
class ArrayAdapter implements AdapterInterface, CacheInterface, LoggerAwareInterface, ResettableInterface
{
use ArrayTrait;
use GetTrait;
private $createCacheItem;

View File

@ -13,10 +13,12 @@ namespace Symfony\Component\Cache\Adapter;
use Psr\Cache\CacheItemInterface;
use Psr\Cache\CacheItemPoolInterface;
use Symfony\Component\Cache\CacheInterface;
use Symfony\Component\Cache\CacheItem;
use Symfony\Component\Cache\Exception\InvalidArgumentException;
use Symfony\Component\Cache\PruneableInterface;
use Symfony\Component\Cache\ResettableInterface;
use Symfony\Component\Cache\Traits\GetTrait;
/**
* Chains several adapters together.
@ -26,8 +28,10 @@ use Symfony\Component\Cache\ResettableInterface;
*
* @author Kévin Dunglas <dunglas@gmail.com>
*/
class ChainAdapter implements AdapterInterface, PruneableInterface, ResettableInterface
class ChainAdapter implements AdapterInterface, CacheInterface, PruneableInterface, ResettableInterface
{
use GetTrait;
private $adapters = array();
private $adapterCount;
private $syncItem;
@ -61,6 +65,8 @@ class ChainAdapter implements AdapterInterface, PruneableInterface, ResettableIn
$item->expiry = $sourceItem->expiry;
$item->isHit = $sourceItem->isHit;
$sourceItem->isTaggable = false;
if (0 < $sourceItem->defaultLifetime && $sourceItem->defaultLifetime < $defaultLifetime) {
$defaultLifetime = $sourceItem->defaultLifetime;
}
@ -75,6 +81,33 @@ class ChainAdapter implements AdapterInterface, PruneableInterface, ResettableIn
);
}
/**
* {@inheritdoc}
*/
public function get(string $key, callable $callback)
{
$lastItem = null;
$i = 0;
$wrap = function (CacheItem $item = null) use ($key, $callback, &$wrap, &$i, &$lastItem) {
$adapter = $this->adapters[$i];
if (isset($this->adapters[++$i])) {
$callback = $wrap;
}
if ($adapter instanceof CacheInterface) {
$value = $adapter->get($key, $callback);
} else {
$value = $this->doGet($adapter, $key, $callback);
}
if (null !== $item) {
($this->syncItem)($lastItem = $lastItem ?? $item, $item);
}
return $value;
};
return $wrap();
}
/**
* {@inheritdoc}
*/

View File

@ -12,13 +12,17 @@
namespace Symfony\Component\Cache\Adapter;
use Psr\Cache\CacheItemInterface;
use Symfony\Component\Cache\CacheInterface;
use Symfony\Component\Cache\CacheItem;
use Symfony\Component\Cache\Traits\GetTrait;
/**
* @author Titouan Galopin <galopintitouan@gmail.com>
*/
class NullAdapter implements AdapterInterface
class NullAdapter implements AdapterInterface, CacheInterface
{
use GetTrait;
private $createCacheItem;
public function __construct()

View File

@ -13,10 +13,12 @@ namespace Symfony\Component\Cache\Adapter;
use Psr\Cache\CacheItemInterface;
use Psr\Cache\CacheItemPoolInterface;
use Symfony\Component\Cache\CacheInterface;
use Symfony\Component\Cache\CacheItem;
use Symfony\Component\Cache\Exception\InvalidArgumentException;
use Symfony\Component\Cache\PruneableInterface;
use Symfony\Component\Cache\ResettableInterface;
use Symfony\Component\Cache\Traits\GetTrait;
use Symfony\Component\Cache\Traits\PhpArrayTrait;
/**
@ -26,9 +28,10 @@ use Symfony\Component\Cache\Traits\PhpArrayTrait;
* @author Titouan Galopin <galopintitouan@gmail.com>
* @author Nicolas Grekas <p@tchwork.com>
*/
class PhpArrayAdapter implements AdapterInterface, PruneableInterface, ResettableInterface
class PhpArrayAdapter implements AdapterInterface, CacheInterface, PruneableInterface, ResettableInterface
{
use PhpArrayTrait;
use GetTrait;
private $createCacheItem;
@ -77,6 +80,31 @@ class PhpArrayAdapter implements AdapterInterface, PruneableInterface, Resettabl
return $fallbackPool;
}
/**
* {@inheritdoc}
*/
public function get(string $key, callable $callback)
{
if (null === $this->values) {
$this->initialize();
}
if (null === $value = $this->values[$key] ?? null) {
if ($this->pool instanceof CacheInterface) {
return $this->pool->get($key, $callback);
}
return $this->doGet($this->pool, $key, $callback);
}
if ('N;' === $value) {
return null;
}
if (\is_string($value) && isset($value[2]) && ':' === $value[1]) {
return unserialize($value);
}
return $value;
}
/**
* {@inheritdoc}
*/

View File

@ -13,17 +13,20 @@ namespace Symfony\Component\Cache\Adapter;
use Psr\Cache\CacheItemInterface;
use Psr\Cache\CacheItemPoolInterface;
use Symfony\Component\Cache\CacheInterface;
use Symfony\Component\Cache\CacheItem;
use Symfony\Component\Cache\PruneableInterface;
use Symfony\Component\Cache\ResettableInterface;
use Symfony\Component\Cache\Traits\GetTrait;
use Symfony\Component\Cache\Traits\ProxyTrait;
/**
* @author Nicolas Grekas <p@tchwork.com>
*/
class ProxyAdapter implements AdapterInterface, PruneableInterface, ResettableInterface
class ProxyAdapter implements AdapterInterface, CacheInterface, PruneableInterface, ResettableInterface
{
use ProxyTrait;
use GetTrait;
private $namespace;
private $namespaceLen;
@ -54,6 +57,20 @@ class ProxyAdapter implements AdapterInterface, PruneableInterface, ResettableIn
);
}
/**
* {@inheritdoc}
*/
public function get(string $key, callable $callback)
{
if (!$this->pool instanceof CacheInterface) {
return $this->doGet($this->pool, $key, $callback);
}
return $this->pool->get($this->getId($key), function ($innerItem) use ($key, $callback) {
return $callback(($this->createCacheItem)($key, $innerItem));
});
}
/**
* {@inheritdoc}
*/

View File

@ -16,16 +16,19 @@ use Psr\Cache\InvalidArgumentException;
use Symfony\Component\Cache\CacheItem;
use Symfony\Component\Cache\PruneableInterface;
use Symfony\Component\Cache\ResettableInterface;
use Symfony\Component\Cache\TaggableCacheInterface;
use Symfony\Component\Cache\Traits\GetTrait;
use Symfony\Component\Cache\Traits\ProxyTrait;
/**
* @author Nicolas Grekas <p@tchwork.com>
*/
class TagAwareAdapter implements TagAwareAdapterInterface, PruneableInterface, ResettableInterface
class TagAwareAdapter implements TagAwareAdapterInterface, TaggableCacheInterface, PruneableInterface, ResettableInterface
{
const TAGS_PREFIX = "\0tags\0";
use ProxyTrait;
use GetTrait;
private $deferred = array();
private $createCacheItem;
@ -58,6 +61,7 @@ class TagAwareAdapter implements TagAwareAdapterInterface, PruneableInterface, R
);
$this->setCacheItemTags = \Closure::bind(
function (CacheItem $item, $key, array &$itemTags) {
$item->isTaggable = true;
if (!$item->isHit) {
return $item;
}

View File

@ -12,6 +12,8 @@
namespace Symfony\Component\Cache\Adapter;
use Psr\Cache\CacheItemInterface;
use Symfony\Component\Cache\CacheInterface;
use Symfony\Component\Cache\CacheItem;
use Symfony\Component\Cache\PruneableInterface;
use Symfony\Component\Cache\ResettableInterface;
@ -22,7 +24,7 @@ use Symfony\Component\Cache\ResettableInterface;
* @author Tobias Nyholm <tobias.nyholm@gmail.com>
* @author Nicolas Grekas <p@tchwork.com>
*/
class TraceableAdapter implements AdapterInterface, PruneableInterface, ResettableInterface
class TraceableAdapter implements AdapterInterface, CacheInterface, PruneableInterface, ResettableInterface
{
protected $pool;
private $calls = array();
@ -32,6 +34,38 @@ class TraceableAdapter implements AdapterInterface, PruneableInterface, Resettab
$this->pool = $pool;
}
/**
* {@inheritdoc}
*/
public function get(string $key, callable $callback)
{
if (!$this->pool instanceof CacheInterface) {
throw new \BadMethodCallException(sprintf('Cannot call "%s::get()": this class doesn\'t implement "%s".', get_class($this->pool), CacheInterface::class));
}
$isHit = true;
$callback = function (CacheItem $item) use ($callback, &$isHit) {
$isHit = $item->isHit();
return $callback($item);
};
$event = $this->start(__FUNCTION__);
try {
$value = $this->pool->get($key, $callback);
$event->result[$key] = \is_object($value) ? \get_class($value) : gettype($value);
} finally {
$event->end = microtime(true);
}
if ($isHit) {
++$event->hits;
} else {
++$event->misses;
}
return $value;
}
/**
* {@inheritdoc}
*/

View File

@ -11,10 +11,12 @@
namespace Symfony\Component\Cache\Adapter;
use Symfony\Component\Cache\TaggableCacheInterface;
/**
* @author Robin Chalas <robin.chalas@gmail.com>
*/
class TraceableTagAwareAdapter extends TraceableAdapter implements TagAwareAdapterInterface
class TraceableTagAwareAdapter extends TraceableAdapter implements TaggableCacheInterface, TagAwareAdapterInterface
{
public function __construct(TagAwareAdapterInterface $pool)
{

View File

@ -1,6 +1,12 @@
CHANGELOG
=========
4.2.0
-----
* added `CacheInterface` and `TaggableCacheInterface`
* throw `LogicException` when `CacheItem::tag()` is called on an item coming from a non tag-aware pool
3.4.0
-----

View File

@ -0,0 +1,37 @@
<?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;
use Psr\Cache\CacheItemInterface;
/**
* Gets and stores items from a cache.
*
* On cache misses, a callback is called that should return the missing value.
* It is given two arguments:
* - the missing cache key
* - the corresponding PSR-6 CacheItemInterface object,
* allowing time-based expiration control.
*
* If you need tag-based invalidation, use TaggableCacheInterface instead.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
interface CacheInterface
{
/**
* @param callable(CacheItemInterface):mixed $callback Should return the computed value for the given key/item
*
* @return mixed The value corresponding to the provided key
*/
public function get(string $key, callable $callback);
}

View File

@ -14,6 +14,7 @@ namespace Symfony\Component\Cache;
use Psr\Cache\CacheItemInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Cache\Exception\InvalidArgumentException;
use Symfony\Component\Cache\Exception\LogicException;
/**
* @author Nicolas Grekas <p@tchwork.com>
@ -29,6 +30,7 @@ final class CacheItem implements CacheItemInterface
protected $prevTags = array();
protected $innerItem;
protected $poolHash;
protected $isTaggable = false;
/**
* {@inheritdoc}
@ -109,7 +111,10 @@ final class CacheItem implements CacheItemInterface
*/
public function tag($tags)
{
if (!\is_array($tags)) {
if (!$this->isTaggable) {
throw new LogicException(sprintf('Cache item "%s" comes from a non tag-aware pool: you cannot tag it.', $this->key));
}
if (!\is_iterable($tags)) {
$tags = array($tags);
}
foreach ($tags as $tag) {

View File

@ -121,7 +121,15 @@ class CacheDataCollector extends DataCollector implements LateDataCollectorInter
foreach ($calls as $call) {
++$statistics[$name]['calls'];
$statistics[$name]['time'] += $call->end - $call->start;
if ('getItem' === $call->name) {
if ('get' === $call->name) {
++$statistics[$name]['reads'];
if ($call->hits) {
++$statistics[$name]['hits'];
} else {
++$statistics[$name]['misses'];
++$statistics[$name]['writes'];
}
} elseif ('getItem' === $call->name) {
++$statistics[$name]['reads'];
if ($call->hits) {
++$statistics[$name]['hits'];

View File

@ -0,0 +1,19 @@
<?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\Exception;
use Psr\Cache\InvalidArgumentException as Psr6CacheInterface;
use Psr\SimpleCache\InvalidArgumentException as SimpleCacheInterface;
class LogicException extends \LogicException implements Psr6CacheInterface, SimpleCacheInterface
{
}

View File

@ -0,0 +1,35 @@
<?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;
/**
* Gets and stores items from a tag-aware cache.
*
* On cache misses, a callback is called that should return the missing value.
* It is given two arguments:
* - the missing cache key
* - the corresponding Symfony CacheItem object,
* allowing time-based *and* tags-based expiration control
*
* If you don't need tags-based invalidation, use CacheInterface instead.
*
* @author Nicolas Grekas <p@tchwork.com>
*/
interface TaggableCacheInterface extends CacheInterface
{
/**
* @param callable(CacheItem):mixed $callback Should return the computed value for the given key/item
*
* @return mixed The value corresponding to the provided key
*/
public function get(string $key, callable $callback);
}

View File

@ -13,6 +13,7 @@ namespace Symfony\Component\Cache\Tests\Adapter;
use Cache\IntegrationTests\CachePoolTest;
use Psr\Cache\CacheItemPoolInterface;
use Symfony\Component\Cache\CacheItem;
use Symfony\Component\Cache\PruneableInterface;
abstract class AdapterTestCase extends CachePoolTest
@ -26,6 +27,26 @@ abstract class AdapterTestCase extends CachePoolTest
}
}
public function testGet()
{
if (isset($this->skippedTests[__FUNCTION__])) {
$this->markTestSkipped($this->skippedTests[__FUNCTION__]);
}
$cache = $this->createCachePool();
$value = mt_rand();
$this->assertSame($value, $cache->get('foo', function (CacheItem $item) use ($value) {
$this->assertSame('foo', $item->getKey());
return $value;
}));
$item = $cache->getItem('foo');
$this->assertSame($value, $item->get());
}
public function testDefaultLifeTime()
{
if (isset($this->skippedTests[__FUNCTION__])) {

View File

@ -21,6 +21,7 @@ use Symfony\Component\Cache\Adapter\PhpArrayAdapter;
class PhpArrayAdapterTest extends AdapterTestCase
{
protected $skippedTests = array(
'testGet' => 'PhpArrayAdapter is read-only.',
'testBasicUsage' => 'PhpArrayAdapter is read-only.',
'testBasicUsageWithLongKey' => 'PhpArrayAdapter is read-only.',
'testClear' => 'PhpArrayAdapter is read-only.',

View File

@ -55,6 +55,9 @@ class CacheItemTest extends TestCase
public function testTag()
{
$item = new CacheItem();
$r = new \ReflectionProperty($item, 'isTaggable');
$r->setAccessible(true);
$r->setValue($item, true);
$this->assertSame($item, $item->tag('foo'));
$this->assertSame($item, $item->tag(array('bar', 'baz')));
@ -72,6 +75,24 @@ class CacheItemTest extends TestCase
public function testInvalidTag($tag)
{
$item = new CacheItem();
$r = new \ReflectionProperty($item, 'isTaggable');
$r->setAccessible(true);
$r->setValue($item, true);
$item->tag($tag);
}
/**
* @expectedException \Symfony\Component\Cache\Exception\LogicException
* @expectedExceptionMessage Cache item "foo" comes from a non tag-aware pool: you cannot tag it.
*/
public function testNonTaggableItem()
{
$item = new CacheItem();
$r = new \ReflectionProperty($item, 'key');
$r->setAccessible(true);
$r->setValue($item, 'foo');
$item->tag(array());
}
}

View File

@ -0,0 +1,43 @@
<?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\Traits;
use Psr\Cache\CacheItemPoolInterface;
/**
* @author Nicolas Grekas <p@tchwork.com>
*
* @internal
*/
trait GetTrait
{
/**
* {@inheritdoc}
*/
public function get(string $key, callable $callback)
{
return $this->doGet($this, $key, $callback);
}
private function doGet(CacheItemPoolInterface $pool, string $key, callable $callback)
{
$item = $pool->getItem($key);
if ($item->isHit()) {
return $item->get();
}
$pool->save($item->set($value = $callback($item)));
return $value;
}
}