Sì, Virginia, c'è un Babbo Natale Differenza tra i framework Web nel 2023

Il viaggio di un programmatore ribelle alla ricerca di un codice per server web ad alte prestazioni prima di soccombere alla pressione del mercato e al debito tecnico
2023-03-24 11:52:06
👁️ 762
💬 0

Contenuto

  1. Introduzione
  2. Il test
  3. PHP/Laravel
  4. PHP puro
  5. Rivedere Laravel
  6. Django
  7. Pallone
  8. Stellina
  9. Node.js/ExpressJS
  10. Ruggine/Actix
  11. Debito tecnico
  12. Risorse

Introduzione

Dopo uno dei miei ultimi colloqui di lavoro, sono rimasto sorpreso nello scoprire che l'azienda per cui avevo fatto domanda stava ancora utilizzando Laravel, un framework PHP che avevo provato circa un decennio fa. Era decente per l'epoca, ma se c'è una costante nella tecnologia e nella moda, è il continuo cambiamento e la ricomparsa di stili e concetti. Se sei un programmatore JavaScript, probabilmente conosci questa vecchia barzelletta

Programmatore 1: "Non mi piace questo nuovo framework JavaScript!"

Programmatore 2: "Non preoccuparti. Aspetta solo sei mesi e ce ne sarà un altro a sostituirlo!"

Per curiosità, ho deciso di vedere esattamente cosa succede quando mettiamo alla prova il vecchio e il nuovo. Naturalmente, il web è pieno di benchmark e affermazioni, di cui il più popolare è probabilmente il Benchmark del framework Web TechEmpower qui . Tuttavia, oggi non faremo nulla di così complicato. Manterremo le cose semplici e carine, in modo che questo articolo non si trasformi in Guerra e pace , e che avrai una piccola possibilità di rimanere sveglio quando avrai finito di leggere. Si applicano le solite avvertenze: questo potrebbe non funzionare allo stesso modo sul tuo computer, diverse versioni del software possono influenzare le prestazioni e il gatto di Schrödinger è diventato in realtà un gatto zombie che era mezzo vivo e mezzo morto esattamente nello stesso momento.

Il test

Ambiente di test

Per questo test userò il mio portatile dotato di un piccolo i5 con Manjaro Linux come mostrato qui.

╰─➤  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
Shell

Il compito da svolgere

Il nostro codice avrà tre semplici compiti per ogni richiesta:

  1. Leggi l'ID della sessione dell'utente corrente da un cookie
  2. Carica informazioni aggiuntive da un database
  3. Restituisci tali informazioni all'utente

Che tipo di test idiota è questo, potresti chiedere? Bene, se guardi le richieste di rete per questa pagina, ne noterai una chiamata sessionvars.js che fa esattamente la stessa cosa.

Il contenuto di sessionvars.js

Vedete, le pagine web moderne sono creature complesse e una delle attività più comuni è la memorizzazione nella cache delle pagine complesse per evitare un carico eccessivo sul server del database.

Se riproduciamo nuovamente una pagina complessa ogni volta che un utente la richiede, potremo servire solo circa 600 utenti al secondo.

╰─➤  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
Shell

Ma se memorizziamo nella cache questa pagina come file HTML statico e lasciamo che Nginx la trasmetta rapidamente all'utente, allora potremo servire 32.000 utenti al secondo, aumentando le prestazioni di un fattore 50 volte.

╰─➤  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
Shell

L'indice statico.en.html è la parte che va a tutti, e solo le parti che differiscono per utente vengono inviate in sessionvars.js. Questo non solo riduce il carico del database e crea un'esperienza migliore per i nostri utenti, ma diminuisce anche le probabilità quantistiche che il nostro server si vaporizzi spontaneamente in una breccia del nucleo di curvatura quando i Klingon attaccano.

Requisiti del codice

Il codice restituito per ogni framework avrà un semplice requisito: mostrare all'utente quante volte ha aggiornato la pagina dicendo "Il conteggio è x". Per semplificare le cose, per ora eviteremo le code Redis, i componenti Kubernetes o le AWS Lambda.

Mostra quante volte hai visitato la pagina

I dati della sessione di ciascun utente verranno salvati in un database PostgreSQL.

La tabella usersessions

Questa tabella del database verrà troncata prima di ogni test.

La tabella dopo essere stata troncata

Semplice ma efficace è il motto di Pafera... al di fuori dei momenti più bui, comunque...

I risultati effettivi del test

PHP/Laravel

Ok, ora possiamo finalmente iniziare a sporcarci le mani. Salteremo la configurazione per Laravel, dato che si tratta solo di un mucchio di comandi composer e artisan.

Per prima cosa, configureremo le impostazioni del nostro database nel file .env

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

Quindi imposteremo un singolo percorso di fallback che invia ogni richiesta al nostro controller.

Route::fallback(SessionController::class);
PHP

E imposta il controller per visualizzare il conteggio. Laravel, di default, memorizza le sessioni nel database. Fornisce anche il session() funzione per interfacciarsi con i dati della nostra sessione, quindi sono bastate un paio di righe di codice per visualizzare la nostra pagina.

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

    $count  += 1;

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

    return 'Count is ' . $count;
  }
}
PHP

Dopo aver configurato php-fpm e Nginx, la nostra pagina appare piuttosto buona...

╰─➤  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
Shell

Almeno finché non vedremo effettivamente i risultati del test...

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
Shell

No, non è un errore di battitura. La nostra macchina di prova è passata da 600 richieste al secondo per il rendering di una pagina complessa... a 21 richieste al secondo per il rendering di "Il conteggio è 1".

Quindi cosa è andato storto? C'è qualcosa che non va nella nostra installazione PHP? Nginx rallenta in qualche modo quando si interfaccia con php-fpm?

PHP puro

Rifacciamo questa pagina in puro codice 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'];

PHP

Abbiamo ora utilizzato 98 righe di codice per fare ciò che quattro righe di codice (e un bel po' di lavoro di configurazione) hanno fatto in Laravel. (Naturalmente, se avessimo gestito correttamente gli errori e inviato messaggi all'utente, questo sarebbe stato circa il doppio delle righe.) Forse possiamo arrivare a 30 richieste al secondo?

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
Shell

Wow! Sembra che non ci sia niente di sbagliato nella nostra installazione PHP, dopotutto. La versione pura di PHP sta facendo 700 richieste al secondo.

Se non c'è niente di sbagliato in PHP, forse abbiamo configurato male Laravel?

Rivedere Laravel

Dopo aver scandagliato il web alla ricerca di problemi di configurazione e suggerimenti sulle prestazioni, due delle tecniche più diffuse sono state quella di mettere in cache i dati di configurazione e di instradamento per evitare di elaborarli per ogni richiesta. Pertanto, seguiremo i loro consigli e proveremo questi suggerimenti.

╰─➤  php artisan config:cache

   INFO  Configuration cached successfully.  

╰─➤  php artisan route:cache

   INFO  Routes cached successfully.  
Shell

Tutto sembra a posto sulla riga di comando. Rifacciamo il 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
Shell

Bene, ora abbiamo aumentato le prestazioni da 21,04 a 28,80 richieste al secondo, un aumento notevole di quasi il 37%! Questo sarebbe piuttosto impressionante per qualsiasi pacchetto software... se non fosse per il fatto che stiamo ancora eseguendo solo 1/24 del numero di richieste della versione PHP pura.

Se pensi che ci sia qualcosa che non va in questo test, dovresti parlare con l'autore del framework PHP Lucinda. Nei risultati del suo test, ha Lucinda batte Laravel di 36x per le richieste HTML e di 90x per le richieste JSON.

Dopo aver testato sulla mia macchina sia Apache che Nginx, non ho motivo di dubitare di lui. Laravel è davvero solo Quello lento! PHP di per sé non è poi così male, ma una volta che aggiungi tutta l'elaborazione extra che Laravel aggiunge a ogni richiesta, allora trovo molto difficile consigliare Laravel come scelta nel 2023.

Django

PHP/Wordpress rappresenta circa il 40% di tutti i siti web sul web , rendendolo di gran lunga il framework più dominante. Personalmente, però, trovo che la popolarità non si traduca necessariamente in qualità, così come non mi ritrovo ad avere un'improvvisa e incontrollabile voglia di quel cibo gourmet straordinario da il ristorante più popolare al mondo ... McDonald's. Dal momento che abbiamo già testato il codice PHP puro, non testeremo Wordpress stesso, poiché qualsiasi cosa che coinvolga Wordpress sarebbe senza dubbio inferiore alle 700 richieste al secondo che abbiamo osservato con PHP puro.

Django è un altro framework popolare che esiste da molto tempo. Se lo hai usato in passato, probabilmente ricorderai con affetto la sua spettacolare interfaccia di amministrazione del database insieme a quanto fosse fastidioso configurare tutto esattamente come volevi. Vediamo quanto funziona bene Django nel 2023, specialmente con la nuova interfaccia ASGI che ha aggiunto a partire dalla versione 4.0.

L'impostazione di Django è notevolmente simile all'impostazione di Laravel, poiché entrambi risalgono all'epoca in cui le architetture MVC erano eleganti e corrette. Salteremo la noiosa configurazione e andremo direttamente all'impostazione della vista.

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}")
Python

Quattro righe di codice sono le stesse della versione Laravel. Vediamo come funziona.

╰─➤  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
Shell

Niente male, con 355 richieste al secondo. È solo la metà delle prestazioni della versione pura di PHP, ma è anche 12 volte quella della versione di Laravel. Django contro Laravel sembra non essere affatto una sfida.

Pallone

Oltre ai framework più grandi, tutto compreso, il lavello della cucina, ci sono anche framework più piccoli che eseguono solo un po' di configurazione di base lasciandoti gestire il resto. Uno dei migliori da usare è Flask e la sua controparte ASGI Quart. Il mio Framework PaferaPy è basato su Flask, quindi so bene quanto sia facile portare a termine le cose mantenendo le prestazioni elevate.

#!/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}'
Python

Come puoi vedere, lo script Flask è più breve dello script PHP puro. Trovo che tra tutti i linguaggi che ho usato, Python è probabilmente il linguaggio più espressivo in termini di tasti digitati. La mancanza di parentesi graffe e tonde, la comprensione di elenchi e dizionari e il blocco basato sull'indentazione anziché sui punti e virgola rendono Python piuttosto semplice ma potente nelle sue capacità.

Sfortunatamente, Python è anche il linguaggio di uso generale più lento in circolazione, nonostante la quantità di software che vi è stato scritto. Il numero di librerie Python disponibili è circa quattro volte superiore a quello di linguaggi simili e copre una vasta gamma di domini, eppure nessuno direbbe che Python è veloce o performante al di fuori di nicchie come NumPy.

Vediamo come la nostra versione Flask si confronta con i nostri framework precedenti.

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
Python

Il nostro script Flask è in realtà più veloce della nostra versione PHP pura!

Se questo ti sorprende, dovresti sapere che la nostra app Flask esegue tutta la sua inizializzazione e configurazione quando avviamo il server gunicorn, mentre PHP riesegue lo script ogni volta che arriva una nuova richiesta. È come se Flask fosse il giovane e impaziente tassista che ha già acceso la macchina e aspetta lungo la strada, mentre PHP è il vecchio autista che resta a casa sua in attesa di una chiamata e solo allora si dirige a prenderti. Essendo un ragazzo della vecchia scuola e provenendo dai giorni in cui PHP era un meraviglioso cambiamento rispetto ai semplici file HTML e SHTML, è un po' triste rendersi conto di quanto tempo è passato, ma le differenze di progettazione rendono davvero difficile per PHP competere con i server Python, Java e Node.js che rimangono semplicemente in memoria e gestiscono le richieste con la facilità agile di un giocoliere.

Stellina

Flask potrebbe essere il nostro framework più veloce finora, ma in realtà è un software piuttosto vecchio. La comunità Python è passata ai nuovi server ASGI asincroni un paio di anni fa e, naturalmente, anch'io ho fatto lo stesso.

La versione più recente del Framework Pafera, PaferaPyAsync , è basato su Starlette. Sebbene esista una versione ASGI di Flask chiamata Quart, le differenze di prestazioni tra Quart e Starlette sono state sufficienti per farmi ribasare il mio codice su Starlette.

La programmazione asincrona può spaventare molte persone, ma in realtà non è un concetto difficile da comprendere, grazie ai ragazzi di Node.js che lo hanno reso popolare più di un decennio fa.

Eravamo soliti combattere la concorrenza con multithreading, multiprocessing, calcolo distribuito, promise chaining e tutti quei momenti divertenti che hanno fatto invecchiare prematuramente e disseccato molti programmatori veterani. Ora, digitiamo semplicemente async di fronte alle nostre funzioni e await davanti a qualsiasi codice che potrebbe richiedere un po' di tempo per essere eseguito. È in effetti più prolisso del codice normale, ma molto meno fastidioso da usare rispetto al dover gestire primitive di sincronizzazione, passaggio di messaggi e risoluzione delle promesse.

Il nostro file Starlette si presenta così:

#!/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",
)
Python

Come puoi vedere, è praticamente copiato e incollato dal nostro script Flask con solo un paio di modifiche al routing e il async/await parole chiave.

Quanto miglioramento può realmente apportare il codice copiato e incollato?

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
Shell

Abbiamo un nuovo campione, signore e signori! Il nostro precedente massimo era la versione PHP pura a 704 richieste al secondo, che è stata poi superata dalla versione Flask a 1080 richieste al secondo. Il nostro script Starlette schiaccia tutti i precedenti contendenti a 4562 richieste al secondo, il che significa un miglioramento di 6 volte rispetto al PHP puro e un miglioramento di 4 volte rispetto a Flask.

Se non hai ancora convertito il tuo codice Python WSGI in ASGI, questo potrebbe essere il momento giusto per iniziare.

Node.js/ExpressJS

Finora abbiamo trattato solo i framework PHP e Python. Tuttavia, una larga parte del mondo usa effettivamente Java, DotNet, Node.js, Ruby on Rails e altre tecnologie simili per i propri siti web. Questa non è affatto una panoramica completa di tutti gli ecosistemi e biomi del mondo, quindi per evitare di fare l'equivalente di programmazione della chimica organica, sceglieremo solo i framework per cui è più facile scrivere codice... tra cui Java non è sicuramente.

A meno che tu non ti sia nascosto sotto la tua copia di K&R C o di Knuth L'arte della programmazione informatica negli ultimi quindici anni, probabilmente hai sentito parlare di Node.js. Quelli di noi che sono in circolazione dall'inizio di JavaScript sono incredibilmente spaventati, stupiti o entrambi per lo stato del JavaScript moderno, ma non si può negare che JavaScript è diventato una forza da non sottovalutare sui server e sui browser. Dopotutto, ora abbiamo persino interi nativi a 64 bit nel linguaggio! È di gran lunga meglio di tutto ciò che viene memorizzato in float a 64 bit!

ExpressJS è probabilmente il server Node.js più semplice da usare, quindi realizzeremo una rapida e semplice applicazione Node.js/ExpressJS per servire il nostro contatore.

/**********************************************************************
 * 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}`));
JavaScript

In realtà questo codice era più facile da scrivere rispetto alle versioni Python, anche se JavaScript nativo diventa piuttosto macchinoso quando le applicazioni diventano più grandi e tutti i tentativi di correggere questo problema, come TypeScript, diventano rapidamente più prolissi di Python.

Vediamo come va!

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
Shell

Potresti aver sentito antiche (antiche per gli standard di Internet comunque...) storie popolari sulla velocità di Node.js&#x27;, e quelle storie sono per lo più vere grazie allo spettacolare lavoro che Google ha fatto con il motore JavaScript V8. In questo caso, però, sebbene la nostra app rapida superi lo script Flask, la sua natura single threaded è sconfitta dai quattro processi asincroni branditi dal Cavaliere Starlette che dice &quot;Ni!&quot;.

Cerchiamo altro aiuto!

╰─➤  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 │
└────┴──────────────┴─────────────┴─────────┴─────────┴──────────┴────────┴──────┴───────────┴──────────┴──────────┴──────────┴──────────┘
Shell

Okay! Ora è una battaglia pari quattro contro quattro! Facciamo il 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
Shell

Non è ancora al livello di Starlette, ma non è male per un rapido hack JavaScript di cinque minuti. Dai miei test, questo script è in realtà un po' trattenuto a livello di interfaccia del database perché node-postgres non è minimamente efficiente quanto psycopg per Python. Passare a sqlite come driver del database produce oltre 3000 richieste al secondo per lo stesso codice ExpressJS.

La cosa principale da notare è che, nonostante la lenta velocità di esecuzione di Python, i framework ASGI possono effettivamente essere competitivi con le soluzioni Node.js per determinati carichi di lavoro.

Ruggine/Actix

Quindi ora ci stiamo avvicinando alla cima della montagna, e per montagna intendo i punteggi di riferimento più alti registrati sia dai topi che dagli uomini.

Se si esaminano la maggior parte dei benchmark dei framework disponibili sul Web, si noterà che ci sono due linguaggi che tendono a dominare la vetta: C++ e Rust. Ho lavorato con C++ fin dagli anni '90 e avevo persino il mio framework Win32 C++ prima che MFC/ATL fosse una cosa, quindi ho molta esperienza con il linguaggio. Non è molto divertente lavorare con qualcosa che già si conosce, quindi faremo una versione Rust. ;)

Rust è relativamente nuovo per quanto riguarda i linguaggi di programmazione, ma è diventato oggetto di curiosità per me quando Linus Torvalds ha annunciato che avrebbe accettato Rust come linguaggio di programmazione del kernel Linux. Per noi programmatori più anziani, è più o meno come dire che questa nuova moda hippie new age sarebbe stata un nuovo emendamento alla Costituzione degli Stati Uniti.

Ora, quando sei un programmatore esperto, tendi a non saltare sul carrozzone tanto velocemente quanto i più giovani, altrimenti potresti essere scottato da rapidi cambiamenti al linguaggio o alle librerie. (Chiunque abbia usato la prima versione di AngularJS saprà di cosa sto parlando.) Rust è ancora in una fase di sviluppo sperimentale, e trovo divertente che così tanti esempi di codice sul web non vengano nemmeno più compilati con le versioni correnti dei pacchetti.

Tuttavia, le prestazioni mostrate dalle applicazioni Rust non possono essere negate. Se non hai mai provato Rip-grep O fd-trova su grandi alberi di codice sorgente, dovresti assolutamente dargli un'occhiata. Sono persino disponibili per la maggior parte delle distribuzioni Linux semplicemente dal gestore dei pacchetti. Stai scambiando la verbosità per le prestazioni con Rust... un quantità di verbosità per un quantità di prestazione.

Il codice completo per Rust è un po' lungo, quindi daremo un'occhiata solo ai gestori rilevanti qui:

// =====================================================================
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
  )))
}
Rust

Questa è molto più complicata delle versioni 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
Shell

E molto più performante!

Il nostro server Rust che utilizza Actix/deadpool_postgres batte ampiamente il nostro precedente campione Starlette del +125%, ExpressJS del +362% e PHP puro del +1366%. (Lascerò la differenza di prestazioni con la versione Laravel come esercizio per il lettore.)

Ho scoperto che imparare il linguaggio Rust in sé è stato più difficile di altri linguaggi, poiché ha molti più insidie di qualsiasi cosa abbia mai visto al di fuori di 6502 Assembly, ma se il tuo server Rust può gestire 14 volte il numero di utenti del tuo server PHP, allora forse c'è qualcosa da guadagnare cambiando tecnologia, dopotutto. Ecco perché la prossima versione del Framework Pafera sarà basata su Rust. La curva di apprendimento è molto più alta rispetto ai linguaggi di scripting, ma le prestazioni ne varranno la pena. Se non puoi dedicare del tempo all'apprendimento di Rust, allora basare il tuo stack tecnologico su Starlette o Node.js non è una cattiva decisione.

Debito tecnico

Negli ultimi vent'anni, siamo passati da siti di hosting statici economici a hosting condiviso con stack LAMP, all'affitto di VPS su AWS, Azure e altri servizi cloud. Oggigiorno, molte aziende si accontentano di prendere decisioni di progettazione in base a chiunque riescano a trovare disponibile o più economico, poiché l'avvento di servizi cloud convenienti ha reso facile buttare più hardware su server e applicazioni lenti. Ciò ha dato loro grandi guadagni a breve termine a scapito di un debito tecnico a lungo termine.

Avvertenza del chirurgo generale della California: questo non è un vero cane spaziale.

70 anni fa, ci fu una grande corsa allo spazio tra l'Unione Sovietica e gli Stati Uniti. I sovietici vinsero la maggior parte delle prime pietre miliari. Avevano il primo satellite nello Sputnik, il primo cane nello spazio in Laika, la prima navicella spaziale sulla luna in Luna 2, il primo uomo e la prima donna nello spazio in Yuri Gagarin e Valentina Tereshkova, e così via...

Ma stavano lentamente accumulando debiti tecnici.

Sebbene i sovietici fossero i primi a raggiungere ciascuno di questi traguardi, i loro processi e obiettivi ingegneristici li stavano portando a concentrarsi sulle sfide a breve termine piuttosto che sulla fattibilità a lungo termine. Vinsero ogni volta che saltarono, ma stavano diventando più stanchi e lenti mentre i loro avversari continuavano a fare passi costanti verso il traguardo.

Una volta che Neil Armstrong fece i suoi storici passi sulla luna in diretta televisiva, gli americani presero il comando e poi ci rimasero mentre il programma sovietico vacillava. Questo non è diverso dalle aziende odierne che si sono concentrate sulla prossima grande novità, sul prossimo grande guadagno o sulla prossima grande tecnologia, senza riuscire a sviluppare abitudini e strategie adeguate per il lungo periodo.

Essere i primi sul mercato non significa che diventerai il player dominante in quel mercato. In alternativa, prendersi il tempo per fare le cose per bene non garantisce il successo, ma aumenta sicuramente le tue possibilità di risultati a lungo termine. Se sei il responsabile tecnico della tua azienda, scegli la direzione e gli strumenti giusti per il tuo carico di lavoro. Non lasciare che la popolarità sostituisca le prestazioni e l'efficienza.

Risorse

Vuoi scaricare un file 7z contenente gli script Rust, ExpressJS, Flask, Starlette e Pure PHP?

Informazioni sull'autore

Jim programma da quando ha ricevuto un IBM PS/2 negli anni '90. Ancora oggi preferisce scrivere HTML e SQL a mano e si concentra sull'efficienza e la correttezza del suo lavoro.