Syntaxe yaml en 2 minutes

Quiconque se lance dans l’aventure Symfony (peut) se retrouver face à cette nouvelle syntaxe qui ressemble (un peu) au json. Massivement utilisée dans les fichiers de configuration, il est indispensable de posséder quelques notions rudimentaires.

Collections

Similaire aux tableaux et autres objets. L’arborescence se fait avec les  indentations, ces dernières se faisant avec des espaces (en général 4).

# List
fruits:
    - apple
    - orange
# Map
nicolas:
    name: Programmer
    job: Developer
    skill: Elite

Syntaxe rapide de l’exemple précédent (sans indentation) :

fruits: [apple, orange]
nicolas: {name: Programmer, job: Developer, skill: Elite}

Scalaires

# A Comment
key: value
numeric: 99
other_numeric: 1e+9
boolean: true
null_value: null
null_again: ~
space_key: value

Les valeurs peuvent s’étendre sur plusieurs lignes :

# Literal block
bloc_littéral: |
    Valeur avec des retours
    de ligne conservées.

        Une phrase indentée dans la valeur.

# Folded block
folded_block: >
    Valeur avec des retours
    de ligne transformées espace.

    Une ligne vide est transformé en retour de ligne

        Une phrase indentée dans la valeur conserve
        son saut de ligne

Références externes

Utiliser le cache Symfony

Théorie

Symfony utilise une implémentation bien connue de l‘interface PSR-6. Elle repose sur 3 concepts :

  • item : une unité d’information identifiée par une paire key/value dans laquelle key est un identifiant unique et valeur l’information elle même (par exemple un tableau, un objet ou une chaîne). Dans notre application, il s’agit d’un objet qui doit implémenter CacheItemInterface.
  • pool : on peut voir ça comme un dépôt qui contient tous nos items. De plus c’est lui qui va réaliser toutes les opérations basiques liées au système de cache (ajout, suppression, mise à jour  etc). Dans notre application, il s’agit d’un objet qui doit implémenter CacheItemPoolInterface.
  • adapter : la « glue » qui fait la liaison entre notre application et le cache. Dans notre application il s’agit d’un service et c’est lui qu’on va utiliser.

On a à notre disposition toute une série d’adapter, qui implémentent tous la même interface (AdapterInterface). L’utilisation d’un adapter plutôt qu’un autre est donc totalement transparent dans notre application. La différence sera l’endroit où est stockée l’information (fichier, base de données etc).

Opérations basiques

Pour terminer la théorie, voici un petit concentré d’utilisation du cache, en supposant que $cache soit le service de cache (ou adapter) :

// Create a new item by trying to get it from the cache
$myAwesomeItem = $cache->getItem('items.awesome');

// Assign a value to the item and save it
$myAwesomeItem->set('Am I cached ?');
$cache->save($myAwesomeItem);

// Retrieve the cache item
$myAwesomeItem = $cache->getItem('items.awesome');
if (!$myAwesomeItem->isHit()) {
    // ... looks like item does not exist in the cache
}
// retrieve the value stored by the item
$value = $myAwesomeItem->get();

// remove the cache item
$cache->deleteItem('items.awesome');

Services

Maintenant que nous en savons un peu plus, listons les services disponibles dans Symfony qui pourraient nous aider :

$ ./bin/console debug:autowiring cache

Psr\Cache\CacheItemPoolInterface                  
    alias to cache.app                            
Psr\SimpleCache\CacheInterface                    
    alias to cache.app.simple                     
Symfony\Component\Cache\Adapter\AdapterInterface  
    alias to cache.app 

On retrouve bien nos interfaces précédentes. Le service cache.app (qu’on retrouve d’ailleurs dans le profiler) va pouvoir être utilisé dans nos contrôleurs tout à fait classiquement :

use Symfony\Component\Cache\Adapter\AdapterInterface;

class FooController extends AbstractController
{
    /**
     * @var AdapterInterface
     */
    private $cache;

    public function __construct(AdapterInterface $cache)
    {
        $this->cache = $cache;
    }

    public function index(): Response
    {
        // Let's caching some nice items !
    }
}

Ensuite, voici un exemple d’utilisation hyper simple :

public function index(): Response
{
    $cache = $this->cache;
    $myAwesomeString = 'I want to be cached !';
    $key = 'awesomestring_' . md5($myAwesomeString);

    // Try to fetch item from cache
    $item = $cache->getItem($key);

    // Somehow it was not found in cache
    if(!$item->isHit()) {
        sleep(5);
        $item->set($myAwesomeString);
        $cache->save($item);
    }
}

On simule une occupation serveur de 5s avec la fonction sleep. Ce qui ce traduit par un affichage assez long de la page (5000ms). Petit tour dans le profiler > section cache, on a deux appels au service app.cache :

  • getItem() qui permet de créer l’objet
  • save() qui enregistre l’objet dans le cache puisqu’il n’existait pas

Maintenant on actualise la page, et c’est là que la magie opère : à peine 200ms de temps d’exécution (à moins que votre serveur ne soit un Pentium II 400, ça devrait tourner en dessous de 500ms) ! Petit tour dans le profiler > section cache, on a plus qu’un appel sur le cache : getItem(), ce qui est plutôt logique puisque l’objet a été créé juste avant.

Configuration

La première chose à faire est de consulter la configuration existante :

$ ./bin/console debug:config framework

Et éventuellement la configuration par défaut :

$ ./bin/console config:dump framework

Concernant la configuration existante, vous devriez avoir quelque chose d’approchant :

framework:
    cache:
        # Adapter used by the cache.app service
        app: cache.adapter.filesystem
        # Adapter used by the cache.system service
        system: cache.adapter.system
        # Directory used by cache.adapter.filesystem adapter
        directory: /home/xblog/www/xblogv7/var/cache/dev/pools
        default_redis_provider: 'redis://localhost'
        default_memcached_provider: 'memcached://localhost'
        pools: {  }

Symfony utilise donc deux services par défaut (on les appelle aussi des « pools ») :

  • cache.app est le service utilisé par votre application pour stocker vos données
  • cache.system est le service utilisé par les composants Symfony pour stocker leurs données (par ex. le Serializer, Validator metadata etc)

Pour configurer ces deux services, il suffit de renseigner les clés app et system . Par exemple, pour utiliser Apcu à la place de filesystem pour notre application, procédez comme suit :

framework:
    cache:
        prefix_seed: xblogv7
        app: cache.adapter.apcu

On peut vérifier que le service a changé en « dumpant » la variable :

public function __construct(AdapterInterface $cache)
{
    $this->cache = $cache;
    dump($this->cache);
}

On utilise bien le cache Apcu :

TraceableAdapter {
  #pool: ApcuAdapter
  -calls: []
}

Serveur de cache Redis

Le nec plus ultra en matière de cache est bien sûr l’utilisatoin d’un serveur dédié à cette fonction, ce qui permet d’allouer des ressources supplémentaires. Avant de continuer vous devez vous assurer que :

  • l’extension php Redis est activé dans le php.ini. Si ce n’est pas le cas, je donne des pistes ici
  • un serveur Redis local et opérationel

Vérifier que le serveur Redis écoute :

$ netstat -tupan | grep redis

Côté Symfony, modifier la configuration comme ceci :

framework:
    cache:
        prefix_seed: xblogv7
        app: cache.adapter.redis
        default_redis_provider: "redis://localhost"

En fait, ici, la clé default_redis_provider est la même que celle par défaut, ne la modifiez que si le serveur a une adresse différente. Dans ce cas vous devez utiliser une chaîne DNS valide, à savoir :

redis://[user:pass@][ip|host|socket[:port]][/db-index]

Maintenant si on dump la variable $cache comme précédemment :

TraceableAdapter {
  #pool: RedisAdapter
  -calls: []
}

L’extension Redis

Voici la procédure pour compiler l’extension Redis à condition d’avoir déjà compilé php avant et d’avoir toujours les sources à disposition. Tout d’abord se placer dans le répertoire des extensions (par exemple /usr/local/phpfarm/src/php-7.2.0/ext). Installer l’extension :

git clone https://github.com/phpredis/phpredis.git
cd phpredis
/usr/local/phpfarm/inst/php-7.2.0/bin/phpize
./configure --with-php-config=/usr/local/phpfarm/inst/php-7.2.0/bin/php-config
make
make test
make install

Bien sûr remplacez les chemins par les vôtre.

Désactiver le cache en développement

Depuis Symfony 4.1, il est possible de désactiver le cache en mode développement en utilisant l’adapter cache.adapter.array : il suffit de créer le fichier config/packages/dev/framework.yaml avec ce contenu :

framework:
    cache:
        app: cache.adapter.array

Références externes

Tirer partie du logger Symfony

Symfony utilise le désormais célèbre monolog. Il est facilement accessible sous forme de service (merci l’injection de dépendance) :

namespace App\Controller\Api;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Psr\Log\LoggerInterface;

class FooController extends AbstractController
{
    public function edit(LoggerInterface $logger)
    {
        $logger->info('Hey ! I am writing in logs !!');
    }
}

Handlers (gestionnaires)

A chaque fois qu’on va envoyer une entrée vers monolog (comme ci dessus), ce dernier va appeler la liste (dans l’ordre) des « handlers » (on appelle ça une pile) définis et leur passer la dite entrée. Un « handler » peut être perçu comme un gestionnaire qui va écrire le message quelque part (fichier, base de données, mail, etc) sous certaines conditions. Sous Symfony 4, en mode dev, on a les gestionnaires suivants (config/packages/dev/monolog.yaml) :

monolog:
    handlers:
        # Handler name
        main:
            # Handler type
            type: stream
            # Where to write the entry log
            path: "%kernel.logs_dir%/%kernel.environment%.log"
            # Handler level
            level: debug
            # Handler channels
            channels: ["!event"]
        console:
            type:   console
            process_psr_3_messages: false
            channels: ["!event", "!doctrine", "!console"]
  • main et console sont les noms des gestionnaires
  • type défini le type du gestionnaire. Pour l’instant on se cantonne à stream (système de fichier)
  • level est le niveau d’erreur minimal requis pour déclencher le gestionnaire
  • channels permet d’indiquer quelles catégories d’entrées sont autorisées ou non

J’admets, ça déroute un peu au début… La question est : que fait le gestionnaire lorsqu’il reçoit une entrée ? Réponse : cela dépend d’abord du niveau d’erreur et du « channel ».

Les gestionnaires sont appelés dans l’ordre dans lequel ils sont définis dans la clé handlers. Pour cette raison, il est conseillé de ne pas utiliser plusieurs fichiers de configuration : un fichier dans le répertoire /prod et un dans le répertoire /dev (et éventuellement un dans /test).

Niveaux d’erreurs

Dans l’ordre, les niveaux d’erreurs (levels) disponibles dans monolog :

  • debug
  • info
  • notice
  • warning
  • error
  • critical
  • alert
  • emergency

Pour reprendre l’exemple précédent, le gestionnaire main a un niveau (clé level) d’erreur fixé à debug. C’est le niveau le plus faible, ce gestionnaire sera donc toujours utilisé quelque soit le niveau d’erreur :

use Psr\Log\LoggerInterface;

class FooController extends AbstractController
{
    public function edit(LoggerInterface $logger)
    {
        $logger->info('Hey ! I am writing in logs !!');
        $logger->critical('Oops something bad is happening');
    }
}

Autrement dit, en mode dev, tout est écrit dans le fichier var/dev.log (clé path). Si maintenant on défini le niveau d’erreur du gestionnaire main à critical, l’entrée info ne sera pas écrite. A noter que la pile des gestionnaires est toujours appelée en totalité, c’est à dire qu’une même entrée peut être écrite à plusieurs endroits (l’utilisation d’un gestionnaire n’arrête pas le traitement de la pile).

Channels

Les channels servent à identifier les entrées log. A chaque channel correspond un service monolog.logger.XXX (remplacez XXX par le nom du channel). Pour lister les channels utilisés dans Symfony, on liste les services correspondant :

$ ./bin/console debug:container --show-private monolog.log

# Output
[0 ] monolog.logger
[1 ] monolog.logger_prototype
[2 ] monolog.logger.request
[3 ] monolog.logger.console
[4 ] monolog.logger.cache
[5 ] monolog.logger.translation
[6 ] monolog.logger.profiler
[7 ] monolog.logger.php
[8 ] monolog.logger.event
[9 ] monolog.logger.router
[10] monolog.logger.security
[11] monolog.logger.doctrine
[12] monolog.logger.debug

Lorsqu’on écrit quelque chose comme :

$logger->info('Hi there');

C’est la classe de $logger (donc le service utilisé) qui va définir le channel de l’entrée log. Par défaut, Symfony va utiliser le service monolog.logger. Dans l’exemple précédent, cela se traduit par les deux entrés suivantes (dans le fichier var/dev.log) :

[2018-10-06 10:18:36] app.INFO: Hey ! I am writing in logs !! [] []
[2018-10-06 10:18:36] app.CRITICAL: Oops something bad is happening [] []

Le service monolog.logger correspond donc au channel app. A noter qu’on on voit le niveau d’erreur juste après le channel (format channel.LEVEL). Maintenant, comment faire pour utiliser un autre channel ?

Il suffit juste d’injecter le service approprié. Par exemple, pour utiliser le channel doctrine, on modifie légèrement notre code précédent :

namespace App\Controller\Api;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Psr\Log\LoggerInterface;

class FooController extends AbstractController
{
    /**
     * @var LoggerInterface
     */
    private $logger;

    public function __construct(LoggerInterface $logger)
    {
        $this->logger = $logger;
    }

    public function edit()
    {
        $this->logger->info('Hey ! I am writing in logs !!');
        $this->logger->critical('Oops something bad is happening');
    }
}

Puis on spécifie le service à utiliser dans services.yaml :

services:
    App\Controller\Api\FooController:
        arguments:
            $logger: '@monolog.logger.doctrine'

On vérifie que le channel doctrine est bien utilisé :

[2018-10-06 13:07:46] doctrine.INFO: Hey ! I am writing in logs !! [] []
[2018-10-06 13:07:46] doctrine.CRITICAL: Oops something bad is happening [] []

Type de gestionnaire

Pour le moment on a utilisé un seul type de gestionnaire : stream. Il en existe d’autres :

  • fingers_crossed : ce gestionnaire stocke dans un buffer les entrées log qu’il reçoit. Si une entrée atteint un certain niveau d’erreur, cela déclenche le « vidage » du buffer en direction d’un autre handler. Plus d’explications juste après.
  • rotating_file : permet de ne conserver les logs (dans le cas d’un stockage dans un système de fichier) qu’un certain nombre de jours. Plus d’explications juste après.
  • syslog : ce gestionnaire utilise la fonction php syslog pour l’entrée log reçue.

Plusieurs fichiers de logs

Grâce aux channels, on va pouvoir séparer nos logs dans différents fichiers. Supposons que je veuille que les entrées log de doctrine soient stockées dans un fichier séparé. Il suffit de créer un gestionnaire qui va prendre en compte que le channel doctrine, et pas les autres :

monolog:
    handlers:
        doctrine_logging:
            type: stream
            path: "%kernel.logs_dir%/doctrine.%kernel.environment%.log"
            level: debug
            channels: ['doctrine']

On a maintenant un fichier /var/log/doctrine.dev.log qui contient, entre autres, nos deux entrées log :

[2018-10-06 21:28:35] doctrine.INFO: Hey ! I am writing in logs !! [] []
[2018-10-06 21:28:35] doctrine.CRITICAL: Oops something bad is happening [] []

Mais ces deux entrées se trouvent encore dans /var/log/dev.log. C’est normal, il faut modifier la configuration du gestionnaire principal pour qu’il écarte le channel doctrine :

monolog:
    handlers:
        main:
            type: stream
            path: "%kernel.logs_dir%/%kernel.environment%.log"
            level: debug
            channels: ["!event", "!doctrine"]

Voyez la syntaxe pour exclure un channel : !channel_name.

Rotation des logs

Il suffit de remplacer stream par rotating_file. Dans ce cas, monolog crée un fichier par jour.

monolog:
    handlers:
        main:
            type:  rotating_file
            path:  '%kernel.logs_dir%/%kernel.environment%.log'
            level: debug
            max_files: 10

Utilisez max_files pour modifier le nombre de jours à conserver.

Filtrer les messages d’erreurs

En production, on a souvent besoin d’une journalisation réduite. Dans ce cas, on utilise le gestionnaire de type fingers_crossed qui va agir comme un buffer : il stocke toutes les entrées de la requête courante puis les transfère à un autre gestionnaire que lorsqu’une entrée log a un niveau d’erreur au moins équivalente à un seuil (qu’on défini bien sûr). Voyons la configuration du mode production de Symfony :

monolog:
    handlers:
        main:
            type: fingers_crossed
            action_level: error
            handler: nested
            excluded_404s:
                # regex: exclude all 404 errors from the logs
                - ^/
        nested:
            type: stream
            path: "%kernel.logs_dir%/%kernel.environment%.log"
            level: debug

La clé action_level défini le seuil de déclenchement, handler défini le gestionnaire à utiliser. Ici, dès qu’une erreur a niveau au moins égal à error, toutes entrées (y compris celle qui déclenche le gestionnaire) sont envoyées au gestionnaire nested qui lui va va écrire dans le fichier prod.log. Comme précédemment, la clé level du gestionnaire nested défini le niveau d’erreur minimal des entrées à inscrire dans le fichier.
L’intérêt de ce gestionnaire est majeur : s’il n’y a pas d’erreurs d’un certain niveau, on n’encombre pas les logs inutilement… Par contre, en cas d’erreur sérieuse, on a tous les messages d’erreur à notre disposition.

Logs système

Un type de gestionnaire intéressant est syslog. Il permet de stocker des entrées log via le système de log de la machine qui fait tourner l’application. Par exemple, en production, on peut ajouter un tel gestionnaire :

monolog:
    handlers:
        main:
            type: fingers_crossed
            action_level: error
            handler: nested
            excluded_404s:
                # regex: exclude all 404 errors from the logs
                - ^/
        nested:
            type: stream
            path: "%kernel.logs_dir%/%kernel.environment%.log"
        # still passed *all* logs, and still only logs error or higher
        syslog_handler:
            type: syslog
            level: debug

Souvenez vous, les gestionnaires sont appelés dans l’ordre. Donc le gestionnaire syslog sera toujours appelé et il inscrira toutes les entrées dans les logs de la machine. Par exemple, sur une machine debian, le fichier est /var/log/syslog.

Happy debugging

monolog est compatible firephp et chromephp. Il suffit d’ajouter un gestionnaire :

monolog:
    handlers:
        firephp:
            type: firephp
            level: info

L’extension du même nom doit être installée et activée sur le navigateur. Ensuite, les entrées log s’affichent dans la console. A réserver au mode dev bien sûr…

Références externes

Symfony reset le kernel entre chaque test

Imaginons un test unitaire sous Symfony qui ne fait rien à part tenter de récupérer l’EntityManager :

namespace Controller;

use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;

class FooControllerTest extends KernelTestCase
{
    public function testFoo()
    {
        $em = $this->getService('doctrine');

        $rand = rand(0,1);
        $this->assertEquals(1, $rand);
    }

    protected function getService($id)
    {
        return self::$kernel
            ->getContainer()
            ->get($id);
    }

    public static function setUpBeforeClass()
    {
        self::bootKernel();
    }
}

Avec un peu de chance l’assertion va fonctionner (1 fois sur 2 en fait). Mais là n’est pas le problème. Ajoutons maintenant un 2ème test :

namespace Controller;

use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;

class FooControllerTest extends KernelTestCase
{
    public function testBoo()
    {
        $em = $this->getService('doctrine');

        $rand = rand(0,1);
        $this->assertEquals(1, $rand);
    }
    //--
}

Et on relance le test :

$ ./bin/phpunit tests/Controller/FooControllerTest.php

Et là une erreur apparait, indépendante du test en lui même. Le container a la valeur null :

1) Controller\FooControllerTest::testBoo
Error: Call to a member function get() on null

Par contre, le premier test passe sans encombre, le service est bien récupéré. Pourquoi ? Il suffit de jeter un œil dans la classe KernelTestCase :

/**
 * Clean up Kernel usage in this test.
 */
protected function tearDown()
{
    static::ensureKernelShutdown();
}

Eh oui, Symfony reset lui même le kernel entre chaque test. La solution : surcharger cette méthode dans notre test :

namespace Controller;

use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;

class FooControllerTest extends KernelTestCase
{
    // --

    protected function tearDown() { }
}

Références externes

Liste des évènements Symfony

Créer la classe

La première chose à faire est de créer la classe Subscriber qui va contenir la logique qu’on souhaite implanter :

namespace App\EventSubscriber;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\KernelEvents;

class ApiProblemSubscriber implements EventSubscriberInterface
{
    public static function getSubscribedEvents(): array
    {
    }
}

Ensuite, compléter la méthode comme suit (c’est un exemple) :

namespace App\EventSubscriber;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\KernelEvents;

class ApiProblemSubscriber implements EventSubscriberInterface
{
    public static function getSubscribedEvents(): array
    {
        return array(
            KernelEvents::EXCEPTION => 'onKernelException'
        );
    }
}

Pour connaître les autres évènements, et surtout le type d’évènement reçu, il suffit de jeter un oeil dans la classe Symfony\Component\HttpKernel\KernelEvents. Dans notre cas, il s’agit de GetResponseForExceptionEvent :

namespace Symfony\Component\HttpKernel;

final class KernelEvents
{
    //--

    /**
     * The EXCEPTION event occurs when an uncaught exception appears.
     *
     * This event allows you to create a response for a thrown exception or
     * to modify the thrown exception.
     *
     * @Event("Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent")
     */
    const EXCEPTION = 'kernel.exception';
}

Exemple complet

Pour finir, on peut maintenant compléter notre Subscriber puisque nous savons quel type d’évènement on va recevoir dans le callback :

namespace App\EventSubscriber;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent;

class ApiProblemSubscriber implements EventSubscriberInterface
{
    public static function getSubscribedEvents(): array
    {
        return array(
            KernelEvents::EXCEPTION => 'onKernelException'
        );
    }

    public function onKernelException(GetResponseForExceptionEvent $event)
    {
        $exception = $event->getException();
        
        // --
    }
}

A noter que tout ceci fonctionne à condition d’avoir l’autoconfigure à true :

# config/services.yaml

services:
    # default configuration for services in *this* file
    _defaults:
        autowire: true
        autoconfigure: true

Références externes