no-framework-tutorial/18-caching.md

253 lines
9.8 KiB
Markdown
Raw Normal View History

2022-04-06 21:43:03 +00:00
[<< previous](17-performance.md) | [next >>](19-database.md)
**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`.
```php
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](https://www.php-fig.org/psr/psr-6) which gives us easy access to many different implementations,
then there is also a much simpler [PSR-16 Simple Cache](https://www.php-fig.org/psr/psr-16) 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](https://symfony.com/doc/current/components/cache.html#cache-component-contracts)
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
<?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](https://www.php.net/manual/en/ref.apcu.php) 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
<?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
<?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:
```php
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:
```php
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
<?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
<?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](17-performance.md) | [next >>](19-database.md)