no-framework-tutorial/18-caching.md
2022-05-31 18:50:35 +02:00

9.8 KiB

<< previous | next >>

DISClAIMER I do not really have a lot of experience when it comes to caching, so this chapter is mostly some random thoughts and ideas I wanted to explore when writing this tutorial, you should definitely take everything that is being said here with caution and try to read up on some other sources. But that holds true for the whole tutorial anyway :)

Caching

In the last chapter we greatly improved the perfomance for the lookup of all our classfiles, but currently we do not have any real bottlenecks in our application like complex queries.

But in a real application we are going to execute some really heavy and time intensive database queries that can take quite a while to be completed.

We can simulate that by adding a simple delay in our FileSystemMarkdownPageRepo.

 return array_map(function (string $filename) {
        usleep(rand(100, 400) * 1000);
        $content = file_get_contents($filename);
        if ($content === false) {
            throw new InternalServerError('cannot read pages');
        }
        $idAndTitle = str_replace([$this->dataPath, '.md'], ['', ''], $filename);
        return new MarkdownPage(
            (int) substr($idAndTitle, 0, 2),
            substr($idAndTitle, 3),
            $content
        );
});

Here I added a function that pauses the scripts execution for a random time between 100 and 400ms for every markdownpage in every call of the all() method.

If you open any page or even the listAction in you browser you will see, that it takes quite a time to render that page. Although this is a silly example we do not really need to query the database on every request, so lets add a way to cache the database results between requests.

The PHP-Community has already adressed the issue of having easy to use access to cache libraries, there is the PSR-6 Caching Interface which gives us easy access to many different implementations, then there is also a much simpler PSR-16 Simple Cache which makes the use even more easy, and most Caching Libraries implement Both interfaces anyway. You would think that this is more than enough solutions to satisfy all the Caching needs around, but the Symfony People decided that Caching should be even simpler and easier to use and defined their own Interface which only needs two methods. You should definitely take a look at the linked documentation as it really blew my mind when I first encountered it.

The basic idea is that you provide a callback that computes the requested value. The Cache implementation then checks if it already has the value stored somewhere and if it doesnt it just executes the callback and stores the value for future calls.

It is really simple and great to use. In a real world application you should definitely use that or a PSR-16 implementation but for this tutorial I wanted to roll out my own solution, so here we go.

As always we are going to define an interface first, I am going to call it EasyCache and place it in the Service/Cache namespace. I will require only one method which is base on the Symfony Cache Contract, and hast a key, a callback, and the duration that the item should be cached as arguments.

<?php

declare(strict_types=1);

namespace Lubian\NoFramework\Service\Cache;

interface EasyCache
{
    /** @param callable(): mixed $callback */
    public function get(string $key, callable $callback, int $ttl = 0): mixed;
}

For the implementation I am going to use the APCu Extension for PHP, but if you are particularly adventurous you can write an implementation using memcache, the filesystem, a database, redis or whatever you desire.

For the sake of writing as less code as possible here is my simple ApcuCache.php

<?php declare(strict_types=1);

namespace Lubian\NoFramework\Service\Cache;

use function apcu_add;
use function apcu_fetch;

final class ApcuCache implements EasyCache
{
    public function get(string $key, callable $callback, int $ttl = 0): mixed
    {
        $success = false;
        $result = apcu_fetch($key, $success);
        if ($success === true) {
            return $result;
        }
        $result = $callback();
        apcu_add($key, $result, $ttl);
        return $result;
    }
}

Now that we have a usable implementation for our cache we can write an implementation of our MarkdownPageRepo interface that usese the Cache and a Repository implementation to speed up the time exepensive calls.

So lets create a new class called CachedMarkdownPageRepo:

<?php declare(strict_types=1);

namespace Lubian\NoFramework\Repository;

use Lubian\NoFramework\Model\MarkdownPage;
use Lubian\NoFramework\Service\Cache\EasyCache;

use function base64_encode;

final class CachedMarkdownPageRepo implements MarkdownPageRepo
{
    public function __construct(
        private EasyCache $cache,
        private MarkdownPageRepo $repo,
    ) {
    }

    /**
     * @inheritDoc
     */
    public function all(): array
    {
        $key = base64_encode(self::class . 'all');
        return $this->cache->get(
            $key,
            fn () => $this->repo->all(),
            300
        );
    }

    public function byName(string $name): MarkdownPage
    {
        $key = base64_encode(self::class . 'byName' . $name);
        return $this->cache->get(
            $key,
            fn () => $this->repo->byName($name),
            300
        );
    }
}

This simple wrapper just requires an EasyCache implementation and a MarkdownPageRepo in the constructor and uses them to cache all queries for 5 minutes. The beauty is that we are not dependent on any implementation here, so we can switch out the Repository or the Cache at any point down the road if we want to.

In order to use that we need to update our config/dependencies.php to add an alias for the EasyCache interface as well as defining our CachedMarkdownPageRepo as implementation for the MarkdownPageRepo interface:

MarkdownPageRepo::class => fn (CachedMarkdownPageRepo $r) => $r,
EasyCache::class => fn (ApcuCache $c) => $c,

If we try to access our webpage now, we are getting an error, as PHP-DI has detected a circular dependency that cannot be autowired.

The Problem is that our CachedMarkdownPageRepo ist defined as the implementation for the MarkdownPageRepo, but it also requires that exact interface as a dependency. To resolve this issue we need to manually tell the container how to build the CachedMarkdownPageRepo by adding another line to the config/dependencies.php file:

CachedMarkdownPageRepo::class => fn (EasyCache $c, FileSystemMarkdownPageRepo $r) => new CachedMarkdownPageRepo($c, $r),

Here we explicitly require the FileSystemMarkdownPageRepo and us that to create the CachedMarkdownPageRepo object.

When you now navigate to the pages list or to a specific page the first load should take a while (because of our added delay) but the following request should be answered blazingly fast.

Before moving on to the next chapter we can take the caching approach even further, in the middleware chapter I talked about a simple CachingMiddleware that caches all the GET-Request for some seconds, as they should not change that often, and we can bypass most of our application logic if we just complelety cache away the responses our application generates, and return them quite early in our Middleware-Pipeline befor the router gets called, or the invoker calls the action, which itself uses some other services to fetch all the needed data.

We will introduce a new Middleware namespace to place our Cache.php middleware:

<?php declare(strict_types=1);

namespace Lubian\NoFramework\Middleware;

use Laminas\Diactoros\Response\Serializer;
use Lubian\NoFramework\Service\Cache\EasyCache;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;

use function base64_encode;

final class Cache implements MiddlewareInterface
{
    public function __construct(
        private readonly EasyCache $cache,
        private readonly Serializer $serializer,
    ) {
    }

    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        if ($request->getMethod() !== 'GET') {
            return $handler->handle($request);
        }
        $keyHash = base64_encode($request->getUri()->getPath());
        $result = $this->cache->get(
            $keyHash,
            fn () => $this->serializer::toString($handler->handle($request)),
            300
        );
        return $this->serializer::fromString($result);
    }
}

The code is quite straight forward, but you might be confused by the Responseserializer I have added here, we need this because the response body is a stream object, which doesnt always gets serialized correctly, therefore I use a class from the laminas project to to all the heavy lifting for us.

We need to add the now middleware to the config/middlewares.php file.

<?php declare(strict_types=1);

use Lubian\NoFramework\Http\RouteMiddleware;
use Lubian\NoFramework\Middleware\Cache;
use Middlewares\TrailingSlash;
use Middlewares\Whoops;

return [
    Whoops::class,
    Cache::class,
    TrailingSlash::class,
    RouteMiddleware::class,
];

You can now use your browser to look if everything works as expected.

Disclaimer in a real application you would take some more consideration when it comes to caching and this simple response cache would quickly get in you way, but as I said earlier this chapter was mostly me playing around with some ideas I had in writing this tutorial.

<< previous | next >>