Skip to content

Added support for gzipping the cached files #47

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: 4.x
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,12 @@ passing an array of `$options` in the constructor:

**Type**: `boolean`
**Default**: `true`

* **gzip_level**: The gzip level to reduce the required cache storage. Use `0` to
deactivate gzipping.

**Type**: `int`
**Default**: `6`

### Generating Content Digests

Expand Down
149 changes: 125 additions & 24 deletions src/Psr6Store.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

namespace Toflar\Psr6HttpCacheStore;

use Psr\Cache\CacheItemInterface;
use Psr\Cache\InvalidArgumentException as CacheInvalidArgumentException;
use Symfony\Component\Cache\Adapter\AdapterInterface;
use Symfony\Component\Cache\Adapter\FilesystemTagAwareAdapter;
Expand Down Expand Up @@ -79,6 +80,16 @@ public function __construct(array $options = [])
$resolver->setDefault('generate_content_digests', true)
->setAllowedTypes('generate_content_digests', 'boolean');

$resolver->setDefault('gzip_level', 6)
->setAllowedTypes('gzip_level', 'int')
->setNormalizer('gzip_level', function (Options $options, int $value): int {
if ($value < 0 || $value > 9) {
throw new \InvalidArgumentException('The gzip_level has to be between 0 (disabled) and 9.');
}

return $value;
});

$resolver->setDefault('cache', function (Options $options) {
if (!isset($options['cache_directory'])) {
throw new MissingOptionsException('The cache_directory option is required unless you set the cache explicitly');
Expand Down Expand Up @@ -119,7 +130,7 @@ public function lookup(Request $request): ?Response
foreach ($entries as $varyKeyResponse => $responseData) {
// This can only happen if one entry only
if (self::NON_VARYING_KEY === $varyKeyResponse) {
return $this->restoreResponse($responseData);
return $this->restoreResponse($request, $responseData);
}

// Otherwise we have to see if Vary headers match
Expand All @@ -129,7 +140,7 @@ public function lookup(Request $request): ?Response
);

if ($varyKeyRequest === $varyKeyResponse) {
return $this->restoreResponse($responseData);
return $this->restoreResponse($request, $responseData);
}
}

Expand All @@ -146,8 +157,6 @@ public function write(Request $request, Response $response): string
$this->saveContentDigest($response);

$cacheKey = $this->getCacheKey($request);
$headers = $response->headers->all();
unset($headers['age']);

/** @var CacheItem $item */
$item = $this->cache->getItem($cacheKey);
Expand All @@ -162,16 +171,19 @@ public function write(Request $request, Response $response): string
$varyKey = $this->getVaryKey($response->getVary(), $request);
$entries[$varyKey] = [
'vary' => $response->getVary(),
'headers' => $headers,
'status' => $response->getStatusCode(),
'uri' => $request->getUri(), // For debugging purposes
];

// Add content if content digests are disabled
if (!$this->options['generate_content_digests']) {
$this->gzipResponse($response);
$entries[$varyKey]['content'] = $response->getContent();
}

// Set headers (after potentially gzipping the response)
$entries[$varyKey]['headers'] = $this->getHeadersForCache($response);

// If the response has a Vary header we remove the non-varying entry
if ($response->hasVary()) {
unset($entries[self::NON_VARYING_KEY]);
Expand All @@ -196,6 +208,14 @@ public function write(Request $request, Response $response): string
return $cacheKey;
}

private function getHeadersForCache(Response $response): array
{
$headers = $response->headers->all();
unset($headers['age']);

return $headers;
}

public function invalidate(Request $request): void
{
$cacheKey = $this->getCacheKey($request);
Expand Down Expand Up @@ -375,6 +395,26 @@ private function getVaryKey(array $vary, Request $request): string
return hash('sha256', $hashData);
}

private function isResponseGzipped(Response $response): bool
{
return $response->headers->get('Content-Encoding') === 'gzip';
}

private function doesRequestSupportGzip(Request $request): bool
{
return \in_array('gzip', $request->getEncodings());
}

private function isGzipSupported(): bool
{
return $this->options['gzip_level'] !== 0 && function_exists('gzencode') && function_exists('gzdecode');
}

private function isCacheGzipped(array $headers): bool
{
return isset($headers['content-encoding'][0]) && $headers['content-encoding'][0] === 'gzip';
}

private function saveContentDigest(Response $response): void
{
if ($response->headers->has('X-Content-Digest')) {
Expand All @@ -391,20 +431,17 @@ private function saveContentDigest(Response $response): void

if ($digestCacheItem->isHit()) {
$cacheValue = $digestCacheItem->get();

// BC
if (\is_string($cacheValue)) {
$cacheValue = [
'expires' => 0, // Forces update to the new format
'contents' => $cacheValue,
];
}
} else {
if ($this->isBinaryFileResponseContentDigest($contentDigest)) {
$contents = $response->getFile()->getPathname();
} else {
$this->gzipResponse($response);
$contents = $response->getContent();
}

$cacheValue = [
'expires' => 0, // Forces storing the new entry
'contents' => $this->isBinaryFileResponseContentDigest($contentDigest) ?
$response->getFile()->getPathname() :
$response->getContent(),
'contents' => $contents
];
}

Expand All @@ -427,6 +464,29 @@ private function saveContentDigest(Response $response): void
}
}

private function gzipResponse(Response $response): void
{
// Do not gzip if already encoded or not supported
if ($response->headers->has('Content-Encoding') ||
$response instanceof BinaryFileResponse ||
!$this->isGzipSupported() ||
$this->isResponseGzipped($response)
) {
return;
}

$encoded = gzencode((string) $response->getContent(), $this->options['gzip_level']);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we add a check here for the case that ->getContent() returns false like the streamed response does for example? https://github.com/symfony/symfony/blob/fd530913d494e2b4e9a624ba6350371d8e801ca3/src/Symfony/Component/HttpFoundation/StreamedResponse.php#L129


// Could not gzip
if (false === $encoded) {
return;
}

// Update the content and set the encoding header
$response->setContent($encoded);
$response->headers->set('Content-Encoding', 'gzip');
}

/**
* Test whether a given digest identifies a BinaryFileResponse.
*
Expand Down Expand Up @@ -481,13 +541,14 @@ private function saveDeferred(CacheItem $item, $data, ?int $expiresAfter = null,
*
* @param array $cacheData An array containing the cache data
*/
private function restoreResponse(array $cacheData): ?Response
private function restoreResponse(Request $request, array $cacheData): ?Response
{
// Check for content digest header
if (!isset($cacheData['headers']['x-content-digest'][0])) {
// No digest was generated but the content was stored inline
if (isset($cacheData['content'])) {
return new Response(
return $this->buildResponseFromCache(
$request,
$cacheData['content'],
$cacheData['status'],
$cacheData['headers']
Expand All @@ -506,11 +567,6 @@ private function restoreResponse(array $cacheData): ?Response

$value = $item->get();

// BC
if (\is_string($value)) {
$value = ['contents' => $value];
}

if ($this->isBinaryFileResponseContentDigest($cacheData['headers']['x-content-digest'][0])) {
try {
$file = new File($value['contents']);
Expand All @@ -525,13 +581,58 @@ private function restoreResponse(array $cacheData): ?Response
);
}

return new Response(
return $this->buildResponseFromCache(
$request,
$value['contents'],
$cacheData['status'],
$cacheData['headers']
);
}

private function buildResponseFromCache(Request $request, string $contents, int $status, array $headers): ?Response
{
// If the cache entry is not gzipped we return the file as is.
if (!$this->isCacheGzipped($headers)) {
return new Response(
$contents,
$status,
$headers
);
}

// Otherwise it was gzipped. Let's check if the client supports gzip, in which case we'll also return as is for
// the client to decode
if ($this->doesRequestSupportGzip($request)) {
return new Response(
$contents,
$status,
$headers
);
}

// Otherwise we now have to decode which we can only do if our setup supports it
if ($this->isGzipSupported()) {
$decoded = gzdecode($contents);

if (false === $decoded) {
return null;
}

// Unset the encoding header because it is now not encoded anymore
unset($headers['content-encoding']);

return new Response(
$decoded,
$status,
$headers
);
}

// Cache file was encoded (previously gzipping was supported and now the setup has changed but the cached entries
// are still here) but could not be decoded anymore here - we're unable to serve a response now.
return null;
}

/**
* Build and return a default lock factory for when no explicit factory
* was specified.
Expand Down
83 changes: 83 additions & 0 deletions tests/Psr6StoreTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
use Symfony\Component\Lock\Exception\LockReleasingException;
use Symfony\Component\Lock\LockFactory;
use Symfony\Component\Lock\SharedLockInterface;
use Symfony\Component\Lock\Store\InMemoryStore;
use Symfony\Component\OptionsResolver\Exception\MissingOptionsException;

class Psr6StoreTest extends TestCase
Expand Down Expand Up @@ -58,6 +59,16 @@ public function testCustomCacheWithoutLockFactory(): void
]);
}

public function testWrongGzipLevel(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('The gzip_level has to be between 0 (disabled) and 9.');

new Psr6Store([
'gzip_level' => 20
]);
}

public function testCustomCacheAndLockFactory(): void
{
$cache = $this->createMock(TagAwareAdapterInterface::class);
Expand Down Expand Up @@ -631,6 +642,77 @@ public function testClear(): void
$this->assertFalse($cacheItem2->isHit());
}

public function testGzipHandling(): void
{
$cache = new ArrayAdapter();
$lockFactory = new LockFactory(new InMemoryStore());
$store = new Psr6Store([
'cache' => $cache,
'lock_factory' => $lockFactory,
'generate_content_digests' => false,
'gzip_level' => 4
]);

$regularRequest = Request::create('https://foobar.com/');
$gzipSupportingRequest = Request::create('https://foobar.com/');
$gzipSupportingRequest->headers->set('Accept-Encoding', 'gzip, deflate, br');

$response = new Response('hello world', 200, ['Cache-Control' => 's-maxage=600, public']);
$store->write($regularRequest, $response);

$cacheKey = $store->getCacheKey($regularRequest);
$cacheItem = $cache->getItem($cacheKey);
$this->assertTrue($cacheItem->isHit());

// Should be gzip encoded on level 4
$this->assertSame(gzencode('hello world', 4), $cacheItem->get()['non-varying']['content']);

// Content should be decoded if we don't support gzip
$response = $store->lookup(Request::create('https://foobar.com/'));
$this->assertSame('hello world', $response->getContent());
$this->assertFalse($response->headers->has('Content-Encoding'));

// Content should be gzip encoded if we support gzip
$response = $store->lookup($gzipSupportingRequest);
$this->assertSame(gzencode('hello world', 4), $response->getContent());
$this->assertSame('gzip', $response->headers->get('Content-Encoding'));

// Gzipped cache file still exists but for some reason, gzip features are not available on the system anymore
// so we cannot decode it anymore - in this case, lookup should work for gzip supporting request but fail for
// the regular request as decoding doesn't work.
$store = new Psr6Store([
'cache' => $cache,
'lock_factory' => $lockFactory,
'generate_content_digests' => false,
'gzip_level' => 0 // Same as not having gzip features available
]);
$this->assertInstanceOf(Response::class, $store->lookup($gzipSupportingRequest));
$this->assertNull($store->lookup($regularRequest));
}

public function testIgnoresGzipIfResponseIsAlreadyEncoded(): void
{
$cache = new ArrayAdapter();
$lockFactory = new LockFactory(new InMemoryStore());
$store = new Psr6Store([
'cache' => $cache,
'lock_factory' => $lockFactory,
'generate_content_digests' => false,
'gzip_level' => 6
]);

$request = Request::create('https://foobar.com/');
$response = new Response('CwWAaGVsbG8gd29ybGQD', 200, ['Cache-Control' => 's-maxage=600, public', 'Content-Encoding' => 'br']);
$store->write($request, $response);

$cacheKey = $store->getCacheKey($request);
$cacheItem = $cache->getItem($cacheKey);
$this->assertTrue($cacheItem->isHit());

// Should be untouched as it is already brotli encoded
$this->assertSame('CwWAaGVsbG8gd29ybGQD', $cacheItem->get()['non-varying']['content']);
}

public function testPruneIgnoredIfCacheBackendDoesNotImplementPrunableInterface(): void
{
$cache = $this->getMockBuilder(RedisAdapter::class)
Expand Down Expand Up @@ -886,6 +968,7 @@ public function testContentDigestExpiresCorrectly(array $responseHeaders, $expec
$store = new Psr6Store([
'cache' => $cache,
'lock_factory' => $this->createMock(LockFactory::class),
'gzip_level' => 0,
]);

$response = new Response('foobar', 200, $responseHeaders);
Expand Down