Oui, Virginie, il y a *un* le père Noël Différence entre les frameworks Web en 2023

Le voyage d'un programmeur provocateur pour trouver un code de serveur Web rapide et performant avant de succomber à la pression du marché et à la dette technique
2023-03-24 11:52:06
👁️ 795
💬 0

Contenu

  1. Introduction
  2. Le test
  3. PHP/Laravel
  4. PHP pur
  5. Revisiter Laravel
  6. Django
  7. Ballon
  8. Starlette
  9. Node.js/ExpressJS
  10. Rouille/Actix
  11. Dette technique
  12. Ressources

Introduction

Après l’un de mes derniers entretiens d’embauche, j’ai été surpris de constater que l’entreprise pour laquelle j’avais postulé utilisait toujours Laravel, un framework PHP que j’avais essayé il y a une dizaine d’années. C’était correct pour l’époque, mais s’il y a une constante dans la technologie comme dans la mode, c’est le changement continuel et le renouvellement des styles et des concepts. Si vous êtes un programmeur JavaScript, vous connaissez probablement cette vieille blague

Programmeur 1 : « Je n’aime pas ce nouveau framework JavaScript ! »

Programmeur 2 : « Ne vous inquiétez pas. Attendez six mois et il y en aura un autre pour le remplacer ! »

Par curiosité, j'ai décidé de voir exactement ce qui se passe lorsque nous mettons à l'épreuve l'ancien et le nouveau. Bien sûr, le Web regorge de tests et d'affirmations, dont le plus populaire est probablement le Références du framework Web TechEmpower ici . Nous n’allons cependant pas faire quelque chose d’aussi compliqué qu’eux aujourd’hui. Nous allons garder les choses simples et agréables pour que cet article ne se transforme pas en Guerre et paix , et que vous aurez une petite chance de rester éveillé au moment où vous aurez fini de lire. Les mises en garde habituelles s'appliquent : cela peut ne pas fonctionner de la même manière sur votre machine, différentes versions de logiciels peuvent affecter les performances et le chat de Schrödinger est en fait devenu un chat zombie à moitié vivant et à moitié mort exactement au même moment.

Le test

Environnement de test

Pour ce test, j'utiliserai mon ordinateur portable équipé d'un petit i5 exécutant Manjaro Linux comme indiqué ici.

╰─➤  uname -a
Linux jimsredmi 5.10.174-1-MANJARO #1 SMP PREEMPT Tuesday Mar 21 11:15:28 UTC 2023 x86_64 GNU/Linux

╰─➤  cat /proc/cpuinfo
processor : 0
vendor_id : GenuineIntel
cpu family  : 6
model   : 126
model name  : Intel(R) Core(TM) i5-1035G1 CPU @ 1.00GHz
stepping  : 5
microcode : 0xb6
cpu MHz   : 990.210
cache size  : 6144 KB

La tâche à accomplir

Notre code aura trois tâches simples pour chaque requête :

  1. Lire l'ID de session de l'utilisateur actuel à partir d'un cookie
  2. Charger des informations supplémentaires à partir d'une base de données
  3. Renvoyer ces informations à l'utilisateur

Vous vous demandez peut-être quel genre de test idiot est-ce ? Eh bien, si vous regardez les requêtes réseau pour cette page, vous remarquerez qu'il y en a une appelée sessionvars.js qui fait exactement la même chose.

Le contenu de sessionvars.js

Vous voyez, les pages Web modernes sont des créatures complexes, et l’une des tâches les plus courantes est la mise en cache de pages complexes pour éviter une charge excessive sur le serveur de base de données.

Si nous réaffichons une page complexe à chaque fois qu'un utilisateur la demande, nous ne pouvons servir qu'environ 600 utilisateurs par seconde.

╰─➤  wrk -d 10s -t 4 -c 100 http://127.0.0.1/system/index.en.html      
Running 10s test @ http://127.0.0.1/system/index.en.html
  4 threads and 100 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency   186.83ms  174.22ms   1.06s    81.16%
    Req/Sec   166.11     58.84   414.00     71.89%
  6213 requests in 10.02s, 49.35MB read
Requests/sec:    619.97
Transfer/sec:      4.92MB

Mais si nous mettons en cache cette page sous forme de fichier HTML statique et laissons Nginx le renvoyer rapidement à l'utilisateur, nous pouvons alors servir 32 000 utilisateurs par seconde, augmentant ainsi les performances d'un facteur 50x.

╰─➤  wrk -d 10s -t 4 -c 100 http://127.0.0.1/system/index.en.html
Running 10s test @ http://127.0.0.1/system/index.en.html
  4 threads and 100 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency     3.03ms  511.95us   6.87ms   68.10%
    Req/Sec     8.20k     1.15k   28.55k    97.26%
  327353 requests in 10.10s, 2.36GB read
Requests/sec:  32410.83
Transfer/sec:    238.99MB

L'index statique.en.html est la partie qui s'adresse à tout le monde, et seules les parties qui diffèrent selon les utilisateurs sont envoyées dans sessionvars.js. Cela réduit non seulement la charge de la base de données et crée une meilleure expérience pour nos utilisateurs, mais diminue également les probabilités quantiques que notre serveur se vaporise spontanément dans une brèche du noyau de distorsion lorsque les Klingons attaquent.

Exigences du code

Le code renvoyé pour chaque framework aura une exigence simple : montrer à l’utilisateur combien de fois il a actualisé la page en disant « Le nombre est x ». Pour simplifier les choses, nous éviterons pour l’instant les files d’attente Redis, les composants Kubernetes ou AWS Lambdas.

Afficher le nombre de fois que vous avez visité la page

Les données de session de chaque utilisateur seront enregistrées dans une base de données PostgreSQL.

La table des sessions utilisateurs

Et cette table de base de données sera tronquée avant chaque test.

Le tableau après avoir été tronqué

Simple mais efficace est la devise de Pafera... en dehors de la chronologie la plus sombre en tout cas...

Les résultats réels des tests

PHP/Laravel

Bon, nous pouvons enfin commencer à nous salir les mains. Nous allons ignorer la configuration de Laravel car il s'agit simplement d'un ensemble de commandes de compositeur et d'artisan.

Tout d’abord, nous allons configurer nos paramètres de base de données dans le fichier .env

DB_CONNECTION=pgsql
DB_HOST=127.0.0.1
DB_PORT=5432
DB_DATABASE=sessiontest
DB_USERNAME=sessiontest
DB_PASSWORD=sessiontest

Nous définirons ensuite une seule route de secours qui envoie chaque requête à notre contrôleur.

Route::fallback(SessionController::class);

Et définissez le contrôleur pour afficher le nombre. Laravel, par défaut, stocke les sessions dans la base de données. Il fournit également le session() fonction d'interface avec nos données de session, il n'a donc fallu que quelques lignes de code pour rendre notre page.

class SessionController extends Controller
{
  public function __invoke(Request $request)
  {
    $count  = session('count', 0);

    $count  += 1;

    session(['count' => $count]);

    return 'Count is ' . $count;
  }
}

Après avoir configuré php-fpm et Nginx, notre page est plutôt belle...

╰─➤  php -v
PHP 8.2.2 (cli) (built: Feb  1 2023 08:33:04) (NTS)
Copyright (c) The PHP Group
Zend Engine v4.2.2, Copyright (c) Zend Technologies
    with Xdebug v3.2.0, Copyright (c) 2002-2022, by Derick Rethans

╰─➤  sudo systemctl restart php-fpm
╰─➤  sudo systemctl restart nginx

Du moins jusqu'à ce que nous voyions réellement les résultats des tests...

PHP/Laravel

╰─➤  wrk -d 10s -t 4 -c 100 http://127.0.0.1
Running 10s test @ http://127.0.0.1
  4 threads and 100 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency     1.08s   546.33ms   1.96s    65.71%
    Req/Sec    12.37      7.28    40.00     56.64%
  211 requests in 10.03s, 177.21KB read
  Socket errors: connect 0, read 0, write 0, timeout 176
Requests/sec:     21.04
Transfer/sec:     17.67KB

Non, ce n'est pas une erreur de frappe. Notre machine de test est passée de 600 requêtes par seconde pour le rendu d'une page complexe... à 21 requêtes par seconde pour le rendu « Count is 1 ».

Alors, qu'est-ce qui s'est passé ? Est-ce qu'il y a un problème avec notre installation PHP ? Est-ce que Nginx ralentit d'une manière ou d'une autre lors de l'interfaçage avec php-fpm ?

PHP pur

Refaisons cette page en pur code PHP.

<?php

// ====================================================================
function uuid4() 
{
  return sprintf(
    '%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
    mt_rand(0, 0xffff), mt_rand(0, 0xffff),
    mt_rand(0, 0xffff),
    mt_rand(0, 0x0fff) | 0x4000,
    mt_rand(0, 0x3fff) | 0x8000,
    mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
  );
}

// ====================================================================
function Query($db, $query, $params = [])
{
  $s  = $db->prepare($query);
  
  $s->setFetchMode(PDO::FETCH_ASSOC);
  $s->execute(array_values($params));
  
  return $s;
}

// ********************************************************************
session_start();

$sessionid  = 0;

if (isset($_SESSION['sessionid']))
{
  $sessionid  = $_SESSION['sessionid'];
}

if (!$sessionid)
{
  $sessionid              = uuid4();
  $_SESSION['sessionid']  = $sessionid;
}

$db   = new PDO('pgsql:host=127.0.0.1 dbname=sessiontest user=sessiontest password=sessiontest');
$data = 0;

try
{
  $result = Query(
    $db,
    'SELECT data FROM usersessions WHERE uid = ?',
    [$sessionid]
  )->fetchAll();
  
  if ($result)
  {
    $data = json_decode($result[0]['data'], 1);
  } 
} catch (Exception $e)
{
  echo $e;

  Query(
    $db,
    'CREATE TABLE usersessions(
      uid     TEXT PRIMARY KEY,
      data    TEXT
    )'
  );
}

if (!$data)
{
  $data = ['count'  => 0];
}

$data['count']++;

if ($data['count'] == 1)
{
  Query(
    $db,
    'INSERT INTO usersessions(uid, data)
    VALUES(?, ?)',
    [$sessionid, json_encode($data)]
  );
} else
{
  Query(
    $db,
    'UPDATE usersessions
      SET data = ?
      WHERE uid = ?',
    [json_encode($data), $sessionid]
  );
}

echo 'Count is ' . $data['count'];

Nous avons maintenant utilisé 98 lignes de code pour faire ce que quatre lignes de code (et tout un tas de travail de configuration) dans Laravel ont fait. (Bien sûr, si nous faisions une gestion des erreurs et des messages destinés aux utilisateurs, cela représenterait environ deux fois plus de lignes.) Peut-être pourrions-nous atteindre 30 requêtes par seconde ?

PHP/Pure PHP

╰─➤  wrk -d 10s -t 4 -c 100 http://127.0.0.1                  
Running 10s test @ http://127.0.0.1
  4 threads and 100 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency   140.79ms   27.88ms 332.31ms   90.75%
    Req/Sec   178.63     58.34   252.00     61.01%
  7074 requests in 10.04s, 3.62MB read
Requests/sec:    704.46
Transfer/sec:    369.43KB

Ouah ! Il semblerait que notre installation PHP ne présente aucun problème. La version PHP pure effectue 700 requêtes par seconde.

S’il n’y a rien de mal avec PHP, peut-être avons-nous mal configuré Laravel ?

Revisiter Laravel

Après avoir parcouru le Web à la recherche de problèmes de configuration et de conseils sur les performances, deux des techniques les plus populaires consistaient à mettre en cache les données de configuration et de routage pour éviter de les traiter à chaque requête. Par conséquent, nous allons suivre leurs conseils et essayer ces conseils.

╰─➤  php artisan config:cache

   INFO  Configuration cached successfully.  

╰─➤  php artisan route:cache

   INFO  Routes cached successfully.  

Tout semble correct sur la ligne de commande. Reprenons le benchmark.

╰─➤  wrk -d 10s -t 4 -c 100 http://127.0.0.1
Running 10s test @ http://127.0.0.1
  4 threads and 100 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency     1.13s   543.50ms   1.98s    61.90%
    Req/Sec    25.45     13.39    50.00     55.77%
  289 requests in 10.04s, 242.15KB read
  Socket errors: connect 0, read 0, write 0, timeout 247
Requests/sec:     28.80
Transfer/sec:     24.13KB

Eh bien, nous avons maintenant augmenté les performances de 21,04 à 28,80 requêtes par seconde, soit une augmentation spectaculaire de près de 37 % ! Cela serait assez impressionnant pour n'importe quel progiciel... sauf que nous ne faisons toujours que 1/24ème du nombre de requêtes de la version PHP pure.

Si vous pensez que quelque chose ne va pas avec ce test, vous devriez en parler avec l'auteur du framework PHP Lucinda. Dans ses résultats de test, il a Lucinda bat Laravel par 36x pour les requêtes HTML et 90x pour les requêtes JSON.

Après avoir testé sur ma propre machine avec Apache et Nginx, je n'ai aucune raison de douter de lui. Laravel est vraiment juste que lent ! PHP en soi n'est pas si mal, mais une fois que vous ajoutez tout le traitement supplémentaire que Laravel ajoute à chaque requête, alors je trouve très difficile de recommander Laravel comme choix en 2023.

Django

Comptes PHP/Wordpress pour environ 40 % de tous les sites Web sur le Web , ce qui en fait de loin le cadre le plus dominant. Personnellement, je trouve que la popularité ne se traduit pas nécessairement par la qualité, pas plus que je ne me retrouve à ressentir une envie soudaine et incontrôlable pour cet extraordinaire aliment gastronomique de le restaurant le plus populaire du monde ... McDonald's. Puisque nous avons déjà testé du code PHP pur, nous n'allons pas tester Wordpress lui-même, car tout ce qui implique Wordpress serait sans aucun doute inférieur aux 700 requêtes par seconde que nous avons observées avec du PHP pur.

Django est un autre framework populaire qui existe depuis longtemps. Si vous l’avez déjà utilisé, vous vous souvenez probablement avec tendresse de son interface d’administration de base de données spectaculaire et de la difficulté à tout configurer comme vous le souhaitiez. Voyons comment Django fonctionne en 2023, en particulier avec la nouvelle interface ASGI qu’il a ajoutée à partir de la version 4.0.

La configuration de Django est remarquablement similaire à celle de Laravel, car ils datent tous deux de l'époque où les architectures MVC étaient élégantes et correctes. Nous allons ignorer la configuration ennuyeuse et passer directement à la configuration de la vue.

from django.shortcuts import render
from django.http import HttpResponse

# =====================================================================
def index(request):
  count = request.session.get('count', 0)
  count += 1
  request.session['count']  = count 
  return HttpResponse(f"Count is {count}")

Quatre lignes de code sont les mêmes que dans la version Laravel. Voyons comment cela fonctionne.

╰─➤  python --version
Python 3.10.9

Python/Django
╰─➤  gunicorn --access-logfile - -k uvicorn.workers.UvicornWorker -w 4 djangotest.asgi
[2023-03-21 15:20:38 +0800] [2886633] [INFO] Starting gunicorn 20.1.0

╰─➤  wrk -d 10s -t 4 -c 100 http://127.0.0.1:8000/sessiontest/
Running 10s test @ http://127.0.0.1:8000/sessiontest/
  4 threads and 100 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency   277.71ms  142.84ms 835.12ms   69.93%
    Req/Sec    91.21     57.57   230.00     61.04%
  3577 requests in 10.06s, 1.46MB read
Requests/sec:    355.44
Transfer/sec:    148.56KB

Pas mal du tout avec 355 requêtes par seconde. C'est seulement la moitié des performances de la version PHP pure, mais c'est aussi 12 fois plus que la version Laravel. Django vs. Laravel ne semble pas être une compétition du tout.

Ballon

Outre les frameworks plus volumineux qui permettent de tout faire, il existe également des frameworks plus petits qui se contentent de faire quelques configurations de base tout en vous laissant gérer le reste. L'un des meilleurs à utiliser est Flask et son homologue ASGI Quart. Le mien Cadre PaferaPy est construit sur Flask, donc je sais très bien à quel point il est facile de faire avancer les choses tout en maintenant les performances.

#!/usr/bin/python3
# -*- coding: utf-8 -*-
#
# Session benchmark test

import json
import psycopg
import uuid

from flask import Flask, session, redirect, url_for, request, current_app, g, abort, send_from_directory
from flask.sessions import SecureCookieSessionInterface

app = Flask('pafera')

app.secret_key  = b'secretkey'

dbconn  = 0

# =====================================================================
@app.route('/', defaults={'path': ''}, methods = ['GET', 'POST'])
@app.route('/<path:path>', methods = ['GET', 'POST'])
def index(path):
  """Handles all requests for the server. 
  
  We route all requests through here to handle the database and session
  logic in one place.
  """
  global dbconn
  
  if not dbconn:
    dbconn  = psycopg.connect('dbname=sessiontest user=sessiontest password=sessiontest')
    
    cursor  = dbconn.execute('''
      CREATE TABLE IF NOT EXISTS usersessions(
        uid     TEXT PRIMARY KEY,
        data    TEXT
      )
    ''')
    cursor.close()
    dbconn.commit()
      
  sessionid = session.get('sessionid', 0)
  
  if not sessionid:
    sessionid = uuid.uuid4().hex
    session['sessionid']  = sessionid
  
  cursor  = dbconn.execute("SELECT data FROM usersessions WHERE uid = %s", [sessionid])
  row     = cursor.fetchone()
  
  count = json.loads(row[0])['count'] if row else 0
  
  count += 1
  
  newdata = json.dumps({'count': count})
  
  if count == 1:
    cursor.execute("""
        INSERT INTO usersessions(uid, data)
        VALUES(%s, %s)
      """,
      [sessionid, newdata]
    )
  else:
    cursor.execute("""
        UPDATE usersessions
        SET data = %s
        WHERE uid = %s
      """,
      [newdata, sessionid]
    )
  
  cursor.close()
  
  dbconn.commit()
  
  return f'Count is {count}'

Comme vous pouvez le voir, le script Flask est plus court que le script PHP pur. Je trouve que parmi tous les langages que j'ai utilisés, Python est probablement le langage le plus expressif en termes de frappes de touches. L'absence d'accolades et de parenthèses, la compréhension des listes et des dictionnaires, et le blocage basé sur l'indentation plutôt que sur les points-virgules rendent Python plutôt simple mais puissant dans ses capacités.

Malheureusement, Python est aussi le langage polyvalent le plus lent qui existe, malgré le nombre de logiciels qui y ont été écrits. Le nombre de bibliothèques Python disponibles est environ quatre fois supérieur à celui des langages similaires et couvre un grand nombre de domaines, mais personne ne dira que Python est rapide ou performant en dehors de niches comme NumPy.

Voyons comment notre version Flask se compare à nos frameworks précédents.

Python/Flask

╰─➤  gunicorn --access-logfile - -w 4 flasksite:app
[2023-03-21 15:32:49 +0800] [2856296] [INFO] Starting gunicorn 20.1.0

╰─➤  wrk -d 10s -t 4 -c 100 http://127.0.0.1:8000
Running 10s test @ http://127.0.0.1:8000
  4 threads and 100 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency    91.84ms   11.97ms 149.63ms   86.18%
    Req/Sec   272.04     39.05   380.00     74.50%
  10842 requests in 10.04s, 3.27MB read
Requests/sec:   1080.28
Transfer/sec:    333.37KB

Notre script Flask est en fait plus rapide que notre version PHP pure !

Si cela vous surprend, sachez que notre application Flask effectue toute son initialisation et sa configuration lorsque nous démarrons le serveur gunicorn, tandis que PHP réexécute le script à chaque fois qu'une nouvelle requête arrive. C'est l'équivalent de Flask, le jeune chauffeur de taxi impatient qui a déjà démarré la voiture et attend au bord de la route, tandis que PHP est le vieux chauffeur qui reste chez lui en attendant un appel et qui ne conduit qu'ensuite pour venir vous chercher. Étant un gars de la vieille école et venant de l'époque où PHP était un merveilleux changement par rapport aux fichiers HTML et SHTML simples, il est un peu triste de réaliser combien de temps s'est écoulé, mais les différences de conception font qu'il est vraiment difficile pour PHP de rivaliser avec les serveurs Python, Java et Node.js qui restent simplement en mémoire et gèrent les requêtes avec la facilité agile d'un jongleur.

Starlette

Flask est peut-être notre framework le plus rapide jusqu'à présent, mais il s'agit en fait d'un logiciel assez ancien. La communauté Python est passée aux nouveaux serveurs ASGI asynchrones il y a quelques années, et bien sûr, j'ai moi-même fait la transition avec eux.

La dernière version du framework Pafera, PaferaPyAsync , est basé sur Starlette. Bien qu'il existe une version ASGI de Flask appelée Quart, les différences de performances entre Quart et Starlette ont été suffisantes pour que je base mon code sur Starlette à la place.

La programmation asynchrone peut être effrayante pour beaucoup de gens, mais ce n’est en fait pas un concept difficile grâce aux gars de Node.js qui ont popularisé le concept il y a plus de dix ans.

Nous avions l'habitude de lutter contre la concurrence avec le multithreading, le multitraitement, le calcul distribué, le chaînage de promesses et tous ces moments amusants qui ont prématurément vieilli et desséché de nombreux programmeurs chevronnés. Maintenant, nous tapons simplement async devant nos fonctions et await devant tout code qui pourrait prendre un certain temps à s'exécuter. Il est en effet plus verbeux que le code normal, mais beaucoup moins ennuyeux à utiliser que de devoir gérer des primitives de synchronisation, le passage de messages et la résolution de promesses.

Notre fichier Starlette ressemble à ceci :

#!/usr/bin/python3
# -*- coding: utf-8 -*-
#
# Session benchmark test

import json
import uuid

import psycopg

from starlette.applications import Starlette
from starlette.responses import Response, PlainTextResponse, JSONResponse, RedirectResponse, HTMLResponse
from starlette.routing import Route, Mount, WebSocketRoute
from starlette_session import SessionMiddleware

dbconn  = 0

# =====================================================================
async def index(R):
  global dbconn
  
  if not dbconn:
    dbconn  = await psycopg.AsyncConnection.connect('dbname=sessiontest user=sessiontest password=sessiontest')
    
    cursor  = await dbconn.execute('''
      CREATE TABLE IF NOT EXISTS usersessions(
        uid     TEXT PRIMARY KEY,
        data    TEXT
      )
    ''')
    await cursor.close()
    await dbconn.commit()
    
  sessionid = R.session.get('sessionid', 0)
  
  if not sessionid:
    sessionid = uuid.uuid4().hex
    R.session['sessionid']  = sessionid
  
  cursor  = await dbconn.execute("SELECT data FROM usersessions WHERE uid = %s", [sessionid])
  row     = await cursor.fetchone()
  
  count = json.loads(row[0])['count'] if row else 0
  
  count += 1
  
  newdata = json.dumps({'count': count})
  
  if count == 1:
    await cursor.execute("""
        INSERT INTO usersessions(uid, data)
        VALUES(%s, %s)
      """,
      [sessionid, newdata]
    )
  else:
    await cursor.execute("""
        UPDATE usersessions
        SET data = %s
        WHERE uid = %s
      """,
      [newdata, sessionid]
    )
  
  await cursor.close()
  await dbconn.commit()
  
  return PlainTextResponse(f'Count is {count}')

# *********************************************************************
app = Starlette(
  debug   = True, 
  routes  = [
    Route('/{path:path}', index, methods = ['GET', 'POST']),
  ],
)

app.add_middleware(
  SessionMiddleware, 
  secret_key  = 'testsecretkey', 
  cookie_name = "pafera",
)

Comme vous pouvez le voir, il s'agit en quelque sorte d'un copié-collé de notre script Flask avec seulement quelques modifications de routage et le async/await mots-clés.

Dans quelle mesure le code copié et collé peut-il réellement nous apporter des améliorations ?

Python/Starlette

╰─➤  gunicorn --access-logfile - -k uvicorn.workers.UvicornWorker -w 4 starlettesite:app                                                                                                130 ↵
[2023-03-21 15:42:34 +0800] [2856220] [INFO] Starting gunicorn 20.1.0

╰─➤  wrk -d 10s -t 4 -c 100 http://127.0.0.1:8000
Running 10s test @ http://127.0.0.1:8000
  4 threads and 100 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency    21.85ms   10.45ms  67.29ms   55.18%
    Req/Sec     1.15k   170.11     1.52k    66.00%
  45809 requests in 10.04s, 13.85MB read
Requests/sec:   4562.82
Transfer/sec:      1.38MB

Mesdames et messieurs, nous avons un nouveau champion ! Notre précédent record était notre version PHP pure avec 704 requêtes par seconde, qui a ensuite été dépassée par notre version Flask avec 1080 requêtes par seconde. Notre script Starlette écrase tous les concurrents précédents avec 4562 requêtes par seconde, ce qui signifie une amélioration de 6x par rapport à PHP pure et de 4x par rapport à Flask.

Si vous n’avez pas encore changé votre code Python WSGI en ASGI, c’est peut-être le bon moment pour commencer.

Node.js/ExpressJS

Jusqu’à présent, nous n’avons abordé que les frameworks PHP et Python. Cependant, une grande partie du monde utilise Java, DotNet, Node.js, Ruby on Rails et d’autres technologies similaires pour ses sites Web. Il ne s’agit en aucun cas d’un aperçu complet de tous les écosystèmes et biomes du monde. Par conséquent, pour éviter de faire l’équivalent en programmation de la chimie organique, nous choisirons uniquement les frameworks pour lesquels il est le plus facile de taper du code… dont Java n’est certainement pas un exemple.

À moins que vous ne vous cachiez sous votre exemplaire de K&amp;R C ou de Knuth L'art de la programmation informatique Depuis quinze ans, vous avez probablement entendu parler de Node.js. Ceux d’entre nous qui ont été là depuis le début de JavaScript sont soit incroyablement effrayés, soit étonnés, ou les deux, par l’état du JavaScript moderne, mais il est indéniable que JavaScript est devenu une force avec laquelle il faut compter sur les serveurs ainsi que sur les navigateurs. Après tout, nous avons même maintenant des entiers natifs de 64 bits dans le langage ! C’est de loin bien mieux que tout ce qui est stocké dans des flottants de 64 bits !

ExpressJS est probablement le serveur Node.js le plus simple à utiliser, nous allons donc créer une application Node.js/ExpressJS rapide et simple pour servir notre compteur.

/**********************************************************************
 * Simple session test using ExpressJS.
 **********************************************************************/
var L           = console.log;

var uuid        = require('uuid4');
var express     = require('express');
var session     = require('express-session');
var MemoryStore = require('memorystore')(session);

var { Client }  = require('pg')
var db          = 0;
var app       = express();

const PORT    = 8000;

//session middleware
app.use(
  session({
    secret:             "secretkey",
    saveUninitialized:  true,
    resave:             false,
    store:              new MemoryStore({
      checkPeriod: 1000 * 60 * 60 * 24 // prune expired entries every 24h
    })
  })
);

app.get('/',
  async function(req,res)
  {
    if (!db)
    {
      db  = new Client({
        user:     'sessiontest',
        host:     '127.0.0.1',
        database: 'sessiontest',
        password: 'sessiontest'
      });
      
      await db.connect();
      
      await db.query(`
        CREATE TABLE IF NOT EXISTS usersessions(
          uid     TEXT PRIMARY KEY,
          data    TEXT
        )`,
        []
      );
    };
    
    var session = req.session;
    
    if (!session.sessionid)
    {
      session.sessionid = uuid();
    }
    
    var row = 0;
    
    let queryresult = await db.query(`
      SELECT data::TEXT
      FROM usersessions 
      WHERE uid = $1`,
      [session.sessionid]
    );
    
    if (queryresult && queryresult.rows.length)
    {
      row = queryresult.rows[0].data;
    } 
    
    var count = 0;
    
    if (row)
    {
      var data  = JSON.parse(row);
      
      data.count  += 1;
      
      count = data.count;
      
      await db.query(`
          UPDATE usersessions
          SET data = $1
          WHERE uid = $2
        `,
        [JSON.stringify(data), session.sessionid]
      );
    } else
    {
      await db.query(`
        INSERT INTO usersessions(uid, data)
          VALUES($1, $2)`,
        [session.sessionid, JSON.stringify({count: 1})]
      );
      
      count = 1;
    }
    
    res.send(`Count is ${count}`);
  }
);

app.listen(PORT, () => console.log(`Server Running at port ${PORT}`));

Ce code était en fait plus facile à écrire que les versions Python, bien que JavaScript natif devienne plutôt difficile à manier lorsque les applications deviennent plus grandes, et toutes les tentatives pour corriger cela, comme TypeScript, deviennent rapidement plus verbeuses que Python.

Voyons comment cela fonctionne !

Node.js/ExpressJS

╰─➤  node --version                                                                                                                                                                     v19.6.0

╰─➤  NODE_ENV=production node nodejsapp.js                                                                                                                                             130 ↵
Server Running at port 8000

╰─➤  wrk -d 10s -t 4 -c 100 http://127.0.0.1:8000
Running 10s test @ http://127.0.0.1:8000
  4 threads and 100 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency    90.41ms    7.20ms 188.29ms   85.16%
    Req/Sec   277.15     37.21   393.00     81.66%
  11018 requests in 10.02s, 3.82MB read
Requests/sec:   1100.12
Transfer/sec:    390.68KB

Vous avez peut-être entendu des histoires anciennes (anciennes selon les standards Internet en tout cas...) sur la vitesse de Node.js, et ces histoires sont en grande partie vraies grâce au travail spectaculaire que Google a réalisé avec le moteur JavaScript V8. Dans ce cas cependant, bien que notre application rapide surpasse le script Flask, sa nature monothread est vaincue par les quatre processus asynchrones maniés par le chevalier Starlette qui dit « Ni ! ».

Allons chercher de l’aide supplémentaire !

╰─➤  pm2 start nodejsapp.js -i 4 

[PM2] Spawning PM2 daemon with pm2_home=/home/jim/.pm2
[PM2] PM2 Successfully daemonized
[PM2] Starting /home/jim/projects/paferarust/nodejsapp.js in cluster_mode (4 instances)
[PM2] Done.
┌────┬──────────────┬─────────────┬─────────┬─────────┬──────────┬────────┬──────┬───────────┬──────────┬──────────┬──────────┬──────────┐
│ id │ name         │ namespace   │ version │ mode    │ pid      │ uptime │ ↺    │ status    │ cpu      │ mem      │ user     │ watching │
├────┼──────────────┼─────────────┼─────────┼─────────┼──────────┼────────┼──────┼───────────┼──────────┼──────────┼──────────┼──────────┤
│ 0  │ nodejsapp    │ default     │ N/A     │ cluster │ 37141    │ 0s     │ 0    │ online    │ 0%       │ 64.6mb   │ jim      │ disabled │
│ 1  │ nodejsapp    │ default     │ N/A     │ cluster │ 37148    │ 0s     │ 0    │ online    │ 0%       │ 64.5mb   │ jim      │ disabled │
│ 2  │ nodejsapp    │ default     │ N/A     │ cluster │ 37159    │ 0s     │ 0    │ online    │ 0%       │ 56.0mb   │ jim      │ disabled │
│ 3  │ nodejsapp    │ default     │ N/A     │ cluster │ 37171    │ 0s     │ 0    │ online    │ 0%       │ 45.3mb   │ jim      │ disabled │
└────┴──────────────┴─────────────┴─────────┴─────────┴──────────┴────────┴──────┴───────────┴──────────┴──────────┴──────────┴──────────┘

Ok ! Maintenant, c'est une bataille à égalité à quatre contre quatre ! Faisons un benchmark !

╰─➤  wrk -d 10s -t 4 -c 100 http://127.0.0.1:8000
Running 10s test @ http://127.0.0.1:8000
  4 threads and 100 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency    45.09ms   19.89ms 176.14ms   60.22%
    Req/Sec   558.93     97.50   770.00     66.17%
  22234 requests in 10.02s, 7.71MB read
Requests/sec:   2218.69
Transfer/sec:    787.89KB

Pas encore au niveau de Starlette, mais ce n'est pas mal pour un hack JavaScript rapide de cinq minutes. D'après mes propres tests, ce script est en fait un peu freiné au niveau de l'interface de base de données car node-postgres est loin d'être aussi efficace que psycopg pour Python. Le passage à sqlite comme pilote de base de données génère plus de 3000 requêtes par seconde pour le même code ExpressJS.

La principale chose à noter est que malgré la lenteur d’exécution de Python, les frameworks ASGI peuvent en fait être compétitifs avec les solutions Node.js pour certaines charges de travail.

Rouille/Actix

Nous nous rapprochons donc désormais du sommet de la montagne, et par montagne, j’entends les scores de référence les plus élevés enregistrés par les souris et les hommes.

Si vous regardez la plupart des benchmarks de frameworks disponibles sur le Web, vous remarquerez que deux langages ont tendance à dominer le top : C++ et Rust. Je travaille avec C++ depuis les années 90, et j'ai même eu mon propre framework Win32 C++ avant que MFC/ATL ne soit une chose, j'ai donc beaucoup d'expérience avec le langage. Ce n'est pas très amusant de travailler avec quelque chose que vous connaissez déjà, nous allons donc faire une version Rust à la place. ;)

Rust est un langage de programmation relativement nouveau, mais il est devenu un objet de curiosité pour moi lorsque Linus Torvalds a annoncé qu’il accepterait Rust comme langage de programmation du noyau Linux. Pour nous, les programmeurs plus âgés, c’est à peu près la même chose que de dire que ce nouveau truc hippie new age allait être un nouvel amendement à la Constitution américaine.

Maintenant, quand vous êtes un programmeur expérimenté, vous avez tendance à ne pas sauter dans le train en marche aussi vite que les plus jeunes, sinon vous risquez de vous faire avoir par les changements rapides du langage ou des bibliothèques. (Quiconque a utilisé la première version d'AngularJS saura de quoi je parle.) Rust est encore quelque peu dans cette phase de développement expérimental, et je trouve amusant que de nombreux exemples de code sur le Web ne compilent même plus avec les versions actuelles des packages.

Cependant, les performances affichées par les applications Rust ne peuvent être niées. Si vous n'avez jamais essayé ripgrep ou fd-trouver sur de grands arbres de code source, vous devriez certainement les essayer. Ils sont même disponibles pour la plupart des distributions Linux simplement à partir du gestionnaire de paquets. Vous échangez la verbosité contre les performances avec Rust... parcelle de verbosité pour un parcelle de performance.

Le code complet de Rust est un peu volumineux, nous allons donc simplement jeter un œil aux gestionnaires pertinents ici :

// =====================================================================
pub async fn RunQuery(
  db:       &web::Data<Pool>,
  query:    &str,
  args:     &[&(dyn ToSql + Sync)]
) -> Result<Vec<tokio_postgres::row::Row>, tokio_postgres::Error>
{  
  let client      = db.get().await.unwrap();
  let statement   = client.prepare_cached(query).await.unwrap();
  
  client.query(&statement, args).await
}

// =====================================================================
pub async fn index(
  req:      HttpRequest,
  session:  Session,
  db:       web::Data<Pool>,
) -> Result<HttpResponse, Error> 
{
  let mut count = 1;
  
  if let Some(sessionid) = session.get::<String>("sessionid")? 
  {
    let rows  = RunQuery(
      &db, 
      "SELECT data 
        FROM usersessions 
        WHERE uid = $1", 
      &[&sessionid]
    ).await.unwrap();
    
    if rows.is_empty()
    {
      let jsondata  = serde_json::json!({
        "count": 1,
      }).to_string();
      
      RunQuery(
        &db, 
        "INSERT INTO usersessions(uid, data)
          VALUES($1, $2)", 
        &[&sessionid, &jsondata]
      ).await
      .expect("Insert failed!");
    } else
    {
      let jsonstring:&str  = rows[0].get(0);
      let countdata: CountData = serde_json::from_str(jsonstring)?;
      
      count = countdata.count;
      
      count += 1;
      
      let jsondata  = serde_json::json!({
        "count": count,
      }).to_string();
      
      RunQuery(
        &db, 
        "UPDATE usersessions
        SET data = $1
        WHERE uid = $2
        ",
        &[&jsondata, &sessionid]
      ).await
      .expect("Update failed!");
    }
  } else 
  {
    let sessionid = Uuid::new_v4().to_string();
    
    let jsondata  = serde_json::json!({
      "count": 1,
    }).to_string();
    
    RunQuery(
      &db, 
      "INSERT INTO usersessions(uid, data)
        VALUES($1, $2)", 
      &[&sessionid, &jsondata]
    ).await
    .expect("Insert failed!");
    
    session.insert("sessionid", sessionid)?;    
  }  
  
  Ok(HttpResponse::Ok().body(format!(
    "Count is {:?}",
    count
  )))
}

C'est beaucoup plus compliqué que les versions Python/Node.js...

Rust/Actix

╰─➤  cargo run --release
[2023-03-21T23:37:25Z INFO  actix_server::builder] starting 4 workers
Server running at http://127.0.0.1:8888/

╰─➤  wrk -d 10s -t 4 -c 100 http://127.0.0.1:8888
Running 10s test @ http://127.0.0.1:8888
  4 threads and 100 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency     9.93ms    3.90ms  77.18ms   94.87%
    Req/Sec     2.59k   226.41     2.83k    89.25%
  102951 requests in 10.03s, 24.59MB read
Requests/sec:  10267.39
Transfer/sec:      2.45MB

Et bien plus performant !

Notre serveur Rust utilisant Actix/deadpool_postgres bat haut la main notre précédent champion Starlette de +125%, ExpressJS de +362% et PHP pur de +1366%. (Je laisse le delta de performance avec la version Laravel comme exercice pour le lecteur.)

J’ai trouvé que l’apprentissage du langage Rust lui-même était plus difficile que celui d’autres langages, car il comporte beaucoup plus de pièges que tout ce que j’ai vu en dehors de l’assembleur 6502, mais si votre serveur Rust peut prendre en charge 14 fois plus d’utilisateurs que votre serveur PHP, alors peut-être qu’il y a quelque chose à gagner à changer de technologie après tout. C’est pourquoi la prochaine version du framework Pafera sera basée sur Rust. La courbe d’apprentissage est bien plus élevée que celle des langages de script, mais les performances en valent la peine. Si vous ne pouvez pas consacrer le temps nécessaire à l’apprentissage de Rust, alors baser votre pile technologique sur Starlette ou Node.js n’est pas non plus une mauvaise décision.

Dette technique

Au cours des vingt dernières années, nous sommes passés de sites d’hébergement statiques bon marché à l’hébergement partagé avec des piles LAMP, en passant par la location de VPS sur AWS, Azure et d’autres services cloud. De nos jours, de nombreuses entreprises se contentent de prendre des décisions de conception en fonction de ce qu’elles peuvent trouver de disponible ou de moins cher, car l’avènement de services cloud pratiques a facilité l’ajout de plus de matériel sur des serveurs et des applications lents. Cela leur a permis de réaliser d’importants gains à court terme au prix d’une dette technique à long terme.

Avertissement du chirurgien général de Californie : ce n’est pas un vrai chien spatial.

Il y a 70 ans, l'Union soviétique et les États-Unis se livraient à une grande course à l'espace. Les Soviétiques ont remporté la plupart des premières étapes. Ils ont eu le premier satellite Spoutnik, le premier chien dans l'espace Laïka, le premier vaisseau spatial lunaire Luna 2, le premier homme et la première femme dans l'espace Youri Gagarine et Valentina Terechkova, etc.

Mais ils accumulaient peu à peu une dette technique.

Bien que les Soviétiques aient été les premiers à réaliser ces exploits, leurs processus d'ingénierie et leurs objectifs les ont poussés à se concentrer sur les défis à court terme plutôt que sur la faisabilité à long terme. Ils ont gagné à chaque fois qu'ils ont fait un bond en avant, mais ils étaient de plus en plus fatigués et lents tandis que leurs adversaires continuaient à avancer à grands pas vers la ligne d'arrivée.

Après que Neil Armstrong eut posé le pied sur la Lune en direct à la télévision, les Américains prirent les devants et y restèrent, tandis que le programme soviétique s’essoufflait. Ce n’est pas différent des entreprises d’aujourd’hui qui se concentrent sur la prochaine grande nouveauté, la prochaine grande rentabilité ou la prochaine grande technologie, sans parvenir à développer les bonnes habitudes et stratégies à long terme.

Être le premier sur le marché ne signifie pas que vous deviendrez l’acteur dominant sur ce marché. Par ailleurs, prendre le temps de faire les choses correctement ne garantit pas le succès, mais augmente certainement vos chances de réussite à long terme. Si vous êtes le responsable technique de votre entreprise, choisissez la bonne direction et les bons outils pour votre charge de travail. Ne laissez pas la popularité remplacer la performance et l’efficacité.

Ressources

Vous souhaitez télécharger un fichier 7z contenant les scripts Rust, ExpressJS, Flask, Starlette et Pure PHP ?

À propos de l'auteur

Jim programme depuis qu'il a reçu une IBM PS/2 dans les années 90. Aujourd'hui encore, il préfère écrire du HTML et du SQL à la main et se concentre sur l'efficacité et la précision dans son travail.