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

Astuce Qui utilise le port ?

Sous Windows, pour savoir quel processus utilise un port, lancez la commande Get-Process dans Powershell :

Exemple pour le port 5433 :

Get-Process -Id (Get-NetTCPConnection -LocalPort 5433).OwningProcess

Marque-page Marque-pages React

Contrairement à Angular, React propose peu de solutions par défaut aux problèmes les plus courants. Il faut souvent ajouter des modules en dépendance, et il est parfois difficile de ne pas se perdre dans la jungle des modules pour React.

Voici une liste de modules et de pratiques utiles pour les besoins courants.

Socle

Mise en place

On peut initialiser facilement une application vierge React + Vite avec typescript, via la commande npm create vite@latest my-app -- --template react-ts.

Routage

Le module react-router fait ça très bien.

Appels API

Pour récupérer des données auprès d’une API ou via d’autres méthodes asynchrones, on peut utiliser SWR ou react-query.

État

Si on souhaite gérer un état globale de l’application au niveau local, on peut utiliser redux avec redux-toolkit ou alors recoil.

Formulaires

Le module react-hook-form fait ça très bien. Il supporte notamment Yup pour gérer la validation de champs.

Internationalisation

Le module react-i18next permet de gérer une appli multilingue.

Design

CSS

Pour restreindre des classes CSS à un composant en particulier, on peut utiliser le concept de modules CSS.

Material

Les principales bibliothèques graphiques sont disponibles pour React, et notamment Material UI. Elle fonctionne très bien, sauf avec le hook-form. C’est problématique et cela nécessite de recréer un composant « Contrôlable », pour chaque type de champ de formulaire.

Qualité

Tests

Plusieurs bibliothèques pour les tests fonctionnent bien avec React, notamment jest.

Lint

On peut combiner prettier et eslint, pour normaliser la base de code. Il existe des extensions à eslint spécialement pour React, afin de détecter et remonter si certaines mauvaises pratiques sont utilisées.

Documentation des composants

Pour documenter et présenter une bibliothèque de composants, on peut utiliser Storybook. Cela permet de manipuler et tester chaque composant, dans une interface dédiée.

Autres

Base de donnée locale

Pour utiliser plus facilement la base de données locale IndexedDB, on peut y accéder via dexie.

Authentification avec JWT

Pour extraire les données d’un jeton JWT, on peut utiliser jwt-decode.

Marque-page Modèle de composant

Voici un modèle de composant React minimal :

// MyComponent.tsx
import React, { ReactNode } from 'react';
import styles from "./MyComponent.module.scss";

interface Props {
  children: ReactNode;
  notRequiredProperty?: string;
}

export const MyComponent: React.FC = ({ children, notRequiredProperty }: Props) => {
  return <div className={styles.myComponentClass}>{children}< /div>;
};

Explications :

  • Le composant est utilisable dans un autre composant, via <MyComponent children={<p>EXAMPLE</p>} />
  • On importe le fichier MyComponent.module.scss, dans lequel se trouve une règle CSS pour la classe myComponentClass.
  • Le composant est un wrapper, auquel on passe un sous-composant via la propriété children.
  • On déclare l’interface Props pour définir les propriétés du composant. Cela facilite l’autocomplétion et permet la validation du typage à la compilation.
  • La propriété notRequiredProperty est facultative lorsqu’on appelle le composant.

Héritage

Si on souhaite créer un wrapper pour un composant existant, on veut souvent qu’il accepte les mêmes propriétés. Dans ce cas, on peut déclarer l’interface et le composant ainsi :

type Props = SomeComponentToWrapProps & {
  extraProperty1: string
};

export const MyComponent: React.FC = ({ extraProperty1, inheritedProp1, ...others }: Props) => {
  // [...]
};

Explications :

  • Les propriétés du composant sont l’union de celles de SomeComponentToWrapProps, avec celle déclarée ici.
  • On indique les quelques propriétés dont on a explicitement besoin, et on peut accéder à toutes les autres via l’objet others.

Marque-page Construire une queryString

Voici une fonction pour construire une queryString à partir d’un objet.

Version JavaScript :

export const buildQueryString = (parameters) => {
  const paramParts = Object.entries(parameters)
    .map(([key, value]) => `${key}=${encodeURI(value)}`);

  return paramParts.length > 0 ? `?${paramParts.join('&')}` : '';
};

Version TypeScript :

export const buildQueryString = (parameters: { [key: string]: string }) => {
  const paramParts = Object.entries(parameters)
    .map(([key, value]) => `${key}=${encodeURI(value)}`);

  return paramParts.length > 0 ? `?${paramParts.join('&')}` : '';
};

Explications :

  • Chaque propriété de l’objet devient une clé de la queryString, dont la valeur échappée.
  • Les paramètres sont séparés par & et ? est ajouté en préfixe de la chaîne.

Astuce Debounce en React

Régulièrement en Javascript, on veut écouter les changements sur un élément, mais ne les prendre en compte qu’à intervalles réguliers.
Par exemple, si on écoute le déplacement de la souris pour lancer un traitement, on n’a pas besoin de l’exécuter à chaque changement de position, mais uniquement tous les 300 ou 500ms.

Pour éviter l’effet « rebond », on utilise alors une fonction de debounce, ou dans React, un hook.

Script

import { useEffect, useState } from 'react';

/**
 * Delays the value change, so it’s only committed once during the specified duration.
 *
 * Usage :
 * <pre>
 * const debouncedValue = useDebouncedValue(realTimeValue, 500);
 * <pre>
 */
export const useDebouncedValue = <T>(input: T, duration = 500): T => {
  const [debouncedValue, setDebouncedValue] = useState(input);

  useEffect(() => {
    const timeout = setTimeout(() => {
      setDebouncedValue(input);
    }, duration);

    return () => {
      clearTimeout(timeout);
    };
  }, [input, duration]);

  return debouncedValue;
};

Exemple d’utilisation

On veut afficher un message dans la console au changement de valeur du champ de formulaire,
mais limiter ce traitement avec un debounce.

import React, { useEffect, useState } from 'react';
import { useDebouncedValue } from './utils/debounce';

export default (() => {
  const [myValue, setMyValue] = useState('');
  const myDebouncedValue = useDebouncedValue(myValue, 500);

  useEffect(() => {
    console.log('This log is displayed when the value of debouncedFilterNom changes', myDebouncedValue);
  }, [myDebouncedValue]);

  const handleValueChange = async (event: any) => {
    setMyValue(event.target.value);
  };

  return (
    <form>
      <input type="text" onChange={handleValueChange} value={myValue} />
    </form>
  );
}) as React.FC;

Astuce Modifier l'auteur des commits Git

Il est possible de modifier l’auteur de tous les anciens commits d’un dépôt Git.

Pour cela, utilisez la commande suivante, après l’avoir personnalisée selon vos besoins :

git filter-branch --env-filter '
WRONG_EMAIL="wrong@email.com"
NEW_NAME="John Doe"
NEW_EMAIL="j.doe@email.com"

if [ "$GIT_COMMITTER_EMAIL" = "$WRONG_EMAIL" ]
then
    export GIT_COMMITTER_NAME="$NEW_NAME"
    export GIT_COMMITTER_EMAIL="$NEW_EMAIL"
fi
if [ "$GIT_AUTHOR_EMAIL" = "$WRONG_EMAIL" ]
then
    export GIT_AUTHOR_NAME="$NEW_NAME"
    export GIT_AUTHOR_EMAIL="$NEW_EMAIL"
fi
' --tag-name-filter cat -- --branches --tags

Explications :

On indique l’adresse email utilisée par tous les commits que l’on souhaite modifier, ainsi que le nom d’utilisateur et l’adresse email correcte que l’on souhaite utiliser à la place.

Astuce Analyser l’occupation de l’espace disque

Sous Linux, l’utilitaire ncdu (pour NCurses Disk Usage) permet de trouver quel fichier ou répertoire vous bouffe tout votre espace disque.

Il est disponible sur les dépôts officiels Debian et Ubuntu et donc facile à installer.

sudo apt install ncdu
ncdu

Note : ncdu a été réécrit dans une v2 encore récente. Selon la version des dépôts, c’est peut-être la v1 qui sera installée.

Astuce SessionStorage vs LocalStorage

Dans une application ou un site web, on peut stocker des informations dans le navigateur de l'utilisateur, pour un nom de domaine spécifique.

Deux approches principales : les cookies et le Storage. Pour la seconde, deux possibilités : sessionStorage ou localStorage.

cookie vs Storage

La principale différence, c'est que les cookies sont accessibles côté client et côté serveur. À l'inverse, il n'y a aucun moyen côté serveur d'accéder au contenu ni du localStorage ni du sessionStorage.

Remarque :

Quand on parle de "sessions" pour le sessionStorage, il n'y a aucun rapport avec les sessions entre client/serveur comme on peut les manipuler en PHP, par exemple.

session vs local

Les données stockées en sessionStorage sont rattachées à un onglet.
Si on recharge la page au sein d'un même onglet, les données sont conservées. En revanche, elles expirent à sa fermeture.

Les données stockées en sessionStorage sont rattachées à un onglet.

Remarque :

Selon le navigateur, si on restaure un onglet fermé précédemment, on récupère le sessionStorage tel qu'il était avant fermeture. C'est le comportement pour les navigateurs basés sur Chrome. De même, si on duplique un onglet ouvert, la copie hérite d'une copie du sessionStorage de l'onglet original.

Pour aller plus loin

Marque-page Refactoring avec Rector

Pour faire du refactoring automatique en PHP, on peut utiliser son IDE ou la lib Rector (installable via Composer).

Elle permet notamment d'automatiser la migration de code PHP vers une version plus récente de PHP.

Quelques exemples :

  • Opérateurs ?? et ?:
  • Promotion de propriété de constructeur

Il est également possible de créer ses propres règles de refactoring.

Grafikart a sorti une vidéo de présentation de l'outil.

Erreur Impossible de charger les dépendances depuis https://jcenter.bintray.com

Si vous construisez des APK Android avec Cordova, vous pouvez rencontrer ce genre d'erreur :

[...]
> Task :app:preReleaseBuild FAILED
FAILURE: Build failed with an exception.
* What went wrong:
Could not resolve all files for configuration ':app:releaseRuntimeClasspath'.
> Could not resolve com.android.support:support-v4:27.+.
  Required by:
      project :app
   > Failed to list versions for com.android.support:support-v4.
      > Unable to load Maven meta-data from https://jcenter.bintray.com/com/android/support/support-v4/maven-metadata.xml.
         > Could not get resource 'https://jcenter.bintray.com/com/android/support/support-v4/maven-metadata.xml'.
            > Could not GET 'https://jcenter.bintray.com/com/android/support/support-v4/maven-metadata.xml'.
               > Read timed out
[...]

Cela est dû à la coupure du dépôt jcenter.

La solution pour le remplacer est décrite dans plein de tickets sur StakOverflow (ex: Question #67418153).
En bref : il faut modifier le fichier build.gradle.

Malheureusement, vous n'avez peut-être pas accès à ce fichier, si c'est une Plateforme d'Intégration Continue qui se charge du build.
Dans ce cas, ce fichier n'est probablement pas versionné, mais générée automatiquement dans un répertoire nommé platforms/.

Entre chaque build, veillez à bien supprimer ce répertoire platforms/. Ainsi, le fichier build.gradle sera régénéré en utilisant des dépôts corrects.