Le jeu du Taquin en React et React Native

Adrien Amoros
Adrien AmorosFebruary 07, 2018
#js#react#integration

Cette série d'articles retrace mon parcours d'intégration au sein de Marmelab. Il s'articule autour du jeu du Taquin (voir Part 3. Le jeu du Taquin en php (framework Symfony)).

Après avoir découvert différents langages, il est temps de passer au Javascript, et en particulier à React. Il m'a dans un premier temps été confié la tâche de faire une application mobile Android à l'aide du framework React Native, puis de faire un site Web en React. Cet article retrace donc les deux sprints liés à cet écosystème.

L'écosystème React

React est une bibliothèque Javascript open-source dévéloppée par Facebook qui permet de construire des Single Page Applications, abréviées SPA.

Logo React

Contrairement aux frameworks comme AngularJS, la bibiothèque React n'impose pas de structure particulière, et ne résoud pas non plus de problématiques d'architecture. Elle permet juste de créer des interfaces graphiques à l'aide de composants.

Prise en main de React Native

J'ai dans un premier temps utilisé le framework React Native, qui est destiné au développement d'applications mobiles. Sa particularité est de générer du code natif. La facilité de développement en Javascript se marie alors avec les perfomances du natif.

Application puzzle 15 gameplay

L'installation de React Native pour Android est simple, les outils de développement sont bien documentés et bien rôdés.

Le rendu est assez proche de ce qui a été fait dans les projets précédents. Le jeu est composé de trois écrans : le menu principal, la liste des parties multijoueur ouvertes et l'affichage de la partie en cours.

De manière générale, j'ai écrit composants assez simples. Ici, le composant Grid se contente de recevoir une grille de jeu en entrée, et d'afficher toutes les cases qui correspondent à des numéros de 1 à 15.

export default class Grid extends Component {
    static propTypes = {
        onPress: PropTypes.func.isRequired,
        grid: PropTypes.array.isRequired,
        readOnly: PropTypes.bool,
    };

    static defaultProps = {
        readOnly: true,
    };

    render() {
        const { grid, readOnly, onPress } = this.props;

        return (
            <View style={styles.column}>
                {grid.map((row, rowKey) => (
                    <View style={styles.row} key={rowKey}>
                        {row.map(tileValue =>
                            tileValue === 0 ? (
                                <View key={tileValue} style={styles.empty} />
                            ) : (
                                <Tile
                                    key={tileValue}
                                    tileValue={tileValue}
                                    enabled={!readOnly}
                                    onPress={onPress}
                                />
                            ),
                        )}
                    </View>
                ))}
            </View>
        );
    }
}

Pour ceux qui ne connaissent que React web, le côté natif n'apparait ici que par l'utilisation du composant <View>, qui est l'équivalent de <div> sur react Native. Donc si on sait faire du React, on sait (en gros) faire du React Native !

Pour la version web de l'application, je n'ai donc eu que peu de modifications à faire dans les composants eux-mêmes.

Le Server-Side Rendering et Next.js

Sur l'application web, une des contraintes de ce projet était de mettre en place le server side rendering (SSR). La problématique est simple : les internautes sont très peu patients, et lorsqu'une page met trop de temps à charger, ils s'en vont. Or, de nos jours les sites web proposent de nombreuses interactions, ce qui se traduit par des pages plus longues à charger. Le SSR permet de palier à ce problème de la manière suivante :

  1. Le client demande à charger une URL
  2. Le serveur génère et retourne seulement la page HTML demandée
  3. Le client affiche rapidement la page, puis télécharge de manière asynchrone le reste de l'application en Javascript
  4. L'application JavaScript "hydrate" alors la page pour la transformer en SPA
  5. A partir de là, le client n'échange plus de HTML avec le serveur, mais uniquement du JSON

J'ai choisi de l'intégrer en utilisant nextjs qui avait déjà été éprouvé par Maxime dans son projet Awale Isomorphic.

Next.js a pour gros avantage de proposer une architecture bien définie, et d'embarquer tout le nécessaire (babel, webpack) pour démarrer un projet SSR. Avec Next.js, on développe une SPA en SSR sans s'en rendre compte. Toute la complexité est cachée, et c'est tant mieux, car ces histoires de routage, de chargement asynchrone de données, de réhydratation de page, sont d'une grande complexité, et il est facile pour un débutant comme moi de s'y perdre et de faire plein d'erreurs. Indispensable pour bien débuter, et se concentrer sur ce qui a de la valeur.

Offline mode et Services workers

L'un des principaux ajouts fonctionnels de ce projet fut le mode offline. Ce dernier se découpe en deux grosses parties.

  1. Lorque le client perd la connexion avec le serveur, et que le joueur a déjà lancé au moins une fois le site auparavent, celui-ci doit fonctionner comme si de rien n'était.
  2. Lorsque l'utilisateur n'a plus internet, le site doit l'indiquer à l'utilisateur et bloquer le mode multijoueur.

Mise en cache

La résolution du premier point s'est faite à l'aide de services workers et du module sw-precache-webpack-plugin.

Sources: sw-precache-webpack-plugin qui est lui même basé sur sw-precache

Le module service worker precache va générer un service worker se chargeant de mettre en cache des ressources que l'on aura auparavant sélectionnées. Dans notre cas, il s'agira de toutes les ressources statiques (images, styles), ainsi que de toutes les pages importantes de l'application (index, game, ...). Elles seront en quelque sorte pré-téléchargées par le client, ce qui lui évitera également d'avoir à les recharger à chaque requête.

La mise en place s'est faite de manière assez fluide. J'ai quand même dû mettre en place une route spécifique dans mon serveur afin d'autoriser /service-worker.js.

switch (pathname) {
    case '/service-worker.js':
        app.serveStatic(req, res, join(__dirname, '.next', pathname));
        break;
    default:
        handle(req, res, parsedUrl);
        break;
}

La dernière étape est d'installer le service worker sur le navigateur du client, pour qu'il puisse effectivement mettre en cache le site. J'ai donc utilisé le composant global Page à cet effet.

componentDidMount() {
    if (!config.useCache) {
        console.warn('The client cache is disabled');
        return;
    }
    if (!('serviceWorker' in navigator)) {
        console.warn('Service worker not supported');
        return;
    }
    navigator.serviceWorker.register('/service-worker.js').catch(err => {
        console.warn(
            'Preload service worker registration failed',
            err.message,
        );
    });
}

Remarque : on notera au passage que le système de cache est activable dans la config.

Pas de difficulté particulière sur ce point, le package sw-precache-webpack-plugin et les bonnes pratiques sont relativement bien documentés.

Réécriture du système de jeu en javascript

Le mode hors-ligne a quant à lui apporté une contrainte importante. En effet, sur les autres projets connectés, une grosse partie de la logique de jeu était fournie par un service tiers, que le client contactait via une api. Désormais, et contrairement aux autres projets, il a été nécessaire de totalement réécrire le jeu en javascript afin de tout embarquer côté client.

Pour ce faire, j'ai repris code précédemment écrite en go. La principale différence fut le retrait du typage et l'utilisation de quelques fonctions natives de javascript. Le shuffler, qui crée une grille de taquin mélangée, est également très différent, puisqu'au lieu d'utiliser des go channels, j'ai utilisé des fonctions asynchrones et des promesses:

const sleep = duration => new Promise(resolve => setTimeout(resolve, duration));

export const shuffle = async (grid, shuffleDuration = SHUFFLE_DURATION) => {
    let stopShuffling = false;
    let shuffledGrid = deepCopyGrid(grid);

    const startShuffling = async () => {
        while (!stopShuffling) {
            const coords = listCoordsMovableTiles(shuffledGrid);
            shuffledGrid = move(shuffledGrid, chooseCoords(coords));
            await sleep(SLEEP_DURATION);
        }
    };

    return await Promise.race([
        sleep(shuffleDuration).then(() => {
            stopShuffling = true;
            return shuffledGrid;
        }),
        new Promise(() => startShuffling()),
    ]);
};

Détection du mode offline

Pour détacter le mode offline, j'ai utilisé un package nommé react-detect-offline. Il a le mérite de ne pas seulement se baser sur la détection native des navigateurs du mode offline (qui ne détecte que l'offline complet), mais aussi de détecter les mauvaises connexions (via un poll d'une URL).

Il propose une approche déclarative simple (deux composants <Online> et <Offline>), bien adaptée à React.

La plus grosse difficulté consistait à intégrer ce module avec le Server-Side Rendering, puisqu'il se base sur l'objet window - qui n'existe pas en server-side. Le package next/dynamic, qui fait un import asynchrone conditionnel, est venu à ma rescousse:

import dynamic from 'next/dynamic';

export const ShowWhenOnline = dynamic(import('./Online'), {
    ssr: false,
});

export const ShowWhenOffline = dynamic(import('./Offline'), {
    ssr: false,
});

Preflight et CORS

Le projet 15-puzzle-web possédant une base de données et tout un mécanisme de gestion d'une partie, il a été décidé de les garder pour ce nouveau projet. Le but étant bien entendu d'apprendre à communiquer avec des APIs depuis une application, mais aussi depuis un navigateur.

Les différents serveurs du projet

Cependant, comme pour tout projet, le problème des cross origin resource sharing, abrégé CORS est apparu. Cette sécurité permet à un serveur d'autoriser ou de refuser des requêtes provenant de domaines différents. Or, dans mon cas, j'utilisais deux serveurs et une application. Un pour servir le client, hébergé sous le domaine localhost:3000, le second hébergé sous localhost. L'application quant à elle n'est pas herbergée, elle n'est donc pas concernée par cette sécurité.

Fonctionnement

  • Le navigateur envoie une requête OPTIONS sur localhost (php) en indiquant qu'il vient de l'origine localhost:3000 (site react).
OPTIONS /api/games/open HTTP/1.1
Host: localhost
Connection: keep-alive
Access-Control-Request-Method: GET
Origin: http://localhost:3000
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/62.0.3202.94 Safari/537.36
Access-Control-Request-Headers: content-type
Accept: */*
Referer: http://localhost:3000/multiplayer
  • Le serveur va alors, selon ses paramètres, répondre qu'il autorise ou pas le domaine de l'hébergeur. Dans cet exemple, toutes les domaines sont autorisés. Cependant, dans la pratique il faudrait restreindre les autorisations.
Request Method:OPTIONS
Status Code:200 OK

Access-Control-Allow-Credentials:true
Access-Control-Allow-Headers:Origin, X-Requested-With, Content-Type, Accept, Authorization
Access-Control-Allow-Methods:POST, GET, PUT, DELETE, PATCH, OPTIONS
Access-Control-Allow-Origin:*
Cache-Control:no-cache, private
Connection:keep-alive
Server:nginx/1.13.6
  • Le client a désormais l'autorisation d'envoyer des requêtes au serveur.

Mise en place

La mise en place de cette sécurité se passe côté serveur. Pour notre serveur Symfony, il a suffit de créer une classe, et d'en faire un service. Ce service sera automatiquement appelé à chaque requête grâce au système d'événements de Symfony.

Vous trouverez ci-dessous la classe et son service associés.

File app/src/Security/CorsListener.php

namespace App\Security;

use Symfony\Component\HttpKernel\Event\FilterResponseEvent;

class CorsListener {
    public function onKernelResponse(FilterResponseEvent $event) {
        $responseHeaders = $event->getResponse()->headers;

        $responseHeaders->set('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Authorization');
        $responseHeaders->set('Access-Control-Allow-Origin', '*');
        $responseHeaders->set('Access-Control-Allow-Credentials', 'true');
        $responseHeaders->set('Access-Control-Allow-Methods', 'POST, GET, PUT, DELETE, PATCH, OPTIONS');
    }
}

File app/config/services.yaml

cors_listener:
    class: App\Security\CorsListener
    tags:
        - {
              name: kernel.event_listener,
              event: kernel.response,
              method: onKernelResponse,
          }

Le mécanisme est assez simmilaire sur le serveur GO si ce n'est qu'il n'y a pas de système d'événements. Pour ce faire, j'ai simplement utilisé le module rs/cors, que j'ai ajouté en tant que handler sur mon router.

c := cors.New(cors.Options{
    AllowedOrigins:   allowedOrigins,
    AllowCredentials: allowedCredentials,
    AllowedHeaders:   allowedHeaders,
    AllowedMethods:   allowedMethods,
})

server := &http.Server{
    Handler:      c.Handler(r),
    Addr:         fmt.Sprintf(":%d", port),
    WriteTimeout: 15 * time.Second,
    ReadTimeout:  15 * time.Second,
}

Le dernier point à ne pas oublier, c'est de penser à autoriser les requêtes OPTIONS sur les routes.

Bilan

Personnel

De manière générale il est très plaisant de faire du Javascript, surtout dans l'écosystème React. Celui-ci est notamment porté par une communauté très active. De nombreuses ressources sont disponibles en ligne, et une multitude de conférences ont lieu régulièrement. Vous pouvez d'ailleurs retrouver le compte rendu de la grande conférence React Europe 2017 à laquelle l'équipe de Marmelab a participé.

J'ai donc pu trouver beaucoup de ressources en ligne à commencer par ce blog. Cependant, à la différence de Symfony, j'ai trouvé qu'il existait beaucoup plus de petites bibliothèques répondant à un problème en particulier. L'inconvénient existe puisqu'on peut vite se retrouver avec de très nombreuses dépendances, ce qui peut complexifier le changement de version majeure du framework. Mais ce n'était pas vraiment la problématique de mon projet.

Gildas et Florian m'ont bien accompagné, et j'ai particulièrement apprécié les conseils et les explications techniques de Gildas, et la manière de gérer les sprints de Florian.

J'ai rencontré beaucoup plus de difficultés sur la partie Android, que ce soit dans l'installation ou lors du développement. De plus, j'ai appris qu'une démonstration au client devait être un peu plus travaillée. Il est en effet préférable de présenter une application qui fonctionne, plutôt que de continuer à développer de nouvelles choses jusqu'au dernier moment.

15-puzzle-app && 15-puzzle-isomorphic

Il reste du travail pour finaliser l'application - notamment, pour remetttre au propre une partie du code, et pour perfectionner l'algorithme d'IA qui suggère les coups à jouer.

Le code sources des deux projets est bien entendu disponible sur GitHub :

N'hésitez pas à les reprendre et à les améliorer =).

Photo by Ross Parmly on Unsplash

Did you like this article? Share it!