Développer son "data" serveur web

Plan

  1. Petite histoire du développement web.
  2. Spécificités des applications web "page unique".
  3. Présentation du mini-site de jeu rpsls.
  4. TP : écrire une API pour ce site.
  5. Extension : permettre l'organisation de tournois
    (compléter le site, ou écrire un site externe).
    et/ou corriger quelques défauts...

Années 80 - début 90

Site web = ensemble de pages HTML "statiques"

Exemple : mathpages.com

Limitations :

  • Inscription/connexion ? (pour un accès privilégié à certaines ressources, par exemple)
  • Fonction recherche ? (Via un champ texte)
  • ...

Milieu des années 90

Apparition (presque) conjointe de PHP et JavaScript.

PHP : dynamisme côté serveur

Résout les deux limitations mentionnées :

  • Formulaire d'inscription/connexion transmis à un script PHP, qui dialogue éventuellement avec une base de données.
  • Terme(s) à rechercher transmis à un script PHP, qui s'occupe d'effectuer la recherche avant d'afficher la page des résultats.
JavaScript : dynamisme côté client

Permet entre autres de :

  • afficher un corrigé en cliquant sur un exercice,
  • modifier le contenu de (certains éléments de) la page,
  • afficher un carrousel d'images qui défilent,
  • afficher un chronomètre,
  • ...
  • afficher des graphes (dynamiques bien sûr),
  • développer des jeux,
  • ...

Note utile sur l'usage du point virgule.

Chargement partiel

Début des années 2000 : arrivée d'"AJAX" :
demande/envoi d'informations au serveur sans recharger la page.

Exemple : enregistrer du texte au fur et à mesure qu'il est tapé.

<textarea onInput="sendText(event.target.value)"
          placeholder="Type text here">
</textarea>

<script>
  function sendText(value) {
    fetch(
      'https://httpbin.org/post',
      {
        method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({value: value})
      }
    )
    .then(res => res.json())
    .then(res => console.log(res));
  }
</script>

Début des années 2010 : communication bidirectionnelle

WebRTC : peer-to-peer (P2P) dans le navigateur
→ communication audio/vidéo "sans serveur intermédiaire".

Exemples : discord.com, zoom.us, meet.jit.si, etc...

WebSockets : communication client ↔ serveur temps réel.

Exemples :

  • Notifications de nouveaux messages (facebook, etc), chats.
  • Jeux : envoyer une action et recevoir celles des adversaires, ...
socket.send('{"code":"move", "value":"p"}') //client
socket.on(message, (data) => { //serveur
  if (data.code == 'move') { socket.send(...) }
})

Server "push" event

Envoi d'une notification depuis le serveur vers les clients connectés.

Cas d'utilisation :

  • Indiquer qu'une mise à jour du site aura lieu dans 3 minutes,
  • Envoyer le corrigé d'un exercice (pendant un cours), ...
/***** Exemple *****/

// Serveur (express.js)
app.get('/stream', function(req, res) {
  res.set({'Content-Type': 'text/event-stream'})
  res.flushHeaders()
  setTimeout(() => res.write(`data: 32\n\n`), 2000)
  setTimeout(() => res.write(`data: 42\n\n`), 4000)
})

// Client (JavaScript)
const eventSource = new EventSource('/stream')
eventSource.onmessage = console.log

The Wayback Machine

Visualiser une page web telle qu'elle apparaissait dans le passé...

Exemple : facebook.com fin 2005 :)

D'autres n'ont guère changé : france-echecs.com fin 2000.

Actuellement...

Single-page application : tout dans une "unique" page.

But = rendre l'expérience utilisateur plus fluide en minimisant son temps d'attente (pas de chargement complet de pages HTML)

Inconvénient : complexifie le développement côté client.

Solution : utiliser une librairie JavaScript :
  • Angular[JS] (Google), React (Facebook)
  • Vue, Ember, ...
  • Backbone, Mithril, ...
  • Meteor

Pourquoi une librairie spécialisée ?

Imaginons que l'on veuille sur une même page :

  • Une liste de joueurs connectés,
  • Une liste de défis (parties en attente d'adversaire).
  • Alice
  • Bob
  • Bob (veut jouer)

Scénario possible :

  1. Alice se déconnecte puis se reconnecte 5 secondes après.
  2. Bob supprime son défi, puis Alice en lance un.

4 manipulations explicites de DOM
(appendChild(), insertBefore(), removeChild() ...)

→ Lourd !

Alice & Bob, suite

On aurait envie de maintenir deux listes (JavaScript) décrivant les contenus des éléments (DOM).

→ Après chaque mise à jour des listes (simple), on redessine tout l'élément DOM (...facile mais reste lourd).

Solution :

Les librairies précitées (Angular etc) permettent de ne manipuler que ces listes JavaScript : elles s'occupent de mettre à jour l'affichage quand c'est nécessaire.

Exemple : Vue.js

  1. {{ todo.text }}
const ListRendering = { data() { return { todos: [ { text: 'Learn JavaScript' }, { text: 'Learn Vue' }, { text: 'Build something awesome' } ] } }, methods: { newTodo: function() { this.$data.todos.push({ text: 'Call Alice' }) } } } Vue.createApp(ListRendering).mount('#list-rendering')

Fichiers présentés dans ce cours

Ressources.zip

(Utiles pour l'exercice et le TP).

Architecture Modèle-Vue-Contrôleur

But = normaliser l'écriture du code pour qu'il soit plus facile à comprendre, étendre, réutiliser...

  • Modèle : code de récupération et modification des données
  • Vue : affichage des données (HTML)
  • Contrôleur : fonctions à exécuter après actions utilisateur

Note : il existe plein d'autres "design patterns" (Google...)

Exemple : publis partagées avec mithril.js

  • Modèle : récupération des articles dans la BDD au chargement de la page, et fonctions de modification (ajout/suppression de posts/commentaires)
  • Vue : code HTML présentant les articles, les formulaires d'ajout, et fonctions appelant celles du contrôleur
  • Contrôleur : liaison entre modèle et vue = définition des opérations à réaliser sur le modèle après actions utilisateur.

Note : l'application telle qu'écrite ne suit pas exactement ce schéma...
...Exercice :)

Autres exemples

Vers Shiny...

Présentation de l'application "rpsls"

Mini-site permettant de jouer à Rock-Paper-Scissors-Lizard-Spock
(en 7 points). "Timeline" :

  1. (Usr) "Je veux jouer !" (clic bouton 'Play')
  2. (App) "Qui es-tu ?" (champ texte)
  3. (Usr) "Bob"
  4. (App) "Ok, bienvenue Bob" (retour au bouton 'Play' : clic)
  5. (App) ...Attente, le poussin cherche... (si aucun adversaire dispo)
  6. (App) Arrivée sur la page de jeu (si adversaire trouvé)
  7. (App,Usr) Clic sur image => détermination du gagnant =>
    mise à jour des points => ...

Étapes 2 à 4 non exécutées si joueur connu (localStorage).

Modes de stockage dans le navigateur

  • Cookies : envoyés au serveur à chaque requête. Pratique pour identifier quelqu'un par exemple, avec un token "secret".
  • localStorage : variables simples (chaînes de caractères seulement).
  • indexedDB : enregistrement d'objets JavaScript plus complexes.

Il y aussi sessionStorage (comme localStorage mais avec effacement à la fermeture de l'onglet), a priori moins utilisé.

TP

Écrire une API permettant de récupérer

  1. des listes de coups à partir d'identifiants de parties (/moves/id).
  2. des parties jouées sur le site (sans la séquence de coups).
    Paramètres suggérés : nom(s), date(s), identifiant.
    (Par exemple /games?id=...&p1=...&p2=...)

Outil suggéré : plumber.

Bonus : corriger deux défauts du site.

  • Jeu stoppé si page rechargée,
  • Coup dévoilé à l'adversaire.

plumber : guide d'utilisation

Le principe est simple : vous écrivez dans un fichier plumber.R les fonctions s'exécutant lorsque l'utilisateur navigue vers une URL.

# Exemple. Run with 'pr("plumber.R") %>% pr_run()'

#* @param msg The message to echo back.
#* @get /echo
function(msg="") {
  list(msg = paste0("The message is: '", msg, "'"))
}

#* @get /data
function() {
  iris
}

Possibilité d'ajouter des filtres s'exécutant avant (logging, authentication...), et de spécifier le sérialiseur (par défaut : JSON).

Changer la sérialisation

#* @param msg The message to echo back.
#* @get /echo
#* @serializer text
function(msg="") {
  paste0("The message is: '", msg, "'")
}

#* @get /plot
#* @serializer png
function() {
  plot(iris)
  # Note: essayez un plot() sans "@serializer png"
}

# ...etc

Cf. documentation.

Parler à une base de données

library(DBI)
con <- dbConnect(RSQLite::SQLite(), ":memory:") #or dbname
dbWriteTable(con, "mtcars", mtcars)

# SELECT FROM WHERE:
res <- dbGetQuery(con, "SELECT * FROM mtcars WHERE cyl = 4")
# Ou dbSendQuery / dbFetch / dbClearResults (+ bas niveau)

# UPDATE/CREATE/DELETE:
nbRows <- dbExecute(con, "UPDATE mtcars SET am=10 WHERE cyl=4")
nbRows <- dbExecute(con, "DELETE FROM mtcars WHERE cyl=6")
dbExecute(con, "CREATE TABLE test(a INTEGER, b VARCHAR)")
dbExecute(con, "INSERT INTO test VALUES (32,'a'),(42,'b')")
# Ou dbSendStatement / dbGetRowsAffected / dbClearResult (+ bas niveau)

Voir cette vignette et le basic usage.

Endpoints

Jusqu'ici on s'est contenté de @get car c'est la seule méthode accessible en naviguant. Il en existe d'autres : "The annotations that generate an endpoint include:"

  • @get : convention = SQL "SELECT FROM WHERE ..."
  • @post : convention = SQL "INSERT INTO ..."
  • @put : convention = SQL "UPDATE ..."
  • @delete : convention = SQL "DELETE ..."
# Exemple.

#* @param cyl The cyl value.
#* @put /cars
function(cyl=-1) {
  con <- dbConnect(RSQLite::SQLite(), "cars.sqlite")
  dbExecute(con, "UPDATE mtcars SET mpg=mpg+1.5 WHERE cyl=:x", list(x=mpg))
  dbDisconnect(con)
}

Endpoints - suite

#* @param mpg Delete cars with mpg lower than this.
#* @delete /cars
function(mpg=0) {
  con <- dbConnect(RSQLite::SQLite(), "cars.sqlite")
  dbExecute(con, "DELETE FROM mtcars WHERE mpg<:x", list(x=mpg))
  dbDisconnect(con)
}

#* @param mpg Add a line to the iris dataset.
#* @param pl Petal.Length [...]
#* @post /iris
function(pl, pw, sl, sw) {
  con <- dbConnect(RSQLite::SQLite(), "iris.sqlite")
  dbExecute(con, "INSERT INTO iris VALUES (:pl, :pw, :sl, :sw)",
                 list(pl=pl, pw=pw, sl=sl, sw=sw))
  dbDisconnect(con)
}

Pour tester POST, PUT ou DELETE il faut par exemple utiliser
curl dans un terminal :

curl -X DELETE http://localhost:8000/cars/20

Alternatives

Il existe plein d'autres façons d'écrire une API :