Ma seconde semaine d'intégration: Jouons à deux avec Symfony
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 :
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 :
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