DEV Community

Gabin ✨✨
Gabin ✨✨

Posted on • Edited on

Partager l'état d'une application sans base de donnée

La plupart de mes projets personnels sont des applications web sans serveur derrière. La principale raison c'est qu'en terme d'hébergement il existe plein d'offres gratuites sans restrictions pour du "statique". À l'inverse héberger un serveur web c'est souvent payant ou limité, avec par exemple une mise en veille après un certain délai d'inactivité. C'est pas la mer à boire pour de petits projets mais si je peux éviter, j'aime autant.

S'imposer de ne pas avoir de serveur lorsqu'on développe une application web, ça rajoute un challenge qui demande d'être créatif. Par exemple quand on aurait bien besoin d'une base de donnée. On peut y trouver différentes solutions selon le besoin. Si l'objectif est de conserver des données saisies par l'utilisateur courant, on peut passer par du stockage navigateur. Ça se complique si on souhaite que l'utilisateur puisse partager ces données, c'est à ce besoin que cet article apporte des solutions.

Exemple d'état à partager

Imaginons que vous créez un jeu de quizz qui génère un nouveau questionnaire avec différents paramètres à chaque fois que l'utilisateur lance une partie. Imaginons maintenant que vous souhaitez que l'utilisateur puisse partager ce challenge unique avec d'autres personnes, en partageant une URL.

Pour l'exemple, voilà à quoi pourrait ressembler un questionnaire :

const gameState =
{
  difficulty: {
    id: "normal",
    modifiers: {
      timePerQuestion: 5000
    }
  },
  questions: [
    {
      id: 1,
      title: "Quelle est la couleur du cheval blanc d'Henri IV ?",
      answers: [
        { title: "Bleu", isCorrect: false },
        { title: "Blanc", isCorrect: true },
        { title: "Rouge", isCorrect: false }
      ]
    },
    // Ici on peut imaginer qu'il y ait encore d'autres questions.
  ]
}
Enter fullscreen mode Exit fullscreen mode

La solution simple mais limitée

Si on veut permettre à l'utilisateur de partager sa partie, le plus simple serait de le passer en paramètre d'URL :

const gameState = /* le questionnaire présenté un peu plus haut */;

const shareableUrl = `https://mon-questionnaire.com/?gameState=${
  encodeURIComponent(
    JSON.stringify(gameState)
  )
}`;
Enter fullscreen mode Exit fullscreen mode

Lorsque cette URL est partagée et qu'on la rencontre, pour restituer le questionnaire il suffirait alors de l'extraire :

const searchParams = new URLSearchParams(window.location.search);
const gameState = JSON.parse(searchParams.get("gameState"));
Enter fullscreen mode Exit fullscreen mode

C'est simple et potentiellement suffisant mais cette approche a plusieurs limites. La première c'est que les données sont clairement visibles, il n'est pas trop compliqué de trouver les bonnes réponses aux questions. La deuxième c'est qu'on peut se retrouver avec des URLs très longues selon le nombre de questions. Et enfin les données peuvent être éditées et corrompues. J'ajouterai aussi que ça ne fait pas une très jolie URL mais ça c'est mon fétiche.

Dans l'idéal, il faut donc qu'on masque ce qui est partagé, qu'on rende la longueur de la chaîne aussi courte que possible et enfin qu'on s'assure que ce qu'on récupère est valide.

La solution plus complète

Pour obfusquer les données on peut les chiffrer de façon à ce qu'elle ne soit pas lisible dans l'URL mais déchiffrable par notre application à la réception. Ça fait la première partie du boulot mais ça complique un peu la deuxième, qui consiste à raccourir au maximum la taille de ce qu'on met dans l'URL.

Plutôt que chiffrer les données, on peut les compresser. Ça aura pour résultat de les obfusquer tout en raccourcissant la chaîne :

import lzString from "lz-string";

const gameState = /* le questionnaire présenté un peu plus haut */;

const shareableUrl = `https://mon-questionnaire.com/?gameState=${
  lzString.compressToEncodedURIComponent(
    JSON.stringify(gameState)
  )
}`;
Enter fullscreen mode Exit fullscreen mode

Cet exemple utilise la librairie lz-string qui permet de compresser une chaîne de caractère dans un format donné, ici en quelque chose de compatible pour une URL. Ça produit quelque chose du type NoIgpghgzgniA0wBMAGJAWAbC+BGArErigOzyq6b5mpIDMK65aSAnABx6F3HNL1NcdfriaoGrJHx6sAurKA, c'est toujours assez long mais plus acceptable.

Ceci étant dit, on peut aller plus loin dans la compression. Jusqu'à maintenant on a compressé la chaîne de caractère résultante de la sérialisation en JSON du questionnaire. Mais on peut également compresser notre questionnaire en lui même. Par exemple le questionnaire donné en exemple plus haut pourrait être transformé comme suit :

const compressedGameState =
[
  // difficulty.id :
  "normal",

  // questions :
  [
    [
      // id :
      1,
      // On part du principe que les réponses à une question
      // sont tirées au hasard. Elles ne sont donc pas statiques
      // mais propres à ce questionnaire.
      // answers :
      [
        [
          // title :
          "Bleu",
          // isCorrect :
          false
        ],
        [
          // title :
          "Blanc",
          // isCorrect :
          true
        ],
        [
          // title :
          "Rouge",
          // isCorrect :
          false
        ]
      ]
    ]
  ]
]
Enter fullscreen mode Exit fullscreen mode

Pour résumer : on supprime les clés et tout ce qui est statique, qu'on peut retrouver dans notre code. Voilà à quoi pourrait ressembler le code qui permet de passer de l'état non compressé à l'état compressé :

function compressGameState(gameState: GameState): CompressedGameState {
  return [
    gameState.difficulty.id,
    gameState.questions.map(question => (
      [
        question.id,
        question.answers.map(answer => (
          [answer.title, answer.isCorrect]
        ))
      ]
    ))
  ];
}
Enter fullscreen mode Exit fullscreen mode

Et pour décompresser l'état :

import { DIFFICULTIES, QUESTIONS } from "./constants";

function decompressGameState(compressedGameState: CompressedGameState): GameState {
  const [difficultyId, questions] = compressedGameState;

  return {
    difficulty: DIFFICULTIES[difficultyId],
    questions: questions.map(([questionId, answers]) => ({
      id: questionId,
      title: QUESTIONS[questionId],
      answers: answers.map(([title, isCorrect]) => ({
        title,
        isCorrect
      }))
    }))
  };
}
Enter fullscreen mode Exit fullscreen mode

Combiné avec la compression de chaîne, ça donne :

import lzString from "lz-string";

const gameState = /* le questionnaire présenté un peu plus haut */;

const shareableUrl = `https://mon-questionnaire.com/?gameState=${
  lzString.compressToEncodedURIComponent(
    JSON.stringify(
      compressGameState(gameState)
    )
  )
}`;
Enter fullscreen mode Exit fullscreen mode

La dernière chose qui nous manque maintenant c'est de s'assurer qu'on récupère de l'URL quelque chose de valide en se protégeant d'une éventuelle malformation. Il s'agit tout simplement de la validation d'un objet, il existe des librairies bien faites si le cas d'usage est complexe mais sinon ça pourrait donner :

function deserializeGameState(compressedString: string): GameState {
  try {
    return (
      decompressGameState(
        JSON.parse(
          lzString.decompressFromEncodedURIComponent(
            compressedString
          )
        )
      )
    );
  } catch(err) {
    throw new Error("Questionnaire corrompu");
  }
}
Enter fullscreen mode Exit fullscreen mode

Cet exemple illustre la technique du paresseux mais en cas de besoin d'une gestion d'erreur plus fine, il est tout à fait possible de valider les éléments un par un.

Exemple réel et complet

J'ai eu l'occasion de mettre en oeuvre cette approche sur Name the Gwent Card :

GitHub logo zhouzi / name-the-gwent-card

In this mini-game, your goal is to name a random Gwent card from its illustration.

name-the-gwent-card

Netlify Status Contributor Covenant PRs Welcome License: MIT

All Contributors

In this mini game, your goal is to name a random Gwent card from its illustration.

This is an unofficial fan work under the Gwent Fan Content Guidelines. Not approved/endorsed by CD PROJEKT RED.

Credits

Installation

Note that you do not need to install this application on your machine if you want to use it. Installation is only required if you want to run a development version (e.g to contribute).

  1. Install Node.js.
  2. Clone this repository.
  3. Run npm install in the repository's directory.
  4. Run npm start to start the application.

This project was…

Toute la logique de compression / décompression et de validation se trouve dans src/app/GameState.ts. Pour voir un exemple, cliquez simplement sur "Play" ou "Jouer" sur la page d'accueil du jeu et observez l'URL.


TLPL

Trop long pas lu, un résumé de l'article en quelques points :

  • Compresser l'état en supprimant tout ce qui est statique : clés, constantes. Par exemple { answer: "Réponse", isCorrect: true } devient ["Réponse", true].
  • Convertir l'état compressé en chaîne de caractères JSON puis la compresser, par exemple avec lz-string.
  • Passer le résultat en paramètre d'URL.
  • Mettre en place une logique de sérialisation et désérialisation de cette URL vers un état valide.

👋 Restons en contact

Suis moi sur Twitter pour des conseils et astuces tous les jours : @gabinaureche.

Top comments (0)