Add caching for Store API route

Overview

In this guide you'll learn how to add a cache layer to your custom Store API route. In this example, we will add a cache layer for the ExampleRoute, which is created in the Add Store API route guide. For the cache invalidation we will write a invalidation subscriber.

Prerequisites

In order to add a cache layer for the Store API route, you first need a Store API route as base. Therefore, you can refer to the Add Store API route guide.
You also should have a look at our Adding custom complex data guide, since this guide is built upon it.

Add cache layer

As you might have learned already from the Add Store API route guide, we use abstract classes to make our routes more decoratable.
This concept is very advantageous if we now want to include a cache layer for the route. There are of course different ways to do this - but in this guide we show how we implemented it in the core.

Add cached route class

First, we create an abstract class called CachedExampleRoute which extends the AbstractExampleRoute.
CachedExampleRoute
services.xml
<plugin root>/src/Core/Content/Example/SalesChannel/CachedExampleRoute.php
1
<?php declare(strict_types=1);
2
3
namespace Swag\BasicExample\Core\Content\Example\SalesChannel;
4
5
use OpenApi\Annotations as OA;
6
use Psr\Log\LoggerInterface;
7
use Shopware\Core\Framework\Adapter\Cache\CacheStateSubscriber;use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
8
use Shopware\Core\System\SalesChannel\SalesChannelContext;
9
use Shopware\Core\Framework\Adapter\Cache\AbstractCacheTracer;
10
use Shopware\Core\Framework\Adapter\Cache\CacheCompressor;
11
use Shopware\Core\Framework\DataAbstractionLayer\Cache\EntityCacheKeyGenerator;
12
use Shopware\Core\Framework\DataAbstractionLayer\FieldSerializer\JsonFieldSerializer;
13
use Shopware\Core\Framework\Routing\Annotation\Entity;
14
use Shopware\Core\Framework\Routing\Annotation\RouteScope;
15
use Shopware\Core\Framework\Routing\Annotation\Since;
16
use Symfony\Component\Cache\Adapter\TagAwareAdapterInterface;
17
use Symfony\Component\HttpFoundation\Request;
18
use Symfony\Component\Routing\Annotation\Route;
19
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
20
21
/**
22
* @Route(defaults={"_routeScope"={"store-api"}})
23
*/
24
class CachedExampleRoute extends AbstractExampleRoute
25
{
26
private AbstractExampleRoute $decorated;
27
28
private TagAwareAdapterInterface $cache;
29
30
private EntityCacheKeyGenerator $generator;
31
32
private AbstractCacheTracer $tracer;
33
34
private array $states;
35
36
private LoggerInterface $logger;
37
38
public function __construct(
39
AbstractExampleRoute $decorated,
40
TagAwareAdapterInterface $cache,
41
EntityCacheKeyGenerator $generator,
42
AbstractCacheTracer $tracer,
43
LoggerInterface $logger
44
) {
45
$this->decorated = $decorated;
46
$this->cache = $cache;
47
$this->generator = $generator;
48
$this->tracer = $tracer;
49
50
// declares that this route can not be cached if the customer is logged in
51
$this->states = [CacheStateSubscriber::STATE_LOGGED_IN];
52
$this->logger = $logger;
53
}
54
55
public function getDecorated(): AbstractExampleRoute
56
{
57
return $this->decorated;
58
}
59
60
/**
61
* @Entity("swag_example")
62
* @OA\Post(
63
* path="/example",
64
* summary="This route can be used to load the swag_example by specific filters",
65
* operationId="readExample",
66
* tags={"Store API", "Example"},
67
* @OA\Parameter(name="Api-Basic-Parameters"),
68
* @OA\Response(
69
* response="200",
70
* description="",
71
* @OA\JsonContent(type="object",
72
* @OA\Property(
73
* property="total",
74
* type="integer",
75
* description="Total amount"
76
* ),
77
* @OA\Property(
78
* property="aggregations",
79
* type="object",
80
* description="aggregation result"
81
* ),
82
* @OA\Property(
83
* property="elements",
84
* type="array",
85
* @OA\Items(ref="#/components/schemas/swag_example_flat")
86
* )
87
* )
88
* )
89
* )
90
* @Route("/store-api/example", name="store-api.example.search", methods={"GET", "POST"})
91
*/
92
public function load(Criteria $criteria, SalesChannelContext $context): ExampleRouteResponse
93
{
94
// The context is provided with a state where the route cannot be cached
95
if ($context->hasState(...$this->states)) {
96
return $this->getDecorated()->load($criteria, $context);
97
}
98
99
// Fetch item from the cache pool
100
$item = $this->cache->getItem(
101
$this->generateKey($criteria, $context)
102
);
103
104
try {
105
if ($item->isHit() && $item->get()) {
106
// Use cache compressor to uncompress the cache value
107
return CacheCompressor::uncompress($item);
108
}
109
} catch (\Throwable $e) {
110
// Something went wrong when uncompress the cache item - we log the error and continue to overwrite the invalid cache item
111
$this->logger->error($e->getMessage());
112
}
113
114
$name = self::buildName();
115
// start tracing of nested cache tags and system config keys
116
$response = $this->tracer->trace($name, function () use ($criteria, $context) {
117
return $this->getDecorated()->load($criteria, $context);
118
});
119
120
// compress cache content to reduce cache size
121
$item = CacheCompressor::compress($item, $response);
122
123
$item->tag(array_merge(
124
// get traced tags and configs
125
$this->tracer->get(self::buildName()),
126
[self::buildName()]
127
));
128
129
$this->cache->save($item);
130
131
return $response;
132
}
133
134
public static function buildName(): string
135
{
136
return 'example-route';
137
}
138
139
private function generateKey(SalesChannelContext $context, Criteria $criteria): string
140
{
141
$parts = [
142
self::buildName(),
143
// generate a hash for the route criteria
144
$this->generator->getCriteriaHash($criteria),
145
// generate a hash for the current context
146
$this->generator->getSalesChannelContextHash($context),
147
];
148
149
return md5(JsonFieldSerializer::encodeJson($parts));
150
}
151
}
Copied!
<plugin root>/src/Resources/config/services.xml
1
<?xml version="1.0" ?>
2
3
<container xmlns="http://symfony.com/schema/dic/services"
4
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
5
xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
6
7
<services>
8
<service id="Swag\BasicExample\Core\Content\Example\SalesChannel\CachedExampleRoute" decorates="Swag\BasicExample\Core\Content\Example\SalesChannel\ExampleRoute" decoration-priority="-1000">
9
<argument type="service" id="Swag\BasicExample\Core\Content\Example\SalesChannel\CachedExampleRoute.inner"/>
10
<argument type="service" id="cache.object"/>
11
<argument type="service" id="Shopware\Core\Framework\DataAbstractionLayer\Cache\EntityCacheKeyGenerator"/>
12
<argument type="service" id="Shopware\Core\Framework\Adapter\Cache\CacheTracer"/>
13
<argument type="service" id="logger" />
14
</service>
15
</services>
16
</container>
Copied!
In the new CachedExampleRoute some core classes are used which simplify the caching.
  • TagAwareAdapterInterface - Used to read, write and tag cache items.
  • EntityCacheKeyGenerator - Used to generate hashes for the context and/or criteria;
  • AbstractCacheTracer - Traces all system config keys that were accessed. The data is needed later for cache invalidation.
  • CacheCompressor - Provides an optimal compression of the cache entries to use as little disk space as possible.

Add cache invalidation

Cache invalidation is much harder to implement than the actual caching. Finding the right balance between too much and too little invalidation is difficult. Therefore, there is no precise guidance or documentation on when to invalidate what. What and how to invalidate depends on what has been cached. For example, the product routes in the core are always invalidated when the product is written, but also when the product is ordered and reaches the out-of-stock status. The entire cache invalidation in Shopware is controlled via events. On the one hand there is the entity written event and on the other hand the corresponding business events like ProductNoLongerAvailableEvent.
CacheInvalidationSubscriber.php
services.xml
<plugin root>/src/Core/Content/Example/SalesChannel/CacheInvalidationSubscriber.php
1
<?php declare(strict_types=1);
2
3
namespace Swag\BasicExample\Core\Content\Example\SalesChannel;
4
5
use Shopware\Core\Framework\DataAbstractionLayer\Event\EntityWrittenContainerEvent;
6
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
7
use Swag\BasicExample\Core\Content\Example\ExampleDefinition;
8
use Shopware\Core\Framework\Adapter\Cache\CacheInvalidator;
9
10
class CacheInvalidationSubscriber implements EventSubscriberInterface
11
{
12
private CacheInvalidator $cacheInvalidator;
13
14
public function __construct(CacheInvalidator $cacheInvalidator)
15
{
16
$this->cacheInvalidator = $cacheInvalidator;
17
}
18
19
public static function getSubscribedEvents()
20
{
21
return [
22
// The EntityWrittenContainerEvent is a generic event that is always thrown when an entities are written. This contains all changed entities
23
EntityWrittenContainerEvent::class => [
24
['invalidate', 2001]
25
],
26
];
27
}
28
29
public function invalidate(EntityWrittenContainerEvent $event): void
30
{
31
// check if own entity written. In some cases you want to use the primary keys for further cache invalidation
32
$changes = $event->getPrimaryKeys(ExampleDefinition::ENTITY_NAME);
33
34
// no example entity changed? Then the cache does not need to be invalidated
35
if (empty($changes)) {
36
return;
37
}
38
39
$this->cacheInvalidator->invalidate([
40
CachedExampleRoute::buildName()
41
]);
42
}
43
}
Copied!
<plugin root>/src/Resources/config/services.xml
1
<?xml version="1.0" ?>
2
3
<container xmlns="http://symfony.com/schema/dic/services"
4
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
5
xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
6
7
<services>
8
<service id=" Swag\BasicExample\Core\Content\Example\SalesChannel\CacheInvalidationSubscriber">
9
<argument type="service" id="Shopware\Core\Framework\Adapter\Cache\CacheInvalidator"/>
10
</service>
11
</services>
12
</container>
Copied!