Symfony & PSR-16 Simple Cache

This week I was working on the integration with the Optimizely Feature Toggles.

Its PHP SDK is mediocre.

It makes the HTTP request for the data file whenever the new PHP process is executed.

The data file is in our case larger than 2 MB, so it has to download a large file, but also validate it and parse it.

That makes around 700 ms overhead on each process.

Therefore I needed a cache mechanism.

But, as we try to write the software in a framework-agnostic way, I wanted to use the industry standard interface – the PSR-16 Simple Cache, to not be coupled to a specific cache implementation.

Finally, I did not want to implement it by myself, nor did I require a library, as we are already using the Symfony framework, which comes bundled with the Symfony Cache component.

Consequently, I decided to just wire the things together.

In this quick post, I just wanna share with you how I did that, as it was not an easy-peasy task 🙂

First, I wrote a decorator class for the Optimizely client, which requires the PSR-16 Simple Cache in the constructor:

<?php declare(strict_types=1);

namespace ddziaduch\example\SharedKernel\FeatureToggle\Framework\Optimizely;

use Psr\SimpleCache\CacheInterface;

final readonly class CacheClientDecorator implement Client
{
    public function __construct(
        private Client $client,
        private CacheInterface $cache,
        private \DateInterval $ttl,
    ) {}

    public function isFeatureToggleEnabled(string $featureName, string $userId): bool
    {
        $key = sprintf('%s_%s', $featureName, $userId);
        
        $valueFromCache = $this->cache->get($key, default: null);
        if (is_bool($valueFromCache)) {
            return $valueFromCache;
        }

        $valueFromClient = $this->client->isFeatureToggleEnabled($featureName, $userId);
        $this->cache->set($key, $valueFromClient, $ttl);

        return $valueFromClient;
    }
}

Pretty straightforward.

What I wanna do now is to wire this CacheInterface with the cache adapter from Symfony. In this case, I would like to utilize the Redis as the cache. Bear in mind that I am using the PHP config and I do not use auto-wire or auto-configure. Here is our config file:

<?php declare(strict_types=1);

use ddziaduch\example\SharedKernel\FeatureToggle\Framework\Optimizely\CacheClientDecorator;
use ddziaduch\example\SharedKernel\FeatureToggle\Framework\Optimizely\Client;
use ddziaduch\example\SharedKernel\FeatureToggle\Framework\Optimizely\ClientAdapter;
use Psr\SimpleCache\CacheInterface;
use Symfony\Component\Cache\Adapter\RedisAdapter;
use Symfony\Component\Cache\Psr16Cache;

return static function (ContainerConfigurator $configurator): void {
    $services = $configurator->services();

    $services->defaults()
        ->autowire(false)
        ->autoconfigure(false);

    $services->set(CacheInterface::class, Psr16Cache::class)->args([
        inline_service(RedisAdapter::class)->args([
            inline_service()->factory([RedisAdapter::class, 'createConnection'])->args([
                env('REDIS_DSN'),
                ['lazy' => true],
            ]),
        ]),
    ])->public();

    $services->set(Client::class, CacheClientDecorator::class)->args([
        inline_service(ClientAdapter::class)->args([
            env('OPTIMIZELY_SDK_KEY'),
        ]),
        service(CacheInterface::class),
        inline_service(DateInterval::class)->args(['PT15M']),
    ]);
};

What is going on here?

I set up Symfony’s PSR16Cache as the default implementation of the PSR-16 cache interface. The PSR16Cache is an adapter that adapts the PSR-6 cache to the much simpler PSR-16 cache.

The PSR16Cache works with multiple cache interfaces such as Redis, Memcached, in-memory, file, etc.

For each interface, there is a separate adapter that can be passed to the instance of PSR16Cache.

In this case, I need to use the Redis, therefore I use the RedisAdapter class.

The RedisAdapter class requires an instance of the Redis class to be passed on.

But, I do not wanna create it on the fly, as this will make a connection to the Redis right away.

What I needed was an on-demand connection, hence I used the static factory method from the RedisAdapter.

This factory method returns a lazy proxy class that calls the Redis only when needed.

Now it is the time to confirm whether it works 🙂 Time for an integration test that will use real cache:

<?php

declare(strict_types=1);

namespace ddziaduch\example\Tests\SharedKernel\Integration\FeatureToggle\Framework\Optimizely;

use DateInterval;
use ddziaduch\example\SharedKernel\FeatureToggle\Framework\Optimizely\CacheClientDecorator;
use ddziaduch\example\SharedKernel\FeatureToggle\Framework\Optimizely\Client;
use ddziaduch\example\Tests\IntegrationTestCase;
use Psr\SimpleCache\CacheInterface;

/** @covers \ddziaduch\example\SharedKernel\FeatureToggle\Framework\Optimizely\CacheClientDecorator */
final class CachedClientTest extends IntegrationTestCase
{
    public function testIsFeatureEnabled(): void
    {
        $client = $this->createMock(Client::class);
        $client->expects(self::once())->method('isFeatureEnabled')->willReturn(true);

        $decorator = new CacheClientDecorator(
            $client,
            self::getContainer()->get(CacheInterface::class),
            new DateInterval('PT1M'),
        );

        self::assertTrue($decorator->isFeatureEnabled('fake_key', 'fake_user_id'));
        self::assertTrue($decorator->isFeatureEnabled('fake_key', 'fake_user_id'));
        self::assertTrue($decorator->isFeatureEnabled('fake_key', 'fake_user_id'));
    }
}

Quite a complex solution, you might think, and that is true. But with the power of the Symfony components I achieved the results in just a few lines of the configuration code.