Onboarding chez Marmelab: Une symfony en une semaine

Guillaume Billey
Guillaume BilleySeptember 15, 2021
#integration#php

Après avoir développé une première version du jeu Tipsy en python, il est temps de passer à un autre niveau et de développer et exposer une api permettant d'y jouer en ligne. Accrochez-vous à votre clavier et voyons comment cela s'est passé.

Let's code

Une API en Symfony

Lors de cette 2ème semaine d'intégration, l'objectif est de développer une version jouable en ligne dans le navigateur. Pour cela, le choix du langage est libre. Ayant un passé Java plus que prononcé, je décide fort logiquement de partir sur le framework java Spring... Non... pas du tout. Pour sortir de ma zone de confort, je décide de partir sur un langage que je n'ai touché que lors de mes études (PHP) et sur un framework que je ne connais pas du tout : Symfony. Vous verrez que ce choix est un peu audacieux sur cette période d'intégration, et me mettra en difficulté au cours de ce sprint.

Before each

Au cours de cette période d'intégration, j'ai pris l'habitude de commencer chaque sprint avec la mise en place d'un projet "coquille vide" fonctionnel dans la techno concernée, permettant de lancer les commandes make install et make run. Cela me permet de me confronter tout de suite aux éventuels problèmes de mise en place du projet qui peuvent être chronophages en fonction du langage.

J'initialise donc un projet Symfony, et découvre le gestionnaire de dépendances de PHP composer et la cli symfony. J'en arrive à avoir le makefile suivant :

install:
	docker-compose build
	docker-compose run --rm symfony bash -ci 'composer install'
	docker-compose run --rm symfony bash -ci 'composer update'
	docker-compose run --rm symfony bash -ci 'symfony console doctrine:schema:update --force'

run:
	docker-compose up --force-recreate -d

Accompagné du docker-compose.yml :

version: '3.3'

services:
    symfony:
        build: docker/symfony
        volumes:
            - .:/app
        ports:
            - 8080:8080
        command: '/usr/local/bin/symfony server:start --port 8080'
    nginx:
        image: nginx
        ports:
            - 80:80
            - 443:443
        volumes:
            - ./docker/nginx/nginx.conf:/etc/nginx/nginx.conf

J'ai ma coquille vide, je peux maintenant réfléchir à mon API.

Le contrat d'API

Étant mon propre client d'API, je suis assez libre concernant le contrat que je définis. J'essaye tout de même d'avoir une API relativement propre s'approchant de la philosophie REST.

Mon unique ressource est une "partie", j'appellerai cette ressource game. Les différentes actions possibles sont les suivantes :

  • Créer une nouvelle partie : POST /game
{
    "playerName": string,
    "playerId": string,
    "withBot": boolean
}
  • Retrouver une partie par son ID : GET /game/{id}
  • Retrouver les parties en attente de joueurs : GET /game/pending
  • Rejoindre une partie existante : POST /game/{id}/join
{
    "playerName": string,
    "playerId": string
}
  • Basculer le plateau : POST /game/{id}/tilt
{
    "playerId": string,
    "direction": "north" | "east" | "south" | "west"
}
  • Replacer un jeton: POST /game/{id}/replace

En réalité le contrat de l'API a bougé au cours du sprint afin de s'adapter aux différents besoins. Par exemple, dans un premier temps, il n'était pas nécessaire de passer l'information withBot lors de la création d'une partie. Ces différentes modifications ont été ajoutées à mesure que le besoin se faisait sentir.

Les couches Symfony

Symfony est un framework MVC. Il est composé d'un ensemble de couches applicatives permettant une séparation des responsabilités :

  • Entity : Les ressources manipulées.
  • Repository : La communication avec la base de données.
  • Service : Le code 'métier', la logique applicative se trouve ici.
  • Controller : L'interface de l'api REST, c'est le point d'entrée des clients.

Mille-feuilles

Venant du monde Java, et ayant notamment beaucoup utilisé Spring je suis déjà familiarisé avec ce découpage. Cela ne m'a pas empêché de tomber dans quelques travers.

L'écriture des entités, des services, et des controllers s'est déroulé sans encombre, allant jusqu'à écrire quelques tests sur mes services. Mais je ne m'attendais pas à bloquer aussi longtemps sur la mise en place du repository, et notamment sur l'utilisation de Doctrine, l'ORM PHP.

De l'importance de la Modélisation

Doctrine est un ORM (Object Relational Mapper), en deux mots (ou un peu plus...), cet outil permet de faire la transformation d'un modèle Objet, nos classes PHP, vers un modèle Relationel, nos tables dans Postgres, et inversement.

Pour faire cela, Doctrine a besoin que nous lui décrivions les relations et les types de structures à mapper (s'il ne peut pas les déduire). Je me suis rapidement trouvé bloqué sur la sérialisation et la dé-sérialisation de ma classe Game. J'ai bloqué une journée sur ce problème consistant à faire fonctionner le mapping une fois dans un sens, une fois dans l'autre. Une journée sur un sprint d'une semaine, c'est long...

Lors de ma modélisation, j'ai fait le choix d'inclure l'état du plateau dans mon objet Game.

    /**
     * @ORM\Column(type="object")
     */
    public $graph;

J'ai choisi cette façon de faire par facilité, j'utilise en effet une librairie pour gérer le graphe représentant mon plateau : graph-ds. Et il me semblait plus simple de gérer le graphe directement via cette librairie, en récupérer l'objet et le stocker en laissant Doctrine faire son travail de dé-sérialisation.

D'après vous, quelle était mon erreur ?

  • Persister un objet que je ne maîtrise pas
  • Persister un objet n'ayant pas de mapping relationnel
  • Les deux

Les deux bien sûr. Persister un objet venant d'une librairie tierce, c'est ne persister que les attributs de cet objet, la sérialisation perd les fonctions associées, et lors de la dé-sérialisation, l'objet est incomplet. Et persister une relation vers un objet directement pose un problème simple à comprendre, mais que j'ai mis du temps à débusquer.

Lorsque je sauvegarde mon jeu, toutes les modifications que j'ai pu faire à mon graph semblent ne pas être persistées, je vois pourtant bien les modifications avant de persister mon jeu.

La raison est toute simple : Doctrine, lorsque je veux persister une partie de Tipsy, va vérifier chaque attribut afin de voir s'il a été modifié ou non et va enregistrer ces modifications. L'attribut graph étant un objet et la comparaison d'objets se faisant par référence, si je change des attributs de cet objet, Doctrine ne verra pas le changement. Il verra juste que la référence n'a pas bougé et qu'il n'y a donc rien à faire.

Ayant perdu trop de temps à diagnostiquer ces 2 problèmes, je ruse un peu en clonant le graph juste avant de persister, ainsi la référence change, et Doctrine détecte le changement.

$this->graph = clone $this->graph;

Mais il aurait été plus malin de revoir ma modélisation, et de ne pas sauvegarder le graphe directement. Je n'ai d'ailleurs pas besoin d'avoir l'état du plateau en lui-même, le plateau est fixe, les obstacles restent au même endroit, les sorties aussi. Il aurait été plus propre de sauvegarder uniquement la position des jetons sur le plateau, m'évitant par la même occasion les problèmes de mapping. Libre à vous de faire un fork du projet pour corriger ça ;-)

Un beau plateau

Nous avons l'API, il nous faut maintenant une interface. Pour cela nous utiliserons Twig, le moteur de template de symfony. Je décide de créer un plateau statique, à base de lignes de div, ayant pour id ses coordonnées sur le plateau :

<div class="row">
    <div class="cell"><div id="cell00"></div></div>
    <div class="cell"><div id="cell01"></div></div>
    <div class="cell"><div id="cell02"></div></div>
    <div class="cell obstacle"><div id="cell03"></div></div>
    <div class="cell"><div id="cell04"></div></div>
    <div class="cell left-exit"><div id="cell05"></div></div>
    <div class="cell"><div id="cell06"></div></div>
</div>
<div class="row">
    <div class="cell top-exit"><div id="cell10"></div></div>
    <div class="cell obstacle"><div id="cell11"></div></div>
    <div class="cell"><div id="cell12"></div></div>
    <div class="cell"><div id="cell13"></div></div>
    <div class="cell"><div id="cell14"></div></div>
    <div class="cell  obstacle"><div id="cell15"></div></div>
    <div class="cell"><div id="cell16"></div></div>
</div>
[...]

Je dessine les jetons à base de css, en utilisant les boucles disponibles dans twig.

{% for flipped_red_puck in game.getPucksIdsBy('red', true) %}

    #cell{{flipped_red_puck}}{ // par exemple : cell10
        width: 28px;
        height: 28px;
        border-radius: 20px;
        border: 6px solid {{red}};
        background-color: darkgrey;
    }

{% endfor %}

Cette façon de faire est tout à fait valable, mais rend difficile la mise en place d'animations, comme faire glisser un jeton d'une case à l'autre. Je me contenterai de ça pour le moment.

Le plateau du Tipsy Niveau design, je pars de très loin et, après avoir passé pas mal de temps à peaufiner mon CSS, j'arrive à un résultat dont je commence à être satisfait. Je prends le parti d'améliorer l'UI/UX au fur et à mesure des sprints, permettant d'avoir des feedbacks rapide sur ce que j'ajoute.

On déploie!

Notre jeu tourne, on peut créer une partie, en rejoindre une, et jouer! Mais sur localhost... Il faudrait mettre ça à disposition du monde, pour que tout le monde puisse y jouer !

Je décide de déployer cela sur AWS, et je commence par partir sur ECS. ECS nous permet de déployer nos containers de la même manière que nous ferions un bête docker-compose up. En principe, il suffit d'utiliser les contextes de docker et en faire pointer un sur le docker engine d'ECS. Une fois cela fait, un simple : docker compose up permet de déployer son container sur ECS (la doc ici).

En pratique... ça n'a pas marché chez moi... Pas de log, pas de message d'erreur, juste rien.

Après avoir cherché un peu et demandé conseil à mes collègues, il m'a été conseillé de passer par EC2, qui fournit tout simplement des VMs, nues ou pas, que l'on configure comme on le souhaite. Je me provisionne donc une VMs via l'interface d'EC2, configure le serveur (j'installe Docker donc), et tente un premier déploiement à la main, avant de scripter tout ça :

git archive -o tipsy.zip HEAD
scp -i ${key} tipsy.zip ${user}@${host}:~/
ssh -i ${key} ${user}@${host} \
'unzip -ou tipsy.zip -d tipsy && \
cd tipsy &&\
cp .env.prod .env &&\
make install &&\
make run'
rm tipsy.zip

Le script est plutôt simple, nous zippons le projet, nous le copions sur le serveur, nous le dézippons sur le serveur et faisons un make install et make run. Au passage, je découvre la commande git archive pour créer un zip du projet. Après avoir galéré 1/2 journée à essayer de configurer un ECS avec docker, en 30 minutes j'ai un serveur et un script de déploiement.

Je ne jette pas l'éponge pour autant et reviendrais sur ECS pour tester, tant la promesse me semble intéressante.

On fait le bilan, calmement, en se remémorant chaque instant

Sur cette semaine, j'ai fait le choix d'utiliser un langage qui m'était (presque) inconnu. Sur une période courte, comme un sprint d'une semaine, c'est ambitieux. J'ai clairement ressenti le challenge, car ce sprint est sans doute le plus éprouvant que j'ai eu à passer sur cette période d'intégration.

Un point sur lequel je pensais mettre plus de temps, mais qui s'est avéré plus rapide à mettre en place, est la partie déploiement. Une approche très pragmatique m'a semblé dans ce cas plus efficace. La mécanique est simple, le script aussi, facile à comprendre, et à maintenir. Il s'agit bien sûr d'un déploiement très simple, mais dans ce cas d'usage, parfaitement adapté et c'est de temps en temps agréable de partir sur des choses simples tant nous avons parfois tendance dans nos métiers à complexifier les choses par anticipation.

À ce moment de l'intégration, j'ai hâte de démarrer la partie IA du jeu, je n'ai jamais fait d'IA, et je vais apprendre énormément de choses. La suite au prochain article.

En attendant, le code de l'api PHP est disponible ici.

Did you like this article? Share it!