Astuce Utiliser le débogage pas à pas avec Docker et PHPStorm

Docker simplifie beaucoup de problématiques d’environnement pour les développeurs.
Quand la configuration de Docker est correcte, un développeur peut lancer un projet rapidement sans avoir à installer la bonne version de PHP, de la base de données, etc.

Malheureusement, cela complexifie aussi la communication entre ce qui tourne sur votre système hôte (ex : Windows) et les services nécessaires au projet (typiquement : PHP).

Voici comment configurer PHPStorm pour utiliser le débogage pas à pas d’XDebug, quand PHP est dockerisé.

Prérequis

PHP avec xDebug

Avant de parler de débogage pas à pas, il faut déjà qu’XDebug soit installé dans votre image docker. Voici un exemple d’image Docker avec PHP 8.4 et XDebug :

FROM php:8.4-fpm-alpine3.20

# Paramétrage du chemin vers les sources dans le conteneur
ENV MOUNT_DIR /srv/php
RUN mkdir -p ${MOUNT_DIR}
WORKDIR ${MOUNT_DIR}

# Installation d’XDebug
RUN install-php-extensions xdebug
COPY config/xdebug.ini $PHP_INI_DIR/conf.d/

Elle utilise ce fichier de config xdebug.ini (dans le répertoire config/) qui sera injecté dans l’image :

;config/xdebug.ini
xdebug.mode = debug,develop,profile
xdebug.start_with_request = trigger
xdebug.client_host = host.docker.internal

On peut builder l’image en local avec une commande du type :

docker build . -t my-group/my-image

Débogage pas à pas

Objectif

Dans l’exemple qui suit, on supposera qu’on souhaite déboguer le fichier src/Controller/TestController.php, lorsqu’on accède à la page http://localhost:8000/test.

Fichier PHP à déboguer

L’exemple utilise Symfony, mais c’est la même chose pour un autre contexte PHP. C’est bien un fichier index.php qui est exécuté ici, et il se charge ensuite d’appeler la méthode number() du fichier TestController.php.

Notez le point rouge dans la marge à gauche (l. 18). Il représente un point d’arrêt : là où vous souhaitez arrêter l’exécution pour déboguer. Pour ajouter un point d’arrêt, cliquez dans la marge à gauche de la ligne de code juste avant laquelle vous souhaitez arrêter l’exécution pour déboguer.
Ici, l’exécution s’arrêtera donc juste avant d’exécuter la ligne 18.

Configuration

Dans PHPStorm, on peut activer le débogage pas à pas en cliquant sur le bouton « téléphone rouge », qui devient vert quand il est activé (aussi disponible en bas du menu Run : Start Listening for PHP Debug Connections).

Boutons de débogage

Mais avant, il faut cliquer sur la liste déroulante Current file à côté, puis sur Edit Configurations....

Nouvelle configuration de Run

Dans la fenêtre de configuration :

  • Cliquez sur le bouton Add
  • Sélectionnez PHP Remote debug
  • Nommez la configuration (ex : Docker)

Boutons de débogage

  • Cliquez sur le lien Validate
  • Sélectionnez le bouton radio Output phpinfo()
  • Créez un fichier php contenant uniquement phpinfo();
  • Affichez-le dans votre navigateur et copiez la source de la page (via Ctrl+U)
  • Collez le code HTML dans le champ texte de la fenêtre de configuration
  • Cliquez sur le bouton Validate.
    PHPStorm va valider une série d’éléments et indiquer si XDebug est bien opérationnel :

Validation de la présence d’XDebug

  • Cliquez sur le bouton Cancel.

Extension de navigateur

Pour simplifier le débogage, vous pouvez installer une extension de navigateur, qui ajoutera une variable dans le header des requêtes, pour indiquer au serveur qu’on souhaite utiliser le débogage pas à pas.

Il en existe plusieurs, comme Xdebug Helper.

Elles fonctionnent toutes plus ou moins de la même manière, en ajoutant un bouton pour activer/désactiver le débogage :

Bouton de l’extension de navigateur

Débogage

  • Cliquez sur le bouton « téléphone rouge » dans PHPStorm pour activer le débogage
  • Cliquez sur le bouton « insecte » (ajouté par l’extension) dans votre navigateur pour activer le débogage
  • Appelez votre page à déboguer.
    La page devrait rester bloquée en cours de chargement, et PHPStorm devrait afficher une fenêtre indiquant qu’il a capté un script à déboguer :

Débogage entrant

  • Cliquez sur le bouton Accept

Normalement, rien ne se passe et la page s’affiche normalement. PHPStorm affiche un message d’alerte, car il n’a pas réussi à faire le lien entre la requête et le fichier PHP concret à déboguer :

Message d’alerte

  • Cliquez sur le lien PHP|Servers dans le message (ou recherchez cette configuration depuis la fenêtre de Settings globale de PHPStorm)
  • Créez un nouveau server (ex : nommé localhost).
  • Par défaut, seulement le répertoire contenant le fichier index.php est mappé. Il faut ajouter le répertoire racine de votre application PHP.
    Dans le tableau, la colonne de gauche indique le chemin physique de votre application sur votre machine ( probablement un répertoire WSL si vous êtes sous Windows). Celle de droite indique le chemin de l’application à l’intérieur de votre conteneur docker (/srv/php correspond au chemin indiqué dans le Dockerfile précédent).

Paramétrage du mapping des fichiers

Normalement, tout est opérationnel. Vous pouvez recharger votre page de test et l’exécution devrait s’arrêter au niveau de votre point d’arrêt.

Dans la section basse de votre IDE, apparait la variable locale $randomNumbers, avec sa valeur courante :

Débogage de la variable $randomNumbers

Marque-page Utiliser les cookies en javascript

L’utilisation des cookies en javascript est à la fois simple et compliquée.

On aimerait une interface d’API un peu plus directe, pour pouvoir juste ajouter/modifier/supprimer un cookie.
Voici un exemple d’implémentation d’une telle API, en javascript puis en typescript.

Version javascript :

//cookie.js
export const ONE_DAY_IN_SECONDS = 24 * 60 * 60 * 1000;
export const SEVEN_DAYS_IN_SECONDS = 7 * ONE_DAY_IN_SECONDS;

/**
 * @param {string} name
 * @param {string} value
 * @param {number} expirationDuration Durée en secondes, ou -1 pour que le cookie soit supprimé à la fermeture de la page.
 */
export function setCookie(name, value, expirationDuration = SEVEN_DAYS_IN_SECONDS) {
    const date = new Date();
    date.setTime(date.getTime() + expirationDuration);

    const expirationPart = expirationDuration > 0
        ? ` expires=${date.toUTCString()};`
        : '';

    document.cookie = `${encodeURIComponent(name)}=${encodeURIComponent(value)};${expirationPart}path=/`;
}

/**
 * @param {string} name
 * @param {string|null} defaultValue
 *
 * @return {string|null}
 */
export function getCookie(name, defaultValue = null) {
    const encodedCookie = document.cookie
        .split('; ')
        .find((cookie) => cookie.startsWith(encodeURIComponent(name) + '='))
        ?.split('=')?.[1];

    return encodedCookie !== undefined ? decodeURIComponent(encodedCookie) : defaultValue;
}

/**
 * @param {string} name
 */
export function deleteCookie(name) {
    const expirationDate = new Date('Thu, 01 Jan 1970 00:00:01 GMT');

    document.cookie = `${encodeURIComponent(name)}=; expires=${expirationDate.toUTCString()}; path=/`;
}

Version typescript :

//cookie.ts
export const ONE_DAY_IN_SECONDS = 24 * 60 * 60 * 1000;
export const SEVEN_DAYS_IN_SECONDS = 7 * ONE_DAY_IN_SECONDS;

/**
 * @param {number} expirationDuration Durée en secondes, ou -1 pour que le cookie soit supprimé à la fermeture de la page.
 */
export const setCookie = (name: string, value: string, expirationDuration: number = SEVEN_DAYS_IN_SECONDS) => {
    const date = new Date();
    date.setTime(date.getTime() + expirationDuration);

    const expirationPart = expirationDuration > 0
        ? ` expires=${date.toUTCString()};`
        : '';

    document.cookie = `${encodeURIComponent(name)}=${encodeURIComponent(value)};${expirationPart}path=/`;
};

export const getCookie = (name: string, defaultValue: string | null = null) => {
    const encodedCookie = document.cookie
        .split('; ')
        .find((cookie) => cookie.startsWith(encodeURIComponent(name) + '='))
        ?.split('=')?.[1];

    return encodedCookie !== undefined ? decodeURIComponent(encodedCookie) : defaultValue;
};

export const deleteCookie = (name: string) => {
    const expirationDate = new Date('Thu, 01 Jan 1970 00:00:01 GMT');

    document.cookie = `${encodeURIComponent(name)}=; expires=${expirationDate.toUTCString()}; path=/`;
};

Erreur Routing et export statique de Next.js vers Azure Static Web Apps

Lorsqu’on fait un export statique d’une application Next.js pour l’héberger sur Azure Static Web Apps, l’application ne fonctionne que lorsqu’on accède à la racine de l’application (/). Les autres chemins (ex : /ma-page) retourne une erreur 404.

Pour que le chemin /ma-page fonctionne, il faut en fait rediriger vers /ma-page/index.html.

On peut faire ça pour toutes les pages automatiquement, via un peu de configuration.

  1. Modifiez le fichier next.config.mjs pour y ajouter trailingSlash: true. Exemple :

    /** @type {import('next').NextConfig} */
    const nextConfig = {
      output: 'export',
      trailingSlash: true,
    };
    
    export default nextConfig;
  2. Créez un fichier staticwebapp.config.json à la racine, destiné à Azure Static Web Apps :

    {
      "navigationFallback": {
        "rewrite": "index.html",
        "exclude": ["*.{png,jpg,gif,svg}", "*.css"]
      }
    }

Astuce Déployer un site statique vers Microsoft Azure

Le service Microsoft Azure permet d’héberger un site statique dans un espace de stockage. Voici comment réaliser le déploiement en lignes de commande.

Prérequis

  1. Installez la CLI Azure

    # pour Debian
    curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash

    Documentation d’installation : https://learn.microsoft.com/fr-fr/cli/azure/install-azure-cli

  2. Activez les sites web statiques dans Azure Storage :

Paramétrage Azure storage

Déploiement

Variables d’environnement à configurer

  • AZURE_APP_ID (ex : 8514903f-ed49-48b6-b5f8-bc4ff676fce3) :
  • AZURE_CLIENT_SECRET (ex : TYY5A~I2d19dffcf938ImHBeS~-.S4db2318T.) :
  • AZURE_TENANT_ID (ex : 44db2318-9218-41b4-b935-2d19dffcf938) :
  • AZURE_STORAGE_ACCOUNT (ex : stmystaticwebsite) :

    AZURE_STORAGE_ACCOUNT

export AZURE_APP_ID='8514903f-ed49-48b6-b5f8-bc4ff676fce3'
export AZURE_CLIENT_SECRET='TYY5A~I2d19dffcf938ImHBeS~-.S4db2318T.'
export AZURE_TENANT_ID='44db2318-9218-41b4-b935-2d19dffcf938'
export AZURE_STORAGE_ACCOUNT='stmystaticwebsite'

Déploiement

# Authentification
az login --service-principal -u $AZURE_APP_ID -p $AZURE_CLIENT_SECRET --tenant $AZURE_TENANT_ID
# Suppression des fichiers statiques existants
az storage blob delete-batch \
  --account-name $AZURE_STORAGE_ACCOUNT --auth-mode login \
  --source '$web' --pattern '*'
# Envoi des nouveaux fichiers statiques
az storage blob upload-batch \
  --account-name $AZURE_STORAGE_ACCOUNT --auth-mode login \
  --destination '$web' -s my_static_app_dir

CDN

Au cas où Microsoft Azure CDN est activé pour servir le site statique, il faut penser à le purger.

Variables d’environnement à configurer

  • AZURE_RESOURCE_GROUP (ex : RG_MY_STATIC_WEBSITE) :

    AZURE_RESOURCE_GROUP

  • AZURE_CDN_PROFILE_NAME (ex : cdnpmystaticwebsite) :

    AZURE_CDN_PROFILE_NAME

  • AZURE_CDN_ENDPOINT_NAME (ex : cdnemystaticwebsite) : c’est le même qu’AZURE_CDN_PROFILE_NAME, mais avec cdne plutôt que cdnp en préfixe.

    export AZURE_RESOURCE_GROUP='RG_MY_STATIC_WEBSITE'
    export AZURE_CDN_PROFILE_NAME='cdnpmystaticwebsite'
    export AZURE_CDN_ENDPOINT_NAME='cdnemystaticwebsite'

Purge du CDN

az cdn endpoint purge \
  --resource-group $AZURE_RESOURCE_GROUP \
  --profile-name $AZURE_CDN_PROFILE_NAME \
  --endpoint-name $AZURE_CDN_ENDPOINT_NAME \
  --content-paths '/*'

Erreur SAVEPOINT DOCTRINE_2 does not exist

Lors de l’exécution d’une migration Doctrine pour le déploiement d’un projet Symfony 5.x, vous lancez généralement cette commande :

php bin/console doctrine:migration:migrate --all-or-nothing --no-interaction

Vous pouvez alors obtenir de MySQL l’erreur SAVEPOINT DOCTRINE_2 does not exist. Cela veut dire que MySQL n’arrive pas à utiliser le système de transaction pour effectuer un rollback.

À défaut de permettre à MySQL d’effectuer son rollback correctement, vous pouvez lui éviter d’avoir à le faire, en corrigeant la migration qui pose le problème.

Plutôt que d’exécuter toutes les migrations d’une traite, exécutez-les une par une, via la commande suivante :

php bin/console doctrine:migration:migrate next --no-interaction

Si la migration échoue, le vrai message d’erreur SQL qui vous intéresse s’affichera, et pas celle concernant le rollback. Si elle réussit, relancez la commande jusqu’à trouver la migration fautive.

Pour connaître l’état des migrations, utilisez la commande status :

php bin/console doctrine:migration:status

Erreur Docker – unauthorized: HTTP Basic: Access denied

Gitlab semble privilégier l’authentification via jeton d’accès personnel, pour se connecter au registre de conteneurs docker.

Remarque : Dans cet article, on considère que l’instance Gitlab hébergeant le registre de conteneurs avec lequel vous souhaitez interagir a pour URL https://my-gitlab.com. Remplacez-la par la vôtre, sans oublier d’y ajouter le port, si besoin.

Si vous n'êtes pas authentifié via un jeton, vous obtenez l’erreur suivante à chaque git pull ou git push :

# Utilisez l’URL de votre instance gitlab privée ou celle de gitlab.com
> docker pull my-gitlab.com/my-project-group/my-project/my/image:latest
Error response from daemon: Head "https://my-gitlab.com/my-project-group/my-project/my/image/manifests/latest": 
unauthorized: HTTP Basic: Access denied. The provided password or token is incorrect or your account has 2FA enabled 
and you must use a personal access token instead of a password. 
See https://my-gitlab.com/help/user/profile/account/two_factor_authentication#troubleshooting

Génération d’un jeton d’accès personnel

Pour éviter cela, il vous faut générer un nouveau jeton d’accès dans Gitlab :

  • Accédez à la page Personal access tokens de gitlab (https://my-gitlab.com/-/user_settings/personal_access_tokens)
  • Cliquez sur le bouton « Ajouter un nouveau jeton »
  • Précisez un nom de jeton (évitez les guillemets " et antislash \)
  • Définissez éventuellement la date d’expiration du jeton
  • Cochez les cases read_api, read_registry (et éventuellement write_registry si vous comptez faire des docker push)
  • Sauvegardez le jeton généré dans un espace sécurisé

Authentification avec le jeton

Authentifiez-vous auprès de votre instance Gitlab, en ligne de commande :

docker login my-gitlab.com --username "My token name" --password "glpat-as767nCwxmEfDhf_yHA7"

Cette commande revient à celle de login classique de docker, avec :

  • pour nom d’utilisateur, le nom du jeton tel qu’affiché dans le tableau listant les jetons
  • pour mot de passe, le jeton sauvegardé

Remarque :
Comme docker l’indique en message d’alerte au lancement de la commande, celle-ci n’est pas sécurisée. En effet, votre mot de passe sera stocké dans l’historique bash des commandes lancées.

Pour éviter cela, stockez plutôt le jeton dans un fichier :

# Créez un fichier ~/my_token.txt contenant votre jeton d’accès
cat ~/my_token.txt | docker login my-gitlab.com --username "My token name" --password-stdin
rm ~/my_token.txt

ou en variable d’environnement :

echo "$MY_TOKEN" | docker login my-gitlab.com --username "My token name" --password-stdin

Astuce Modifier les métadonnées

Dans une application React, vous pouvez définir des métadonnées via des balises <meta> à l'intérieur de la balise <head>, directement dans le fichier index.html.

<!doctype html>
<html lang="fr">
<head>
    <meta charset="UTF-8"/>
    <base href="/"/>
    <meta name="viewport" content="width=device-width, initial-scale=1.0, interactive-widget=overlays-content">
    <meta name="description" content="A sample app that introduces useMetaTag and useTitleTag hooks"/>
    <title>My Awesome app v1.1</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

Malheureusement, ces données sont statiques et ne s'adaptent pas dynamiquement à la page en cours. Pour palier cela, la solution consiste à modifier ou ajouter ces balises dynamiquement dans le DOM en javascript.

Le projet React Helmet devrait convenir à cette tâche, mais ne semble plus maintenu.

Voici deux hooks simples permettant de gérer vous-même vos balises <meta> et <title>.

Balises <meta> :

/**
 * Crée ou met à jour dynamiquement une balise meta dans le head de la page.
 *
 * @param {string} name Valeur de l'attribut `name` de la balise
 * @param {string} content Valeur de l'attribut `content` de la balise
 */
export const useMetaTag = (name: string, content: string) => {
    let metaElement = document.querySelector(`meta[name='${name}']`);
    if (!metaElement) {
      metaElement = document.createElement('meta');
      metaElement.setAttribute('name', name);
      document.head.appendChild(metaElement);
    }

    metaElement.setAttribute('content', content);
  };

Balise <title> :

/**
 * Crée ou met à jour dynamiquement une balise title dans le head de la page.
 *
 * @param {string} title Titre de la page
 */
export const useTitleTag = (title: string) => {
    let titleElement = document.querySelector('title');
    if (!titleElement) {
      titleElement = document.createElement('title') as HTMLTitleElement;
      document.head.appendChild(titleElement);
    }

    titleElement.textContent = title;
  };

Exemple d'utilisation :

import { useNavigation } from './helpers/useNavigation.ts';
import { useTitleTag } from './helpers/useTitleTag.ts';
import { version } from '../../package.json';

export const App = () => {
  useMetaTag('description', 'A sample app that introduces useMetaTag and useTitleTag hooks');
  useTitleTag(`My Awesome app - v${version}`);
}

Note : Si vous souhaitez des métadonnées spécifiques à une certaine page, utiliser ces hooks dans votre composant associé à votre route plutôt que dans le App.tsx.

Astuce Afficher le numéro de version

Pour simplifier les mises à jour de votre application, il est préférable de n'indiquer le numéro de version qu'à un seul endroit. Cet endroit peut être un fichier mis à jour manuellement ou via la CI. Par exemple le package.json.

Ex :

{
  "name": "my-app",
  "version": "0.0.1",
  [...]
}

Pour récupérer le numéro de ce fichier, vous pouvez utiliser simplement les imports javascript :

// src/app/App.tsx
import { version } from '../../package.json';

Si vous utilisez typescript, cela nécessite d'activer l'option resolveJsonModule dans tsconfig.json :

{
  "compilerOptions": {
    [...]
    "resolveJsonModule": true,
    [...]
  }
}

Astuce Structure d'un projet React

Comme pour pas mal d'aspects, React ne propose pas vraiment de solution standard pour organiser la structure de son projet.

Voici quelques pistes possibles, se rapprochant de ce qu'on peut trouver dans l'écosystème React.

Structure simple

src/
- components/
  - MyComponent/
    - MyComponent.tsx
- helpers/
- hooks/
- model/
  - MyEntity.ts
- services/

On regroupe ici chaque élément par type (les hooks avec les hooks, les composants avec les composants, les interfaces du modèle entre elles…). Au fur et à mesure que le projet grossit, on ajoute des sous répertoires. Par exemple :

src/
- components/
  - forms/
    - fields/
      - MyField/
        - MyField.tsx
    - MyForm/
        - MyForm.tsx
  - pages/
    - MyPage.tsx
- helpers/
- hooks/
- model/
  - interfaces/
    - MyEntity.ts
  - enums/
    - MyEnum.ts
- services/

Atomic design

Cette approche se focalise sur le découpage des composants, et nous pousse à réfléchir à leurs responsabilités. Pour le reste, on peut conserver la structure précédente.

src/
- components/
  - atoms/
    - MyField/
      - MyField.tsx
  - molecules/
    - MyForm/
      - MyForm.tsx
  - organisms/
    - MyForm/
      - MyForm.tsx

Le principe, c'est de hiérarchiser les composants en 3 groupes :

  • les atomes, sont les composants élémentaires de plus petit niveau, à la base de tout. On y trouve typiquement des boutons, des champs de formulaires… Bref, des composants réutilisables et sans aucun rapport avec le métier.
  • les molécules constituent le niveau intermédiaire, et représentent des ensembles fonctionnels réutilisables. On peut y ranger les formulaires, les listes…
  • les organismes correspondent au niveau le plus haut, typiquement à une page simple. Par exemple une liste de tâches, avec des filtres et une pagination.

Les dépendances sont interdites vers un niveau supérieur. Autrement dit, vos atomes ne doivent dépendre de rien.

Vous en saurez plus avec cet article de blog : https://blog.logrocket.com/atomic-design-react-native.

Bonus :

Si vous devez gérer des pages complexes, qui proposent plusieurs fonctionnalités indépendantes, mais sur un même écran, vous pouvez ajouter un quatrième niveau pages/, qui servira de passe-plat entre le routeur et les organismes.

Cela permet aux organismes de s'affranchir des paramètres d'URL et ajoute plus de souplesse si tout à coup, vous devez remplacer votre page de création d'utilisateur par une fenêtre modale sur votre page de liste.

De plus, comme pour la structure simple, vous pouvez créer des sous-répertoires pour regrouper vos composants (ex : buttons/, fields/, lists/, cards/…).

Features

Le problème des deux types de structures précédentes, c'est que les fichiers sont disséminés un peu partout, plutôt que d'être regroupés par zone fonctionnelle. Si vous voulez supprimer une page, vous devrez peut-être supprimer des fichiers dans model/, components/ et hooks/.

Pour éviter cela, vous pouvez utiliser la structure proposée sur le dépôt bulletproof-react.

Le principe est de créer un répertoire features/, contenant autant de sous-répertoires que de zones fonctionnelles de votre application. Dans chacun d'eux, on y retrouvera la structure simple (voire Atomic design) présentée précédemment.

Par exemple :

plusieurs features

Cette structure n'a d'intérêt que si l'on respecte une hiérarchie entre les features. Une première feature peut dépendre d'une ou plusieurs autres, mais ces deux autres ne doivent pas dépendre de la première. Pour résumer, attention aux dépendances cycliques.

L'exercice n'est pas forcément simple, et si l'on n'arrive pas à éviter les dépendances bidirectionnelles entre deux composantes métier, c'est qu'elles sont trop intriquées pour les séparer en deux features distinctes.
On peut alors éventuellement utiliser une troisième feature (ex : core), dont dépendront les deux autres, ou placer les fichiers transversaux dans des répertoires à la racine de src/ comme proposé par bulletproof-react.

Astuce Gestionnaire de rollbacks

Problématique

La plupart des SGBD proposent un fonctionnement en transactions qui permet, en cas d’erreur, de retrouver les données telles qu’ells étaient au début de la transaction.

Schématiquement, cela se passe ainsi :

  • On déclare le début d’une transaction
  • On effectue toutes les opérations que l’on souhaite
  • Si l’on considère que tout s’est bien passé, on valide la transaction et toutes les modifications de données induites par les opérations sont entérinées
  • Si, à l’inverse, une opération échoue, on effectue un rollback qui remet les données comme elles étaient au début

Ce fonctionnement est facile à mettre en œuvre dans Symfony, avec Doctrine. C’est plutôt simple et efficace, mais cela ne concerne que les écritures en base de données.

On peut avoir exactement le même besoin pour des écritures via des appels API.

Exemple avec une création de compte

  • Je crée un compte utilisateur dans mon application (donnant lieu à une écriture en base de données)
  • J’appelle une API externe d’authentification, pour lui demander de créer ce compte utilisateur (dans sa base de données)
  • J’appelle une autre API externe de gestion de fiches client, pour lui demander d’en créer une nouvelle pour cet utilisateur (dans sa base de données)

L’appel à la seconde API échoue. Sans fiche client, l’application ne fonctionnera pas correctement et on préfère donc annuler complètement la création du compte. Il nous faut donc annuler :

  • l’écriture dans la BDD de l’application
  • l’écriture réalisée auprès de la 1re API

Cet exemple est encore relativement simple s’il n’y a que deux traitements à annuler, mais s’ils sont nombreux, un service dédié aux rollbacks va vite devenir utile.

Service de gestion de rollbacks

Voici un exemple simple d’un tel service, avec deux méthodes

  • registerRollbackOperation() : ajoute une opération de rollback dans un registre interne au service
  • rollbackAll() : exécute toutes les opérations de rollbacks du registre

Note :

Contrairement à celui de Doctrine, on ne déclare pas de début de transaction, et on ne valide pas de transaction.
On ne peut donc pas réinitialiser le registre pour commencer une seconde transaction.
On peut tout de même gérer plusieurs lots de rollbacks indépendants, en utilisant plusieurs instances du service en parallèle.

<?php

namespace App\Service;

use Psr\Log\LoggerInterface;
use Symfony\Component\Serializer\SerializerInterface;

class RollbackService
{
    private array $rollbackOperations = [];

    public function __construct(
        private readonly SerializerInterface $serializer,
        private readonly LoggerInterface $logger,
    ) {
    }

    /**
     * Ajoute une opération de rollback au registre.
     *
     * @param string $pool Lot d’opération
     * @param callable $rollbackFunction Fonction à exécuter lors du rollback
     * @param array $rollbackArguments Arguments à passer à la fonction de rollback
     * @param array $context Contexte de l’opération, utile pour les logs
     *
     * @return void
     */
    public function registerRollbackOperation(
        string $pool,
        callable $rollbackFunction,
        array $rollbackArguments,
        array $context,
    ): void {
        $this->rollbackOperations[] = [
            'pool' => $pool,
            'function' => $rollbackFunction,
            'arguments' => $rollbackArguments,
            'context' => $context,
        ];
    }

    /**
     * Dépile et exécute les opérations de rollback du registre, une à une, de la dernière à la première ajoutée.
     *
     * @return void
     */
    public function rollbackAll(): void
    {
        $totalNbOfOperations = \count($this->rollbackOperations);
        $index = 0;
        while ($rollbackOperation = array_pop($this->rollbackOperations)) {
            $index++;
            $this->logger->debug(
                sprintf('Rollback %s/%s - %s', $index, $totalNbOfOperations, $rollbackOperation['pool']),
            );

            try {
                $rollbackOperation['function'](...$rollbackOperation['arguments']);
                $this->logger->debug(
                    sprintf(
                        'Rollback %s/%s successful - %s %s',
                        $index,
                        $totalNbOfOperations,
                        $rollbackOperation['pool'],
                        $this->argsAndContextToString($rollbackOperation),
                    ),
                );
            } catch (\Exception $e) {
                $this->logger->error(
                    sprintf(
                        'Failure for rollback %s/%s - %s : %s %s',
                        $index,
                        $totalNbOfOperations,
                        $rollbackOperation['pool'],
                        $e->getMessage(),
                        $this->argsAndContextToString($rollbackOperation),
                    ),
                    ['exception' => $e],
                );
            }
        }
    }

    private function argsAndContextToString(array $rollbackOperation): string
    {
        return sprintf(
            '(args : %s; context : %s)',
            $this->encode($rollbackOperation['arguments']),
            $this->encode($rollbackOperation['context']),
        );
    }

    private function encode(mixed $context): string
    {
        return $this->serializer->serialize($context, 'json');
    }
}

Exemple d’utilisation, combiné avec les transactions Doctrine:

<?php

namespace App\Service;

use Doctrine\ORM\EntityManagerInterface;
use App\Entity\User;

class AccountCreationService
{
    public function __construct(
        private readonly EntityManagerInterface $entityManager,
        private readonly Api1Service $api1Service,
        private readonly Api2Service $api2Service,
        private readonly RollbackService $rollbackService,
    ) {
    }

    public function createAccountFromAdminInterface(array $data): void
    {
        $this->entityManager->beginTransaction();
        try {
            $this->persistUser($data);
            $this->createAccount($data);
            $this->createCustomerSheet($data);

            $this->entityManager->commit();
        } catch (\Exception $e) {
            $this->rollbackService->rollbackAll();
            $this->entityManager->rollBack();

            throw $e;
        }
    }

    public function persistUser(array $data): void
    {
        $this->entityManager->persist(User::fromData($data));
        $this->entityManager->flush();
    }

    public function createAccount(array $data): void
    {
        $accountId = $this->api1Service->createAccount($data);

        $this->rollbackService->registerRollbackOperation(
            'API 1',
            $this->api1Service->deleteAccount(...),
            [$accountId],
            [
                'description' => 'Account creation from admin interface',
            ],
        );
    }

    public function createCustomerSheet(array $data): void
    {
        $customerSheetId = $this->api2Service->createCustomerSheet($data);

        $this->rollbackService->registerRollbackOperation(
            'API 2',
            $this->api2Service->deleteCustomerSheet(...),
            [$customerSheetId],
            [
                'description' => 'Account creation from admin interface',
            ],
        );
    }
}

Explications :

  • À la création d’un compte depuis l’interface d’admin, on veut exécuter 3 opérations, que l’on place dans un try-catch
  • Comme on veut être capable de réinitialiser des données dans la BDD de notre application via Doctrine, on encadre ces opérations avec les méthodes beginTransaction() et commit() de l’entity Manager.
  • Les deux méthodes de création auprès des API sont similaires : on demande la création d’un utilisateur, puis on déclare sa suppression comme opération de rollback.
  • En cas d’erreur, on déclenche les opérations de rollback de notre RollbackService et le rollback de Doctrine.

Notes :

  • La méthode User::fromData() simule l’instanciation d’une entité User à partir d’un tableau de données
  • La notation myfunction(...) permet de générer une callable depuis PHP 8.2.
  • On utilise le Sérialiseur de Symfony pour transformer les tableaux de contexte et d’arguments en chaîne JSON pour les logs

Aller plus loin

Organisation des services

Si on appelle à plusieurs endroits les fonctions de création pour les API, on peut imaginer la structure suivante :

  • Service\Api1Service
  • Service\Api1ServiceWithRollbacks

Le premier service effectue les appels basiques à l’API (typiquement des requêtes HTTP).
Le second service utilise le premier. Il contient des méthodes comme le createCustomerSheet() ci-dessus, qui gèrent plusieurs appels successifs à l’API.

Rollback des suppressions/modifications

Pour rétablir les données telles qu’avant suppression ou modification, il faut les passer en argument de l’opération de rollback. Pour cela, il faut déjà les avoir sous la main, et donc les avoir récupérées auprès de l’API.

On peut donc imaginer une méthode updateCustomerSheet() qui effectue ces étapes :

  • Appel de l’API pour mémoriser la fiche client actuelle
  • Appel de l’API pour mettre à jour la fiche avec les nouvelles données
  • Déclaration de l’opération de callback : un appel à l’API pour mettre à jour la fiche avec les données mémorisées

RollbackService

On pourrait implémenter un RollbackService plus complet/complexe, permettant :

  • de gérer des lots d’opérations de rollback, que l’on pourrait appeler transactions
  • d’intégrer directement le rollback doctrine