Journal Des nouvelles d'Ulfius, framework web en C

Posté par  (site web personnel) . Licence CC By‑SA.
Étiquettes :
43
9
oct.
2018

Sommaire

J'avais parlé ici d'Ulfius, un framework web écrit en C pour se faciliter la vie quand on veut développer des API web.

J'en profite donc de sortir la dernière version 2.4 pour en parler à nouveau et vous raconter ce que ca peut faire.

Adresse du projet: https://github.com/babelouest/ulfius

Le besoin est d'avoir un framework web pour écrire des applications web en C, en combinant la rapidité d'exécution ainsi que la consommation de mémoire plus réduite, avec un niveau d'abstraction permettant de réduire le temps de développement sans avoir à se préoccuper du bas-niveau (interprétation et validation de la requête, construction de la réponse, etc.).

Avec ca j'ai créé mon SSO, mon système domotique ou encore mon serveur de streaming audio.

Je fais ici une description simplifiée des principales fonctionnalités, mais la documentation officielle est bien mieux fournie!

Implémenter des API HTTP

La principale fonctionnalité est l'implémentation d'API HTTP.

En dessous d'ulfius, il y a l'honorable libmicrohttpd qui gère les parties bas-niveau, notamment réseau, file d'attente, conformité aux standards.

Ulfius s'est largement inspiré d'autres frameworks comme nodejs express pour son utilisation. On déclare d'abord une instance ulfius qui va écouter sur un port TCP donné, puis à cette instance on associe des endpoints http qui correspondent à des adresses HTTP (méthode + url).

Un endpoint peut avoir des variables dans son url pour faire du REST plus facilement. Par exemple, si on associe l'url GET /api/user/@username à un endpoint, le contenu après /user/ sera affecté à la variable username disponible dans la fonction callback.
À chaque endpoint, on déclare une fonction callback qui sera exécutée lorsqu'un client fera un appel à l'url.
Un endpoint peut avoir un pattern "générique" du type /api/user/* ce qui signifie que toute url qui commence par /api/user/ sera interceptée.
Chaque fois qu'une url sera appelée par un client, ce sera traité dans un thread séparé.

Dans la fonction callback, on a accès à deux structures de données:
- struct _u_request qui contient l'ensemble des paramètres de la requête HTTP (headers, paramètres d'url ou de body, corps du body, etc.)
- struct _u_response qui permet à la fonction callback de spécifier la réponse à renvoyer au client avec tous les paramètres HTTP disponibles (statut, headers, body, cookies, etc.)

L'exemple du Hello World! est le suivant:

/**
 * test.c
 * Small Hello World! example
 * to compile with gcc, run the following command
 * gcc -o test test.c -lulfius
 */
#include <stdio.h>
#include <ulfius.h>

#define PORT 8080

/**
 * Callback function for the web application on /helloworld url call
 */
int callback_hello_world (const struct _u_request * request, struct _u_response * response, void * user_data) {
  ulfius_set_string_body_response(response, 200, "Hello World!");
  return U_CALLBACK_CONTINUE;
}

/**
 * main function
 */
int main(void) {
  struct _u_instance instance;

  // Initialize instance with the port number
  if (ulfius_init_instance(&instance, PORT, NULL, NULL) != U_OK) {
    fprintf(stderr, "Error ulfius_init_instance, abort\n");
    return(1);
  }

  // Endpoint list declaration
  ulfius_add_endpoint_by_val(&instance, "GET", "/helloworld", NULL, 0, &callback_hello_world, NULL);

  // Start the framework
  if (ulfius_start_framework(&instance) == U_OK) {
    printf("Start framework on port %d\n", instance.port);

    // Wait for the user to press <enter> on the console to quit the application
    getchar();
  } else {
    fprintf(stderr, "Error starting framework\n");
  }
  printf("End framework\n");

  ulfius_stop_framework(&instance);
  ulfius_clean_instance(&instance);

  return 0;
}

Ainsi, le programme peut traiter la requête HTTP en ayant tout sur place, et remplir la réponse HTTP dans une seule structure avec des paramètres lisibles, genre "Je veux que ma réponse ait le statut 400 avec dans le body 'Erreur dans le paramètre toto' et dans le header, la paire clé:valeur".

ulfius_set_string_body_response(response, 400, "Erreur dans le paramètre toto");
u_map_put(response->map_header, "clé", "valeur");

Une interface optionnelle avec la librairie jansson permet d'échanger plus facilement du JSON dans le corps de la requête ou de la réponse.

Une nouveauté depuis Ulfius 2.0 est la possibilité de déclarer autant de endpoints que l'on souhaite pour une même url ou des urls qui se croisent. Un paramètre permettant de spécifier la priorité d'un endpoint permet de spécifier quelle callback est appelée avant quelle autre.

Cela permet par exemple de déclarer une callback qui authentifie, une autre callback qui traite la demande dans la requête, et une dernière qui gzipe ca à la fin.

Pour renvoyer de grosses réponses HTTP bien lourdes, plutôt que de tout mettre dans la variable struct _u_response.binary_body qui sera nécessairement stockée en mémoire, on peut aussi utiliser la fonctionnalité de réponse par lot, qui permet de remplir le body de la réponse morceau par morceau. C'est pratique quand tu veux par exemple envoyer des fichiers potentiellement gros, voire faire du streaming audio dont tu ne connais pas la taille à l'avance.

Aussi, pour traiter un upload de fichier potentiellement gros, on peut aussi le traiter par lot, comme le streaming, mais dans la requête.

Faire des requêtes sortantes HTTP ou SMTP

L'alter-ego aux API HTTP est le client HTTP, qui permet de faire des requêtes sortantes. Ca utilise les mêmes structures que pour les API HTTP, mais à l'inverse évidemment, à savoir qu'on remplit un struct _u_request, et le résultat est éventuellement stocké dans un struct _u_response. Il a aussi la possibilité de traiter la réponse par morceaux si on attend un résultat trop gros.

struct _u_request request;
struct _u_response response;

ulfius_init_request(&request);
ulfius_init_response(&response);

request.http_verb = strdup("GET");
request.http_url = strdup("https://linuxfr.org/");

if (ulfius_send_http_request(&request, &response) != U_OK) {
  perror("problème!");
} else {
  printf("Mon site préféré: %.*s", response.binary_body_length, response.binary_body);
}

ulfius_clean_request(&request);
ulfius_clean_response(&response);

On peut aussi faire des envois de courriels, en spécifiant soi-même tous les paramètres.

int ulfius_send_smtp_email(const char * host, 
                           const int port, 
                           const int use_tls, 
                           const int verify_certificate, 
                           const char * user, 
                           const char * password, 
                           const char * from, 
                           const char * to, 
                           const char * cc, 
                           const char * bcc, 
                           const char * subject, 
                           const char * mail_body);

Les requêtes sortantes sont basées sur l'excellentissime libcurl. Elles ne réinventent rien, mais permettent de me faciliter l'usage de ces fonctionnalités pour les cas principaux.

Gérer des websockets

Les websockets ont été spécifiées pour permettre l'échange d'informations full-duplex entre le serveur web et les clients, donc permettre au serveur de pousser des informations au client sans que le client n'ait rien demandé, ou permettre au client d'envoyer des informations au serveur sur la même connexion.

Ulfius gère les websocket coté client ou serveur.
Coté serveur on spécifie une url sur laquelle sera hébergé le service websocket, coté client on spécifie l'adresse du service websocket auquel on souhaite se connecter.

Dans les deux cas, on gère les échanges de messages à travers 2 types de fonction callback: le gestionnaire principal et le message entrant.

Le gestionnaire principal est appelé une fois lorsque la connexion est établie, et gardera la connexion ouverte tant qu'il est en cours d'exécution. Dans cette fonction on pourra lire les messages reçus, ou envoyer des messages. Lorsque la fonction se termine, la connexion websocket est fermée. À l'inverse, si la connection est fermée par l'autre extrémitée, cette fonction ne sera pas fermée par le framework, mais elle devra elle-même se fermer toute seule.

// Envoyer un message texte toutes les 2 secondes tant que la connexion est ouverte
void websocket_manager_callback(const struct _u_request * request,
                                struct _websocket_manager * websocket_manager,
                                void * websocket_manager_user_data) {
  while (ulfius_websocket_wait_close(websocket_manager, 2000) == U_WEBSOCKET_STATUS_OPEN) {
    if (ulfius_websocket_send_message(websocket_manager, U_WEBSOCKET_OPCODE_TEXT, o_strlen("Message without fragmentation from client"), "Message without fragmentation from client") != U_OK) {
      fprintf(stderr, "Error send message without fragmentation\n");
    }
  }
}

Le message entrant est appelé à chaque nouveau message reçu. La fin de l'exécution de cette fonction ne cloture pas la connexion.

void websocket_incoming_message_callback (const struct _u_request * request,
                                         struct _websocket_manager * websocket_manager,
                                         const struct _websocket_message * last_message,
void * websocket_incoming_message_user_data) {
  fprintf(stdout, "Incoming message, opcode: 0x%02x, mask: %d, len: %zu\n", last_message->opcode, last_message->has_mask, last_message->data_len);
  if (last_message->opcode == U_WEBSOCKET_OPCODE_TEXT) {
    fprintf(stdout, "text payload '%.*s'\n", last_message->data_len, last_message->data);
  } else if (last_message->opcode == U_WEBSOCKET_OPCODE_BINARY) {
    fprintf(stdout, "binary payload\n");
  }
}

C'est libre sous license LGPL. Je reste pas mal actif sur le développement, mais les nouvelles fonctionnalités arrivent au gré des besoins et du temps que je peux y passer.
Ulfius commence à avoir une base d'utilisateurs sympathique, mais si ca peut servir à d'autres, j'en serai ravi!

  • # Conventions

    Posté par  (site web personnel) . Évalué à 6.

    Intéressant, par contre je me demande pourquoi les struct sont précisées par _ en début de nom, c'est moche dans le code utilisateur.

    git is great because linus did it, mercurial is better because he didn't

    • [^] # Re: Conventions

      Posté par  . Évalué à 5. Dernière modification le 10 octobre 2018 à 09:10.

      Je pense que la raison c'est d'éviter les collisions avec d'autres bibliothèques C, mais dans ce cas le mieux aurait été de mettre un vrai préfixe, d'autant que'accessoirement, ça casse sans vraie raison la compatibilité avec C++. Je trouve ça dommage (un peu comme freetype et leurs foutues macros qui commencent par des '_': ça compile, mais ça génère un bruit de malade sous la forme de warnings, et comme je transforme les warnings en erreurs… :/ mais freetype à au moins l'histoire et un «quasi-monopole» dans leur domaine, ça excuse.).

  • # Cutelyst

    Posté par  . Évalué à 6.

    Hello,
    Je n'y connais pas grand chose, mais dans le domaine des cadriciels web en C/C++, j'ai vu passer parfois des articles sur Cutelyst. Ça se base sur un Qt minimal (runtime pas bien lourd), c'est rapide, et assez élégant.
    Et puis il y en a forcément en Rust & Go.
    Enfin, bravo pour ton travail et merci pour l'info.

    • [^] # Re: Cutelyst

      Posté par  (site web personnel) . Évalué à 8. Dernière modification le 10 octobre 2018 à 11:16.

      En Go, le hello world http c'est :

              package main
      
              import (
                  "io"
                  "log"
                  "net/http"
              )
      
              func main() {
                  // Hello world, the web server
      
                  helloHandler := func(w http.ResponseWriter, req *http.Request) {
                      io.WriteString(w, "Hello, world!\n")
                  }
      
                  http.HandleFunc("/hello", helloHandler)
                  log.Fatal(http.ListenAndServe(":8080", nil))
              }

      "La première sécurité est la liberté"

      • [^] # Re: Cutelyst

        Posté par  . Évalué à 3. Dernière modification le 10 octobre 2018 à 21:10.

        Excusez mon ignorance, mais pourriez vous m'éclairer.

        Je ne suis pas développeur web (front ou back) et j'en suis encore resté aux vieilles méthodes : Nginx / PHP / un p'tit framework HTML CSS et je trouve ça plutôt efficace, j'arrive à faire un peu ce que je veux, enfin ça correspond à mes besoins).

        Je vois bien la façon que vous indiquez ici semble puissante. Exit le serveur web, tout est dans le code, manipulation des URL comme on l'entend - excellent pour faire des web services par exemple - ou quelque chose de vraiment facile à déployer.

        Mais lorsqu'il s'agit de faire autre chose qu'un webservice ou générer des pages plus complexes que des simples "Hello World" là j'ai du mal à voir … comment on génère le HTML ?
        On répète n fois

        io.WriteString(w, "Hello, world!\n")    
        

        Pour placer ses balises,façon echo en php … ? J'imagine que non, évidemment.
        On est obligé de jongler avec le JS et des technos comme AngularJS  ?

        • [^] # Re: Cutelyst

          Posté par  (site web personnel) . Évalué à 10.

          C'est une approche à base de webservice plutôt que de génération dynamique de page HTML.

          C'est plutôt orienté pour faire des applications ou des services web, par opposition aux sites web qui sont efficaces pour afficher de l'information statique aux utilisateurs: site web de la boite, blog, etc.

          On va prendre un cas d'utilisation simple: afficher dans une page la liste des courses stockée dans une BD.

          Dans ce que tu appelles la "vieille méthode", tu as une page index.php dont le code php exécuté coté serveur va chercher la liste des courses en faisant la requête SQL directement dans la BD, parse le résultat et génère la page HTML correspondante avec ta liste de courses puis l'envoie au client.

          Dans la "nouvelle méthode", la liste des courses est accessible sur une URL qui lui est propre et qui renvoie la liste des courses dans un tableau JSON par exemple, par exemple /api/courses/. À coté, tu as par exemple une page HTML statique mais contenant du code javascript, le javascript va appeler l'API /api/courses au chargement de la page, parser le résultat et générer le tableau en modifiant le DOM de la page.
          Et dans cette approche, tu peux avoir le webservice hébergé sur un serveur, et la page HTML statique hébergée sur un autre serveur type apache qui ne servira que du statique et sera sous stéroïdes pour servir des simples fichiers plats.

          Je vais pas te dire qu'une approche est mieux que l'autre, c'est pas le sujet, et puis ca dépend des cas.

          Dans la "vieille méthode", on centralise la gestion des données et son affichage au même endroit, et ca peut limiter tes possibilités d'extension.

          Dans l'approche par webservice, on a une distinction plus forte entre les données et l'affichage, ca permet d'accéder aux données via d'autres clients (application mobile, script shell, autre application web, une page PHP, etc.) sans avoir à changer le backend ou rajouter un nouveau backend à chaque fois.
          Ca permet aussi de faire évoluer le backend et le front-end de manière distincte en limitant les impacts, voire de rajouter des fonctionnalités au backend dont on sait qu'elles ne seront pas utilisées par tous les clients, mais sans avoir à patcher les clients qui n'utiliseront pas les nouvelles fonctionnalités.

          Mais rien n'est absolu et les deux approches sont plus complémentaires qu'autre chose, celui qui dira que PHP va mourir parce que SOAP/REST/Whatever va devenir la norme, je lui réponds que PHP était là bien avant sa naissance et sera encore là bien après sa mort.
          De plus, si je mets des guillemets à "ancienne méthode", c'est parce que l'approche par webservice n'a pas réinventé l'eau chaude non plus. Entre une page html statique qui appelle une API en javascript pour afficher un tableau et une page php qui fait un require() pour appeler le module php qui va afficher le même tableau, l'approche n'est pas fondamentalement différente.

          L'approche par webservice n'est pas magique non plus, c'est très facile de faire une application web qui sera lente voire inutilisable parce qu'elle fait trop d'appels à trop d'API en même temps par exemple. Ou tomber dans l'enfer des applications mal foutues par design comme expliqué dans un article pointé dans un autre journal.

        • [^] # Re: Cutelyst

          Posté par  (site web personnel) . Évalué à -1.

          Je ne connais pas PHP, mais pour moi un des meilleurs frameworks webs actuels est ASP.NET core. On utilise une architecture modèle/vue/contrôleur ou juste modèle/contrôleur dans le cas d'une API, ce qui permet de bien séparer les parties de l'application.

          Après, on crée un service, des "repositories", on configure l'injection de dépendances pour que ça colle, et ça tourne !

          • [^] # Re: Cutelyst

            Posté par  . Évalué à 3.

            Je ne connais pas ASP.NET, mais il y a longtemps que (presque) plus personne ne code en PHP from scrath.
            De nombreux frameworks existent (Zend Framework, Symfony, Laravel, …), permettant de mâcher le travail redondant, chiant, inintéressant, [insérez ici l'adjectif que vous voulez]…

        • [^] # Re: Cutelyst

          Posté par  (site web personnel) . Évalué à 2.

          En go tu as tout pour faire des web app dynamiques comme tu dis. Il y a des classes de template, middleware, etc…

          Regarde ici par exemple: https://gowebexamples.com

    • [^] # Re: Cutelyst

      Posté par  (site web personnel) . Évalué à 1.

      Le hello world en Rust avec Rocket:

      #![feature(plugin)]
      #![plugin(rocket_codegen)]
      
      extern crate rocket;
      
      #[get("/")]
      fn index() -> &'static str {
          "Hello, world!"
      }
      
      fn main() {
          rocket::ignite().mount("/", routes![index]).launch();
      }
      • [^] # Re: Cutelyst

        Posté par  . Évalué à 4.

        En Go, le hello world http c'est :

        Le hello world en Rust avec Rocket

        Si c'est pour jouer à «dans mon langage c'est plus court et/ou plus élégant», le hello world en Python avec Flask:

        from flask import Flask
        app = Flask(__name__)
        
        @app.route("/")
        def hello():
            return "Hello World!"

        (C'est vendredi)

  • # l'ulfius ou l'onion?

    Posté par  (site web personnel) . Évalué à 4.

    Une autre lib du même genre: https://www.coralbits.com/libonion/

    Le post ci-dessus est une grosse connerie, ne le lisez pas sérieusement.

  • # Troll de langage de programmation

    Posté par  (site web personnel) . Évalué à 1.

    Avant de commencer mon troll, je tiens à préciser que ce n'est absolument pas une critique personnelle ou du projet. Ulfius représente un travail que je ne veux pas dénigrer.
    À la question « Pourquoi tu fait ça ? », la réponse « Pourquoi pas ! » est complètement valide.
    J'imagine que c'est les mêmes raison qui pousse certain à écrire un Serveur HTTP en Brainfuck ou à contribuer à la.wikipedia.org

    Mais je ne peut pas me retenir de lancer un troll sur les langages de programmations.
    En 2018, il n'y a aucune raison (mis à part le "fun") de commencer un projet en C.
    En particulier, c'est même limite irresponsable d'écrire un serveur web en C, au vu des contrainte de sécurité.
    En pratique, il existe suffisamment de langages récent et supérieur tel que C++, Go, Rust, Python, etc. pour faire ce genre de chose.

    Alors pour le fun c'est vrai que ça peut être amusant d'écrire un web framework en C ou en Brainfuck, mais ce n'est pas un truc à utiliser en production.

    • [^] # Re: Troll de langage de programmation

      Posté par  . Évalué à 4.

      En 2018, il n'y a aucune raison (mis à part le "fun") de commencer un projet en C.

      Sauf si tu fais de l'embarqué, sauf si tu veux toucher au matériel, sauf si tu veux des contraintes temporelles fortes. Attention, le C n'est sans doute pas le seul langage à envisager mais il a toujours du sens pour ces projets.

      En pratique, il existe suffisamment de langages récent et supérieur tel que C++, Go, Rust, Python, etc. pour faire ce genre de chose.

      Tu avais prévenu, c'est du troll! Je suis tombé dedans.

      • [^] # Re: Troll de langage de programmation

        Posté par  (site web personnel) . Évalué à 2.

        Pour le coup, je suis d'accord avec lui. J'ai du mal à voir comment le C peut être supérieur au Rust de ce point de vue. Rust est comme un C avec une sécurité améliorée, avec lequel on peut faire de l'embarqué. Et on peut dire ça des autres langages cités (mais peut-être dans une moindre mesure).

      • [^] # Re: Troll de langage de programmation

        Posté par  (site web personnel) . Évalué à 4.

        Sauf si tu fais de l'embarqué, sauf si tu veux toucher au matériel, sauf si tu veux des contraintes temporelles fortes.

        Rust ou C++ fonctionnent très bien avec toutes ces contraintes.

        Peut être une exception si tu vise une architecture ésotérique obsolete pour laquelle il n'existe qu'un compilateur C proprio et tout pourri. Mais on ne choisis pas une telle architecture pour de nouveau projets.

      • [^] # Re: Troll de langage de programmation

        Posté par  (site web personnel) . Évalué à 2.

        Sauf si tu fais de l'embarqué, sauf si tu veux toucher au matériel, sauf si tu veux des contraintes temporelles fortes. Attention, le C n'est sans doute pas le seul langage à envisager mais il a toujours du sens pour ces projets.

        Je ne sais pas, je connais un ami qui a développé une interface de pointage pour les cartes de transport en commun. Ils ont fait ça en Qt Quick (QML donc avec du Javascript) et D-Bus. Bien que ce soit de l'embarqué ça fonctionne très bien.

        git is great because linus did it, mercurial is better because he didn't

    • [^] # Re: Troll de langage de programmation

      Posté par  (Mastodon) . Évalué à 6. Dernière modification le 15 octobre 2018 à 13:15.

      En pratique, il existe suffisamment de langages récent et supérieur tel que C++, Go, Rust, Python, etc. pour faire ce genre de chose.

      Et s'il veut que son framework puisse être utilisé par tous ces langages à la fois et pas juste un seul, il l'écrit en C et les autres font un binding.

      • [^] # Re: Troll de langage de programmation

        Posté par  (site web personnel) . Évalué à 3. Dernière modification le 15 octobre 2018 à 18:52.

        Je ne vois pas le rapport. Il est possible de faire des bindings combinant tout ces langages depuis pratiquement chacun de ces langages.

    • [^] # Re: Troll de langage de programmation

      Posté par  (site web personnel) . Évalué à 6. Dernière modification le 15 octobre 2018 à 21:50.

      J'imagine que le fait que le C soit un langage qu'il maîtrise est une bonne raison.

    • [^] # Re: Troll de langage de programmation

      Posté par  . Évalué à 5.

      Comparer Brainfuck et C, c'est cela qui est irresponsable.

      Nginx et Apache HTTP sont écrits en C.

      De plus si tu prends la peine de lire le code de l'article, il est très propre.

      On peut faire des trucs crado dans n'importe quel langage.

      • [^] # Re: Troll de langage de programmation

        Posté par  (site web personnel) . Évalué à 3.

        Le problème c'est qu'il est très difficile, même pour des programmeurs compétents, de faire un logiciel un tant soit peu ambitieux en C sans qu'il y ait dedans des erreurs mineures ayant des conséquences majeures (buffer overflow et compagnie).

        Si tu as 99% de chances d'avoir des failles de sécurité avec un langage, et 1% avec un autre (toutes choses étant égales par ailleurs), alors dire que ça revient au même car des failles sont possibles dans les 2 cas serait loin d'être très pertinent.

        • [^] # Re: Troll de langage de programmation

          Posté par  . Évalué à 3.

          Je suis d'accord avec ce morceau de phrase:

          Le problème c'est qu'il est très difficile, même pour des programmeurs compétents, de faire un logiciel un tant soit peu ambitieux.

          toutes choses étant égales par ailleurs

          Oui, c'est sûr, si tu utilises un langage qui gère la mémoire, forcément, les bugs de gestion de la mémoire ne sont plus dans ton programme. Mais ils ne disparaissent pas pour autant. Si l'infrastructure de ton langage présente une faille de sécurité dans son gestionnaire de mémoire, d'un seul coup, ce sont tous les programmes écrits dans ce langage qui sont vulnérables. Alors c'est sûr que ça va arriver moins souvent, mais le jour où ça arrive, c'est la catastrophe. Quand tu utilises un langage qui rend les failles de sécurité plus difficiles à écrire, tu échanges un risque local contre un risque systémique. Je ne dis pas qu'il ne faut pas préférer un tel langage au C dans la plupart des cas, mais il ne faut pas non plus penser que le 1% de failles de sécurité potentiel du super langage ne peuvent pas avoir des conséquences catastrophiques.

          • [^] # Re: Troll de langage de programmation

            Posté par  (site web personnel) . Évalué à 2.

            Tout à fait, et je me suis bien gardé de vendre du rêve avec un hypothétique langage 100% sécurisé. Mais la meilleure solution (pas parfaite évidemment) reste la diversité ; qu'on n'utilise pas tous le même noyau sur le même processeur, avec la même libC compilé avec le même compilateur, la même bibliothèque cryptographique ou le même algorithme de chiffrement. Donc avoir, par exemple, des logiciels en Ada sur OpenBSD/PC, et d'autres en Rust sur Linux/ARM, ou en Haskell sur Haiku/Power9. Rien qui n'évite les catastrophes certes, mais rien qui ne suggère que 99% et 1% soit la même chose.

Suivre le flux des commentaires

Note : les commentaires appartiennent à celles et ceux qui les ont postés. Nous n’en sommes pas responsables.