| Total Complexity | 41 |
| Total Lines | 282 |
| Duplicated Lines | 0 % |
| Changes | 0 | ||
Complex classes like CacheResponseSubscriber often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.
Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.
While breaking up the class, it is a good idea to analyze how other classes use CacheResponseSubscriber, and based on these observations, apply Extract Interface, too.
| 1 | <?php declare(strict_types=1); |
||
| 26 | #[Package('storefront')] |
||
| 27 | class CacheResponseSubscriber implements EventSubscriberInterface |
||
| 28 | { |
||
| 29 | final public const STATE_LOGGED_IN = CacheStateSubscriber::STATE_LOGGED_IN; |
||
| 30 | final public const STATE_CART_FILLED = CacheStateSubscriber::STATE_CART_FILLED; |
||
| 31 | |||
| 32 | final public const CURRENCY_COOKIE = 'sw-currency'; |
||
| 33 | final public const CONTEXT_CACHE_COOKIE = 'sw-cache-hash'; |
||
| 34 | final public const SYSTEM_STATE_COOKIE = 'sw-states'; |
||
| 35 | final public const INVALIDATION_STATES_HEADER = 'sw-invalidation-states'; |
||
| 36 | |||
| 37 | private const CORE_HTTP_CACHED_ROUTES = [ |
||
| 38 | 'api.acl.privileges.get', |
||
| 39 | ]; |
||
| 40 | |||
| 41 | /** |
||
| 42 | * @internal |
||
| 43 | */ |
||
| 44 | public function __construct( |
||
| 45 | private readonly CartService $cartService, |
||
| 46 | private readonly int $defaultTtl, |
||
| 47 | private readonly bool $httpCacheEnabled, |
||
| 48 | private readonly MaintenanceModeResolver $maintenanceResolver, |
||
| 49 | private readonly bool $reverseProxyEnabled, |
||
| 50 | private readonly ?string $staleWhileRevalidate, |
||
| 51 | private readonly ?string $staleIfError |
||
| 52 | ) { |
||
| 53 | } |
||
| 54 | |||
| 55 | /** |
||
| 56 | * @return array<string, string|array{0: string, 1: int}|list<array{0: string, 1?: int}>> |
||
|
|
|||
| 57 | */ |
||
| 58 | public static function getSubscribedEvents(): array |
||
| 59 | { |
||
| 60 | return [ |
||
| 61 | KernelEvents::REQUEST => 'addHttpCacheToCoreRoutes', |
||
| 62 | KernelEvents::RESPONSE => [ |
||
| 63 | ['setResponseCache', -1500], |
||
| 64 | ['setResponseCacheHeader', 1500], |
||
| 65 | ], |
||
| 66 | BeforeSendResponseEvent::class => 'updateCacheControlForBrowser', |
||
| 67 | ]; |
||
| 68 | } |
||
| 69 | |||
| 70 | public function addHttpCacheToCoreRoutes(RequestEvent $event): void |
||
| 71 | { |
||
| 72 | $request = $event->getRequest(); |
||
| 73 | $route = $request->attributes->get('_route'); |
||
| 74 | |||
| 75 | if (\in_array($route, self::CORE_HTTP_CACHED_ROUTES, true)) { |
||
| 76 | $request->attributes->set(PlatformRequest::ATTRIBUTE_HTTP_CACHE, true); |
||
| 77 | } |
||
| 78 | } |
||
| 79 | |||
| 80 | public function setResponseCache(ResponseEvent $event): void |
||
| 81 | { |
||
| 82 | if (!$this->httpCacheEnabled) { |
||
| 83 | return; |
||
| 84 | } |
||
| 85 | |||
| 86 | $response = $event->getResponse(); |
||
| 87 | |||
| 88 | $request = $event->getRequest(); |
||
| 89 | |||
| 90 | $context = $request->attributes->get(PlatformRequest::ATTRIBUTE_SALES_CHANNEL_CONTEXT_OBJECT); |
||
| 91 | |||
| 92 | if (!$context instanceof SalesChannelContext) { |
||
| 93 | return; |
||
| 94 | } |
||
| 95 | |||
| 96 | if (!$this->maintenanceResolver->shouldBeCached($request)) { |
||
| 97 | return; |
||
| 98 | } |
||
| 99 | |||
| 100 | $route = $request->attributes->get('_route'); |
||
| 101 | if ($route === 'frontend.checkout.configure') { |
||
| 102 | $this->setCurrencyCookie($request, $response); |
||
| 103 | } |
||
| 104 | |||
| 105 | $cart = $this->cartService->getCart($context->getToken(), $context); |
||
| 106 | |||
| 107 | $states = $this->updateSystemState($cart, $context, $request, $response); |
||
| 108 | |||
| 109 | // We need to allow it on login, otherwise the state is wrong |
||
| 110 | if (!($route === 'frontend.account.login' || $request->getMethod() === Request::METHOD_GET)) { |
||
| 111 | return; |
||
| 112 | } |
||
| 113 | |||
| 114 | if ($context->getCustomer() || $cart->getLineItems()->count() > 0) { |
||
| 115 | $newValue = $this->buildCacheHash($context); |
||
| 116 | |||
| 117 | if ($request->cookies->get(self::CONTEXT_CACHE_COOKIE, '') !== $newValue) { |
||
| 118 | $cookie = Cookie::create(self::CONTEXT_CACHE_COOKIE, $newValue); |
||
| 119 | $cookie->setSecureDefault($request->isSecure()); |
||
| 120 | |||
| 121 | $response->headers->setCookie($cookie); |
||
| 122 | } |
||
| 123 | } elseif ($request->cookies->has(self::CONTEXT_CACHE_COOKIE)) { |
||
| 124 | $response->headers->removeCookie(self::CONTEXT_CACHE_COOKIE); |
||
| 125 | $response->headers->clearCookie(self::CONTEXT_CACHE_COOKIE); |
||
| 126 | } |
||
| 127 | |||
| 128 | /** @var bool|array{maxAge?: int, states?: list<string>}|null $cache */ |
||
| 129 | $cache = $request->attributes->get(PlatformRequest::ATTRIBUTE_HTTP_CACHE); |
||
| 130 | if (!$cache) { |
||
| 131 | return; |
||
| 132 | } |
||
| 133 | |||
| 134 | if ($cache === true) { |
||
| 135 | $cache = []; |
||
| 136 | } |
||
| 137 | |||
| 138 | if ($this->hasInvalidationState($cache['states'] ?? [], $states)) { |
||
| 139 | return; |
||
| 140 | } |
||
| 141 | |||
| 142 | $maxAge = $cache['maxAge'] ?? $this->defaultTtl; |
||
| 143 | |||
| 144 | $response->setSharedMaxAge($maxAge); |
||
| 145 | $response->headers->addCacheControlDirective('must-revalidate'); |
||
| 146 | $response->headers->set( |
||
| 147 | self::INVALIDATION_STATES_HEADER, |
||
| 148 | implode(',', $cache['states'] ?? []) |
||
| 149 | ); |
||
| 150 | |||
| 151 | if ($this->staleIfError !== null) { |
||
| 152 | $response->headers->addCacheControlDirective('stale-if-error', $this->staleIfError); |
||
| 153 | } |
||
| 154 | |||
| 155 | if ($this->staleWhileRevalidate !== null) { |
||
| 156 | $response->headers->addCacheControlDirective('stale-while-revalidate', $this->staleWhileRevalidate); |
||
| 157 | } |
||
| 158 | } |
||
| 159 | |||
| 160 | public function setResponseCacheHeader(ResponseEvent $event): void |
||
| 161 | { |
||
| 162 | if (!$this->httpCacheEnabled) { |
||
| 163 | return; |
||
| 164 | } |
||
| 165 | |||
| 166 | $response = $event->getResponse(); |
||
| 167 | |||
| 168 | $request = $event->getRequest(); |
||
| 169 | |||
| 170 | /** @var bool|array{maxAge?: int, states?: list<string>}|null $cache */ |
||
| 171 | $cache = $request->attributes->get(PlatformRequest::ATTRIBUTE_HTTP_CACHE); |
||
| 172 | if (!$cache) { |
||
| 173 | return; |
||
| 174 | } |
||
| 175 | |||
| 176 | $response->headers->set(AbstractSessionListener::NO_AUTO_CACHE_CONTROL_HEADER, '1'); |
||
| 177 | } |
||
| 178 | |||
| 179 | /** |
||
| 180 | * In the default HttpCache implementation the reverse proxy cache is implemented too in PHP and triggered before the response is send to the client. We don't need to send the "real" cache-control headers to the end client (browser/cloudflare). |
||
| 181 | * If a external reverse proxy cache is used we still need to provide the actual cache-control, so the external system can cache the system correctly and set the cache-control again to |
||
| 182 | */ |
||
| 183 | public function updateCacheControlForBrowser(BeforeSendResponseEvent $event): void |
||
| 201 | } |
||
| 202 | } |
||
| 203 | |||
| 204 | /** |
||
| 205 | * @param list<string> $cacheStates |
||
| 206 | * @param list<string> $states |
||
| 207 | */ |
||
| 208 | private function hasInvalidationState(array $cacheStates, array $states): bool |
||
| 209 | { |
||
| 210 | foreach ($states as $state) { |
||
| 211 | if (\in_array($state, $cacheStates, true)) { |
||
| 212 | return true; |
||
| 213 | } |
||
| 214 | } |
||
| 215 | |||
| 216 | return false; |
||
| 217 | } |
||
| 218 | |||
| 219 | private function buildCacheHash(SalesChannelContext $context): string |
||
| 220 | { |
||
| 221 | return md5(json_encode([ |
||
| 222 | $context->getRuleIds(), |
||
| 223 | $context->getContext()->getVersionId(), |
||
| 224 | $context->getCurrency()->getId(), |
||
| 225 | $context->getCustomer() ? 'logged-in' : 'not-logged-in', |
||
| 226 | ], \JSON_THROW_ON_ERROR)); |
||
| 227 | } |
||
| 228 | |||
| 229 | /** |
||
| 230 | * System states can be used to stop caching routes at certain states. For example, |
||
| 231 | * the checkout routes are no longer cached if the customer has products in the cart or is logged in. |
||
| 232 | * |
||
| 233 | * @return list<string> |
||
| 234 | */ |
||
| 235 | private function updateSystemState(Cart $cart, SalesChannelContext $context, Request $request, Response $response): array |
||
| 236 | { |
||
| 237 | $states = $this->getSystemStates($request, $context, $cart); |
||
| 238 | |||
| 239 | if (empty($states)) { |
||
| 240 | if ($request->cookies->has(self::SYSTEM_STATE_COOKIE)) { |
||
| 241 | $response->headers->removeCookie(self::SYSTEM_STATE_COOKIE); |
||
| 242 | $response->headers->clearCookie(self::SYSTEM_STATE_COOKIE); |
||
| 243 | } |
||
| 244 | |||
| 245 | return []; |
||
| 246 | } |
||
| 247 | |||
| 248 | $newStates = implode(',', $states); |
||
| 249 | |||
| 250 | if ($request->cookies->get(self::SYSTEM_STATE_COOKIE) !== $newStates) { |
||
| 251 | $cookie = Cookie::create(self::SYSTEM_STATE_COOKIE, $newStates); |
||
| 252 | $cookie->setSecureDefault($request->isSecure()); |
||
| 253 | |||
| 254 | $response->headers->setCookie($cookie); |
||
| 255 | } |
||
| 256 | |||
| 257 | return $states; |
||
| 258 | } |
||
| 259 | |||
| 260 | /** |
||
| 261 | * @return list<string> |
||
| 262 | */ |
||
| 263 | private function getSystemStates(Request $request, SalesChannelContext $context, Cart $cart): array |
||
| 264 | { |
||
| 265 | $states = []; |
||
| 266 | $swStates = (string) $request->cookies->get(self::SYSTEM_STATE_COOKIE); |
||
| 267 | if ($swStates !== '') { |
||
| 268 | $states = array_flip(explode(',', $swStates)); |
||
| 269 | } |
||
| 270 | |||
| 271 | $states = $this->switchState($states, self::STATE_LOGGED_IN, $context->getCustomer() !== null); |
||
| 272 | |||
| 273 | $states = $this->switchState($states, self::STATE_CART_FILLED, $cart->getLineItems()->count() > 0); |
||
| 274 | |||
| 275 | return array_keys($states); |
||
| 276 | } |
||
| 277 | |||
| 278 | /** |
||
| 279 | * @param array<string, int|bool> $states |
||
| 280 | * |
||
| 281 | * @return array<string, int|bool> |
||
| 282 | */ |
||
| 283 | private function switchState(array $states, string $key, bool $match): array |
||
| 284 | { |
||
| 285 | if ($match) { |
||
| 286 | $states[$key] = true; |
||
| 287 | |||
| 288 | return $states; |
||
| 289 | } |
||
| 290 | |||
| 291 | unset($states[$key]); |
||
| 292 | |||
| 293 | return $states; |
||
| 294 | } |
||
| 295 | |||
| 296 | private function setCurrencyCookie(Request $request, Response $response): void |
||
| 308 | } |
||
| 309 | } |
||
| 310 |