Ma seconde semaine d'intégration: Jouons à deux avec Symfony

Pierre Haller
Pierre HallerJuly 25, 2019
#integration#php

Cet article est le second de la série relatant mon arrivée et mon intégration chez Marmelab

Ma première semaine d'intégration est terminée. Je démarre donc une nouvelle semaine avec un nouveau défi. Après la découverte de la modélisation du jeu en Python, je dois cette fois-ci développer le jeu du Quixo en PHP, en mode web "à l'ancienne", avec le framework Symfony. J'ai déjà utilisé des composants de ce framework, mais jamais l'approche "full-stack". J'ai 5 jours pour apprendre un framework, porter mon code Python en PHP, et implémenter un serveur de jeu multijoueurs. Mission impossible ?

Première étape : Découverte du board sur Trello

Comme la semaine dernière, je commence par étudier le board Trello qu'on m'a préparé. Cette fois-ci, il comporte 36 cartes, soit une quinzaine de plus que la semaine précédente. Je retrouve quelques cartes que j'ai déjà développées en python concernant la logique de jeu ; je ne devrais avoir qu'à adapter le code en PHP pour cette partie. D'autres cartes sont des nouveautés :

  • Persister les parties dans une base de données de mon choix.
  • Permettre à 2 joueurs de s'affronter à distance.
  • Pouvoir annuler la sélection d'un cube.
  • Enregistrer l'utilisateur avec un compte.
  • Jouer contre l'ordinateur.

Après avoir estimé le temps requis pour chaque carte, il apparaît que je manquerai de temps pour réaliser l'ensemble des fonctionnalités. Le PO choisit les cartes les plus importantes qui rentrent dans le budget de ma semaine d'intégration, en m'expliquant qu'il espére que j'aurai le temps d'aller plus loin...

Mise en place du projet

Docker

C'est parti pour le développement du projet. Je commence par lister les outils nécessaires pour le projet. J'aurai besoin :

  • De PHP, pour exécuter le serveur Symfony.
  • De Nginx, qui sera le serveur web permettant l'accès à l'application Symfony.
  • De PostgreSQL, une base de données pour la persistance des parties et des joueurs.

Comme pour le projet précédent, je vais utiliser Docker pour ne pas avoir à installer tout cela sur ma machine. Mais cette fois-ci, j'aurai besoin de 3 containers, un pour chaque outil listé ci-dessus. Je vais donc utiliser docker-compose, qui permet justement de définir et de monter des applications multi-containers, et que j'ai déjà utilisé par le passé.

Je crée un fichier docker-compose.yml définissant chacun de mes services:

services:
    nginx:
        image: nginx:alpine
        ports:
            - '80:80'
        volumes:
            - ./docker/nginx/site.conf:/etc/nginx/conf.d/default.conf
        depends_on:
            - php
    postgres:
        image: postgres:11
        environment:
            - POSTGRES_USER=*****
            - POSTGRES_PASSWORD=*****
            - POSTGRES_DB=*****
        ports:
            - '5432:5432'
    php:
        build: docker/php
        ports:
            - '9000:9000'
        volumes:
            - ./quixo:/quixo
        depends_on:
            - postgres

Pour les deux premiers services, j'utilise telles quelles les images officielles proposées sur Docker hub. Pour le container PHP, je découvre que l'image officielle ne suffit pas. J'ai également besoin de paquets supplémentaires:

  • Plusieurs bibliothèques de compressions comme libzip nécessaires à Symfony.
  • Des pilotes pdo_pgsql permettant l'accès de PHP à la base de données Postgres.
  • Le gestionnaire de dépendances composer.

Pour cela, je crée un fichier Dockerfile, qui définit les commandes exécutées par Docker lors du build :

FROM php:7.3.6-fpm

RUN apt-get update

RUN apt-get install -y zlib1g-dev libpq-dev git libicu-dev libxml2-dev libzip-dev\
    && docker-php-ext-configure pgsql -with-pgsql=/usr/local/pgsql \
    && docker-php-ext-install pdo pdo_pgsql pgsql \

RUN curl https://getcomposer.org/composer.phar -o /usr/bin/composer && chmod +x /usr/bin/composer

WORKDIR /quixo

Pas de problème sur cette partie, la documentation en ligne sur la mise en place d'un environnement Docker pour PHP + Symfony + Postgres + NGINX est claire et regorge d'exemples.

Makefile

Comme la semaine dernière, pour automatiser l'installation, le test et le lancement du projet, je crée un fichier Makefile. Il comporte, entre autres, 3 commandes :

  • install qui installe les dépendances PHP (composer-install) et qui crée les base de données (init-db) :
    composer-install:
        ## Exécute la commande `composer install` dans le container `php`
        ## --rm permet d'arrêter le container une fois la commande exécuter
        ## -no--deps indique qu'il n'est pas nécessaire de créer le container postgres dont dépend le container PHP.
        docker-compose run --rm --no-deps php bash -ci '/usr/bin/composer install'

    init-db:
        ## Ici, nous avons besoin du container postgres, il ne faut donc pas ajouter l'option --no-deps
        docker-compose run --rm php bash
            ./bin/console doctrine:database:create --if-not-exists &&
            ./bin/console doctrine:schema:update --force

    install:
        $(MAKE) composer-install
        $(MAKE) init-db
  • start qui lance l'application. Pour cela, il suffit de créer les containers :
    start:
        docker-compose up -d
  • stop qui arrête l'application :
    stop:
        docker-compose down

Avantage : make install et make start étaient déjà les commandes que je devais lancer la semaine dernière. Langage différent, mais commandes identiques : je vois l'intérêt du makefile.

Création de l'application

À l'aide de composer, je peux créer le squelette de l'application grâce à un générateur Symfony : composer create-project symfony/skeleton quixo. Les fichiers et les dossiers nécessaires au fonctionnement de Symfony sont automatiquement créés :

quixo/
├─ bin/
│  └─ console
├─ config/
├─ public/
│  └─ index.php
├─ src/
│  └─ Kernel.php
├─ var/
│  ├─ cache/
│  └─ log/
└─ vendor/

Après avoir lancé l'application, j'ai accès à mon nouveau site. Pour une première utilisation de ce générateur, je suis agréablement surpris : c'est super rapide et ça fonctionne bien.

Pour l'instant il affiche un simple hello world !. Il est donc temps de commencer le développement.

Développement en PHP

PHP ❤️ Types

J'ai déjà pu travailler en PHP avec le framework Symfony lors d'une précédente mission. Mais j'utilisais la version 5.6 de PHP. Pour ce projet, j'utilise la version 7.3.6 de PHP. J'ai donc accès à de nouvelles fonctionnalités de PHP que je n'ai jamais utilisées. Parmi ces fonctionnalités, j'utiliserai surtout les deux suivantes :

  • déclaration de type scalaire ;
  • déclaration du type de retour.

Grâce à ça, je peux définir le type de chaque paramètre d'entrée d'une fonction ainsi que son retour. Cela me permettra d'éviter les problèmes liés aux types des variables et de rendre le code plus compréhensible. Par exemple, voici la signature de la fonction permettant de jouer un cube :

public function playCube(Game $game, Coords $coords, int $team): Game

L'utilisation des types m'a évité quelques bugs lors du développement. Et ils ne m'ont jamais fait perdre de temps, car les types sont facultatifs en PHP. On verra dans un prochain article que les types peuvent être beaucoup plus contraignants dans d'autres langages.

L'affichage du plateau

La première carte du projet consiste à afficher un plateau vide. Pour cela j'ai besoin :

  • D'un contrôleur composé d'une action qui renverra une réponse.
  • D'une route /game qui appellera cette action.
  • D'une vue qui affichera le plateau.
  • D'une entité Game composée, entre autre, du plateau de jeu.
  • D'un repository qui gérera la persistance de l'entité Game.

Pour gérer la persistance, j'utilise l'ORM Doctrine, inclus avec Symfony, que j'avais déjà utilisé. Pour la création de la table game, j'aurai uniquement besoin d'ajouter des annotations sur ses attributs :

class Game
{
    /**
     * @ORM\Column(type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id;

    /** @ORM\Column(type="json") */
    private $board;

Pour le plateau de jeu, que j'ai choisi de représenter par un tableau à deux dimensions, j'utiliserai un type json en base de données.

Voici le résultat après quelques lignes de CSS : Empty board

Cette première carte a aussi été l'occasion d'écrire le premier test unitaire et le premier test d'intégration, et de brancher le repository à l'intégration continue (sous Travis).

Rien de très compliqué, malgré le nombre de composants que j'utilise (Twig, Doctrine, Symfony, phpUnit, CSS). La seule difficulté a été de retarder le démarrage de symfony dans le docker compose, pour qu'il attende la disponibilité du conteneur Postgres. A défaut d'utiliser le script wait-for-it.sh, que j'ai découvert plus tard, j'ai eu recours à un petit hack dans mon makefile.

Portage du code Python

Pas de difficulté sur ce sujet - j'ai pu utliser le même modèle en PHP que sur mon projet précédent en Python. Mais je trouve que PHP m'a contraint à écrire du code moins lisible dans ce cas précis. En particulier, j'ai du écrire des boucles imbriquées là où, en Python, j'utilisais des List Comprehensions.

Déplacer un cube

Une fois le plateau de jeu affiché, je dois permettre au joueur de déplacer un cube. Pour ça, je dois rendre cliquable les cubes déplaçables. La solution la plus simple serait d'ajouter un élément anchor autour des cubes qui réaliserait une requête GET vers le serveur. Mais cette solution n'est pas envisageable. En effet, un joueur pourrait créer cette requête pendant le tour de son adversaire et jouer à sa place. Après en avoir discuté avec mon tuteur et le Product Owner, nous décidons d'utiliser des formulaires qui exécuteront une requête POST de façon sécurisée.

Pour réaliser ces formulaires, j'ai essayé d'utiliser les FormBuilder Symfony. Ils permettent de créer des formulaires sécurisés simplement en fonction d'une entité. Par exemple, pour une entité task :

$form = $this->createFormBuilder($task)
             ->add('task', TextType::class)
             ->add('dueDate', DateType::class)
             ->add('save', SubmitType::class, ['label' => 'Create Task'])
             ->getForm();

Pour le rendu, c'est encore plus simple :

{{ form(form) }}

Malheureusement, les FormBuilder ne sont pas prévus pour l'utilisation que je souhaite en faire. La création du formulaire reste simple, mais je bloque lors de l'affichage. En effet, il est difficile de faire correspondre les coordonnées des cubes avec le formulaire correspondant.

Après m'être concerté avec mon tuteur, il apparaît qu'il sera plus simple de ne pas utiliser de FormBuilder mais de créer les formulaires en html. Mais cette façon de faire ne nous protège pas de la faille que nous avons vue précédemment. Pour se protéger de cela, je peux injecter un token csrf_token dans le formulaire grâce à un helper Twig. Voici le résultat :

<div class="cube cube-{{cube|getCubeSymbol}}">
    {% if movables|isCubeInMovables(x, y) %}
    <form
        class="form-cube"
        action="{{ url('game', { id: game.id }) }}"
        method="post"
    >
        <input
            type="hidden"
            name="token"
            value="{{ csrf_token('move-cube') }}"
        />
        <!-- Injection du token -->
        <input type="hidden" name="x" value="{{ x }}" />
        <input type="hidden" name="y" value="{{ y }}" />
        <button class="movable-cube-btn" type="submit"></button>
    </form>
    {% endif %}
</div>

Je devrai également vérifier que le token est valide dans mon action :

if ($this->isCsrfTokenValid('move-cube', $submittedToken)) {
    $coordsSelected = new Coords(
        $request->request->getInt('x'),
        $request->request->getInt('y')
    );
    $game = $gameManager->playCube($game, $coordsSelected, GameManager::CROSS_TEAM);
}

La génération et la vérification du token est réalisée par Symfony, c'est vraiment simple pour le développeur.

Le joueur peut désormais sélectionner et déplacer un cube de façon sécurisée.

Jouer à deux

Pour permettre à deux joueurs de s'affronter en ligne, j'ai besoin d'identifier l'émetteur des requêtes. Pour cela, j'utilise le mécanisme des sessions. Symfony met à disposition une interface nommée SessionInterface, permettant de gérer les sessions très simplement. Pour stocker une information en session, j'ai besoin de définir une clé et une valeur.

La clé sera composée d'un préfixe statique ainsi que de l'id de la partie. La valeur sera l'équipe du joueur pour cette partie. Pour stocker cela, une ligne suffit :

$this->session->set(self::PREFIX_GAME . $game->getId(), strval($team))

Pour récupérer l'équipe du joueur, je n'ai qu'à réaliser un get sur cette clé :

$team = (int) $this->session->get(self::PREFIX_GAME . $game->getId(), DEFAULT_VALUE);

Comme pour les tokens CSRF, toute la complexité est gérée par symfony, et c'est très simple à utiliser.

Je peux désormais identifier l'équipe de chaque joueur. En mettant à jour mon controlleur, deux joueurs peuvent s'affronter au Quixo sur leur navigateur !

Voici le résultat : Example quixo game

Conclusion

Cette semaine aura été intensive. Je m'étais fixé l'objectif d'arriver jusqu'au mode de jeu contre l'ordinateur mais je n'ai pas eu le temps. J'aurais également aimé avoir une interface plus accueillante avec des cubes plus réalistes.

Mais ce n'est que partie remise. La semaine prochaine, je devrai réaliser un bot dans un langage que je n'ai jamais utilisé : le GO.

J'ai appris à utiliser Travis, la mise en place d'un projet Symfony, la gestion des sessions avec Symfony, la protection CSRF en mode "débrayé" du FormBuilder, et pas mal d'autres petites choses.

Comme tous les projets d'intégration chez Marmelab, le code est open source et est hébergé sur GitHub. N'hésitez pas à ouvrir une PR si vous avez des idées d'amélioration.

Si vous n'avez pas lu l'article sur ma première semaine d'intégration chez Marmelab, vous le trouverez ici : Quixo, le morpion de l'intégration: Un jeu en python sur console

Did you like this article? Share it!