Skip to content

6. Portées des données

Une application web sert de nombreux utilisateurs à la fois, et chacun d'eux enchaîne de nombreuses requêtes. Une donnée peut donc être partagée à plusieurs niveaux, qu'on appelle sa portée :

  • la portée application : la donnée est partagée par tous les utilisateurs, pendant toute la vie du serveur (la liste des médecins, par exemple) ;
  • la portée requête : la donnée n'existe que le temps de traiter une requête (le modèle d'une vue, l'utilisateur connecté tel qu'il a été reconnu pour cette requête) ;
  • la portée client : la donnée est propre à un utilisateur, et doit survivre d'une requête à la suivante (la langue choisie, l'utilisateur connecté).

Les applications web ont longtemps utilisé pour cette dernière portée une session tenue par le serveur : une mémoire propre à chaque navigateur, retrouvée grâce à un cookie d'identification ([JSESSIONID] en [Java], [PHPSESSID] en [PHP]). La session de [Flask] est différente : nous l'avons vu à l'exemple 20, elle est rangée dans un cookie signé, chez le client. Le serveur ne garde rien entre deux requêtes d'un même utilisateur : on dit qu'il est sans état (*stateless*). C'est ce qui permet, par exemple, de faire tourner plusieurs serveurs identiques derrière un répartiteur de charge : n'importe lequel peut traiter n'importe quelle requête. La contrepartie : un cookie est sous le contrôle de l'utilisateur, qui peut le lire, et tenter de le modifier. On n'y met donc que des données sans secret, et, si l'on doit pouvoir leur faire confiance, signées par le serveur, qui détecte alors toute modification : c'est le cas de la session de [Flask] et du jeton d'authentification (chapitre sur l'authentification).

En [Flask], chaque portée a son support :

Portée
Support
Durée de vie
application
un objet créé au démarrage, rangé dans [app.extensions] et retrouvé par [current_app] (exemple 06)
toute la vie du serveur, partagé par toutes les requêtes
requête
l'objet [g], et l'objet [request] lui-même (exemple 16)
le traitement d'une requête
session
l'objet [session], un dictionnaire rangé dans un cookie signé
jusqu'à la fermeture du navigateur (par défaut)
client
un cookie ordinaire, déposé par [set_cookie] (exemple 22)
la durée fixée par [max_age]

Les objets [current_app], [g], [request] et [session] s'importent du paquet [flask]. Ce sont des mandataires (exemple 05) : pendant une requête, chacun désigne l'objet de cette requête (ou de l'application qui la traite).

6.1. Exemple 23 : les portées

1
2
3
4
5
6
7
8
9
23-portees/
├── app.py
├── controllers/
│   └── portees.py
├── services/
│   └── compteur.py
└── templates/
    └── portees/
        └── portees.html

6.1.1. Les objets : services/compteur.py

# services/compteur.py : un compteur partagé, et les informations d'une requête
from itertools import count
from threading import Lock


# portée APPLICATION : une seule instance (cf. app.py)
class CompteurService:

    def __init__(self):
        self._total = 0
        # plusieurs requêtes peuvent s'exécuter EN MÊME TEMPS : le verrou garantit
        # que deux incrémentations simultanées ne se perdent pas
        self._verrou = Lock()

    def incrementer(self) -> int:
        with self._verrou:
            self._total += 1
            return self._total


# portée REQUÊTE : une instance par requête (cf. app.py, fonction debut)
class RequeteInfos:

    # un compteur commun à toutes les instances (attribut de CLASSE) ;
    # next(...) est atomique : deux threads n'obtiennent jamais le même numéro
    _instances = count(1)

    def __init__(self, debut: str):
        self.numero = next(RequeteInfos._instances)
        self.debut = debut
  • lignes 7-18 : un compteur de visites, de portée application : un seul objet, partagé par toutes les requêtes. Comme pour la liste des tâches de l'exemple 20, un verrou protège l'incrémentation : l'instruction [self._total += 1] n'est pas atomique (elle lit la valeur, l'augmente, puis l'écrit), et deux threads qui l'exécuteraient en même temps pourraient lire la même valeur : une visite serait perdue ;
  • lignes 22-30 : des informations de portée requête : un objet par requête, qui porte un numéro et l'heure de la requête. Le numéro est tiré d'un compteur commun à toutes les instances : [count(1)], du module standard [itertools], est un itérateur qui produit 1, 2, 3... à chaque appel de [next]. C'est un attribut de classe (déclaré au niveau de la classe, et non dans le constructeur) : il est partagé par toutes les instances.

6.1.2. La configuration : app.py

# app.py : l'application de l'exemple 23
from datetime import datetime
from pathlib import Path

from flask import Blueprint, Flask, g

from controllers.portees import portees
from services.compteur import CompteurService, RequeteInfos

BOOTSTRAP = Path(__file__).resolve().parent.parent / "commun" / "bootstrap"


def create_app():
    app = Flask(__name__)
    app.jinja_env.trim_blocks = True
    app.jinja_env.lstrip_blocks = True
    # la clé qui signe le cookie de la session (portée SESSION)
    app.config["SECRET_KEY"] = "une-cle-secrete-pour-l-exemple-23"
    # portée APPLICATION : un seul objet, créé au démarrage, partagé par TOUTES
    # les requêtes de TOUS les utilisateurs
    app.extensions["compteur"] = CompteurService()
    app.register_blueprint(Blueprint("bootstrap", __name__, static_folder=BOOTSTRAP,
                                     static_url_path="/bootstrap"))
    app.register_blueprint(portees)

    # portée REQUÊTE : l'objet g naît et meurt avec chaque requête. Cette
    # fonction, exécutée AVANT l'action, y range un objet que l'action et la
    # vue retrouveront pendant CETTE requête
    @app.before_request
    def debut():
        g.infos = RequeteInfos(datetime.now().strftime("%H:%M:%S.%f")[:-3])

    return app


if __name__ == "__main__":
    create_app().run(port=5000, debug=True)
  • ligne 18 : la clé qui signe le cookie de la session ;
  • ligne 21 : l'objet de portée application ;
  • lignes 29-31 : avant chaque requête, un objet [RequeteInfos] est rangé dans [g]. L'action et la vue le retrouveront, pendant cette requête seulement. [strftime("%H:%M:%S.%f")] met l'heure en forme avec ses microsecondes ([%f], six chiffres) ; [[:-3]] retire les trois derniers caractères de la chaîne : il reste les millisecondes.

6.1.3. Le contrôleur : controllers/portees.py

# controllers/portees.py : les quatre portées des données
from flask import Blueprint, current_app, g, make_response, render_template, request, session

portees = Blueprint("portees", __name__)


@portees.get("/")
def afficher():
    # APPLICATION : le compteur partagé
    total = current_app.extensions["compteur"].incrementer()
    # SESSION : un dictionnaire propre à CE navigateur, que Flask range dans un
    # cookie SIGNÉ (session) ; il dure jusqu'à la fermeture du navigateur
    session["visites"] = session.get("visites", 0) + 1
    # CLIENT : une donnée conservée par le NAVIGATEUR dans un cookie ordinaire,
    # renvoyée au serveur à chaque requête
    mes_visites = vers_entier(request.cookies.get("mesVisites")) + 1
    reponse = make_response(render_template(
        "portees/portees.html", total=total, instance=g.infos.numero,
        visites_session=session["visites"], mes_visites=mes_visites))
    reponse.set_cookie("mesVisites", str(mes_visites), httponly=True, samesite="Lax")
    return reponse


# "41" -> 41 ; None ou "abc" -> 0
def vers_entier(texte: str | None) -> int:
    try:
        return int(texte)
    except (TypeError, ValueError):
        return 0
  • ligne 10 : la portée application. [current_app] désigne l'application qui traite la requête en cours ;
  • ligne 13 : la portée session. [session] s'utilise comme un dictionnaire : [session.get("visites", 0)] rend la valeur de la clé, ou 0 si elle est absente. Quand l'action modifie la session, [Flask] envoie, avec la réponse, un nouveau cookie [session] qui contient le dictionnaire, signé ;
  • ligne 16 : la portée client, avec un cookie ordinaire, [mesVisites], lu dans [request.cookies]. Sa valeur est une chaîne, fournie par le navigateur, et donc peut-être absente ou invalide : la fonction [vers_entier] rend 0 dans ces deux cas ;
  • lignes 17-20 : pour déposer un cookie, il faut l'objet réponse : [make_response] le construit à partir de la page produite par la vue (exemple 05), puis [set_cookie] lui ajoute l'entête [Set-Cookie]. Sans [max_age], le cookie dure jusqu'à la fermeture du navigateur (c'est un cookie « de session », au sens des navigateurs) ;
  • ligne 18 : la portée requête : l'objet rangé dans [g] par la fonction [debut].

6.1.4. La vue : templates/portees/portees.html

<!doctype html>
<html lang="fr">
<head>
  <meta charset="utf-8">
  <title>Portées</title>
  <link rel="stylesheet" href="{{ url_for('bootstrap.static', filename='css/bootstrap.min.css') }}">
</head>
<body>
  <div class="container py-3">
    <h1 class="h3">Les portées des données</h1>
    {# g et session sont accessibles dans les vues, pendant la MÊME requête #}
    <ul>
      <li>application : {{ total }} visite(s) du site, tous navigateurs confondus</li>
      <li>requête : objet n° {{ instance }} (n° {{ g.infos.numero }} dans la vue),
        requête reçue à {{ g.infos.debut }}</li>
      <li>session : {{ visites_session }} visite(s) de ce navigateur ({{ session["visites"] }} dans la vue)</li>
      <li>client : {{ mes_visites }} visite(s) de ce navigateur</li>
    </ul>
  </div>
</body>
</html>

Les objets [g] et [session] sont aussi accessibles dans les vues. La vue affiche les valeurs reçues de l'action, et, entre parenthèses, les mêmes valeurs lues directement dans [g] et [session] : pendant une même requête, action et vue voient les mêmes objets.

6.1.5. Exécution

Après deux visites, dans un navigateur :

Image

Suivons les requêtes avec [curl], qui joue le rôle d'un premier navigateur en conservant ses cookies dans le fichier [a.txt] :

curl -i -c a.txt -b a.txt http://localhost:5000/
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 595
Set-Cookie: mesVisites=1; HttpOnly; Path=/; SameSite=Lax
Set-Cookie: session=eyJ2aXNpdGVzIjoxfQ.arfKwA.Q05nRwG-I_EsAKciaI87smp-DDY; HttpOnly; Path=/
...
      <li>application : 1 visite(s) du site, tous navigateurs confondus</li>
      <li>requête : objet n° 2 (n° 2 dans la vue),
        requête reçue à 15:38:08.535</li>
      <li>session : 1 visite(s) de ce navigateur (1 dans la vue)</li>
      <li>client : 1 visite(s) de ce navigateur</li>
...

La réponse dépose deux cookies. Le premier, [mesVisites], contient la valeur en clair. Le second, [session], contient le dictionnaire de la session : la partie avant le premier point est le JSON [{"visites":1}], encodé en base 64 (non compressé cette fois, car il est court) ; viennent ensuite la date et la signature.

L'objet de portée requête porte le numéro 2, alors que c'est la première visite de la page : c'est que la fonction [debut] s'exécute avant chaque requête reçue par l'application, y compris une requête pour laquelle aucune action n'existe (ici, une requête vers une URL inexistante, faite juste avant) et les requêtes de fichiers statiques. Dans le navigateur, chaque page provoque ainsi une seconde requête, pour la feuille de style de [Bootstrap] : d'où les numéros impairs de la copie d'écran. Une seconde visite, avec les mêmes cookies :

curl -i -c a.txt -b a.txt http://localhost:5000/
1
2
3
4
5
6
7
8
Set-Cookie: mesVisites=2; HttpOnly; Path=/; SameSite=Lax
Set-Cookie: session=eyJ2aXNpdGVzIjoyfQ.arfKwA.D9rLAmX6E4r5SiWa6DaPmLr_Cl0; HttpOnly; Path=/
...
      <li>application : 2 visite(s) du site, tous navigateurs confondus</li>
      <li>requête : objet n° 3 (n° 3 dans la vue),
        requête reçue à 15:38:08.552</li>
      <li>session : 2 visite(s) de ce navigateur (2 dans la vue)</li>
      <li>client : 2 visite(s) de ce navigateur</li>

Un second « navigateur », avec son propre fichier de cookies, vide au départ :

curl -c b.txt -b b.txt http://localhost:5000/
1
2
3
4
5
      <li>application : 3 visite(s) du site, tous navigateurs confondus</li>
      <li>requête : objet n° 4 (n° 4 dans la vue),
        requête reçue à 15:38:08.565</li>
      <li>session : 1 visite(s) de ce navigateur (1 dans la vue)</li>
      <li>client : 1 visite(s) de ce navigateur</li>

Le compteur de l'application a continué ; la session et le cookie du second navigateur repartent de 1. Enfin, un utilisateur malhonnête fabrique ses cookies : 1000 visites dans [mesVisites], et une session qui prétend contenir [{"visites":999}] (encodé en base 64 : [eyJ2aXNpdGVzIjo5OTl9]), avec la signature d'une autre session :

curl -b "mesVisites=1000;session=eyJ2aXNpdGVzIjo5OTl9.arfKuA.chOe9TXdLhGaUJOQo0QMT_hbPnE" http://localhost:5000/
1
2
3
4
      <li>application : 4 visite(s) du site, tous navigateurs confondus</li>
      ...
      <li>session : 1 visite(s) de ce navigateur (1 dans la vue)</li>
      <li>client : 1001 visite(s) de ce navigateur</li>

Le cookie ordinaire a été accepté tel quel : 1001 visites. La session falsifiée, elle, a été rejetée : sa signature ne correspond pas à son contenu, et [Flask] l'a ignorée, comme si elle était absente. C'est toute la différence entre un cookie ordinaire et un cookie signé. Retenons-en la règle : une donnée venue du client n'est digne de confiance que si elle est signée par le serveur ; et même signée, elle reste lisible : on n'y met rien de secret.