Astuce Prompt trop long à cause de nvm

Nvm est un outil qui permet d’utiliser plusieurs versions de Node.js en parallèle.

Il est simple à utiliser et ajoute automatiquement quelques lignes au fichier ~/.bashrc sur votre système Linux, pour pouvoir démarrer correctement.

Malheureusement, il peut ralentir le démarrage de votre bash de plusieurs secondes, et vous devrez même parfois attendre 10 secondes avant de voir le prompt apparaître, pour taper vos commandes.
Ce n’est pas dramatique, mais ce n’est vraiment pas idéal, surtout si vous n’avez pas besoin de nvm souvent (parce que vous lancez plutôt Node directement dans Docker, par exemple).

Typiquement, votre IDE utilise peut-être bash au lancement, pour trouver les exécutables dont il a besoin. Si bash est trop long à démarrer, l’IDE peut considérer que git n’est pas installé, par exemple.

Pour éviter cela, il faut déplacer les lignes ajoutées par nvm à votre .bashrc, dans un fichier .sh dédié, qui ne sera exécuté que lorsque vous avez besoin de nvm, et pas à chaque lancement de bash.

  • Créez un fichier load_nvm.sh, contenant
#!/bin/bash

# Delete the aliases
unalias nvm
unalias npm
unalias node

# (This is the loader code nvm put in my .bashrc)
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"  # This loads nvm
[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion"  # This loads nvm bash_completion
  • Modifiez le fichier ~/.bashrc, pour remplacer
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"  # This loads nvm
[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion"  # This loads nvm bash_completion

par

# nvm
alias nvm='. ~/load_nvm.sh; nvm "$@"'
alias npm='. ~/load_nvm.sh; npm "$@"'
alias node='. ~/load_nvm.sh; node "$@"'

Erreur Client version is too old

Si vous utilisez Gitlab CI pour builder des images docker, vous obtenez cette erreur, ce qui bloque la CI :

ERROR: Error response from daemon: client version 1.43 is too old. Minimum supported API version is 1.44, please upgrade your client to a newer version: driver not connecting

Cela veut dire que le client docker utilisé par la CI est trop ancien (ici 1.43 au lieu de 1.44).

Si Gitlab utilise le docker présent sur le système, à vous de le mettre à jour sur le serveur.

Sinon, vous utilisez une image docker qui contient un client docker permettant de builder une image docker 🤯.
Dans ce cas, pour utiliser un client plus récent, vous devez utiliser une image plus récente.

Vous pouvez spécifier cette version directement dans le fichier .gitlab-ci.yml à votre étape de build :

docker-build-backend-base:
    stage: docker-build-base
    only:
        refs:
            - merge_requests
        changes:
            - .gitlab-ci.yml
    image: docker:29.0.0
    interruptible: true
    variables:
        DOCKER_BUILDKIT: 1
        DOCKER_DRIVER: overlay2
    before_script:
        - apk update && apk add make
    script:
        - docker build -t gitlab.mydomain.com:1234/group/project/backend/base:latest -f ./docker/image/php/base/Dockerfile ./docker/image/php/base

La ligne importante ici est image: docker:29.0.0, qui force l’utilisation de la version 29 de l’image. Cette image contient un client Docker >= 1.44.

Marque-page Organiser un espace de documentation technique

Il est souvent difficile de s’y retrouver dans un espace documentaire. Sur quelle page (ou document) se trouve l’information dont j’ai besoin ? Comment y accéder ?
En général, on peut contourner le problème avec une recherche performante et une bonne indexation.

Mais cela n’aide pas vraiment pour la contribution et la mise à jour de la documentation. Comment savoir si ce qu’on veut ajouter est déjà présent ?
De plus, selon ce dont qu’on recherche, on n’a pas forcément envie d’avoir la même granularité dans les résultats.
Par exemple, si on recherche comment se connecter au VPN d’un client, on n’a pas sans doute pas besoin d’un paragraphe qui nous explique ce qu’est un VPN et à quoi il sert. Mais peut-être que ce paragraphe est quand même utile pour un autre utilisateur novice ?

Comme l’organisation de la documentation est un problème récurrent, des gens se sont penchés sur la question. Certains ont proposé l’approche Diataxis (https://diataxis.fr).

Elle préconise de structurer sa documentation en 4 parties :

  • Tutoriels
  • Recettes (How-to guides)
  • Référence
  • Explication

Le lecteur se dirigera vers l’une ou l’autre des sections, selon s’il souhaite :

  • Apprendre/débuter
  • Effectuer une tâche précise
  • Avoir des informations
  • Comprendre des sujets de fond

Astuce Couleurs de lignes alternées avec Excel

Dans un fichier Excel, si la couleur des lignes alterne mais que vous n’êtes pas dans un tableau, c’est que la couleur a été spécifiée pour chaque ligne manuellement.
Si vous ajoutez de nouvelles lignes, elles ne porteront donc pas l’alternance de couleur.

Pour corriger toutes vos nouvelles lignes d’un coup :

  • Sélectionnez juste deux lignes qui se suivent, avec la bonne alternance de couleur
  • Cliquer sur le bouton Reproduire la mise en forme
  • Sélectionnez toutes les lignes pour lesquelles corriger la coloration

L’alternance devrait maintenant être correcte.

Astuce Ouvrir une application en tant qu’administrateur sous Windows

Sous Windows, la solution classique pour ouvrir une application en tant qu’administrateur, c’est d’utiliser sa souris :

  • Dans l’explorateur Windows, faites un clic-droit sur l’icône de l’application
  • Dans le menu contextuel qui apparaît, cliquer sur l’option « Exécuter en tant qu’administrateur ».

Note : parfois, cette option n’apparaît que si on maintient la touche Ctrl enfoncée lors du clic-droit.

Un autre raccourci clavier

Il existe une autre méthode, plus rapide, pour ouvrir les applications de la barre des tâches ou du menu Démarrer :

  • Maintenez les touches Windows + Ctrl + Shift
  • Cliquer sur l’icône de l’application à exécuter en tant qu’administrateur

Astuce Dupliquer une ligne n fois avec PostgreSQL

Par exemple, vous avez une donnée en base, et pour vos tests, vous en voudriez 10 000.

Il est possible d’utiliser cette boucle SQL pour faire 10 000 insertions :

-- Copie d’une ligne n fois
--
-- Paramètres modifiables :
--   - nb de fois (ici 10000)
--   - table concernée (ici my_table)
--   - liste des colonnes à copier (ici column_1, column_2, column_x)
--   - id de la ligne à copier (ici 1)

DO
$$
    DECLARE
        i integer;
    BEGIN
        FOR i IN 1..10000
            LOOP
                INSERT INTO public.my_table (column_1, column_2, column_x)
                SELECT column_1, column_2, column_x
                FROM public.my_table
                WHERE id = 1;
            END LOOP;
    END
$$ LANGUAGE plpgsql;

Pour générer cette requête avec toutes les colonnes de votre table, vous pouvez faire générer l’INSERT directement à PostgreSQL, avec la requête suivante :

-- Génération de la requête d’insertion avec toutes les colonnes sauf la colonne id (car auto-incrémentale)
--
-- Paramètres modifiables :
--   - table concernée (ici my_table)
--   - id de la ligne à copier (ici 1)
WITH cols AS (SELECT column_name, ordinal_position
              FROM information_schema.columns
              WHERE table_schema = 'public'
                AND table_name = 'my_table'
                AND column_name NOT IN ('id')
              ORDER BY ordinal_position)
SELECT format(
               'INSERT INTO %I.%I (%s) SELECT %s FROM %I.%I WHERE id = 1;',
               'public',
               'my_table', string_agg(quote_ident(column_name), ', '), string_agg(quote_ident(column_name), ', '),
               'public', 'my_table') AS sql

Un dernier problème se pose si certaines colonnes doivent contenir des données uniques.
Dans les INSERT précédents, on ne gère que le cas de la colonne id, supposée auto-incrémentale. Pour générer des données pour d’autres colonnes, remplacez la requête précédente par celle-ci :

-- Génération de la requête de copie avec toutes les colonnes sauf celle contenant des ID uniques
--
-- Paramètres modifiables :
--   - table concernée (ici my_table)
--   - liste des colonnes pour lesquelles générer des uuid uniques (ici 'uuid1', 'uuid2')
--   - id de la ligne à copier (ici 1)
WITH cols AS (SELECT column_name, ordinal_position
              FROM information_schema.columns
              WHERE table_schema = 'public'
                AND table_name = 'my_table'
                AND column_name NOT IN ('id', 'uuid1', 'uuid2')
              ORDER BY ordinal_position)
SELECT format(
               'INSERT INTO %I.%I (uuid1, uuid2, %s) SELECT gen_random_uuid(), gen_random_uuid(), %s FROM %I.%I WHERE id = 1;',
               'public',
               'my_table', string_agg(quote_ident(column_name), ', '), string_agg(quote_ident(column_name), ', '),
               'public', 'my_table') AS sql

Note : si les valeurs uniques ne sont pas des UUID, il faut rechercher si PostgreSQL propose d’autres fonctions de génération aléatoires que gen_random_uuid().

Lister les clés étrangères qui référencent une table MySQL

On a parfois besoin de supprimer une entrée en base de donnée, mais qui est potentiellement référencées dans plusieurs autres tables. Plutôt que d’exécuter la requête de suppression et de corriger/supprimer une par une les données qui référencent et interdisent la suppression, on peut rechercher toutes les références possibles dans les autres tables.

-- Liste de toutes les clés étrangères vers la colonne `id` de `table1`
SELECT *
FROM
    INFORMATION_SCHEMA.KEY_COLUMN_USAGE
WHERE
    REFERENCED_TABLE_NAME = 'table1'
  AND REFERENCED_COLUMN_NAME = 'id';

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.

  • Pour Chrome : https://chromewebstore.google.com/detail/xdebug-helper/eadndfjplgieldjbigjakmdgkmoaaaoc
  • Pour Firefox : https://addons.mozilla.org/fr/firefox/addon/xdebug-helper-for-firefox/

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.