Emeric

Blog Tech - @emerick42

Objectifs : Approfondir la notion de Rapid Application Development. Thèmes : Rapid Application Development, Long Terme, Direction Technique, Métier, Organisation. Longueur : ~1400 mots.

Long Terme ?

Avant de parler de Rapid Application Development (qu'on abrégera RAD dans la suite de cet article), il faut revenir sur la notion de terme d'un projet, sa période.

En général, on va considérer trois périodes possibles : le court, moyen et long terme. Wikipedia a une définition intéressante de la notion de long terme en économie :

On appelle longue période, celle au cours de laquelle la capacité productive de l'Offreur peut se modifier.

Dans un vocabulaire plus centré autour d'un projet informatique, on pourrait traduire par une période au cours de laquelle le service est amené à évoluer.

À contrario, l'article cité définit le court terme de cette manière :

On appelle courte période, celle dont la durée permet de faire varier la production, les équipements étant constants. Dans cet intervalle, c'est le taux d'utilisation ou d'emploi des équipements qui s'accroît et délivre un volume de production supérieur.

Un projet informatique court terme serait donc un projet où le service n'est pas amené à changer, seulement son intensité d'utilisation.

Enfin, le moyen terme y est défini comme la période égale à la durée de vie des équipements significatifs de l'activité : en informatique, la durée de vie des principales fonctionnalités d'un projet (hors fonctionnalités de support du métier).

Pour résumer : le court terme, c'est quand le projet informatique n'est absolument pas modifié. À partir du moment où l'on modifie uniquement des fonctionnalités annexes au métier, on est dans un projet moyen terme. Enfin, dès qu'on se met à modifier des fonctionnalités métier cœur, on se situe sur du long terme.

Rapid Application Development (RAD)

On entend beaucoup dire dans l'univers PHP que faire du RAD, c'est savoir utiliser EasyAdmin pour son client. Faire du RAD, c'est commencer un projet avec API Platform. Faire du RAD, c'est aimer concevoir son métier sous forme de CRUD.

Le RAD est une méthode formalisée et publiée par James Martin en 1991, basée sur de nombreux travaux durant les années 80. Elle est d'ailleurs considérée comme la base du postulat Agile, pour ceux qui connaîtraient mieux cette notion. Les objectifs de cette méthode étant de diminuer le time-to-market, améliorer l'évolutivité du produit, et diminuer les risques, par rapport aux méthodes “en cascade” :

Développer des logiciels dans un cycle qualifié d'itératif, d'incrémental et d'adaptatif.

Faire du RAD, c'est utiliser une méthodologie de développement logiciel qui consiste à : – concevoir petit à petit son logiciel, avec de courtes phases répétées de conception, d'implémentation, de test et de validation (itératif), – ajouter petit à petit les fonctionnalités du logiciel, avec des livrables fonctionnels à chaque nouvel ajout (incrémental), – et permettre la modification simple des fonctionnalités existantes, pour suivre au mieux les changements tactiques et stratégiques (adaptatif).

Mettre en place du RAD

En croisant les deux premières sections de cette article, on peut déduire une information intéressante :

Le long terme, c'est à partir du moment où les fonctionnalités principales d'une application évoluent. Le RAD, c'est une méthode basée sur l'évolution des fonctionnalités d'une application. Un projet en RAD doit être géré comme un projet long terme.

Oui, un projet en RAD doit être géré comme un projet long terme. Et voici quelques éléments pour aller dans ce sens.

Concéder du métier pour la technique

Le métier doit être organisé pour pouvoir faire des concessions pour la technique. Il peut s'agir de minimiser les fonctionnalités souhaitées, ou bien de prioriser différemment des sections d'un métier.

Si on prend l'exemple du métier Paiement d'une application, la décision peut être de n'accepter que le paiement via Paypal (minimisation des fonctionnalités). On peut aussi accepter le paiement via carte bleue sur la première année, et reconduire à une échéance plus lointaine l'acceptation du paiement en plusieurs fois (priorisation différente).

Produire du code accessible

La technique doit être organisée pour délivrer des produits à forte accessibilité. On peut documenter le code et l'infrastructure. Segmenter le code selon le métier permet de le documenter implicitement : il est plus aisé pour un nouveau développeur de le maîtriser car il utilise des termes proches de ceux du métier. Enfin, l'utilisation d'outils du “plus bas niveau commun” (le langage de programmation pour les développeurs, le système d'exploitation pour l'administration système) est une des démarches les plus fortes d'accessibilité. À l'inverse, l'utilisation de surcouches implique un besoin de spécialisation plus fort pour les (futurs) mainteneurs.

Dans notre implémentation du métier Paiement, on préférera par exemple l'utilisation d'un client PHP officiel de Paypal, plutôt que l'utilisation d'un bundle Symfony JMS Paypal. Niveau segmentation métier, un couplage faible dans un dossier src/Domain/Payment indépendant (inspiré de DDD), basé sur des interfaces et de la composition rendra le code plus simple à reprendre et à documenter.

Produire du code robuste

Les implémentations métier produites doivent être résilientes. Les tests automatiques permettent de s'assurer du fonctionnement normal du métier. À côté de ça, les revues de code peuvent apporter une vérification en amont et plus théorique des failles d'un système. De manière générale, la programmation défensive et ses concepts sont une bonne source d'inspiration pour aider à produire du code robuste.

Pour notre métier de Paiement, on pourrait tester unitairement les règles les plus complexes avec PHPUnit : calcul des prix, application des coupons de réduction. Pour un point aussi critique, il faudrait bien sûr définir et implémenter les comportements en cas d'échec à la moindre étape d'un paiement (que ce soit en retour de l'API, lors de l'insertion dans notre BDD, tout peut échouer).

Protocoliser les dépendances

Les dépendances doivent être placées derrière des protocoles définis par le métier. C'est bien entendu vrai pour les dépendances externes, mais c'est aussi recommandé pour les dépendances internes : au sein d'une même application, un sous-métier devrait communiquer avec un autre sous-métier à travers un protocole défini.

En reprenant l'exemple de notre Paiement, on définira un protocole (en PHP, le protocole le plus simple étant une interface) de paiement autour de notre dépendance au SDK Paypal : une méthode, ou un ensemble de méthodes, avec des paramètres et des valeurs de retour spécifiques à notre besoin métier. L'implémentation de ce protocole étant le seul code ayant une dépendance sur le SDK.

Former une équipe d'experts généralistes

L'équipe technique doit être en mesure d'adresser avec efficacité toutes ces problématiques. Pour ça, il va falloir composer avec un niveau suffisamment élevé d'expertise. Outre pour l'accessibilité et la robustesse du code, une équipe d'experts généralistes est nécessaire pour s'adapter à un métier qui peut changer de direction à n'importe quel moment et toujours trouver les meilleurs compromis.

Trouver de bonnes réponses à toutes les questions posées auparavant dans cet article nécessite une expérience globale sur les différents systèmes de Paiement existants. Il faut par exemple connaître les limites de Stripe, d'un point de vue technique, métier et financier. Il faut avoir expérimenté Dalenys pour savoir à quel point on peut l'intégrer derrière des protocoles simples.

Mettre l'humain au centre du dispositif

Le projet doit se construire autour de chacun de ses équipiers (on en revient aux méthodes agiles). Notamment autour de l'apprentissage de chacun. L'expérience, l'apprentissage passé qui permet de faire des choix plus efficace pour le contexte donné. La formation, l'apprentissage futur qui permet une meilleure efficacité sur le temps, un renouvellement des équipes et l'émergence de nouvelles options.

Pour finir avec le module de Paiement, la solution sera choisie en fonction des habitudes croisées des mainteneurs de ce métier. De la même façon, l'intégration d'un junior à l'évolution de ce métier pourra se faire via une micro-formation sur une heure, explicitant les objectifs, raisons et spécificités de l'implémentation.

C'est tout ce qu'on veut

Faire du Rapid Application Development, c'est faire un choix de complexité pour atteindre un niveau très fort d'adaptabilité pour le projet. C'est tout ce que voudrait n'importe quel client, n'importe quel product owner. Mais pour réussir, ça demande une organisation particulière et beaucoup d'expertise. Et dans un mauvais cadre, ou mal géré, ça peut coûter très cher.

En tout cas, ce n'est ni quelque chose de simple, ni quelque chose d'efficace tout le temps. Ni une méthode qui consiste à faire les choses le plus vite possible sans se poser de question. Et c'est encore moins faire du CRUD.

Liens

Objectifs : Découvrir la bibliothèque Nom pour faire du parsing de données en Rust. Thèmes : Développement, Rust, Nom, Parsing, Combinatory Parsing.

Consommer un message binaire

Je me suis remis à développer en Rust ces derniers temps, au travers de deux projets de serveurs. L'un d'eux est un serveur pour un jeu navigateur à base de WebSockets. Est donc arrivée à un moment la question :

Qu'est-ce qu'on a de bien à notre disposition pour parser des messages binaires en Rust ?

L'objectif de ce serveur en Rust étant d'avoir des performances excellentes – d'où la volonté d'avoir un protocole binaire bien conçu –, il fallait trouver un système léger et sans trop de complexité. Extensible et modifiable aussi (les spécifications du protocole changent souvent). Testable unitairement, si possible.

Et j'ai découvert Nom.

Manger de la donnée, la transformer en ensembles cohérents

Un message binaire, c'est une suite finie d'octets. En utilisant le protocole WebSocket, avec le type de message binary, on sait qu'on peut directement recevoir un message binaire. Pour exemple, voici le message Identité conçu pour le serveur (le client se présente au serveur) :

 0         1 2             3->X+3
+---------+---------------+------+
| 0x0 (8) | length X (16) | name |
+---------+---------------+------+

Sur le premier octet, on a l'opcode 0x0. Les deux octets suivants correspondent à la longueur X en octets du nom renseigné. Et enfin, X octets pour le nom en lui même.

Dans l'idéal, on souhaite que notre parser ait le prototype suivant :

fn parse_identity(input: &[u8]) -> Result<String, Error>;

Une méthode parse_identity, qui prend en paramètre une suite finie d'octets &[u8], et qui renvoie : * le nom, en tant que String, si le parsing réussit, * ou une Error descriptive si l'opération échoue.

On peut implémenter cette méthode de façon naïve :

fn parse_identity(input: &[u8]) -> Result<String, Error> {
    /// Consomme le premier octet, et vérifie que la valeur est 0x0. Sinon, renvoie une erreur.
    if let Err(error) = parse_opcode(input[0]) {
        return Err(error);
    }
    /// Puis consomme les deux prochains octets, et stock la longueur. Sinon, renvoie une erreur.
    let length = match parse_length(input[1..3]) {
        Ok(length) => length,
        Err(error) => return Err(error),
    }
    /// Puis consomme "length" octets si disponibles, et renvoie la chaîne. Sinon, renvoie une erreur.
    if 3 + length < input.len() {
        parse_name(input[3..3 + length])
    } else {
        Err(Error::new())
    }
}

fn parse_opcode(input: u8) -> Result<(), Error>;
fn parse_length(input: &[u8]) -> Result<u16, Error>;
fn parse_name(input: &[u8]) -> Result<String, Error>;

En regardant ce code, on peut visualiser un modèle de construction qui se répète : notre fonction parse_identity combine d'autres fonctions parse_x, chacune consommant petit à petit des données, et renvoyant soit une donnée construite, soit une erreur.

Ce modèle est la base d'une technique de parsing qui s'appelle le Combinatory Parsing.

Implémenter le parser avec Nom

Maintenant qu'on a une idée du fonctionnement global, on va pouvoir s'intéresser à une implémentation concrète en utilisant la bibliothèque Nom, ici en version 5.1.2.

Nom va fournir un ensemble complet de fonctions utilitaires, combinables, pour définir notre propre parser. La plus grosse difficulté consiste à connaître les parsers disponibles et savoir quand les utiliser.

On va commencer par recréer le prototype de notre fonction parse_identity, en utilisant Nom :

use nom::IResult;

fn parse_identity(input: &[u8]) -> IResult<&[u8], String>; 

IResult est un type définissant le résultat d'un parser Nom. Il a 3 paramètres : l'input restant (ici &[u8]), l'output généré (ici String), et optionnellement l'erreur lancée (ici implicitement (&[u8], nom::error::ErrorKind)).

Puis, on va implémenter une première partie, la récupération de l'opcode :

use nom::{Err, IResult};
use nom::bytes::complete::tag;
use nom::error::ErrorKind;

fn parse_identity(input: &[u8]) -> IResult<&[u8], String> {
    /// On crée un parser qui reprend le parser "tag" fourni par Nom.
    let parse_opcode = tag([0]);

    /// On exécute le parser, et on récupère l'input restant.
    let (input, _) = parse_opcode(input)?;

    /// On renvoie l'input restant et une erreur.
    Err(Err::Error((input, ErrorKind::Tag)))    
}

Le principe du parser tag est d'essayer de consommer la suite d'octets donnée, ou de renvoyer une erreur.

Ensuite, on veut implémenter notre parse_length et notre parse_name :

use nom::{Err, IResult};
use nom::bytes::complete::{tag, take};
use nom::combinator::flat_map;
use nom::error::ErrorKind;
use nom::number::complete::be_u16;

fn parse_identity(input: &[u8]) -> IResult<&[u8], String> {
    let parse_opcode = tag([0]);
    let parse_name = flat_map(be_u16, take);

    let (input, _) = parse_opcode(input)?;
    let (input, name): (_, &[u8]) = parse_name(input)?;

    match String::from_utf8(name.to_vec()) {
        Ok(name) => Ok((input, name)),
        Err(_) => Err(Err::Error((input, ErrorKind::Tag)))
    }
}

On utilise ici une combinaison de be_u16 pour récupérer la longueur sur deux octets en Big Endian, de take pour récupérer une quantité précise d'octets, et de flat_map pour automatiquement passer le résultat du premier parser en paramètre du second. On essaye de convertir le contenu de name en chaîne de caractère UTF-8 (ça peut échouer si la séquence d'octets n'est pas une séquence UTF-8 valide), et la fonction est désormais correctement implémentée !

Cela dit, on va faire encore une petite révision. Actuellement, le parser utilise un style séquentiel. On appelle manuellement d'abord parse_opcode, puis si ça ne renvoie pas d'erreur, on jette le résultat et on appelle parse_name. Il y a une fonction Nom pour ça :

use nom::{Err, IResult};
use nom::bytes::complete::{tag, take};
use nom::combinator::flat_map;
use nom::error::ErrorKind;
use nom::number::complete::be_u16;
use nom::sequence::preceded;

fn parse_identity(input: &[u8]) -> IResult<&[u8], String> {
    let parse_opcode = tag([0]);
    let parse_name = flat_map(be_u16, take);
    let parse_identity = preceded(parse_opcode, parse_name);

    let (input, name) = parse_identity(input)?;

    match String::from_utf8(name.to_vec()) {
        Ok(name) => Ok((input, name)),
        Err(_) => Err(Err::Error((input, ErrorKind::Tag)))
    }
}

Le parser preceded prend en paramètre deux parsers, qu'il appliquera séquenciellement, et ne renverra que le résultat du second. On se retrouve maintenant avec une seule méga-combinaison pour notre parser (on a juste la conversion en chaîne de caractère UTF-8 qui est une étape séparée).

Installer Nom

Pour installer Nom dans un projet rust, il suffit d'ajouter la dépendance nom = "5.1.2" (version actuelle) au fichier cargo.toml.

Un dernier point : Nom est totalement compatible avec des tests unitaires Rust. On peut ajouter au fichier créé auparavant un module test derrière un flag de compilation #[cfg(test)] (qui excluera des binaires compilés ce module), contenant directement le test unitaire. Ce qui donne un fichier final avec :

use nom::{Err, IResult};
use nom::bytes::complete::{tag, take};
use nom::combinator::flat_map;
use nom::error::ErrorKind;
use nom::number::complete::be_u16;
use nom::sequence::preceded;

fn parse_identity(input: &[u8]) -> IResult<&[u8], String> {
    let parse_opcode = tag([0]);
    let parse_name = flat_map(be_u16, take);
    let parse_identity = preceded(parse_opcode, parse_name);

    let (input, name) = parse_identity(input)?;

    match String::from_utf8(name.to_vec()) {
        Ok(name) => Ok((input, name)),
        Err(_) => Err(Err::Error((input, ErrorKind::Tag)))
    }
}

#[cfg(test)]
mod test {
    use super::*;

    #[test]
    fn test_parse_identity() {
        let data = vec![0, 0, 2, 49, 49];
        assert_eq!(
            parse_identity(&data),
            Ok((
                &data[5..5],
                String::from("11")
            ))
        )
    }
}

Comme tous les tests en Rust, ils sont exécutables en utilisant la commande cargo test. C'est vivement recommandé d'associer des tests à chacun des parsers conçus : c'est rapide à relancer et pratique pour du TDD.

Liens

Objectifs : Réfléchir à la gestion des dépendances dans un projet, et résoudre le problème de la dépendance incontrôlée sur Composer. Thèmes : Développement, PHP, Dépendances, Composer, Déploiement.

Dependency manager

L'utilisation de dépendances dans une stack logicielle est soumise à une règle d'or :

Pour chaque dépendance, mettre en place un contrôle des versions autorisées, en fonction de la confiance accordée à son mainteneur.

Dans l'univers JavaScript, on utiliserait Yarn, qui permet via un package.json de décrire un schéma des versions de dépendances acceptées.

Côté PHP, on a pris l'habitude d'utiliser Composer depuis quelques années pour gérer nos dépendances. Et ça donne :

{
    "require": {
        "monolog/monolog": "1.0.*"
    }
}

Exemple tiré de la documentation officielle de Composer

Dans cet exemple, on intègre le package monolog/monolog à notre projet, en explicitant la contrainte : version 1.0, ou tout patch sur cette version mineure.

Semantic versioning

Tout ça peut fonctionner car basé sur Semantic Versioning, une RFC qui définit un ensemble de règles pour la numérotation des versions (qui crée donc une cohésion sur la signification des numéros de version).

Dans l'exemple précédent, c'est Semantic Versioning qui nous permet de traduire la numérotation 1.0.* en version 1.0, ou tout patch sur cette version mineure.

Ensuite, c'est la confiance qu'on a envers les mainteneurs de la dépendance monolog/monolog qui nous permet d'être souple et d'accepter tous les patchs qui pourraient être publiés.

Dans le cas où l'on intègre la dépendance d'un mainteneur auquel on accorde moins de confiance, on peut remplacer par "monolog/monolog": "1.0.8", afin de laisser la place au moins de risque possible (la version choisie ayant été testée directement lors des développements, et étant donc fonctionnelle à un moment T).

Verrouillage

Le dernier mécanisme — nécessaire — apporté par les Dependency Manager modernes est le mécanisme de verrouillage des dépendances. Il permet de verrouiller les versions des dépendances de manière stricte lors de phases de release (jusque là, l'application des contraintes de version se fait lors des phases de développement).

Dans le cas de Composer, ce mécanisme est implémenté via le fichier composer.lock :

{
    "_readme": [
        "This file locks the dependencies of your project to a known state",
        "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
        "This file is @generated automatically"
    ],
    "content-hash": "2bdb78875a3c225905ed078c9d9140f6",
    "packages": [
        {
            "name": "psr/cache",
            "version": "1.0.1",
            "source": {
                "type": "git",
                "url": "https://github.com/php-fig/cache.git",
                "reference": "d11b50ad223250cf17b86e38383413f5a6764bf8"
            },
            "dist": {
                "type": "zip",
                "url": "https://api.github.com/repos/php-fig/cache/zipball/d11b50ad223250cf17b86e38383413f5a6764bf8",
                "reference": "d11b50ad223250cf17b86e38383413f5a6764bf8",
                "shasum": ""
            },
            ...
        },
        ...
     ]
}

On voit que ce fichier (qui doit faire partie des sources à release) fixe la dépendance psr/cache à la référence de commit précise d11b50. On maîtrise donc entièrement quelle version de notre dépendance va être utilisée lors d'un déploiement.

Recommandation officielle

#!/bin/sh

EXPECTED_CHECKSUM="$(wget -q -O - https://composer.github.io/installer.sig)"
php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');"
ACTUAL_CHECKSUM="$(php -r "echo hash_file('sha384', 'composer-setup.php');")"

if [ "$EXPECTED_CHECKSUM" != "$ACTUAL_CHECKSUM" ]
then
    >&2 echo 'ERROR: Invalid installer checksum'
    rm composer-setup.php
    exit 1
fi

php composer-setup.php --quiet
RESULT=$?
rm composer-setup.php
exit $RESULT

Script d'installation automatique proposé par la documentation officielle de Composer

Composer propose un utilitaire d'installation, signé, sous la forme du script PHP composer-setup.php. La documentation indique qu'on peut choisir la version de l'utilitaire à utiliser. Cependant, il n'est pas indiqué qu'on DEVRAIT choisir la version de Composer à installer, et comment faire.

Cordonnier mal chaussé

Fin 2018, Composer sortait sa version 1.7.3 (un patch sur une version mineur, donc). Le script d'installation ci-dessus étant massivement utilisé, fleurirent sur Twitter et Github une pluie de commentaires de type :

Yesterday composer install works fine. Without any changes to composer.json & composer.lock today it fails

https://github.com/composer/composer/issues/7764 https://github.com/composer/composer/issues/7974 https://github.com/composer/composer/issues/7813

Et des réponses des mainteneurs du type :

This isn't a bug, it was a bug before which was fixed.

Avec l'arrivée de Composer 2 dans les prochains mois, les scripts de déploiement en production des applications PHP risquent de se mettre à planter. On commence à voir apparaître dans les logs de déploiement des Deprecation Notices qui n'existaient pas quelques mois auparavant.

Être strict AUSSI avec Composer

Les mainteneurs de Composer ont fait un travail admirable sur la stabilité du projet, et ont permis que puisse être utilisée quasiment n'importe quelle version de l'outil sans causer de problème. La confiance s'est donc installée, et la sécurité énormément relâchée. Afin de passer correctement le cap des prochains mois, il sera sans doute judicieux d'être à nouveau strict avec le Composer souhaité sur chaque projet.

Le script d'installation automatique pour Linux propose (voir les sources sur Github, bien que cela ne semble pas documenté) plusieurs options, dont --version qui nous intéresse particulièrement :

Composer Installer
------------------
Options
--help               this help
--check              for checking environment only
--force              forces the installation
--ansi               force ANSI color output
--no-ansi            disable ANSI color output
--quiet              do not output unimportant messages
--install-dir="..."  accepts a target installation directory
--preview            install the latest version from the preview (alpha/beta/rc) channel instead of stable
--snapshot           install the latest version from the snapshot (dev builds) channel instead of stable
--version="..."      accepts a specific version to install instead of the latest
--filename="..."     accepts a target filename (default: composer.phar)
--disable-tls        disable SSL/TLS security for file downloads
--cafile="..."       accepts a path to a Certificate Authority (CA) certificate file for SSL/TLS verification

L'option --version semble accepter en paramètre n'importe quel tag disponible au niveau du dépôt git Composer. Pour installer une version précise de Composer, on peut donc remplacer dans notre script :

php composer-setup.php --quiet --version="1.10.6"

Il ne semble malheureusement pas y avoir de possibilité d'utiliser une contrainte plus souple de version (de type 1.10.* ou ^1.10) à ce jour.

Liens

Objectif : Découvrir quelques astuces pour mieux gérer l'environnement de développement de son équipe. Thèmes : Développement, Docker-Compose, Makefile, PHP.

Docker-Compose

On débute avec seulement notre projet PHP. La première chose à faire, c'est de mettre en place l'outil qui nous permettra de créer, de recréer, à volonté, la stack qui est utilisée pour développer sur le logiciel.

On va vouloir définir l'état actif normal de notre stack de développement. Et uniquement cet état. Quelles parties du logiciel doivent tourner obligatoirement ? Lesquelles sont à contrario temporaires ? Cet état actif normal, on va vouloir l'écrire dans un fichier, et laisser notre outil gérer lui même les actions à effectuer pour l'atteindre.

Pour ça, il y a Docker-Compose. C'est un ensemble de commandes qui vont encapsuler l'accès à Docker – qu'on utilise, lui, pour gérer toutes les problématiques de portabilité –. On écrit dans un fichier descriptif, conventionnellement docker-compose.yml, l'état de la stack qu'on souhaitera faire tourner via Docker.

Un exemple de docker-compose.yml, utilisé pour faire tourner l'infrastructure de développement en local de green.vanoix.com :

version: '3.7'

services:
    application:
        build: ./docker/build/application
        volumes:
            - .:/var/www/app
        ports:
            - "80:80"
        networks:
            - backend
        secrets:
            -
                source: ssh_key
                target: /root/.ssh/id_rsa
    database:
        image: mariadb:latest
        volumes:
            - ./docker/data/database:/var/lib/mysql
        environment:
            - 'MYSQL_ALLOW_EMPTY_PASSWORD=yes'
            - 'MYSQL_DATABASE=application'
        networks:
            - backend

secrets:
    ssh_key:
        file: ~/.ssh/id_rsa

networks:
    backend: ~

On décrit : – D'abord les services qu'on va vouloir faire tourner, les volumes qui leur seront rattachés, etc. – Puis les secrets qu'on va vouloir utiliser. Qu'on va ensuite ajouter à chaque service où c'est nécessaire. – Enfin les réseaux qui devront être créés. Qu'on renseigne, de la même manière, au niveau des services qui les utiliseront.

Makefile

Maintenant que notre cible est définie, on va vouloir la rendre accessible, rapidement. D'une part, on cherche à diminuer la difficulté à mettre en route cette stack. D'une autre, on cherche à diminuer le temps pour ce faire.

Il va donc falloir réduire au maximum le nombre d'étapes, et automatiser chacune le plus possible.

Pour sa portabilité, le fait qu'il soit un standard, et qu'il permet de gérer tout ce qu'un shell peut gérer – donc, tout –, on va utiliser make. De manière assez conventionnelle, la règle principale par défaut est souvent nommée install. On retrouve aussi la règle run (ou start), qui permet de lancer le logiciel. Souvent la règle clean pour nettoyer l'environnement local. Et parfois, stop dans le cas d'un service en arrière-plan.

Dans notre cas, un fichier Makefile contenant ceci devrait suffir. Moins il y a de règles, plus le projet est simple à aborder :

.PHONY: start stop

start:
    docker-compose build
    docker-compose run application composer install
    docker-compose up -d

stop:
    docker-compose down

Rappel sur le .PHONY : chaque règle (ou cible) est normalement associée au fichier du même nom. Pour décorréler les deux, on renseigne la règle dans l'énumération .PHONY.

Makefile et Docker-Compose

Le principal problème avec ce que l'on a fait, c'est qu'on va essayer de rebuild les containers et réinstaller les dépendances Composer, à chaque lancement du projet. On perd beaucoup de temps. D'un autre côté, si on souhaite seulement réinstaller les containers, on démarre aussi l'application. On perd beaucoup en ressources. Cela traduit un problème de conception, sur les différents cas d'utilisation de nos automatismes.

Pour corriger cela, on va définir les états actifs normaux de l'environnement de développement. Puis les décrire, dans un fichier, sous forme de règles idempotentes. Puis – comme avec docker-compose – on considérera que le travail de notre outil n'est que de mettre le système dans l'état souhaité.

Ici, on définit trois états pour un environnement local : – l'état installé, lorsque les containers sont construits et les dépendances logicielles installées, – l'état démarré, lorsque le logiciel a été lancé, – et l'état stoppé, lorsque le logiciel a été arrêté.

make et docker se combinent à merveille, afin de définir les règles de passage à un état spécifique. Voici l'exemple du Makefile utilisé pour le projet green.vanoix.com :

.PHONY: install start stop

install:
	docker-compose build
	docker-compose run --no-deps --rm application composer install

start:
	docker-compose up -d

stop:
	docker-compose down

On définit une règle par état ciblé, et une série d'action pour chaque règle. Sur un plan plus technique, l'astuce consiste à profiter de la capacité de Docker-Compose à éxécuter seul (run --no-deps) un container temporaire (--rm), basé sur un service à l'arrêt (application), pour installer les dépendances PHP sur le système de fichiers (composer install).

README

Dans le cas d'applications plus complexes, il reste encore une chose à paufiner : la gestion du contexte. Souvent, on va considérer que le développeur devra avoir tel prérequis installé sur sa machine. Tel service lancé en fond. Telle configuration SSH. Tel dossier à tel emplacement. Telle connaissance acquise.

On va vouloir gérer tous ces cas particuliers. Mais sans sacrifier les performances et la simplicité de notre système. On peut encadrer notre environnement de développement en utilisant des outils de communication.

Le fichier README.md est classique et efficace, et un excellent liant avec tous les autres outils. Un exemple de template minimal qui permet de gérer la configuration du host et clef SSH sur un projet :

## Dev Environment

This project uses Docker (docker-compose version >=3.5) to manage the local development environment.

### Requirements

#### Hosts Configuration

You must have this entry in your `/etc/hosts` file:

```
127.0.0.1 myproject.localhost
```

#### SSH key forwarding

The project uses Docker Secrets to handle the SSH key forwarding to your container (mainly used to access private repositories).

Your local SSH key must be located at `~/.ssh/id_rsa`.

### Installation

```
make install && make start
```

The project should be now successfully running.

Liens