Maîtriser la Sérialisation des Données en PHP avec SF Serializer

La gestion efficace de la représentation et du transfert des données est cruciale dans n’importe quel projet web. C’est ici que le SF Serializer, un composant clé du framework Symfony, se distingue. Il offre une manière avancée de transformer des objets de données en différents formats et vice versa, le rendant un outil indispensable lors des traitements de données. 

Cet article propose une approche pratique du Serializer de Symfony.

La Sérialisation des données : qu’est ce que le Serializer Symfony ?

Le SF Serializer permet la conversion des objets en différentes représentations telles que JSON, XML ou CSV, et inversement. Cette fonctionnalité permet de simplifier la communication avec des API externes, des services externes ou encore avec les utilisateurs de votre API. Combiné aux validators et aux Dtos (Data transfer object), vos API CRUD sont propres et ordonnées.

Cas d’usage

Plusieurs cas d’usages sont envisageables :

  • Communication avec des API externes
  • Désérialisation dans les contrôleurs 
  • Communication inter-services (via HTTP, RabbitMQ, Redis, échange de fichiers, etc.)

Alternatives et Comparaisons

Il existe des alternatives comme serialize, json_encode, ou jms/serializer mais SF Serializer offre une solution plus complète et nativement intégrée dans l’écosystème Symfony.

Architecture

Schéma d'architecture de la sérialisation des données avec SF Serializer de Symfony.

Figure 1 : Architecture du serializer (tiré de la documentation officielle : https://symfony.com/doc/current/components/serializer.html)

Fonctionnement

L’architecture de SF Serializer repose sur des arrays de normalisateurs et d’encodeurs. Il sélectionne le premier objet avec une réponse positive de la méthode supports*, ce qui le rend facilement extensible.

En pratique, Symfony instancie le Serializer pour nous (via son conteneur d’injection), plus ou moins de cette manière:

Ici, l’ObjectNormalizer et le JsonEncoder sont les premiers éléments du tableau, ils seront donc prioritaires si leurs méthodes supports* répond de manière positive (true) par rapport aux autres éléments.

Symfony utilise le conteneur d’injection de dépendances pour récupérer les normalisateurs et les encodeurs, cela permet de personnaliser le comportement des normalisateurs et des encodeurs. L’utilisation du conteneur permet également de modifier la priorité des encodeurs et normalisateurs via le service.yaml

Installation

Sous Symfony, vous pouvez:

  • Utiliser le meta-package “composer require symfony/serializer-pack”
  • Installer les composants de manière individuelle:

Utilisation

Dans Symfony, le serializer s’intègre facilement, et peut se récupérer de la manière suivante:

Via le constructeur d’un service symfony.

Pour illustrer son utilisation, prenons un objet “Basique” à sérialiser:

<?php
// Dans apps/api/src/Dto/LuckyObjectDto.php
namespace App\Dto;

use Symfony\Component\Serializer\Annotation\Groups;

class LuckyObjectDto
{
    #[Groups(['api_lucky_object_get'])]
    public string $id;


    #[Groups(['api_lucky_object_get'])]
    public \DateTimeImmutable $startAt;

    #[SensitiveParameter] // This attribute prevent php from logging $secret into stacktraces; not related to serializations
    public string $secret;

    #[Groups(['api_lucky_object_get'])]
    public function getStaticName(): string {
        return "LuckyObject";
    }


    #[Groups(['api_lucky_object_get'])]
    public function getEndAt(): \DateTimeImmutable {
        return $this->startAt->modify('+1 day');
    }
}

Notons ici l’utilisation de l’annotation “#[Groups]” sur chaque attribut à sérialiser. Je recommande de toujours utiliser les groupes lors de la sérialisation afin de permettre de ne pas sérialiser certains éléments confidentiels. Cela permet que lors de l’ajout d’un nouvel attribut dans une entité par un autre développeur de cacher cet attribut par défaut.

Les attributs autre que ceux des groupes ne seront pas sérialisés

Dans ce cas, `$secret` ne sera pas sérialisé car il n’est associé à aucun groupe.

Enfin, nous pouvons utiliser le serializer dans notre contrôleur :

<?php
// src/Controller/LuckyController.php
namespace App\Controller;

use App\Dto\LuckyObjectDto;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Serializer\SerializerInterface;

class LuckyController extends AbstractController
{

    #[Route('/lucky/object/{id}', name: 'api_lucky_object_get', methods: ['GET'])]
    public function getObject(string $id): JsonResponse
    {
        $luckyObjectDto = new LuckyObjectDto();
        $luckyObjectDto->id = $id;
        $luckyObjectDto->startAt = new \DateTimeImmutable();
        return new JsonResponse($luckyObjectDto);
    }
}
// Renvoi {"id":"test","startAt":{"date":"2024-02-21 13:18:50.792244","timezone_type":3,"timezone":"UTC"}}

Ici, “return new JsonResponse($luckyObjectDto);” n’utilise pas le serializer de Symfony.

Il faut donc changer la méthode pour injecter et utiliser le serializer.

public function getObject(string $id, SerializerInterface $serializer): JsonResponse // Injecter le serializer
    {
        $luckyObjectDto = new LuckyObjectDto();
        $luckyObjectDto->id = $id;
        $luckyObjectDto->startAt = new \DateTimeImmutable();
        return new JsonResponse($serializer->serialize($luckyObjectDto));
    }

// Renvoi {"staticName":"LuckyObject","endAt":"2024-02-22T13:21:17+00:00","id":"test","startAt":"2024-02-21T13:21:17+00:00"}

Comme nous étendons “AbstractController”, Symfony nous fourni le sucre syntaxique “$this->json”, nous pouvons changer le code en :

public function getObject(string $id): JsonResponse
    {
        $luckyObjectDto = new LuckyObjectDto();
        $luckyObjectDto->id = $id;
        $luckyObjectDto->startAt = new \DateTimeImmutable();
        return $this->json($luckyObjectDto, context: ['groups' => ["api_lucky_object_get"]]);
    }
// Renvoi {"id":"test","startAt":"2024-02-21T13:20:28+00:00","staticName":"LuckyObject","endAt":"2024-02-22T13:20:28+00:00"}

pour utiliser le serializer.

Attention: si aucun groupe n’est spécifié, le serializer par défaut va sérialiser toutes les propriétés/méthodes associé à un groupe.

Afin d’éviter les erreurs lors des sérialisations d’entités, je recommande d’ajouter un groupe “none” par défaut au service ObjectNormalizer afin de ne rien sérialiser si aucun groupe n’est spécifié. Cela force les développeurs à ajouter un groupe.

// Dans apps/api/config/services.yaml

    Symfony\Component\Serializer\Normalizer\ObjectNormalizer:
        decorates: serializer.normalizer.object
        arguments:
            $defaultContext:
                groups:
                    - none

Maintenant, “return new JsonResponse($serializer->serialize($luckyObjectDto));” renverra “[]”, c’est-à-dire un tableau vide. Si vous voulez obtenir un objet JSON vide à la place, vous pouvez utiliser l’option du contexte “preserve_empty_objects” qui conservera les objet PHP vide en objet JSON vide plutôt que de transformer les objets PHP vide en objet JSON vide :

// Dans apps/api/config/services.yaml
    Symfony\Component\Serializer\Normalizer\ObjectNormalizer:
        decorates: serializer.normalizer.object
        arguments:
            $defaultContext:
		   preserve_empty_objects: true
   groups:
                    - none

Normalisation et Dénormalisation

La normalisation consiste à transformer un objet en array et la dénormalisation est le processus inverse, c’est-à-dire transformer un array en objet. Afin de transformer des objets en array (et vice-versa), Symfony se base sur des “normalizers” / “Denormalizers”. Plusieurs (dé)normalisateurs sont déjà disponibles / pré-enregistrés (“built-in”).

Les plus communs sont :

  • ObjectNormalizer: Permet de transformer un objet en array. Ce normalisateur utilise une définition pour pouvoir normaliser un objet. Généralement, cette définition est réalisée via l’attribut “Groups”. C’est ce normalisateur qui appelle un à un vos getters/propriétés pour transformer votre objet en array
  • JsonSerializableNormalizer: Utilise l’interface JsonSerializable pour normaliser votre objet
  • DateNormalizer: Permet de transformer une string en date (et vice-versa)

Le Serializer de Symfony sert également de (dé)normalisateur, implémentant l’interface (De)NormalizerInterface et sélectionnant le (de)normalisateur enregistré à utiliser selon le premier (de)normalisateur dont la méthode supports(De)Normalization répond de manière positive.

La récursivité des (de)normalisateurs

Les (dé)normalisateurs peuvent appeler d’autres (dé)normalisateurs en cascade, lorsqu’ils implémentent l’interface SerializerAwareInterface ou  (De)NormalizerAwareInterface. Ces interfaces ne sont pas obligatoires mais simplifient grandement la configuration Symfony.

Lors de la cascade, ils appellent en réalité la méthode (de)normalize du serializer orchestrateur. Par exemple, l’ObjectNormalizer, rappel le sérialisateur pour un objet enfant (qui appel à nouveau l’ObjectNormalizer ensuite).

La normalisation

La normalisation permet de transformer un objet en tableau de primitive (ou d’autres tableau)

Généralement, la normalisation ne conserve pas le type de l’objet, c’est-à-dire que le tableau ne contient pas nécessairement le type.

Il n’est donc pas possible, de manière générale, de deviner le type de l’objet à partir du tableau normalisé.

Pour normaliser un objet, vous pouvez injecter directement la NormalizerInterface

En se basant sur les exemples précédents :

public function getObject(NormalizerInterface $normalizer): JsonResponse {
        $luckyObjectDto = new LuckyObjectDto();

        $luckyObjectDto->id = "foobaz";
        $luckyObjectDto->startAt = new \DateTimeImmutable();
        var_dump($normalizer->normalize($luckyObjectDto, context: ["groups" => ['api_lucky_object_get']]));
    }
/** Renvoi array(4) {
  ["id"]=>
  string(6) "foobaz"
  ["startAt"]=>
  string(25) "2024-02-21T17:44:07+00:00"
  ["staticName"]=>
  string(11) "LuckyObject"
  ["endAt"]=>
  string(25) "2024-02-22T17:44:07+00:00"
}
**/

Normaliser une propriété via une callback

Il est possible de normaliser un attribut de manière spécifique, sans impacter les autres attributs ou le normalisateur par défaut:

Réécrivons notre route

#[Route('/lucky/object/{id}', name: 'api_lucky_object_get', methods: ['GET'])]
    public function getObject(string $id): JsonResponse {
        // all callback parameters are optional (you can omit the ones you don't use)
        $dateCallback = function ($innerObject, $outerObject, string $attributeName, string $format = null, array $context = []) {
            return $innerObject instanceof \DateTimeInterface ? $innerObject->format('Y-m-d') : '';
        };

        $defaultContext = [
            \Symfony\Component\Serializer\Normalizer\AbstractNormalizer::CALLBACKS => [
                'startAt' => $dateCallback,
                'endAt' => $dateCallback,
            ],
            'groups' => ['api_lucky_object_get']
        ];

        $luckyObjectDto = new LuckyObjectDto();

        $luckyObjectDto->id = $id;
        $luckyObjectDto->startAt = new \DateTimeImmutable();
        return $this->json($luckyObjectDto, 200, [], $defaultContext);
    }
// Renvoit {"id":"test","startAt":"2024-02-21","staticName":"LuckyObject","endAt":"2024-02-22"}

Dans ce code :

$dateCallback = function ($innerObject, $outerObject, string $attributeName, string $format = null, array $context = []) {
            return $innerObject instanceof \DateTimeInterface ? $innerObject->format('Y-m-d') : '';
        };

Définit la callback: pour tous les attributs où cette callback est utilisé, si l’élément est une date, on formate la date en Année-Mois-Jour.

$defaultContext = [
            \Symfony\Component\Serializer\Normalizer\AbstractNormalizer::CALLBACKS => [
                'startAt' => $dateCallback,
                'endAt' => $dateCallback,
            ],
            'groups' => ['api_lucky_object_get']
        ];

Définit le contexte. On y a ajouté:

  • Le groupe (tiré de l’exemple précédent)
  • La callback, disponible pour le startAt et le endAt

La dénormalisation

La dénormalisation permet de transformer un tableau en un objet.

Afin de dé-normaliser un tableau en un objet, il est nécessaire de connaître le type de l’objet final à l’avance. Nous avons besoin de connaître uniquement l’objet parent afin de pouvoir dé-normaliser (les types des sous objets sont spécifiés depuis l’objet parent, via les types PHP ou la PHPdoc).

La dénormalisation peux utiliser la PHPDoc, ce qui permet de typer les propriétés avec des array d’objet

De plus, la dénormalisation est dite “TypeSafe”, c’est à dire que si les types ne sont pas cohérent, le sérialisateur enverra une exception

Pour finir, l’ArrayDenormalizer permet de gérer les listes/array en ajoutant “[]” à votre type

Par exemple:

public function getObject(string $id, DenormalizerInterface $denormalizer): JsonResponse {
        $myNormalizedArray  = [[
            "id"=> $id,
            "startAt"=> "2024-02-21T17:44:07+00:00",
            "secret"=> "toto"
        ]];
        var_dump($denormalizer->denormalize($myNormalizedArray, LuckyObjectDto::class."[]", "json", ["groups" => ['api_lucky_object_get']]));
        return new JsonResponse(["success" => "ok"]);
    }

/** Affiche array(1) {
  [0]=>
  object(App\Dto\LuckyObjectDto)#207 (2) {
    ["id"]=>
    string(19) "DenormalizeExemple1"
    ["startAt"]=>
    object(DateTimeImmutable)#213 (3) {
      ["date"]=>
      string(26) "2024-02-21 17:44:07.000000"
      ["timezone_type"]=>
      int(1)
      ["timezone"]=>
      string(6) "+00:00"
    }
    ["secret"]=>
    uninitialized(string)
  }
}
{"success":"ok"}
**/

La désérialisation

La désérialisation est le processus qui rassemble le décodage et la dénormalisation. Elle permet donc de passer d’une chaîne de caractère JSON à un objet PHP.

$myStr = <<<JSON
[{
 "id": "DenormalizeExemple1",
 "startAt": "2024-02-21T17:44:07+00:00",
 "secret": "toto"
}]
JSON;

$serializer->deserialize($myStr, LuckyObjectDto::class."[]", "json", ["groups" => ['api_lucky_object_get']])

Les groupes sont également appliqués à la désérialisation.

L’utilisation avec l’attribut MapRequestPayload

L’attribut MapRequestPayload permet de dé-sérialiser le contenu d’une requête HTTP en un objet PHP
Cet attribut déclenche également le validateur de symfony.

Son utilisation permet donc d’utiliser à la fois le serializer de Symfony et le validateur, en un seul attribut. Nous pouvons donc obtenir un objet PHP valide, avec un minimum de code à écrire et des messages de validation facilement exploitables.

Reprenons notre DTO et ajoutons un nouveau champs “name”, ainsi qu’un nouveau groupe pour notre appel POST

namespace App\Dto;

use Symfony\Component\Serializer\Annotation\Groups;

class LuckyObjectDto
{
    #[Groups(['api_lucky_object_get', 'api_lucky_object_post'])]
    public string $id;


    #[Groups(['api_lucky_object_get', 'api_lucky_object_post'])]
    public \DateTimeImmutable $startAt;

    #[SensitiveParameter] // This attribute prevent php from logging $secret into stacktraces
    public string $secret;

    #[Groups(['api_lucky_object_get', 'api_lucky_object_post'])]
    public string $name;
  
    #[Groups(['api_lucky_object_get', 'api_lucky_object_post'])]
    public function getStaticName(): string {
        return "LuckyObject";
    }
    
    #[Groups(['api_lucky_object_get', 'api_lucky_object_post'])]
    public function getEndAt(): \DateTimeImmutable {
        return $this->startAt->modify('+1 day');
    }
}

écrivons une route associée :

 #[Route('/lucky/object', name: 'api_lucky_object_post', methods: ['post'])]
    public function postObject(
        #[MapRequestPayload(
            serializationContext: [
                'groups' => ['api_lucky_object_post']
            ]
        )]  LuckyObjectDto $luckyObjectDto): JsonResponse {
        var_dump($luckyObjectDto);
        return new JsonResponse(["success" => "ok"]);
    }
/** Affiche
object(App\Dto\LuckyObjectDto)#170 (2) {
    [
        "id"
    ]=>
  string(19) "DenormalizeExemple1"
  [
        "startAt"
    ]=>
  object(DateTimeImmutable)#174 (3) {
        [
            "date"
        ]=>
    string(26) "2024-02-21 17:44:07.000000"
    [
            "timezone_type"
        ]=>
    int(1)
    [
            "timezone"
        ]=>
    string(6) "+00:00"
    }
  [
        "secret"
    ]=>
  uninitialized(string)
  [
        "name"
    ]=>
  uninitialized(string)
}
{
    "success": "ok"
}
**/

Ici, le name n’est pas initialisé mais la requête est bien traitée !
Il est préférable, pour avoir un objet cohérent en PHP, d’utiliser le constructeur, car un objet avec des propriétés publiques / privées non initialisées est OK en PHP

Nous pourrions modifier notre DTO pour avoir un constructeur et non des variables publiques, mais cette méthode, bien qu’elle respecte mieux les principes SOLID, n’est pas recommandée pour les DTOs qui représente des requêtes car elle ne permet pas à PHP d’initialiser l’objet et de renvoyer un message compréhensible de manière automatisé.

C’est pourquoi, je recommande de passer tous les attributs en publique et d’utiliser le validateur de symfony, ainsi :

  1. Symfony va désérialiser votre objet
  2. Ensuite, symfony va utiliser le validateur pour valider l’objet pour obtenir un message clair

Modifions notre DTO dans ce sens :

class LuckyObjectDto
{
    #[Groups(['api_lucky_object_get', 'api_lucky_object_post'])]
    #[Assert\NotBlank]
    public string $id;


    #[Groups(['api_lucky_object_get', 'api_lucky_object_post'])]
    #[Assert\NotBlank]
    public \DateTimeImmutable $startAt;

    #[SensitiveParameter] // This attribute prevent php from logging $secret into stacktraces
    public string $secret;


    #[Groups(['api_lucky_object_get', 'api_lucky_object_post'])]
    #[Assert\NotBlank]
    public string $name;
    
    #[Groups(['api_lucky_object_get', 'api_lucky_object_post'])]
    public function getStaticName(): string {
        return "LuckyObject";
    }

    #[Groups(['api_lucky_object_get', 'api_lucky_object_post'])]
    public function getEndAt(): \DateTimeImmutable {
        return $this->startAt->modify('+1 day');
    }
}

Maintenant, la route renvoi :

{
    "type": "https://symfony.com/errors/validation",
    "title": "Validation Failed",
    "status": 422,
    "detail": "name: This value should not be blank.",
    "violations": [
        {
            "propertyPath": "name",
            "title": "This value should not be blank.",
            "template": "This value should not be blank.",
            "parameters": {
                "{{ value }}": "null"
            },
            "type": "urn:uuid:c1051bb4-d103-4f74-8988-acbcafc7fdc3"
        }
    ]
}

Ce qui est facile à exploiter 

Disclamer: Il n’est pas nécessaire d’exploiter les groupes dans des DTO de requêtes. De manière générale, on préfère écrire un DTO par requête et la notion de groupe n’est donc pas utile dans ce cas. Ainsi, nous préférons les groupes pour les réponses (par exemple lorsque l’on sérialise une entité) et nous n’utilisons pas les groupes pour la désérialisation de manière générale. Dans notre cas et sans decorator, cela nécessite (à la suite de notre modification) d’utiliser le groupe “*” lors de l’utilisation de l’attribut “MapRequestPayload”.

Cependant, dans certains cas, cela peut être intéressant, comme lors d’une route qui est exploité par deux type d’utilisateur différent (Admin et Utilisateur par exemple).

Créer un dé-normalisateur personnalisé

Créer un dé-normalisateur personnalisé sous symfony est assez simple

Imaginons une communication avec une API externe, qui exploite toujours le même format de réponse pour les listes :

{
  "counter": 4,
  "rows": [
    {
      "id": "CustomDenormalizeExemple1",
      "startAt": "2024-02-21T17:44:07+00:00",
      "secret": "foobaz",
      "name": "CustomDenormalizeExemple1Name"
    },
    {
      "id": "CustomDenormalizeExemple2",
      "startAt": "2024-02-21T17:44:07+00:00",
      "secret": "xorf",
      "name": "CustomDenormalizeExemple2Name"
    }
  ]
}

Il serait possible d’écrire un DTO “simple”, en précisant les types des tableaux via la PHPdoc (PHP ne gère pas les génériques de manière native) :

<?php
// Dans LuckyObjectListDto.php
namespace App\Dto;

use Symfony\Component\Validator\Constraints as Assert;

class LuckyObjectListDto
{
    public int $count;

    /**
     * @var array<LuckyObjectDto>
    */
    #[Assert\Valid]
    #[Assert\NotBlank]
    public array $rows;
}

 #[Route('/lucky/object/denormalizer/custom', name: 'api_lucky_object_denormalizer_custom_post', methods: ['post'])]
    public function postObjectDenormalizerCustom(ValidatorInterface $validator, SerializerInterface $serializer): JsonResponse {
        $externalApiResponseText = <<<JSON
{
  "count": 4,
  "rows": [
    {
      "id": "CustomDenormalizeExemple1",
      "startAt": "2024-02-21T17:44:07+00:00",
      "secret": "foobaz",
      "name": "CustomDenormalizeExemple1Name"
    },
    {
      "id": "CustomDenormalizeExemple2",
      "startAt": "2024-02-21T17:44:07+00:00",
      "secret": "xorf",
      "name": "CustomDenormalizeExemple2Name"
    }
  ]
}
JSON;
        $externalApiResponse = $this->serializer->deserialize($externalApiResponseText, LuckyObjectListDto::class, "json", ['groups' => ['*']]);
        $errors = $validator->validate($externalApiResponse);
        if (count($errors) > 0) {
            $this->logger->error($errors);
            throw new \Exception("validation failed"); // Hide private informations to externals services
        }
        var_dump($externalApiResponse);
        return new JsonResponse(["success" => "ok"]);
    }
/** Affiche
object(App\Dto\LuckyObjectListDto)#242 (2) {
    [
        "count"
    ]=>
  int(4)
  [
        "rows"
    ]=>
  array(2) {
        [
            0
        ]=>
    object(App\Dto\LuckyObjectDto)#286 (4) {
            [
                "id"
            ]=>
      string(25) "CustomDenormalizeExemple1"
      [
                "startAt"
            ]=>
      object(DateTimeImmutable)#292 (3) {
                [
                    "date"
                ]=>
        string(26) "2024-02-21 17:44:07.000000"
        [
                    "timezone_type"
                ]=>
        int(1)
        [
                    "timezone"
                ]=>
        string(6) "+00:00"
            }
      [
                "secret"
            ]=>
      string(6) "foobaz"
      [
                "name"
            ]=>
      string(29) "CustomDenormalizeExemple1Name"
        }
    [
            1
        ]=>
    object(App\Dto\LuckyObjectDto)#300 (4) {
            [
                "id"
            ]=>
      string(25) "CustomDenormalizeExemple2"
      [
                "startAt"
            ]=>
      object(DateTimeImmutable)#278 (3) {
                [
                    "date"
                ]=>
        string(26) "2024-02-21 17:44:07.000000"
        [
                    "timezone_type"
                ]=>
        int(1)
        [
                    "timezone"
                ]=>
        string(6) "+00:00"
            }
      [
                "secret"
            ]=>
      string(4) "xorf"
      [
                "name"
            ]=>
      string(29) "CustomDenormalizeExemple2Name"
        }
    }
}
{
    "success": "ok"
}
**/

Super, cela fonctionne. Par contre, si nous développons un autre appel avec un objet différent, nous devons re-coder un nouvel DTO, par exemple :

<?php
// Dans UserListDto.php
namespace App\Dto;

use Symfony\Component\Validator\Constraints as Assert;

class UserListDto
{
    public int $count;

    /**
     * @var array<UserDto>
    */
    #[Assert\Valid]
    #[Assert\NotBlank]
    public array $rows;
}

Les développeurs aimant le principe DRY voudront sûrement réutiliser le même ListDto dans le cadre d’un autre appel. La PHPdoc nous permet de faire ceci :

<?php
// Dans CollectionDto.php
declare(strict_types=1);

namespace App\Dto;
use Symfony\Component\Validator\Constraints as Assert;

/**
 * @template T
 */
class CollectionDto
{
    public const API_RETURN_COUNTER = 'count'; // Keep it sync with the API return
    public const API_RETURN_ROWS = 'rows';

    public int $count;
    /**
     * @var array<T>
     */
    #[Assert\Valid]
    public array $rows;
}

Malheureusement le sérialiseur ne permet pas cette forme de manière native.

Écrivons un dé-normalisateur custom :

<?php
// Dans Serializer/Denormalizer/CollectionDenormalizer.php
declare(strict_types=1);

namespace App\Serializer\Denormalizer;

use Symfony\Component\Serializer\Exception\BadMethodCallException;
use Symfony\Component\Serializer\Exception\InvalidArgumentException;
use Symfony\Component\Serializer\Exception\NotNormalizableValueException;
use Symfony\Component\Serializer\Normalizer\CacheableSupportsMethodInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerAwareInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerAwareTrait;
use App\Dto\CollectionDto;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;

/**
 * Denormalizes arrays of objects.
 *
 * @final
 */
class CollectionDenormalizer implements DenormalizerInterface, DenormalizerAwareInterface
{
    use DenormalizerAwareTrait;

    /**
     * {@inheritdoc}
     *
     * @throws NotNormalizableValueException
     */
    public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed
    {
        if ($this->denormalizer === null) {
            throw new BadMethodCallException('Please set a denormalizer before calling denormalize()!');
        }
        if (!\is_array($data)) {
            throw new InvalidArgumentException('Data expected to be an array, ' . get_debug_type($data) . ' given.');
        }
        if (!$this->isValidGenericCollection($type)) {
            throw new InvalidArgumentException('Unsupported class: ' . $type);
        }

        if (!\array_key_exists(CollectionDto::API_RETURN_COUNTER, $data)) {
            throw new InvalidArgumentException('No counters found, format is {counter: int, rows: []}');
        }

        if (!\array_key_exists(CollectionDto::API_RETURN_ROWS, $data)) {
            throw new InvalidArgumentException('No rows found, format is {counter: int, rows: []}');
        }

        $subType = $this->getSubType($type);
        $rowsNormalized = $data[CollectionDto::API_RETURN_ROWS];

        $rows = $this->denormalizer->denormalize($rowsNormalized, $subType . '[]', $format, $context);

        $collectionDto = new CollectionDto();
        $collectionDto->count = $data[CollectionDto::API_RETURN_COUNTER];
        $collectionDto->rows = $rows;
        return $collectionDto;
    }

    private function getRegex(): string
    {
        $collectionClassname = preg_quote(CollectionDto::class);

        return "/^{$collectionClassname}<{1}(.*)>{1}$/m";
    }

    private function isValidGenericCollection(string $type): bool
    {
        return !!preg_match($this->getRegex(), $type);
    }

    private function getSubType(string $type): string
    {
        $matches = [];
        $totalMatches = preg_match_all($this->getRegex(), $type, $matches);
        if (\count($matches) !== 2) {
            throw new \Exception("Unexcepted number of captured groups {$totalMatches}");
        }

        return $matches[1][0];
    }

    /**
     * {@inheritdoc}
     */
    public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool
    {
        if ($this->denormalizer === null) {
            throw new BadMethodCallException(sprintf('The nested denormalizer needs to be set to allow "%s()" to be used.', __METHOD__));
        }

        if (!$this->isValidGenericCollection($type)) {
            return false;
        }

        $subType = $this->getSubType($type);
        return $this->denormalizer->supportsDenormalization($data, $subType, $format, $context);
    }

    public function getSupportedTypes(?string $format): array
    {
        return [
            '*' => true,
        ];
    }
}

Analysons ensemble les lignes :

class CollectionDenormalizer implements DenormalizerInterface, DenormalizerAwareInterface
{
    use DenormalizerAwareTrait;

La déclaration de la classe :

  •  Il est nécessaire d’implémenter à minima l’interface DenormalizerInterface.
  • L’implémentation de l’interface DenormalizerAwareInterface permet à symfony d’injecter automatiquement le serializer / deserializer. Cela est très utile pour faire des appels en cascade (comme vu dans “La récursivité des (de)normalisateurs”)
  • Le trait permet d’avoir une implémentation classique de l’interface DenormalizerAwareInterface

public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed
    {
        if ($this->denormalizer === null) {
            throw new BadMethodCallException('Please set a denormalizer before calling denormalize()!');
        }
        if (!\is_array($data)) {
            throw new InvalidArgumentException('Data expected to be an array, ' . get_debug_type($data) . ' given.');
        }
        if (!$this->isValidGenericCollection($type)) {
            throw new InvalidArgumentException('Unsupported class: ' . $type);
        }

        if (!\array_key_exists(CollectionDto::API_RETURN_COUNTER, $data)) {
            throw new InvalidArgumentException('No counters found, format is {counter: int, rows: []}');
        }

        if (!\array_key_exists(CollectionDto::API_RETURN_ROWS, $data)) {
            throw new InvalidArgumentException('No rows found, format is {counter: int, rows: []}');
        }

        $subType = $this->getSubType($type);
        $rowsNormalized = $data[CollectionDto::API_RETURN_ROWS];

        $rows = $this->denormalizer->denormalize($rowsNormalized, $subType . '[]', $format, $context);

        $collectionDto = new CollectionDto();
        $collectionDto->count = $data[CollectionDto::API_RETURN_COUNTER];
        $collectionDto->rows = $rows;
        return $collectionDto;
    }

La méthode de dénormalisation. Cette méthode est appelé uniquement si : 

  • Si supportsDenormalization renvoi “true”
  • Si le type de l’objet est bien présent dans getSupportedTypes.

Le premier bloc de code, une batterie de `if` permet de faire toutes les vérifications nécessaires :

if ($this->denormalizer === null) { // Vérifie que nous avons bien un denormalizer injecté dans l'objet
            throw new BadMethodCallException('Please set a denormalizer before calling denormalize()!');
        }
        if (!\is_array($data)) { // Vérifie que la data à dénormalisée est bien un array
            throw new InvalidArgumentException('Data expected to be an array, ' . get_debug_type($data) . ' given.');
        }
        if (!$this->isValidGenericCollection($type)) { // Vérifie que le type demandé soit bien celui que le dénormalisateur supporte
            throw new InvalidArgumentException('Unsupported class: ' . $type);
        }

        if (!\array_key_exists(CollectionDto::API_RETURN_COUNTER, $data)) { // Vérifie que la clé "count" existe dans le tableau
            throw new InvalidArgumentException('No counters found, format is {counter: int, rows: []}');
        }

        if (!\array_key_exists(CollectionDto::API_RETURN_ROWS, $data)) { // Vérifie que la clé "rows" existe dans le tableau
            throw new InvalidArgumentException('No rows found, format is {counter: int, rows: []}');
}

Ensuite vient le code qui permet de récupérer le sous-type ainsi que de vérifier que le “type” envoyé au normalisateur soit bien le type attendu :

$subType = $this->getSubType($type); // Récupère le sous-type depuis le type envoyé (la valeur entre les chevrons "<>")

Puis vient le code qui appel le serializer pour désérialiser “rows” et qui construit l’objet CollectionDto :

$rowsNormalized = $data[CollectionDto::API_RETURN_ROWS]; // Récupère les rows

        $rows = $this->denormalizer->denormalize($rowsNormalized, $subType . '[]', $format, $context); // Denormalize les rows via le dénormalisateur injecté, ici le Serializer de symfony. Le Serializer appelle, en interne, le ArrayDenormalizer

        $collectionDto = new CollectionDto();
        $collectionDto->count = $data[CollectionDto::API_RETURN_COUNTER];
        $collectionDto->rows = $rows;
        return $collectionDto;

Ensuite, quelques méthodes qui permettent d’extraire le sous type :

private function getRegex(): string // Return a regex that test if the type has the expected format
    {
        $collectionClassname = preg_quote(CollectionDto::class);

        return "/^{$collectionClassname}<{1}(.*)>{1}$/m";
    }

    private function isValidGenericCollection(string $type): bool // Check that the type is the expected one
    {
        return !!preg_match($this->getRegex(), $type);
    }

    private function getSubType(string $type): string // Récupère le sous type, la chaîne de caractère entre chevrons
    {
        $matches = [];
        $totalMatches = preg_match_all($this->getRegex(), $type, $matches);
        if (\count($matches) !== 2) {
            throw new \Exception("Unexcepted number of captured groups {$totalMatches}");
        }

        return $matches[1][0];
    }

Pour finir, les deux dernières méthodes permettent à Symfony de savoir si ce dé-normalisateur doit être utilisé pour un type donné :

/**
     * {@inheritdoc}
     */
    public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool
    {
        if ($this->denormalizer === null) { // Vérifie qu'un dénormalisateur est injecté
            throw new BadMethodCallException(sprintf('The nested denormalizer needs to be set to allow "%s()" to be used.', __METHOD__));
        }

        if (!$this->isValidGenericCollection($type)) { // Vérifie que le type soit bien de la forme attendue
            return false;
        }

        $subType = $this->getSubType($type); // Récupère le sous type

        // Vérifie que le sous type puisse être dénormalisé
        return $this->denormalizer->supportsDenormalization($data, $subType, $format, $context);
    }


    public function getSupportedTypes(?string $format): array
    {
        return [
            '*' => true, // Les génériques n'étant pas native en PHP, il n'y pas de type spécifique pour notre dénormalisateur
        ];
    }
  • supportsDenormalization : Permet de savoir de manière dynamique si le dé-normalisateur doit être utilisé
  • getSupportedTypes : Permet de savoir de manière statique si le dé-normalisateur doit être utilisé. Cela permet d’améliorer les performances de Symfony lors du choix du dé-normalisateur. Pour chaque type, la boolean correspond à si le résultat peut être mis en cache

Changeons ensuite notre route du contrôleur :

#[Route('/lucky/object/denormalizer/custom', name: 'api_lucky_object_denormalizer_custom_post', methods: ['post'])]
    public function postObjectDenormalizerCustom(ValidatorInterface $validator, SerializerInterface $serializer): JsonResponse {
        $externalApiResponseText = <<<JSON
{
  "count": 4,
  "rows": [
    {
      "id": "CustomDenormalizeExemple1",
      "startAt": "2024-02-21T17:44:07+00:00",
      "secret": "foobaz",
      "name": "CustomDenormalizeExemple1Name"
    },
    {
      "id": "CustomDenormalizeExemple2",
      "startAt": "2024-02-21T17:44:07+00:00",
      "secret": "xorf"
    }
  ]
}
JSON;
        $externalApiResponse = $this->serializer->deserialize($externalApiResponseText, CollectionDto::class.'<'.LuckyObjectDto::class.'>', "json", ['groups' => ['*']]);
        $errors = $validator->validate($externalApiResponse);
        if (count($errors) > 0) {
            $this->logger->error($errors);
            throw new \Exception("validation failed");
        }
        var_dump($externalApiResponse);
        return new JsonResponse(["success" => "ok"]);
    }
/**
Renvoi:
object(App\Dto\CollectionDto)#234 (2) {
    [
        "count"
    ]=>
  int(7)
  [
        "rows"
    ]=>
  array(2) {
        [
            0
        ]=>
    object(App\Dto\LuckyObjectDto)#236 (4) {
            [
                "id"
            ]=>
      string(25) "CustomDenormalizeExemple1"
      [
                "startAt"
            ]=>
      object(DateTimeImmutable)#241 (3) {
                [
                    "date"
                ]=>
        string(26) "2024-02-21 17:44:07.000000"
        [
                    "timezone_type"
                ]=>
        int(1)
        [
                    "timezone"
                ]=>
        string(6) "+00:00"
            }
      [
                "secret"
            ]=>
      string(6) "foobaz"
      [
                "name"
            ]=>
      string(29) "CustomDenormalizeExemple1Name"
        }
    [
            1
        ]=>
    object(App\Dto\LuckyObjectDto)#247 (4) {
            [
                "id"
            ]=>
      string(25) "CustomDenormalizeExemple2"
      [
                "startAt"
            ]=>
      object(DateTimeImmutable)#228 (3) {
                [
                    "date"
                ]=>
        string(26) "2024-02-21 17:44:07.000000"
        [
                    "timezone_type"
                ]=>
        int(1)
        [
                    "timezone"
                ]=>
        string(6) "+00:00"
            }
      [
                "secret"
            ]=>
      string(4) "xorf"
      [
                "name"
            ]=>
      string(29) "CustomDenormalizeExemple2Name"
        }
    }
}
{
    "success": "ok"
}
*/

Et voilà, grâce à la magie de Symfony, le dé-normalisateur est automatiquement enregistré dans le sérialisateur et en bonus, le validator valide également “$externalApiResponse”. De plus, votre IDE a la capacité d’auto complete les éléments de “rows”.

Gestion des Références Circulaires

Les références circulaires, souvent dues à des relations bidirectionnelles, peuvent être un défi. La technique la plus classique pour éviter ce genre de problème est de ne pas avoir de relation bidirectionnelle lors de l’ajout des groupes de sérialisation.

Cependant si cela est vraiment nécessaire, le serializer propose de gérer les références circulaire en délégant la normalisation à une callback. 

Le code suivant ne gère pas les références circulaire :

On suppose 2 Dtos :

class UserDto
{
    #[Groups(['api_user_post'])]
    public string $name;


    #[Groups(['api_user_post'])]
    public CompanyDto $company;
}

class CompanyDto
{
    #[Groups(['api_user_post'])]
    public string $name;

    #[Groups(['api_user_post'])]
    public UserDto $user;
}

Et une méthode de contrôleur :

#[Route('/user/{id}', name: 'api_user_post', methods: ['post'])]
    public function postUser(string $id, SerializerInterface $serializer): JsonResponse
    {
        $userDto = new UserDto();
        $companyDto = new CompanyDto();
        $userDto->name = "Ben";
        $companyDto->name = "TheCodingMachine";
        $userDto->company = $companyDto;
        $companyDto->user = $userDto;
        return $this->json($userDto, 200, [], ['groups' => ["api_user_post"]]);
    }

L’exemple renvoi une exception avec comme message :

A circular reference has been detected when serializing the object of class \"App\\Dto\\UserDto\" (configured limit: 1).

Afin de renseigner une callback, il faut changer la méthode en :

public function postUser(string $id, SerializerInterface $serializer): JsonResponse
    {
        $userDto = new UserDto();
        $companyDto = new CompanyDto();
        $userDto->name = "Ben";
        $companyDto->name = "TheCodingMachine";
        $userDto->company = $companyDto;
        $companyDto->user = $userDto;
        return $this->json($userDto, 200, [], ['groups' => ["api_user_post"], AbstractNormalizer::CIRCULAR_REFERENCE_HANDLER => function($object, $format, $context) {
            if ($object instanceof CompanyDto) {
                return $object->name;
            }
            if ($object instanceof UserDto) {
                return $object->name;
            }
            throw new \Exception();
        }]);
    }

L’attribut MaxDepth

L’attribut MaxDepth permet de gérer la profondeur des arbres. 

Modifions notre CompanyDto :

class CompanyDto
{
    #[Groups(['api_user_post', 'api_company_post'])]
    #[Assert\NotBlank]
    public string $name;

    #[Groups(['api_user_post'])]
    public UserDto $user;

    #[Groups(['api_company_post'])]
    public CompanyDto $subCompany;
}

Et supposons la route suivante :

#[Route('/company/{id}', name: 'api_company_post', methods: ['post'])]
    public function postCompany(string $id, SerializerInterface $serializer): JsonResponse
    {
        $companyDto1 = new CompanyDto();
        $companyDto2 = new CompanyDto();
        $companyDto3 = new CompanyDto();
        $companyDto4 = new CompanyDto();
        $companyDto1->name = "TheCodingMachine Holding";
        $companyDto1->subCompany = $companyDto2;
        $companyDto2->name = "TheCodingMachine France";
        $companyDto2->subCompany = $companyDto3;
        $companyDto3->name = "TheCodingMachine Paris";
        $companyDto3->subCompany = $companyDto4;
        $companyDto4->name = "TheCodingMachine Saint Lazare";
        return $this->json($companyDto1, 200, [], ['groups' => ["api_company_post"]]);
    }

/** Renvoi: 
{
    "name": "TheCodingMachine Holding",
    "subCompany": {
        "name": "TheCodingMachine France",
        "subCompany": {
            "name": "TheCodingMachine Paris",
            "subCompany": {
                "name": "TheCodingMachine Saint Lazare"
            }
        }
    }
}
*/

En ajoutant l’attribut MaxDepth et en ajoutant l’option enable_max_depth au contexte, il est possible de diminuer la profondeur de l’arbre. Il faut donc ajouter l’attribut MaxDepth au DTO :

#[Groups(['api_company_post'])]
    #[MaxDepth(1)]
    public CompanyDto $subCompany;

Et ajouter l’option au contexte :

return $this->json($companyDto1, 200, [], ['groups' => ["api_company_post"], AbstractObjectNormalizer::ENABLE_MAX_DEPTH => true]);
/**
Renvoi 
   {
    "name": "TheCodingMachine Holding",
    "subCompany": {
        "name": "TheCodingMachine France"
    }
}
*/

Il est également possible de contrôler le comportement de la sérialisation lors de la détection d’une profondeur.

return $this->json($companyDto1, 200, [], ['groups' => ["api_company_post"], AbstractObjectNormalizer::ENABLE_MAX_DEPTH => true, AbstractObjectNormalizer::MAX_DEPTH_HANDLER => function(object $object) {
            return "More companies...";
}]);

/**
Renvoi 
{
    "name": "TheCodingMachine Holding",
    "subCompany": {
        "name": "TheCodingMachine France",
        "subCompany": "More companies..."
    }
}
*/

Considérations de Performance et de Sécurité

L’intégration avec Doctrine nécessite de comprendre les mécanismes de normalisation. Le lazy loading dans Doctrine implique que le SF Serializer peut déclencher le chargement d’entités liées. Cela peut conduire à des problèmes de performance potentiel (le problème N+1). Des solutions comme les FetchJoins et l’hydratation multi-étapes sont alors recommandées.

En matière de sécurité, l’utilisation de groupes permet une sérialisation contrôlée, évitant l’exposition de données sensibles et abordant, en partie, les préoccupations de performance.

Conclusion

Le serializer est un outil essentiel lorsqu’il s’agit de convertir des données entre leur représentation sous forme de chaînes de caractères et des objets dans votre application, et vice versa.

Cet outil est hautement extensible, ce qui signifie que vous pouvez l’adapter pour répondre aux besoins spécifiques de votre application. Vous pouvez ajouter des fonctionnalités supplémentaires et personnaliser son comportement selon les exigences de votre projet.

Cependant, il est important de noter que le serializer peut être une arme à double tranchant. Bien qu’il soit puissant, il peut également être dangereux si vous ne faites pas attention. Lorsque vous utilisez le serializer, vous pourriez involontairement exposer des données sensibles si vous ne prenez pas les précautions nécessaires.

C’est là que les groupes de sérialisation entrent en jeu. Les groupes de sérialisation permettent de définir une stratégie spécifique pour la transformation des objets en chaînes de caractères et vice versa. Il est crucial de définir ces groupes en amont avec votre équipe, en tenant compte des enjeux de sécurité et de performance de votre application. En déterminant précisément quels attributs d’objet doivent être sérialiser ou désérialiser dans quelles circonstances, vous pouvez mieux contrôler la manière dont vos données sont exposées et manipulées par l’application.

Article qui pourrait également intéresser – Utilisation de Docker : démarrage et bonne pratique


par Benoît Ngo

Articles similaires TAG