Gérer et versionner les bouchons applicatifs dans un train agile

par Sébastien LAMOTTE - Tech Lead DevOps
| minutes de lecture

Introduction

Lorsque nous avons rejoint notre client sur son projet, nous avons embarqué dans un train agile regroupant plus de 15 équipes ayant pour objectif de déployer à terme entre 30 et 50 micro-services. Nous avons rapidement identifié un point d'attention majeur lié aux liens entre applications. En effet, si les dépendances avec les systèmes externes sont nombreuses, les interdépendances entre équipes - et entre applications au sein de ces équipes - le sont encore plus.
Dès le premier sprint, le besoin de bouchonner les applications (mock) a émergé, et ce afin de répondre à plusieurs enjeux critiques :
- Garantir la confidentialité des données et éviter l'utilisation de données réelles (risque lié aux Données à Caractère Personnel/RGPD)
- Assurer la cohérence et la synchronisation des bouchons partagés entre plusieurs équipes
- Limiter les approches locales ou non versionnées, synonymes d’incohérences, de duplication et de perte de temps
Cette situation nous a conduit à adopter une stratégie de bouchonnage d'API robuste, versionnée et centralisée, capable de soutenir la collaboration inter équipes tout en répondant aux exigences de sécurité et de fiabilité.

Bénéfices attendus

L'adoption d'une telle stratégie offre de nombreux avantages :
- Accélération des processus de développement et de test en réduisant les retards causés par les dépendances internes et externes
- Réduction de la dépendance par rapport aux applications externes et aux autres produits indisponibles ou instables
- Partage des informations des bouchons et création de scénarios déterministes
- Limitation des anomalies liées aux jeux de données lors des tests d'intégration

Le gain majeur consiste en une qualité accrue lors des tests d'intégration et de recette et in-fine, un time-to-market réduit.

Exigences de la solution

La solution mise en place se doit de respecter plusieurs exigences :
- La solution permet de bouchonner des API REST et des services web SOAP
- La solution permet de configurer des bouchons dynamiques (c'est à dire dont la réponse dépend de la requête)
- La solution permet à tous les profils (développeurs, experts métiers, testeurs...) de rédiger des définitions de bouchons
- La solution permet de partager les définitions des bouchons, de les centraliser et de les versionner
- La solution permet aux équipes responsables de bouchons d'effectuer un contrôle ou des revues des bouchons avant que ceux-ci ne soient poussés vers les environnements de recette
- La solution permet le déploiement de ces définitions de bouchons dans un environnement de façon simple et sans avoir de compétences techniques particulières ("one click")

La solution

Le premier composant à évaluer est le serveur de mocks en lui-même. Pour des raisons de cout et de simplicité de maintenance, nous avons fait le choix de prendre un produit du marché plutôt que de développer une solution adhoc. 
Trois produits semblaient répondre à nos besoins :
- CastleMock
- WireMock
- Mikrocks

Leurs fonctionnalités principales sont résumées ci-dessous : 

FonctionnalitéCastleMockWireMockMikroks
Types de bouchonsREST et SOAPREST, SOAP, GraphQL (via une extension), gRPC (via une extension)REST, SOAP, GraphQL, gRPC
IHMBasiqueOui via des extensionsComplète
DéploiementDéployé dans Tomcat ou Docker (un seul conteneur)Standalone, embarqué dans le code Java ou Docker (un seul conteneur)Docker/Kubernetes (multiples conteneurs)
Réponses dynamiquesOuiTemplating et réponses dynamiquesTemplating et réponses dynamiques
Scénarios de testPas de scénarios statefullPossibilité de définir des scénarios statefull (via une extension)Oui, avec des "stateful mocks"
IntégrationLimitéeEtendue : intégration native dans les tests unitaires et les TI, administrable via une APIEtendue : dans Jenkins, via une API ou une CLI
SupportCommunauté réduiteCommunauté très importante, support commercial possibleCommunauté importante, projet de la Cloud Native Computing Foundation sandbox
PersistanceSur le système de fichiersSur le système de fichiersDans MongoDB
PersonnalisationLimitéeNombreuses extensions, possibilité d'ajouter des métadonnées dans les définitions des bouchonsImportante
Difficulté de prise en mainAisée : démarrage rapideModérée : nécessite du temps pour aborder les fonctionnalités avancéesImportante

 

Si on met de côté une prise en main relativement compliquée, Mikroks semblait être le bon choix. Pour autant, nous avons retenu Wiremock car :
- Il répond à nos exigences (REST, SOAP, réponses dynamiques)
- Il est relativement facile à prendre en main
- Il est facile à déployer
- Il permet de pousser les définitions des bouchons au format JSON via une API, et donc :
- De stocker ces définitions au format JSON
- D'automatiser les déploiements
- Il est déjà utilisé par les équipes de développement et intégré dans les tests d'intégration automatisés en Java

Ce dernier critère est important car il autorise la réutilisation des bouchons depuis les phases de test d'intégration jusqu'aux recettes utilisateur sans avoir à réécrire les jeux de données.

Le reste de la solution est basé sur les composants suivants, déjà en place dans le SI du client :
- Gitlab pour centraliser les définitions des bouchons. Tout comme pour le code applicatif, Gitlab permet d'appliquer un modèle de branching adapté (gitflow / trunk based...), de suivre les modifications apportées aux définitions des bouchons et de faciliter le travail de contrôle à l'aide des Merge Request
- Jenkins pour automatiser le déploiement des définitions de bouchons. 

Installer localement Wiremock

Wiremock est disponible sous forme d'image Docker (https://hub.docker.com/r/wiremock/wiremock). Il est donc aisé de démarrer une instance de test :

docker run -it --rm \
  -p 8081:8080 \
  --name wiremock \
  wiremock/wiremock:3.10.0

En production, il faudra être attentif au système de fichier sur lequel seront persistées les définitions des bouchons, mais en local, cette commande suffit pour tester la solution.

Un des manques de Wiremock est son absence d'IHM. Plusieurs alternatives existent, et celle de holomekc (https://github.com/holomekc/wiremock) semble répondre à nos besoins : elle est simple, rapide et maintenue. Par ailleurs, elle est également disponible sous la forme d'une image Docker unique intégrant à la fois Wiremock et l'IHM, simplifiant au maximum le déploiement de la solution.
docker run -it --rm \
  -p 8081:8080 \
  --name wiremock \
  wiremock/wiremock-gui:3.10.0

        
L'IHM est accessible dès le démarrage du conteneur et se trouve à l'adresse http://localhost:8081/__admin/webapp/

 

Bouchons et API dans Wiremock

Bouchons

Wiremock permet de stocker les bouchons de façon unitaire sous forme de stub (un "morceau") dans un fichier JSON. Ainsi, le fichier suivant permet de définir un mock qui retourne {"response": "Hello world"} lorsqu'on invoque l'URL /helloworld :


{
    "mappings": [
        {
            "name": "hello world",
            "request": {
                "url": "/helloworld",
                "method": "GET"
            },
            "response": {
                "status": 200,
                "jsonBody": {"response": "Hello world"},
                "headers": {}
            },
            "persistent": true,
            "priority": 5
        }
    ],
    "meta": {
        "total": 1
    }
}

Il est possible de définir plusieurs mocks par fichier en ajoutant des éléments dans le tableau mappings.

NOTE: Il est important que les bouchons respectent les contrats d'interface (OpenAPI, WSDL...) des applications bouchonnées. Sans cela, les tests effectués dans les environnements bouchonnés ne seraient plus pertinents et mettraient en risque la solution lors du passage en production..

API

Le serveur Wiremock expose une API d'administration décrite ici : https://wiremock.org/docs/standalone/admin-api-reference/, dont quelques opérations permettent d'automatiser le déploiement des mocks : 

OpérationDescription
`POST` `/__admin/mappings/import`Créé un nouveau moc
`DELETE` `/__admin/mappings`Supprime tous les mocks
`POST` `/__admin/mappings/save`Persiste tous les mocks

Il est donc aisé de scripter une purge des mocks puis l'injection d'un ou plusieurs fichiers et de les persister :

# Purger tous les mocks
curl -X DELETE http://localhost:8081/__admin/mappings

# Injecter le mock 'helloworld'
curl -v -d @./hello-world.json http://localhost:8081/__admin/mappings/import

# Persister les mocks
curl -X POST http://localhost:8081/__admin/mappings/save

Structure du dépôt Git et gouvernance

Le fait de faire bouchonner plusieurs applications par plusieurs équipes pour de multiples environnements a posé la question de la gouvernance de ces bouchons : qui est responsable d'un bouchon ? comment éviter les doublons et les conflits ? 

Il a donc été nécessaire de définir des règles d'organisation et de rédaction des mocks : 
- Sur l'environnement de DEVELOPPEMENT, l'équipe consommatrice est responsable de ses mocks
- Hors environnement de DEVELOPPEMENT, une équipe transverse est responsable de tous les mocks

- L'arborescence des mocks dans le dépôt Git est la suivante : 

📁 cible/
└── 📁 environnement/
    └── 📁 operation/
    └── 📄 fichier JSON

- Les contraintes de nommage suivantes s'appliquent :
- La cible est l'application que l'on bouchonne
- L'environnement est l'environnement sur lequel on bouchonne ( DEVELOPPEMENT, INTEGRATION, RECETTE PREPROD)
- L'`operation` est l'opération bouchonnée (par ex. ajouterUtilisateur)
- Sur l'environnement de DEVELOPPEMENT, le fichier JSON est nommé selon ce motif : <consommateur>-<texte libre>.json
- <consommateur> est à remplacer par le nom de l'équipe qui va consommer ce mock 
- <texte libre> est un texte libre qui doit décrire le contenu du mock
- Hors environnement de DEVELOPPEMENT, le fichier JSON est nommé selon ce motif : <texte libre>.json

En outre, il a été nécessaire de réécrire les URL des mocks sur l'environnement de DEVELOPPMENT pour faire apparaître le consommateur et éviter des collisions d'URL. 
Ainsi, si les équipes equipe1 et equipe2 définissent chacune le mock suivant...
{
  "mappings": [
    {
      "name": "hello world",
      "request": {
        "url": "/helloworld",
        "method": "GET"
      },
      "response": {
        "status": 200,
        "jsonBody": {"response": "Hello world"},
        "headers": {}
      },
      "persistent": true,
      "priority": 5
    }
  ],
  "meta": {
    "total": 1
  }
}
Elles doivent créer deux fichiers dans l'arborescence du dépôt :
📁 hello/
└── 📁 DEVELOPPEMENT/
    └── 📁 helloworld/
         ├── 📄 equipe1-helloworld.json
    └── 📄 equipe2-helloworld.json
Finalement, un pipeline modifie à la volée les champs `url` des mocks afin d'exposer les URL suivantes :
- http://localhost:8081/equipe1/helloworld
- http://localhost:8081/equipe2/helloworld

Cette approche permet d'accorder à toutes les équipes une complète indépendance et un contrôle total sur la rédaction de leurs bouchons lors des phases de développement, tout en garantissant une cohérence des jeux de données sur les autres environnements.

 

Générer des bouchons avec des données fictives

Une des plus-values des bouchons est de fournir des données fausses, mais suffisamment proches de la réalité pour permettre de tester de multiples cas métier ; la génération en masse de bouchons réalistes est donc un enjeu significatif pour les équipes de test. Si des jeux de données fictives ne peuvent pas être fournis par les applications qui sont bouchonnées, il est possible de faire appel à des composants tiers.

Parmi les composants permettant de générer des données fictives, Faker (https://fakerjs.dev/guide/) est suffisamment polyvalent et simple d'utilisation pour s'intégrer rapidement dans notre boite à outils. Il permet entre autres de générer des données :
- De personnes : noms, genres, biographies, intitulés de poste...
- De lieux : adresses, codes postaux, noms de rue, états, pays...
- Temporelles : passé, présent, futur, récent, bientôt… 
- Financières : informations fictives sur des comptes ou des transactions
- Commerciales : prix, noms de produits...

Par exemple, en se basant sur un template de stub pour Wiremock comme celui-ci...

{
  "mappings": [
    {
      "name": "{{name}}",
      "request": {
        "url": "{{url}}",
        "method": "{{method}}"
      },
      "response": {
        "status": "{{status}}",
        "jsonBody": "{{body}}",
        "headers": {}
      },
      "persistent": true,
      "priority": 5
    }
  ],
  "meta": {
    "total": 1
  }
}
Et à l'aide d'un script python :
import json
from faker import Faker
import random

# Initialize the faker
fake = Faker()

""" Replace mustache placeholders """
def replace_mustache_placeholders(obj, values):
    if isinstance(obj, dict):
        return {k: replace_mustache_placeholders(v, values) for k, v in obj.items()}
    elif isinstance(obj, list):
        return [replace_mustache_placeholders(item, values) for item in obj]
    elif isinstance(obj, str) and obj.startswith('{{') and obj.endswith('}}'):
        key = obj[2:-2].strip()
        return values.get(key, obj)
    else:
        return obj

""" Generate result """
def generate_json_from_template(template_file_name, output_file_name, values):
    with open(template_file_name, 'r') as f:
        template_data = json.load(f)

    filled_data = replace_mustache_placeholders(template_data, values)

    with open(output_file_name, 'w') as f:
        json.dump(filled_data, f, indent=2)

def main_loop():
    for i in range(10):
        name = fake.name()
        # create response object
        body = {
            "name": name,
            "age": random.randint(18, 99),
            "address": {
                "street": fake.street_address(),
                "city": fake.city(),
                "zip_code": fake.postcode()
            }
        }
        # placeholders values dictionnary
        values = {
            'name': f"GET getPerson ({name})",
            'url': f"/api/v1/person/{i}",
            'method': 'GET',
            'status': 200,
            'body': body
        }
        generate_json_from_template('template.json', f"output_{i}.json", values)

if __name__ == '__main__':
    main_loop()
Il est possible de générer une quantité illimitée de bouchons retournant une adresse postale complète (nom complet, rue, ville code postal) :
{
  "mappings": [
    {
      "name": "GET getPerson (Brandon Wolf)",
      "request": {
        "url": "/api/v1/person/5",
        "method": "GET"
      },
      "response": {
        "status": 200,
        "jsonBody": {
          "name": "Brandon Wolf",
          "age": 68,
          "address": {
            "street": "215 Morales Orchard",
            "city": "Guzmanville",
            "zip_code": "29287"
          }
        },
        "headers": {}
      },
      "persistent": true,
      "priority": 5
    }
  ],
  "meta": {
    "total": 1
  }
}
Il est donc possible de générer des jeux de données importants ou complexes (par exemple impliquant plusieurs bouchons cohérents) en ne nécessitant qu'une charge de travail relativement limitée. 

 

Manipuler les bouchons : URL et métadonnées

Injection de métadonnées

Le schéma des stubs Wiremock autorise l'injection de métadonnées dans les définitions de bouchons. Ces métadonnées ne sont pas utilisées dans le bouchon en lui-même mais peuvent faciliter la collaboration entre équipes en apportant des informations sur les bouchons qui sont déployés.
Nous avons ainsi été en mesure d'injecter :
- La version courant des bouchons, issue du tag Git
- La cible (l'application que l'on bouchonne), issue du répertoire dans lequel se trouve le fichier JSON
- L'équipe consommatrice, issue du nom du fichier dans l'environnement DEVELOPPEMENT


Un script shell et l'outil jq suffisent :

#!/bin/sh

NEW_TAG_VERSION=1.0.1-RC1

# Add version to metadata
for file in $(find . -type f -name "*.json"); do
  echo "Adding 'version' to $file"
  temp_file=$(mktemp)
  cmd=$(printf '.mappings |= map(. + {"metadata": {"version": "%s"}})' $NEW_TAG_VERSION)
  jq "$cmd" $file > $temp_file
  mv "$temp_file" "$file"
done

# Add target to metadata
for file in $(find . -type f -name "*.json"); do
  echo "Adding 'cible' to $file"
  CIBLE=$(echo $file | cut -d'/' -f2)
  temp_file=$(mktemp)
  cmd=$(printf '.mappings[].metadata += {"cible": "%s"}' $CIBLE)
  jq "$cmd" $file > $temp_file
  mv "$temp_file" "$file"
done

# Add consumer only if environment is 'DEVELOPPEMENT'
for file in $(find . -type f -path "*/DEVELOPPEMENT/*" -name "*.json"); do
  echo "Adding 'consumer' to $file"
  CONSUMER=$(echo "${file##*/}" | cut -d'-' -f1)
  temp_file=$(mktemp)
  cmd=$(printf '.mappings[].metadata += {"consommateur": "%s"}' $CONSUMER)
  jq "$cmd" $file > $temp_file
  mv "$temp_file" "$file"
done
En appliquant le script sur une arborescence plus complète :
📁 hello/
├── 📁 DEVELOPPEMENT/
│   ├── 📁 foobar/
│   │   └── 📄 systemteam-stub1.json
│   └── 📁 helloworld/
│       └── 📄 systemteam-stub2.json
└── 📁 RECETTE/
    └── 📁 helloworld/
        └── 📄 stub3.json
Nous avons obtenu le résultat ci-dessous :
Adding 'version' to ./hello/DEVELOPPEMENT/foobar/systemteam-stub1.json
Adding 'version' to ./hello/DEVELOPPEMENT/helloworld/systemteam-stub2.json
Adding 'version' to ./hello/RECETTE/helloworld/stub3.json
Adding 'cible' to ./hello/DEVELOPPEMENT/foobar/systemteam-stub1.json
Adding 'cible' to ./hello/DEVELOPPEMENT/helloworld/systemteam-stub2.json
Adding 'cible' to ./hello/RECETTE/helloworld/stub3.json
Adding 'consumer' to ./hello/DEVELOPPEMENT/foobar/systemteam-stub1.json
Adding 'consumer' to ./hello/DEVELOPPEMENT/helloworld/systemteam-stub2.json
Et voici à quoi ressemble un stub dans lequel les métadonnées ont été injectées :
{
  "mappings": [
    {
      "name": "hello world",
      "request": {
        "url": "/helloworld",
        "method": "GET"
      },
      "response": {
        "status": 200,
        "jsonBody": {"response": "Hello world"},
        "headers": {}
      },
      "persistent": true,
      "priority": 5,
      "metadata": {
        "version": "1.0.1-RC1",
        "cible": "cible1",
        "consommateur": "systemteam"
      }
    }
  ],
  "meta": {
    "total": 1
  }
}

Récriture des URL

De la même façon, il a été possible de réécrire les URL avec jq, et ce pour chacune des capacités offertes par Wiremock (url, urlPath, urlPathPattern et urlPattern)

#!/bin/sh

# Add consumer in URL only if environment is 'DEVELOPPEMENT'
for file in $(find . -type f -path "*/DEVELOPPEMENT/*" -name "*.json"); do
  echo "Adding 'consumer' to url in $file"
  CONSUMER=$(echo "${file##*/}" | cut -d'-' -f1)
  temp_file=$(mktemp)

  # Prepend the consumer to the values of urlPathPattern, url, urlPath, and urlPattern only if they exist
  cmd=$(printf '.mappings |= map(if .request then (.request |= with_entries(if .key | IN("urlPathPattern", "url", "urlPath", "urlPattern") then .value = "/%s" + .value else . end)) else . end)' $CONSUMER)
  jq "$cmd" $file > $temp_file

  mv "$temp_file" "$file"
done

Le résultat est le suivant, exposant ainsi le bouchon sur l'URL  http://localhost:8081/systemteam/helloworld :
{
  "mappings": [
    {
      "name": "hello world",
      "request": {
        "url": "/systemteam/helloworld",
        "method": "GET"
      },
      "response": {
        "status": 200,
        "jsonBody": {"response": "Hello world"},
        "headers": {}
      },
      "persistent": true,
      "priority": 5,
      "metadata": {
        "version": "1.0.1-RC1",
        "cible": "hello",
        "consommateur": "hello.json"
      }
    }
  ],
  "meta": {
    "total": 1
  }
}

Industrialisation

Afin de fournir aux équipes une solution pérenne et automatisée, nous avons compilé tous ces éléments dans 4 pipelines Jenkins :
- Le Pipeline de build est exécuté automatiquement à chaque fois que le code est poussé sur le server. Il ne fait que vérifier la syntaxe des fichiers JSON avec jq
- Les Pipeline de Release Candidate et Pipeline de Release Finale sont manuels et effectuent les opérations suivantes :
- Vérification de la syntaxe des fichiers JSON
- Injection les métadonnées
- Réécriture les URL
- Création une release (= un tag dans Git)
- Le Pipeline de déploiement est manuel et effectue les opérations suivantes :
- Checkout du tag sélectionné
- Suppression tous les mocks déjà déployés dans l'environnement sélectionné
- Déploiement tous les mocks conçus pour l'environnement sélectionné (en tenant compte des règles décrites ci-dessus pour l'environnement de DEVELOPPEMENT)

Ces automatisations ont permis aux équipes de travailler avec les mocks comme avec n'importe quelle base de code et de profiter d'une chaine CI/CD complète.

Conclusion

L'industrialisation de notre stratégie de mock a apporté de la valeur à chaque étape du cycle de vie de développement : 
- En fournissant une source unique de vérité pour les simulations, elle a favorisé la collaboration transparente entre les équipes, réduisant ainsi les malentendus et garantissant l'alignement
- En permettant de créer des bouchons fiables et versionnés, elle a accélèré les cycles de développement et permis aux équipes de travailler de manière efficace et cohérente
- En simplifiant la gestion des cas de test complexes ou dépendants de l'extérieur, elle a apporté de la stabilité aux environnements manipulés par les équipes de test, réduisant ainsi de façon significative les risques d'erreurs liés aux jeux de données

Enfin, elle nous a permis de rappeler au client notre savoir-faire en termes d'industrialisation et d'intégration, et de prouver notre engagement à faire de chaque projet un moteur de l'amélioration continue.

Search

pratiques

tools

Contenus associés

Apache Kafka, un nouvel usage à venir ? KIP-932 Queues for Kafka

Kafka est une plateforme de data streaming offrant réplication, partitionnement et ordre des messages. Mal adapté au message queuing, la KIP-932 introduira des share groups pour plus de flexibilité et d’usages.

Vers un Numérique Responsable

Le jour du dépassement est atteint de plus en plus tôt dans l'année. L'informatique a une part de responsabilité conséquente dans ce dépassement.
L'article vous en dira plus sur le sujet, ainsi que quelques notions d'éco-conceptions.

Podman : une alternative à Docker

Podman, alternative open source à Docker, offre une CLI similaire, compatible avec Docker Compose, rootless et sans démon. Facile à installer sous WSL2, il utilise les normes Open Container Initiative