Poke-fu-mi est une application qui permet d'organiser des combats entre maîtres Pokémon mais les règles ne sont pas exactement celles du jeu classique.
Pour le déroulement d'une partie, voilà ce qu'il se passe (en considérant que les joueurs ont déjà créé un compte et ont composé une liste de pokemon pour jouer) :
- Un joueur crée un Salon.
- Un second joueur peut rejoindre le salon soit par une invitation, soit en accédant à un salon public sans invitation.
- Le match est lancé, les 10 pokemon de chaque joueur vont être confrontés dans une succession de rounds 2 à 2.
- Chaque round consiste à comparer les types de chaque pokemon et à déclarer le vainqueur du round selon le nombre de points gagnés pour chaque type supérieur à l'adversaire.
- Lorsque les 10 rounds ont été réalisés, le joueur vainqueur du match est celui qui aura accumulé le plus de points.
- Le salon est clos. Pour lancer une nouvelle partie, le joueur revient à l'étape 1.
- 1. Pokefumi : Nx + Docker + Typescript + Express + Jwt + OpenAPI + Jest
- 1.1. Fonctionnalités
- 1.2. Démos
- 1.3. Résumé du travail réalisé par rapport aux spécifications fournies
- 1.4. Schéma d'architecture
- 1.5. Description des différents services
- 1.6. Choix techniques
- 1.7. Documentation de référence et exemples de requêtes / réponses
- 1.8. Pour tester les microservices
- 1.9. Liste des targets nx
- 1.10. Choix de conception
- 1.11. Evolutions possibles de l'application
- Node.JS 16 + Typescript 4.6
- Nx monorepo :
- targets pour construire le projet, générer la documentation, construire les images docker, faire des tests, etc.
- Webpack 5 pour un développement plus rapide
- Prettier + Eslint
- Approche "schema-first" :
- Clients axios rest générés à partir des schémas OpenAPI avec openapi-typescript-codegen
- Routeur et stubs générés à partir du schéma OpenAPI avec oats-ts
- Base de données SQLite générée à partir d'un schéma avec l'ORM Prisma
- Documentation des endpoints Rest générée à partir des schémas OpenAPI avec widdershins
- .devcontainer disponible pour lancer facilement un environnement de développement avec vscode
- Tests d'intégrations avec Jest
- Microservices rest écrits avec le routeur Express.JS
- Authentification des utilisateurs par jeton JWT avec express-jwt
- Validation avec zod des corps de requête
- Api Gateway avec Krakend-ce
- Configuration générée à partir des schémas OpenAPI avec openapi2krakend
- Déploiement continu avec Heroku
Tests e2e : execution automatique des tests d'intégration pour tester chaque endpoint de chaque microservice.
Automatise un scénario de test de match avec deux utilisateurs de bout en bout (du service
user
au servicestats
). Efface les bases de données et créé les utilisateurs au démarrage.
Docker-compose avec l'Api Gateway Krakend et le script test.sh.
Lance les containers puis un scénario de test de match avec deux utilisateurs à l'aide d'un script de Bash et de Curl. Créé les utilisateurs, obtient leur jeton JWT, créé le match et créé un Deck. Affiche ensuite le score des joueurs et les stats.
-
En tant que joueur, je peux …
- m'inscrire à la plateforme avec un nom d'utilisateur unique
- me connecter à la plateforme en utilisant mon nom d’utilisateur et un mot de passe
- voir la liste des joueurs (avec leur score cummulé sur toutes leurs parties)
- voir la liste des matchs (en cours, terminés, en attente)
- voir les détails d’un match: joueurs, Pokemons utilisés, etc
- inviter un autre joueur à un match (créer un match)
- consulter les invitations reçues
- accepter une invitation à un match (joindre un match existant)
- créer un deck pour un match
- envoyer un Pokemon à l’arena et consulter le résultat du combat (le joueur n'envoie pas un Pokemon en particulier mais envoie un deck, donc au moins un Pokemon, à l'arena)
-
En tant qu’administrateur, je peux …
- me connecter à la plateforme en utilisant mon nom d’utilisateur et un mot de passe
- voir la liste des joueurs
- voir la liste des matchs
- effacer et modifier les joueurs et les matchs
- consulter les statistiques de la plateforme : nombre de matchs par jour, nombre de matchs par Pokemon, nombre de victoires par Pokemon, etc
- Pour accéder aux ressources exposées par l’API gateway, il faut être authentifié (sauf pour l’inscription et le login) (partiellement réalisé, mais possible avec Krakend)
- Un joueur ne peut pas participer à plus de 3 matchs simultanément
- Pour avoir le résultat d’un combat, les deux joueurs ont du avoir envoyé leur Pokemon à l’arena
- Pour la version finale :
- On ne peut pas accéder aux endpoints de microservices directement, seulement via un proxy/gateway
- Trouver un moyen de produire des statistiques sans requêter directement l’API qui est trop surchargée
L'application est divisée en 4 services principaux : user, match, matchmaking, stats
Nom du service | API Gateway | User service | Matchmaking service | Stat service | Round service |
---|---|---|---|---|---|
Actions | Interface |
|
|
|
envoyer un Pokemon à l’arena et consulter le résultat du combat |
Dépendances | * | User service (besoin du nom d’utilisateur) Pokeapi | Matchmaking service, Stats service (envoi des stats) Pokeapi | ||
Tables (BDD) | N/A | User | Match | StatRound | N/A (utilise un cache LRU en mémoire vive) |
Ce graphique a été généré avec la commande
nx graph
- Applications (microservices Rest ExpressJS)
- apps/user : pour gérer les informations de chaque utilisateur et la création de nouveaux utilisateurs. Écoute sur le port
3333
- apps/matchmaking : pour gérer les invitations à un match vers un autre joueur ou afficher les matchs publics. Écoute sur le port
3334
- apps/round : pour gérer le déroulement d'un combat, en confrontant deux à deux chaque Pokemon et en donnant le score. Écoute sur le port
3335
- apps/stats : pour obtenir les statistiques sur les matchs en général (scores, victoires). Écoute sur le port
3337
- apps/user : pour gérer les informations de chaque utilisateur et la création de nouveaux utilisateurs. Écoute sur le port
- Packages (bibliothèques de code)
- packages/pokefumi-api : client axios Rest générés à partir de openapi-typescript-codegen
- packages/pokefumi-e2e : tests jest d'intégration
- packages/pokefumi-common : ancienne couche modèle, en partie utilisée par le service
Round
Plusieurs choix techniques ont été décidés au cours du développement de l'application pour s'adapter aux imprévus ou améliorer le projet.
Une approche qui consiste à d'abord penser au schéma d'API avant de coder ! Cela permet de modéliser notre API Rest et de bien y réfléchir (comme en GraphQL). De plus, le fait d'avoir un schéma OpenAPI permet de générer la documentation et les clients axios facilement ! Le package @pokefumi/pokefumi-api est généré automatiquement à partir des schémas OpenAPI.
Nx, c'est l'outil de construction fait pour les monorepos. Il est pratique, et pas pratique à la fois.
- Avantages :
- génération de code avec des
generators
: les squelettes de code de chaque service ont été générés au départ - exécuter la construction de tous les services en une seule commande
- système de cache : on ne reconstruit les projets que si le code à changé
- Hot Module Reload de webpack : c'est mieux que nodemon car on ne recharge que le code modifié
- génération de code avec des
- Désavantages :
- Nx utilise webpack 5 et nous n'avons pas de moyen de le désactiver. On peut avoir des problèmes en Node.JS, par exemple pour détecter les bibliothèques qui sont natives de celles qui sont téléchargées (voir le fichier webpack.config.js pour voir un workaround)
- il fonctionne très mal avec prisma ! En effet, prisma génère un client
@prisma/client
qui sert à contacter la base de données. Le problème est que lorsque plusieurs services utilisent prisma, il existe plusieurs@prisma/client
générés, un pour chaque service. - Webpack à du mal à différencier les services. Une solution, c'est de faire ceci
Prisma est un ORM Typescript en plein essor qui permet de gérer la base de données. Il est "type-safe". Il est très facile à utiliser, mais possède quelques inconvénients :
- Il utilise un moteur de requête SQL écrit en Rust, ce qui implique un téléchargement supplémentaire
- Il est encore instable (incompatibilité avec les NPM workspace par ex.).
Les NPM workspaces sont une fonctionnalité intéressante, comparable au yarn workspaces mais en moins bien. Elles ont été développées et publiées en 2020 et restent instables. Elle permettent dans une structure monorepo d'installer les dépendances du parent et des enfants en une seule commande. De plus, les dépendances communes entre les enfants sont partagées : un arbre de dépendance est construit et des liens symboliques sont créés. Cela permet d'économiser de l'espace de stockage. Hélas, il y a des incompatibilités avec Prisma. De plus, si deux sous-projets utilisent une même bibliothèque mais avec une version différente, la version la plus haute sera prise.
C'est plus sympathique quand on peut voir le résultat en direct de notre commit ! Heroku permet de déployer chaque micro-service à chaque modification de code. Ils tournent dans une image docker.
Liste des services déployés avec endpoint "exemple" :
- https://pokefumi-user.herokuapp.com/users
- https://pokefumi-matchmaking.herokuapp.com/matchs
- https://pokefumi-round.herokuapp.com/api
- https://pokefumi-stats.herokuapp.com/rounds/count-a-day-last-30-days
Les tests e2e sont réalisés avec Jest. C'est un framework de tests très populaire. Les tests sont fais de bouts en bouts, en déroulant un scénario de création d'utilisateur, de match et de participations à des rounds. Tous les services sont ainsi testés.
Voir okefumi-e2e.spec.ts.
Nous avons choisi d'utiliser un API Gateway différent que celui vu en cours (Nginx) : Krakend-ce. Car il :
- est portable (un seul fichier binaire, cross-plateforme)
- est écrit en Golang
- est facilement extensible avec des plugins téléchargés à la volée
- supporte les Jetons d'authentification JWT gratuitement (ce n'est pas le cas de Nginx, seulement dans la version entreprise)
- possède un très bon module de debug dans la sortie standard
- s'interface bien avec la norme OpenAPI, il existe un outil nommé openapi2krakend qui permet de générer la configuration Krakend à partir d'un ensemble de schéma OpenAPI
Le service de User
utilise l'ORM Prisma
pour gérer la base de données SQLite, notamment la table Match
.
Voir le fichier prisma.schema.prisma.
Il hash les mots de passe avec le module crypto
de Node.JS en SHA-256.
Cela permet d'éviter une éventuelle fuite des mots de passes.
Voir user.repository.ts.
Lorsque l'utilisateur se connecte, il obtient un jeton JWT. Ce jeton est chiffré à l'aide de l'algorithme HS-256. Il contient un payload avec l'ID de l'utilisateur connecté et la date d'expiration du jeton. Cela permet une authentification sans états et donc éviter un stockage de session côté serveur. Voir user.repository.ts
Le champs statut
du schéma de BDD a été mis en prévision d'un système de statut (inactif, en jeu, etc.).
Le service de Matchmaking
utilise lui aussi l'ORM Prisma
pour gérer la base de données SQLite, notamment la table Match
.
Voir le fichier prisma.schema.prisma.
Il authentifie les utilisateurs (pour la création du match et pour en joindre un) avec un jeton JWT (express-jwt
).
Cela permet une authentification sans états et donc éviter un stockage de session côté serveur.
Comparé aux autres services, il possède une spécificité : les corps de requêtes et les paramètres d'URL sont validés avec zod et le middleware zod-express-middleware
.
Zod est une bibliothèque de validation moderne qui permet de créer des schémas de validation (voir matchmaking.controller.ts) pour exemple.
Zod est très modulable et possède une fonctionnalité d'inférence de type très intéressante, qui permet d'extraire les types natifs à partir d'un schéma.
La gestion des erreurs se fait avec le middleware express-async-handler
, qui permet de simplifier la tâche.
Nous avons décidé de stocker les 10 Pokemons dans un champs de type texte.
Le format est le suivant : ID ID ID...
. 10 ID séparés par des espaces.
Cela permet d'éviter d'avoir une table servant juste à stocker des Pokemons.
Par contre, cela nécessite de la validation côté client pour être sûr du stockage des 10 Pokemons sous ce format-ci.
C'est là ou zod
rempli sa mission.
Voir matchmaking.controller.ts#L19-L23.
Le service Round
authentifie les utilisateurs (pour la création du match et pour en joindre un) avec un jeton JWT (express-jwt
).
Cela permet une authentification sans états et donc éviter un stockage de session côté serveur.
Pour stocker les rounds, il utilise un cache 'last recently used' qui classe les rounds par ordre d'utilisation. Tout est stocké dans la mémoire vive pour un temps donnée, ainsi il n'y a pas de persistence sur les rounds. Voir cet exemple
Comment faire pour attendre que l'autre joueur joue son Pokemon ?
Notre choix a été de garder la connexion ouverte tant que l'autre joueur n'a pas joué son Pokemon. Ainsi, lorsque le premier joueur envoi un pokemon à l'arena, il est attente de réponse. Lorsque le second adversaire joue, les deux sont débloqués et reçoivent le résultat du round. Le délai d'attente (timeout) maximum de réponse est de 30 secondes. Si au bout de 30 secondes, l'autre joueur n'a pas joué, la connexion est coupée.
Une meilleure implémentation serait avec des Websocket, qui permettre d'éviter les temps d'attentes bloquants.
Le service de Stats
utilise lui aussi l'ORM Prisma
pour gérer la base de données SQLite, notamment la table StatRound
.
Voir le fichier prisma.schema.prisma.
Le routeur express.js, les types et les schémas de validation sont entièrement générés avec oats-ts à partir du schéma OpenAPI stats.schema.yaml. Les fichiers générés sont stockés dans le dossier apps/stats/src/app/generated-oats/. Voir par exemple le routeur ou encore les types de réponses. Le script de génération est le fichier generate.ts.
Cela nous permet de faire en sorte que le service Stats
corresponde bien à son modèle spécifié dans le schéma OpenAPI. De plus, toute la partie validation des corps de requêtes et des paramètres de chaîne est générée.
Le développeur n'a qu'a implémenter le code métier, coeur du service en "codant dans les trous", c-a-d en implémentant les stubs.
Voir le fichier d'implémentation StatsApiImpl.ts
Oats-ts est encore un outil jeune, mais il est prometteur !
Comment obtenir les statistiques de manière intelligente ?
Il était demandé de "trouver un moyen de produire des statistiques sans requêter directement l’API qui est trop surchargée".
Nous en avons trouvé un : les statistiques sont envoyées par le service Round
à chaque round.
Elles sont agrégés par le service Stats
à la demande du client. Cela permet d'éviter d'appeler les services Round
et Matchmaking
.
Ainsi, les statistiques se font seulement sur les rounds.
Note: chaque service possède un fichier OpenAPI décrivant ses endpoints (voir matchmaking.schema.yaml par exemple). La documentation ci-dessous est générée à l'aide de widdershins
- Service de gestion des utilisateurs, pour gérer les informations de chaque utilisateur et la création de nouveaux utilisateurs : docs/user.md
- Service de matchmaking, pour gérer les invitations à un match vers un autre joueur ou afficher les matchs publics : docs/matchmaking.md
- Service de gestion d'un round, pour gérer le déroulement d'un combat, en confrontant deux à deux chaque Pokemon et en donnant le score: docs/round.md
- Service de statistiques, pour obtenir les statistiques sur les matchs en général (scores, victoires) : docs/stats.md
Pour lancer : utiliser le devcontainer vscode, ou installer nx en global (faire la commande dans un terminal) : npm i -g nx
.
Version de nodejs conseillée : 16.X.X
-
Installer les dépendances
npm i && nx affected --target=install --all
-
Générer tous les clients prisma
nx affected --target=generate --all
-
Créer les BDD sqlite
nx affected --target=push --all
-
Créer un fichier .env à la racine de chaque micro-service dans
apps/
avec le secret du jeton JWT. Par exemple :# apps/user/.env # Clef de chiffrement utilisée pour chiffrer les jetons JWT_SECRET=ILIKEPOTATOES
-
Ensuite pour lancer le user service :
nx run user:serve
. Pour lancer le matchmaking service par ex. :nx run matchmaking:serve
.
Des tests automatisés sont disponibles pour tester les services. Ils sont programmés avec Jest. Ils lancent automatiquement les micro-services. Voir packages/pokefumi-e2e/src/lib/pokefumi-e2e.spec.ts.
Note : les bases de données sont effacées au démarrage des tests.
Éteignez tous les microservices avant de lancer les tests, ils seront démarrés automatiquement.
nx run pokefumi-e2e:test-e2e
Note : l'API Gateway étant une image docker, il est nécessaire d'avoir Docker afin de pouvoir le tester.
Note : nous vous conseillons au minimum 5GO d'espace de stockage disponible !
Pour lancer :
docker-compose up
: lance la construction de tous les services et puis les active avec l'API Gateway
L'API Gateway est accessible sur le port 8000.
Un fichier test.sh est disponible à la racine du projet. Il permet de tester les services en utilisant un script bash.
Note : vous devez avoir curl et Node.JS d'installé. Un environnement POSIX est conseillé
bash test.sh
Voici la liste des targets nx disponibles :
nx affected --target=docs --all
: génère tous les fichiers de documentation à partir des fichier openapi de chaque servicenx affected --target=generate --all
: génère tous les clients prisma de chaque servicenpx prisma generate
, les clients http de chaque service packages/pokefumi-api/src/lib/generated-sources/ et le serveur du service stats apps/stats/src/app/generated-oats/nx affected --target=install --all
: installe toutes les dépendances de chaque servicenpm install
nx affected --target=push --all
: créer toutes les base de données sqlite de chaque service et synchronise les schémas de BDDnpx prisma db push
nx affected --target=docker --all
: build les images docker de chaque servicenx run pokefumi-e2e:test-e2e
: exécute les tests d'intégrationnx run-many --target=serve --all
: lance tous les micro-services
Générés avec prisma-erd
erDiagram
User {
Int id
String username
String statut
Int score
String password
}
erDiagram
Match {
Int id
DateTime createdAt
DateTime updatedAt
String authorPokemons
String opponentPokemons
Int authorId
Int opponentId
String status
Int winnerId
}
erDiagram
StatRound {
Int idPokemon
DateTime dateMatch
Int idMatch
Boolean victory
Int team
}
La première version du service de Stats devait faire appel aux autres services pour se mettre à jour à la demande du client. Nous avons pensé que cela entraînait trop de temps d'attente lors de cette opération et qu'il vaudrait mieux limiter les appels aux autres services. Pour cela, la seconde version de l'API Stats, implémentée actuellement, consiste en une mise à jour de sa BDD en temps réel : pendant le déroulement d'un match, pour chaque round, le résultat du round est transmis de façon synchrone au service Stats par le service Round. On se retrouve donc avec une API Stats qui ne fait que recevoir (et donc transmettre au client/IHM les statistiques agrégées).
La question de la délégation des responsabilités entre les services a été source de nombreux désaccords dans les différentes phases de conception et a résulté en différentes versions progressives au cours du développement du projet.
Cela a notamment été le cas pour les services Matchmaking et Round, pour déterminer lesquels devaient gérer le déroulement d'un match. Au départ, nous avons pensé qu'un seul service suffisait pour assurer cette responsabilité. Puis, nous avons décidé d'en faire deux : Matchmaking
et Round
.
Pour rappel, dans la culture vidéo-ludique, le matchmaking consiste à la création d'une partie, à la recherche d'autres joueurs et au lancement d'une partie; ensuite il réapparaît à la fin du match pour afficher les résultats et permettre de relancer une partie ou d'échanger dans le salon avec les autres joueurs. Nous avonc donc décidé de limiter la gestion du match dans le service Matchmaking pour respecter ce concept.
Une API Matchmaking
gère le déroulement d'un match,
tandis qu'une API Round
gère un round spécifiquement.
Lorsque 10 rounds sont joués, le match est fermé par le service Round
en envoyant une requête "close" au service Matchmaking
.
Chaque round est stocké en cache dans la mémoire vide pendant un temps donné. Ainsi, le service
Round
ne possède pas de base de données.
Ce dernier incrémente ensuite le score en envoyant
une requête au service User
.
Le service Round
est divisé en deux services distincts pour améliorer la séparation des responsabilités et au vu de la taille que le service prend :
- un service qui va gérer la succession des rounds et des scores :
Round
. - un service qui va gérer les accès avec PokeAPI, qui traite les informations des pokemon et compare leurs valeurs pour déclarer le gagnant d'un round :
ComputeRound
.
Nous avons pensé aux aspects futurs de l'application Pokefumi si elle venait à être développée complètement (en plus de la partie Vue et Contrôleur).
Actuellement, un salon correspond à un match avec deux joueurs, et se ferme à la fin du match. Cependant, nous avons pensé qu'un salon pourrait correspondre à une succession de matchs en permettant aux deux joueurs de rejouer directement entre eux, sans devoir créer un nouveau salon et de recommencer le processus d'invitation. Cela permettrait aussi en BDD de limiter la répétition de certaines données, comme le Salon contiendrait une liste de Matchs et plus seulement un Match, et il y aurait donc moins de Salons enregistrés.
Actuellement, les statistiques se font sur les rounds et non pas les matchs. Il serait possible en stockant les dates des matchs de stocker le timecode avec les minutes et secondes pour déterminer le temps moyen d'attente entre la création d'un salon et la résolution d'un match. Il serait aussi possible de faire des statistiques pour suivre la fréquentation de l'application et identifier les pics d'activités, selon le nombre de matchs par jour ou heure. Cela pourrait aussi résulter sur l'affichage d'un graphique montrant visuellement l'évolution de l'activité.
Ce projet ayant pour vocation d'être une démonstration, la gestion des erreurs reste encore limitée. Le service Round par exemple ne renvoie que 2 codes d'erreur et est susceptible de s'arrêter abruptement en cas d'erreur.
1.11.4. Bonus 💰 : comment ajouter un service de vente de Pokemon "rares" que l'on peut ajouter à son Docker ?
Avec Nx, c'est plutôt simple !
-
Générer un nouveau service :
nx generate @nrwl/express:application
-
Modéliser son schéma OpenAPI et son schéma prisma (ajouter une table inventaire, stock de pokemons, etc.)
-
Implémenter le service
-
Ajouter les targets Nx
generate
,install
,push
-
Générer les clients Rest axios :
nx run pokefumi-api:generate
-
Ajouter des tests e2e : pokefumi-e2e.spec.ts
-
Créer son Dockerfile en s'inspirant du Dockerfile du service
User
-
Ajouter un nouveau container au docker-compose :
sales: build: context: ./ dockerfile: apps/sales/Dockerfile restart: on-failure environment: - JWT_SECRET=ILIKEPOTATOES - BASE_URL_USER=http://user:3333 - BASE_URL_ROUND=http://round:3335 - BASE_URL_STATS=http://stats:3337 - BASE_URL_MATCHMAKING=http://matchmaking:3334
-
Ajouter les endpoints à la configuration krakend : krakend.json
-
Modifier le script test.sh en conséquences
Bonne chance !