PSR-6 and PSR-16 in Netgen Stack

von Borna Matijanić , Mario Blažek -

There is no web or web app today that is not utilizing some sort of caching. It is a common way to improve the performance of any project. This makes caching libraries ubiquitous, which means that every framework has its own caching library. Having a wide range of caching libraries is definitely a good thing, but this also means that there are caching libraries with various levels of functionality. This forces developers to learn multiple libraries and APIs which may or may not provide the functionality they need.

We live in an era of frameworks. Today, having a good library is not enough if that library doesn’t work in the framework of your choice. This requires a caching library to provide an adapter for a given framework. This is a common problem that developers of caching libraries face – to support only a limited number of frameworks or create a large number of adapter classes.

Future looks cached and standardized

Nice people from the Framework Interoperability Group came up with a solution. They crafted a common caching interface in the form of PSR-6, which covered a middle ground for all caching libraries. While being robust, to cover as many caching scenarios as possible, some developers demanded a simple standard for caching libraries. PHP FIG folks jumped into that and formed another standard called PSR-16. Let’s check these caching standards more in detail.

PSR-6

The goal of PSR-6 is to allow developers creating cache-aware libraries that can be integrated into existing frameworks and systems without the need for custom development. States in the standard definition.

Standard defines which data types must be supported by caching libraries. The best practice is that the application should be able to work without cache, so cache libraries must, in the case of error, return the miss instead of corrupted data. The standard defines two important concepts – the item and the pool. The item represents a single key/value pair with the pool. It states that a key must be unique and immutable, while the value can be changed at any point in time. The pool represents a collection of items in a caching system. The pool is a logical repository of all items it contains.

The PSR-6 defines four interfaces:

  • CacheItemInterface - defines an item inside a cache system
  • CacheItemPoolInterface - acts as a repository of cache items
  • CacheException - is intended for use when critical errors occur
  • InvalidArgumentException - represents invalid arguments passed into a method

Regarding the error handling, an error in the cache system should not result in application failure. Thus, underlying data store errors must not bubble up and cases where requested keys don't exist should not be considered errors.

PSR-16

Even though PSR-6 solves the problem of standardization of framework-agnostic cache, it does it in a too in-depth way for most use cases. PSR-16 gives us a layer of simplification and ease of use on top of PSR-6 for most cache operations (get, set, has) and provides an adapter class that allows PSR-16 operations on PSR-6 cache objects. Maybe the biggest benefit from PSR-16 is the support of multiple-key operations speeding up development time as processing speed by eliminating round-trip delay time. 

By no means should PSR-16 be a replacement for PSR-6 and solve all possible edge cases. It was designed to be only a layer of convenience and a tool to speed up the development process.

Simple cache (PSR-16) does introduce a new CacheInterface that should act as a combined replacement for CacheItemInterface and CacheItemPoolInterface. For exceptions, everything is the same, posing CacheException and InvalidArgumentException.

Caching on the shoulders of giants

There are many fine and rock-solid caching libraries available. Libraries like Symfony Cache Component, PHP Cache or Laminas Cache are both PSR-6 and PSR-16 compliant. Symfony Cache component includes two different sets of cache interfaces. One is generic and standardized PSR-6 and the other is Cache Contracts, which is out of the scope of this blog post. The PSR-16 standard is supported by the interoperability adapters. This means that most of the time developers are using PSR-6 compatible cache services, except for the situation when they strictly need a PSR-16 compatible cache. In that case, a very simple process of wrapping the PSR-6 compatible cache inside a PSR-16 interoperability adapter is required. Alongside the concepts already defined in the standard, like item and pool, Symfony adds another one called Adapter. It provides the implementation of the actual caching mechanism, to store the data in the filesystem, or Redis, for example. Currently, Symfony supports a wide array of adapters.

Caching PSR’s in Netgen Stack

Netgen Stack was developed to speed up our projects with standardized development environment and practices. We’ve also been developing add-ons and extensions to the core eZ CMS. Netgen Stack is an integrated set of our most valuable modules that serve as a foundation for all of our projects and is available as an Open Source solution for other companies to use. Netgen Stack is based on eZ Platform and eZ Platform is based on Symfony, which makes all cache-related stuff from Symfony available in Netgen Stack. For the basis of this blog post, we will use the default Netgen Media Site setup. Complete solution and code example that we will be showing here are hosted on this repository.

A simple OpenWeatherMap client

For the purpose of this blog post, we decided to use the OpenWeatherMap service to demonstrate usage of caching interfaces. It seems like a good example to fetch the data from the remote service and display it on our website. There is no point in fetching the remote data on every reload on our website, as we can hit the API rate limit very quickly. To tackle this issue we will implement some sort of caching by using both PSR-6 and PSR-16 standards. We will implement a simple client that retrieves the current weather data from the OpenWeatherMap. Our client is going to be based on the HTTPlug bundle that was covered in one of the previous blog posts.

First, we will define an interface for our client. This will help us make our code more solid, as we will implement two additional clients that support caching. Type hinting this interface in the controller will allow us to swap the client implementation of our choice without the need to touch the controller code. 

<?php

declare(strict_types=1);

namespace AppBundle\Weather;

interface OpenWeatherMapClientInterface
{
    /**
     * Returns the current weather information
     * for given city.
     *
     * @param string $city
     *
     * @return array
    */
    public function getCurrentWeather(string $city): array;
}

Let’s create the client implementation of this interface. As dependencies, we require an HTTP client implementation, which is provided to us by the HTTPlug, a PSR-17 message factory, and our implementation of a simple URL builder for the OpenWeatherMap service. The implementation of getCurrentWeather() method is pretty much straightforward, sends a request, does a simple error handling, JSON decodes the response body and returns the data.

<?php

declare(strict_types=1);

namespace AppBundle\Weather;

use Http\Client\HttpClient;
use Http\Message\MessageFactory;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

final class OpenWeatherMapClient implements OpenWeatherMapClientInterface
{
    /**
     * @var \AppBundle\Weather\OpenWeatherMapUrlBuilder
     */
    private $urlBuilder;

    /**
    * @var \Http\Client\HttpClient
    */
    private $client;

    /**
    * @var \Http\Message\MessageFactory
    */
    private $messageFactory;

    public function __construct(
        OpenWeatherMapUrlBuilder $urlBuilder,
        HttpClient $client,
        MessageFactory $messageFactory
    ) {
        $this->client = $client;
        $this->messageFactory = $messageFactory;
        $this->urlBuilder = $urlBuilder;
    }

    public function getCurrentWeather(string $city): array
    {
        $url = $this->urlBuilder->getUrl($city);

        $request = $this->messageFactory
            ->createRequest(
                Request::METHOD_GET,
                $url
        );

        try {
            $response = $this->client->sendRequest($request);
        } catch (\Exception $exception) {
            return [];
        }

        if ($response->getStatusCode() !== Response::HTTP_OK) {
            return [];
        }

        return json_decode((string) $response->getBody(), true);
    }
}

The URL construction code is extracted to a separate class, not to pollute the client implementation. It takes the two site access aware parameters, base OpenWeatherMap URL and API key. It also prepends the units, language and city parameters to the URL.

<?php

declare(strict_types=1);

namespace AppBundle\Weather;

use eZ\Publish\Core\MVC\ConfigResolverInterface;

class OpenWeatherMapUrlBuilder
{
    /**
    * @var string
    */
    private $baseUrl;

    /**
    * @var string
    */
    private $appid;

    public function __construct(ConfigResolverInterface $configResolver)
    {
        $this->baseUrl = $configResolver->getParameter('weather.base_url', 'app');
        $this->appid = $configResolver->getParameter('weather.appid', 'app');
    }

    public function getUrl(string $city): string
    {
        $queryData = [
            'q' => $city,
            'appid' => $this->appid,
            'units' => 'metric',
            'lang' => 'en',
        ];

        return $this->baseUrl . '?' . http_build_query($queryData);
    }
}

The parameters are defined in parameters.yml file inside the bundle.

parameters:
    app.default.weather.base_url: 'api.openweathermap.org/data/2.5/weather'
    app.default.weather.appid: 'your_token_here'

And finally, let’s wire everything inside Symfony’s dependency injection container:

services:
    app.weather.config_resolver:
        class: AppBundle\Weather\OpenWeatherMapUrlBuilder
        public: false
        arguments:
            - '@ezpublish.config.resolver'

    app.weather.client:
        class: AppBundle\Weather\OpenWeatherMapClient
        arguments:
            - '@app.weather.config_resolver'
            - '@httplug.client.default'
            - '@httplug.message_factory'

Expose implementation through the controller

Now that our client implementation is ready, let’s create a controller to allow the outside world to communicate with our super cool weather service. The controller is going to be lightweight, requiring only two dependencies – Twig Environment, responsible for rendering a Twig template, and one of the implementations of OpenWeatherMap clients.

<?php

declare(strict_types=1);

namespace AppBundle\Controller;

use AppBundle\Weather\Cities;
use AppBundle\Weather\OpenWeatherMapClientInterface;
use Symfony\Component\HttpFoundation\Response;
use Twig\Environment;

class WeatherController
{
    /**
    * @var \Twig\Environment
    */
    private $twig;

    /**
    * @var \AppBundle\Weather\OpenWeatherMapClient
    */
    private $openWeatherMapClient;

    public function __construct(Environment $twig, OpenWeatherMapClientInterface $openWeatherMapClient)
    {
        $this->twig = $twig;
        $twig->getExtension(\Twig\Extension\CoreExtension::class)->setTimezone('Europe/Zagreb');
        $this->openWeatherMapClient = $openWeatherMapClient;
    }

    public function current(string $city): Response
    {
        $city = mb_strtolower(trim($city));

        $response = new Response();

        if (!$this->isValid($city)) {
            return $response->setStatusCode(Response::HTTP_BAD_REQUEST);
        }

        $weather = $this->openWeatherMapClient->getCurrentWeather($city);

        $renderedContent = $this->twig->render('@ezdesign/weather/current.html.twig', ['weather' => $weather]);

        $response->setContent($renderedContent);

        return $response;
    }

    private function isValid(string $city): bool
    {
        return in_array($city, Cities::getCities(), true);
    }
}

The method current() accepts the string argument which should be one of the city names allowed by the Cities validator. We allow our customer to access the weather information for cities that are in the domain of our website, for example.

<?php

declare(strict_types=1);

namespace AppBundle\Weather;

final class Cities
{
    public const CITY_ZAGREB = 'zagreb';
    public const CITY_LONDON = 'london';
    public const CITY_PARIS = 'paris';
    public const CITY_VIENNA = 'vienna';

    public static function getCities(): array
    {
        return [
            self::CITY_ZAGREB,
            self::CITY_LONDON,
            self::CITY_PARIS,
            self::CITY_VIENNA,
        ];
    }
}

And as everything in Symfony, register this controller inside dependency injection container:

services:
    app.weather.controller:
        class: AppBundle\Controller\WeatherController
        public: true
        arguments:
            - '@twig'
            - '@app.weather.client'

Now when the controller is ready let’s move to route configuration. We want a simple route that allows passing in a string identifier of the city. This is achieved very easily within the Symfony’s routing. Let’s use /weather/current/{city}, where the city is any string allowed by the Cities validator.

current:
    path: /weather/current/{city}
    defaults: { _controller: app.weather.controller:current }
    methods: [GET]
    requirements:
        city: '\w+'

And finally, nicely print API results inside a Twig template.


{% extends nglayouts.layoutTemplate %}

{% block content %}
    <article class="view-type view-type-full ng-article vf1">
        <header class="full-page-header full-article-header">
            <div class="container">
                <div class="full-page-main-tag">
                    {{ weather.sys.country }}
                </div>
                <h1 class="full-page-title">Weather for {{ weather.name }}</h1>
                <div class="full-page-info">
                    Longitude: {{ weather.coord.lon }}
                    Latitude: {{ weather.coord.lat }}
                </div>
           </div>
        </header>

        <div class="container">
            <div class="full-article-content">
                <div class="full-article-body">
                <h4>Weather</h4>
                   {% for info in weather.weather %}
                       Main: {{ info.main|default('none') }}<br>
                       Description: {{ info.description|default('none') }}<br>
                   {% endfor %}
                <hr>

                <h4>Temperature</h4>
                    Temperature: {{ weather.main.temp|default('none') }}<br>
                    Feels like: {{ weather.main.feels_like|default('none') }}<br>
                    Min temperature: {{ weather.main.temp_min|default('none') }}<br>
                    Max temperature: {{ weather.main.temp_max|default('none') }}<br>
                <hr>

                <h4>Wind</h4>
                    Wind speed: {{ weather.wind.speed|default('none') }}<br>
                    Wind direction: {{ weather.wind.deg|default('none') }}<br>
                <hr>

                <h4>Info</h4>
                    Sunrise: {{ weather.sys.sunrise|date("H:i:s") }}<br>
                    Sunset: {{ weather.sys.sunset|date("H:i:s") }}<br>
                <hr>

                </div>
            </div>
        </div>
    </article>
{% endblock %}
 

That’s it. The information about the current weather for the given city is retrieved from the OpenWeatherMap service, processed and displayed on our website, excellent. Everything works fine except the one thing, every time we do a reload site performs an API call to the remote service. Even if the remote site tolerates a large number of requests, this is not acceptable for us, as it slows our site down. The period of time required to do a roundtrip to remote service and back is making our site unresponsive. Let’s do something to address this issue.

Moving to PSR-6

We can all agree that a weather report does not change every second or every minute. The information on OpenWeatherMap can be safely retrieved every half an hour, or even every hour. To solve this problem we can implement some sort of a caching mechanism. Using a cache mechanism is important to improve the application performance, but it should not be required to make the application work. As already mentioned before, the Netgen Stack is based on Symfony, which comes with a Cache component. Let’s utilize Symfony's PSR-6 compatible cache service.

There is no reason to create a completely new implementation of OpenWeatherMap client from scratch since we already have a working one. Rather than developing a new one, let’s use the decorator pattern to add additional behaviour to our original client implementation. The new class named Psr6DecoratedOpenWeatherMapClient is going to be created. For class dependencies, we need original client implementation, Symfony’s implementation of CacheItemPoolInterface, eZ’s configuration resolver and our simple cache key registry service.

<?php

declare(strict_types=1);

namespace AppBundle\Weather;

use eZ\Publish\Core\MVC\ConfigResolverInterface;
use Psr\Cache\CacheItemPoolInterface;

final class Psr6DecoratedOpenWeatherMapClient implements OpenWeatherMapClientInterface
{
    /**
    * @var \AppBundle\Weather\OpenWeatherMapClientInterface
    */
    private $client;

    /**
    * @var \Psr\Cache\CacheItemPoolInterface
    */
    private $pool;

    /**
    * @var \eZ\Publish\Core\MVC\ConfigResolverInterface
    */
    private $configResolver;

    /**
    * @var int
    */
    private $ttl;

    /**
    * @var \AppBundle\Weather\CacheKeyRegistry
    */
    private $keyRegistry;

    public function __construct(
        OpenWeatherMapClientInterface $client,
        CacheItemPoolInterface $pool,
        ConfigResolverInterface $configResolver,
        CacheKeyRegistry $keyRegistry
    ) {
        $this->client = $client;
        $this->pool = $pool;
        $this->configResolver = $configResolver;
        $this->ttl = $this->configResolver->getParameter('weather.ttl', 'app');
        $this->keyRegistry = $keyRegistry;
    }

    public function getCurrentWeather(string $city): array
    {
        $key = $this->keyRegistry->getCurrentWeatherKey($city);

        $item = $this->pool->getItem($key);

        if ($item->isHit()) {
            return $item->get();
        }

        $weather = $this->client->getCurrentWeather($city);

       if (empty($weather)) {
           return [];
       }

       $item->set($weather);
       $item->expiresAfter($this->ttl);

       $this->pool->save($item);

    return $weather;
    }
} 

Don't forget to add a TTL parameter:

parameters:
    app.default.weather.ttl: 3600

You will notice that every operation, except the retrieval of cache item and persistence, is done on an instance of CacheItemInterface. Getting the item from cache always returns a CacheItem object. It is the responsibility of the developer that uses the PSR-6 compatible cache library to call isHit() method to check if the returned item is still fresh. Method get() will return the data associated with the key, set() will assign the data to CacheItem. Two additional methods are provided to control the expiration time:

  • expiresAt() - which takes the \DateTimeInterface as the argument - defines the point in time after which the item must be considered expired
  • expiresAfter() - takes int of \DateInterval - defines the period of time from the present after which the item must be considered expired

By default, cache items are stored permanently. How permanently it depends on the used storage mechanism. For example, if you store data to Redis permanently, and Redis persistence is turned off, that data would be preserved until the next restart of Redis service.

You would notice that there is no mention of any low-level implementation of a cache storage mechanism here. That is the responsibility of the CacheItemPoolInterface implementer. Selecting the desired storage mechanism, in our case, is done through dependency injection configuration of service in question. We will come to that part in a second.

Cache key name generation logic is extracted to a standalone class. It acts like a simple key registry, which can be injected wherever is needed, instead of spreading the cache key generation throughout the entire project.

<?php

declare(strict_types=1);

namespace AppBundle\Weather;

final class CacheKeyRegistry
{
    public function getCurrentWeatherKey(string $city): string
    {
        return 'openweather_map_city_' . $city;
    }
} 

For this approach of service decoration, we need to use two additional service definition options when registering our caching implementation with dependency injection container, decorates and decoration_inner_name. I’m not going into detail about these options, for more information, you can consult Symfony's documentation about the service decoration. As previously mentioned, now is the time to select the cache storage mechanism. Symfony provides the cache.app service, which implements a filesystem cache storage.

services:
    app.weather.key_registry:
        class: AppBundle\Weather\CacheKeyRegistry

    app.weather.psr6_client:
        class: AppBundle\Weather\Psr6DecoratedOpenWeatherMapClient
        decorates: app.weather.client
        decoration_inner_name: app.weather.client.inner
        public: false
        arguments:
            - '@app.weather.client.inner'
            - '@cache.app'
            - '@ezpublish.config.resolver'
            - '@app.weather.key_registry

Migration to the other cache storage mechanism is possible. If we want to store application cache to Redis for example, we need to include cache.redis.yml configuration file from app/config/cache_pool in config.yml file.

imports:
    - { resource: cache_pool/cache.redis.yml }

This creates another service in the dependency injection container, called cache.redis. Swapping the service argument of app.weather.psr6_client from cache.app to cache.redis, forces our PSR-6 OpenWeatherMap client to use the Redis for cache storage without touching any piece of the code.

services:
    app.weather.psr6_client:
        class: AppBundle\Weather\Psr6DecoratedOpenWeatherMapClient
        decorates: app.weather.client
        decoration_inner_name: app.weather.client.inner
        public: false
        arguments:
            - '@app.weather.client.inner'
            - '@cache.redis'
            - '@ezpublish.config.resolver'
            - '@app.weather.key_registry

Caching implementation is now ready. There is no need to touch the controller code as we used the decorator pattern with a bit of help from Symfony. If you reload the site, only the first request is going to be sent to a remote service. Every other reload will serve the weather data from our cache implementation for a valid period of time.

Simplifying the client with PSR-16

For simple cache implementation, we took the same approach as in PSR-6 and defined a decorator class that works with the original OpenWeatherMapClient class object. Practically everything stays the same as in PSR-6 except cache logic approach and methods used.

<?php

declare(strict_types=1);

namespace AppBundle\Weather;

use eZ\Publish\Core\MVC\ConfigResolverInterface;
use Psr\Cache\CacheItemPoolInterface;
use Symfony\Component\Cache\Simple\Psr6Cache;

final class Psr16DecoratedOpenWeatherMapClient implements OpenWeatherMapClientInterface
{
    /**
    * @var \AppBundle\Weather\OpenWeatherMapClientInterface
    */
    private $client;

    /**
    * @var \eZ\Publish\Core\MVC\ConfigResolverInterface
    */
    private $configResolver;

    /**
    * @var int
    */
    private $ttl;

    /**
    * @var \Psr\SimpleCache\CacheInterface
    */
    private $cache;

    /**
    * @var \AppBundle\Weather\CacheKeyRegistry
    */
    private $keyRegistry;

    public function __construct(
        OpenWeatherMapClient $client,
        CacheItemPoolInterface $cache,
        ConfigResolverInterface $configResolver,
        CacheKeyRegistry $keyRegistry
    ) {
        $this->client = $client;
        $this->configResolver = $configResolver;
        $this->ttl = $this->configResolver->getParameter('weather.ttl', 'app');
        $this->cache = new Psr6Cache($cache);
        $this->keyRegistry = $keyRegistry;
    }

    public function getCurrentWeather(string $city): array
    {
        $key = $this->keyRegistry->getCurrentWeatherKey($city);

        $weather = $this->cache->get($key);

        if (!$weather) {
            $weather = $this->client->getCurrentWeather($city);

            $this->cache->set($key, $weather, $this->ttl);
        }

    return $weather;
    }
}

 And a service definition:

app.weather.psr16_client:
    class: AppBundle\Weather\Psr16DecoratedOpenWeatherMapClient
    decorates: app.weather.client
    decoration_inner_name: app.weather.client.inner
    public: false
    arguments:
        - '@app.weather.client.inner'
        - '@cache.app'
        - '@ezpublish.config.resolver'
        - '@app.weather.key_registry' 

The first and biggest difference is no distinction between cache pool and cache item, we only have a cache and everything else is handled in the implementing library. Since we are using Symfony, we have adapters for interoperability in the stack out of the box. So in the constructor, we define cache property as a PSR6 cache instance constructed from the desired cache pool interface. 

By using a simple cache, we did (almost) the same thing in getCurrentWeather() in only half of the code length. And we all know that the number of lines does not need to mean anything, but fewer lines for the same output definitely tells us we are going in the right direction. We do not need to care about pool handling, checking if an item is hit and saving the pool if everything passes. One downside is that intuitively we would check cache with has() and then use get() but it is recommended that has() is only to be used for cache warming type purposes and not to be used within the operations of your live application for get/set, as this method is subject to a race condition where your has() will return true and immediately after, another script can remove it, making the state of your app out of date. For that reason, we need to use get() and check if we got null or a valid value.

If we used to cache every OpenWeatherMapClient object property by itself, in the simple cache we would use getMultiple() and setMultiple() and do everything with 2 calls instead of who knows how many in PSR-6. 

Again, PSR-16 is not designed to solve all possible edge cases but speeds up the development process A LOT for most common uses.

Conclusion

Some developers argue that PSR-6 tries to do too much. With the example cases covered in this blog post, we can argue back that PSR-6 does just enough to comfortably use it and solve the everyday cache related issues. Some will set for PSR-6, the other may go even the simpler way by consuming the PSR-16 standardized libraries. Both PSR-6 and PSR-16 deserve a place in our applications. But having the only good and well-defined standard without the implementer’s libraries would make these standards an empty paper. Combining both, the well-defined standard and high-quality libraries like Symfony Cache makes every PHP developer happy.

Diese Seite verwendet Cookies. Einige dieser Cookies sind unverzichtbar, während andere uns helfen, deine Erfahrung zu verbessern, indem sie uns Einblicke in die Nutzung der Website geben.

Ausführlichere Informationen über die von uns verwendeten Cookies findest du in unserer Datenschutzrichtlinie.

Einstellungen anpassen
  • Notwendige Cookies ermöglichen die Kernfunktionen. Die Website kann ohne diese Cookies nicht richtig funktionieren und kann nur deaktiviert werden, indem du deine Browsereinstellungen änderst.