Ja, Virginia, er *is* een Sinterklaas Verschil tussen webframeworks in 2023

De reis van een trotse programmeur om snel presterende webservercode te vinden voordat hij bezwijkt onder de druk van de markt en technische schulden
2023-03-24 11:52:06
๐Ÿ‘๏ธ 800
๐Ÿ’ฌ 0

Inhoud

  1. Invoering
  2. De test
  3. PHP/Laravel
  4. Zuiver PHP
  5. Laravel opnieuw bekeken
  6. Django
  7. Fles
  8. Sterretje
  9. Node.js/ExpressJS
  10. Roest/Actix
  11. Technische schuld
  12. Bronnen

Invoering

Na een van mijn meest recente sollicitatiegesprekken was ik verrast toen ik erachter kwam dat het bedrijf waar ik op solliciteerde nog steeds Laravel gebruikte, een PHP-framework dat ik ongeveer tien jaar geleden heb geprobeerd. Het was behoorlijk voor die tijd, maar als er รฉรฉn constante is in technologie en mode, dan is het wel de voortdurende verandering en het opnieuw opduiken van stijlen en concepten. Als je een JavaScript-programmeur bent, ben je waarschijnlijk bekend met deze oude mop

Programmeur 1: "Ik vind dit nieuwe JavaScript-framework niet leuk!"

Programmeur 2: "Geen zorgen. Wacht gewoon zes maanden en er komt er nog een om hem te vervangen!"

Uit nieuwsgierigheid besloot ik om eens te kijken wat er precies gebeurt als we oud en nieuw op de proef stellen. Natuurlijk staat het web vol met benchmarks en claims, waarvan de populairste waarschijnlijk de TechEmpower Web Framework Benchmarks hier . We gaan vandaag echter niet iets doen dat ook maar in de buurt komt van wat zij doen. We houden het lekker simpel, zodat dit artikel niet verandert in Oorlog en vrede , en dat je een kleine kans hebt om wakker te blijven tegen de tijd dat je klaar bent met lezen. De gebruikelijke kanttekeningen zijn van toepassing: dit werkt mogelijk niet hetzelfde op jouw machine, verschillende softwareversies kunnen de prestaties beรฏnvloeden en Schrรถdingers kat werd in feite een zombiekat die half levend en half dood was op exact hetzelfde moment.

De test

Testomgeving

Voor deze test gebruik ik mijn laptop, uitgerust met een nietige i5 met Manjaro Linux, zoals hier afgebeeld.

โ•ฐโ”€โžค  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

De taak die voor ons ligt

Onze code bevat drie eenvoudige taken voor elk verzoek:

  1. Lees de sessie-ID van de huidige gebruiker uit een cookie
  2. Extra informatie laden vanuit een database
  3. Geef die informatie terug aan de gebruiker

Wat is dat voor een idiote test, vraagt u zich misschien af? Nou, als u kijkt naar de netwerkverzoeken voor deze pagina, ziet u er een genaamd sessionvars.js die precies hetzelfde doet.

De inhoud van sessionvars.js

Moderne webpagina's zijn complexe wezens en een van de meest voorkomende taken is het cachen van complexe pagina's om overmatige belasting van de databaseserver te voorkomen.

Als we een complexe pagina elke keer opnieuw renderen als een gebruiker deze opvraagt, kunnen we slechts ongeveer 600 gebruikers per seconde bedienen.

โ•ฐโ”€โžค  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

Maar als we deze pagina cachen als een statisch HTML-bestand en Nginx het snel naar de gebruiker laten sturen, dan kunnen we 32.000 gebruikers per seconde bedienen, wat de prestaties met een factor 50 verhoogt.

โ•ฐโ”€โžค  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

De statische index.en.html is het deel dat naar iedereen gaat, en alleen de delen die per gebruiker verschillen worden verzonden in sessionvars.js. Dit vermindert niet alleen de databasebelasting en creรซert een betere ervaring voor onze gebruikers, maar vermindert ook de kwantumwaarschijnlijkheden dat onze server spontaan zal verdampen in een warp core breach wanneer de Klingons aanvallen.

Codevereisten

De geretourneerde code voor elk framework heeft รฉรฉn simpele vereiste: laat de gebruiker zien hoe vaak ze de pagina hebben vernieuwd door te zeggen "Aantal is x". Om het simpel te houden, blijven we voorlopig weg van Redis-wachtrijen, Kubernetes-componenten of AWS Lambdas.

Weergeven hoe vaak u de pagina hebt bezocht

De sessiegegevens van elke gebruiker worden opgeslagen in een PostgreSQL-database.

De gebruikerssessies tabel

Deze databasetabel wordt voor elke test afgekapt.

De tabel nadat deze is afgebroken

Simpel maar effectief is het motto van Pafera... in ieder geval buiten de donkerste tijdlijnen...

De werkelijke testresultaten

PHP/Laravel

Okรฉ, nu kunnen we eindelijk onze handen vuil maken. We slaan de setup voor Laravel over, want het is gewoon een stel composer- en artisan-commando's.

Eerst zullen we onze database-instellingen in het .env-bestand instellen

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

Vervolgens stellen we รฉรฉn enkele fallback-route in die elk verzoek naar onze controller stuurt.

Route::fallback(SessionController::class);

En stel de controller in om het aantal weer te geven. Laravel slaat standaard sessies op in de database. Het biedt ook de session() functie om te communiceren met onze sessiegegevens, dus het kostte slechts een paar regels code om onze pagina te renderen.

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

    $count  += 1;

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

    return 'Count is ' . $count;
  }
}

Nadat we php-fpm en Nginx hadden ingesteld, zag onze pagina er behoorlijk goed uit...

โ•ฐโ”€โžค  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

In ieder geval totdat we de testresultaten daadwerkelijk zien...

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

Nee, dat is geen typefout. Onze testmachine is van 600 verzoeken per seconde gegaan om een complexe pagina te renderen... naar 21 verzoeken per seconde om "Count is 1" te renderen.

Dus wat ging er mis? Is er iets mis met onze PHP-installatie? Wordt Nginx op de een of andere manier langzamer bij het communiceren met php-fpm?

Zuiver PHP

Laten we deze pagina opnieuw maken in pure PHP-code.

<?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'];

We hebben nu 98 regels code gebruikt om te doen wat vier regels code (en een heleboel configuratiewerk) in Laravel deden. (Als we de foutverwerking en de berichten voor de gebruiker goed zouden regelen, zou dit natuurlijk ongeveer twee keer zoveel regels zijn.) Misschien kunnen we 30 verzoeken per seconde halen?

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

Whoa! Het lijkt erop dat er toch niks mis is met onze PHP-installatie. De pure PHP-versie doet 700 verzoeken per seconde.

Als er niets mis is met PHP, hebben we Laravel misschien verkeerd geconfigureerd?

Laravel opnieuw bekeken

Na het web te hebben doorzocht naar configuratieproblemen en prestatietips, waren twee van de populairste technieken om de configuratie te cachen en gegevens te routeren om te voorkomen dat ze voor elke aanvraag werden verwerkt. Daarom zullen we hun advies ter harte nemen en deze tips uitproberen.

โ•ฐโ”€โžค  php artisan config:cache

   INFO  Configuration cached successfully.  

โ•ฐโ”€โžค  php artisan route:cache

   INFO  Routes cached successfully.  

Alles ziet er goed uit op de opdrachtregel. Laten we de benchmark opnieuw doen.

โ•ฐโ”€โžค  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

Nou, we hebben de performance nu verhoogd van 21,04 naar 28,80 verzoeken per seconde, een dramatische stijging van bijna 37%! Dit zou behoorlijk indrukwekkend zijn voor elk softwarepakket... ware het niet dat we nog steeds maar 1/24e van het aantal verzoeken van de pure PHP-versie doen.

Als u denkt dat er iets mis moet zijn met deze test, moet u met de auteur van het Lucinda PHP-framework praten. In zijn testresultaten heeft hij Lucinda verslaat Laravel met 36x voor HTML-verzoeken en 90x voor JSON-verzoeken.

Na het testen op mijn eigen machine met zowel Apache als Nginx, heb ik geen reden om aan hem te twijfelen. Laravel is echt gewoon Dat langzaam! PHP op zichzelf is niet zo slecht, maar als je alle extra verwerking toevoegt die Laravel aan elke aanvraag toevoegt, dan vind ik het erg moeilijk om Laravel aan te bevelen als een keuze in 2023.

Django

PHP/Wordpress-accounts voor ongeveer 40% van alle websites op het web , waardoor het veruit het meest dominante raamwerk is. Persoonlijk vind ik echter dat populariteit niet per se in kwaliteit resulteert, net zo min als ik een plotselinge oncontroleerbare behoefte voel aan dat buitengewone gastronomische eten van het populairste restaurant ter wereld ... McDonald&#x27;s. Omdat we al pure PHP-code hebben getest, gaan we Wordpress zelf niet testen, aangezien alles wat met Wordpress te maken heeft ongetwijfeld lager zou zijn dan de 700 verzoeken per seconde die we met pure PHP hebben waargenomen.

Django is een ander populair framework dat al heel lang bestaat. Als je het in het verleden hebt gebruikt, denk je waarschijnlijk met plezier terug aan de spectaculaire databasebeheerinterface en hoe vervelend het was om alles precies zo te configureren als je wilde. Laten we eens kijken hoe goed Django in 2023 werkt, vooral met de nieuwe ASGI-interface die het heeft toegevoegd in versie 4.0.

Het instellen van Django lijkt opvallend veel op het instellen van Laravel, aangezien ze allebei uit de tijd kwamen waarin MVC-architecturen stijlvol en correct waren. We slaan de saaie configuratie over en gaan direct naar het instellen van de weergave.

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

Vier regels code is hetzelfde als bij de Laravel-versie. Laten we eens kijken hoe het presteert.

โ•ฐโ”€โžค  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

Helemaal niet slecht met 355 verzoeken per seconde. Het is maar de helft van de prestaties van de pure PHP-versie, maar het is ook 12x zo goed als die van de Laravel-versie. Django vs. Laravel lijkt helemaal geen wedstrijd te zijn.

Fles

Naast de grotere alles-inclusief-de-keuken-gootsteen-frameworks, zijn er ook kleinere frameworks die alleen wat basisinstellingen doen en jou de rest laten regelen. Een van de beste om te gebruiken is Flask en zijn ASGI-tegenhanger Quart. Mijn eigen PaferaPy-framework is gebouwd op Flask, dus ik weet heel goed hoe gemakkelijk het is om dingen gedaan te krijgen en tegelijkertijd de prestaties te behouden.

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

Zoals u kunt zien, is het Flask-script korter dan het pure PHP-script. Ik vind dat van alle talen die ik heb gebruikt, Python waarschijnlijk de meest expressieve taal is in termen van getypte toetsaanslagen. Het ontbreken van accolades en haakjes, lijst- en dictbegrip en blokkering op basis van inspringing in plaats van puntkomma's maken Python vrij eenvoudig, maar krachtig in zijn mogelijkheden.

Helaas is Python ook de langzaamste algemene taal die er is, ondanks hoeveel software erin is geschreven. Het aantal beschikbare Python-bibliotheken is ongeveer vier keer groter dan vergelijkbare talen en bestrijkt een groot aantal domeinen, maar niemand zou zeggen dat Python snel of performant is buiten niches als NumPy.

Laten we eens kijken hoe onze Flask-versie zich verhoudt tot onze vorige frameworks.

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

Ons Flask-script is zelfs sneller dan onze pure PHP-versie!

Als je hierdoor verrast bent, moet je je realiseren dat onze Flask-app al zijn initialisatie en configuratie uitvoert wanneer we de gunicorn-server opstarten, terwijl PHP het script opnieuw uitvoert telkens wanneer er een nieuw verzoek binnenkomt. Het is vergelijkbaar met Flask als de jonge, enthousiaste taxichauffeur die de auto al heeft gestart en langs de weg staat te wachten, terwijl PHP de oude chauffeur is die thuis blijft wachten op een telefoontje en pas dan naar je toe rijdt om je op te halen. Als old school guy en afkomstig uit de tijd dat PHP een geweldige verandering was voor gewone HTML- en SHTML-bestanden, is het een beetje triest om te beseffen hoeveel tijd er is verstreken, maar de ontwerpverschillen maken het echt moeilijk voor PHP om te concurreren met Python-, Java- en Node.js-servers die gewoon in het geheugen blijven en verzoeken afhandelen met het gemak van een jongleur.

Sterretje

Flask is misschien wel ons snelste framework tot nu toe, maar het is eigenlijk behoorlijk oude software. De Python-community is een paar jaar geleden overgestapt op de nieuwere asychrone ASGI-servers, en ik ben natuurlijk ook met ze meegegaan.

De nieuwste versie van het Pafera Framework, PaferaPyAsync , is gebaseerd op Starlette. Hoewel er een ASGI-versie van Flask is genaamd Quart, waren de prestatieverschillen tussen Quart en Starlette genoeg voor mij om mijn code opnieuw op Starlette te baseren.

Asychroon programmeren kan voor veel mensen beangstigend zijn, maar het is eigenlijk geen moeilijk concept, dankzij de Node.js-mensen die het concept al ruim tien jaar geleden populair hebben gemaakt.

Vroeger vochten we tegen gelijktijdigheid met multithreading, multiprocessing, distributed computing, promise chaining en al die leuke tijden die veel ervaren programmeurs voortijdig verouderden en uitdroogden. Nu typen we gewoon async voor onze functies en await voor elke code die een tijdje kan duren om uit te voeren. Het is inderdaad meer omslachtig dan normale code, maar veel minder vervelend om te gebruiken dan te moeten omgaan met synchronisatieprimitieven, berichtpassing en het oplossen van beloftes.

Ons Starlette-bestand ziet er als volgt uit:

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

Zoals u kunt zien, is het vrijwel gekopieerd en geplakt uit ons Flask-script met slechts een paar routeringswijzigingen en de async/await Trefwoorden.

Hoeveel verbetering kan het kopiรซren en plakken van code ons nu werkelijk opleveren?

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

We hebben een nieuwe kampioen, dames en heren! Onze vorige high was onze pure PHP-versie met 704 verzoeken per seconde, die vervolgens werd ingehaald door onze Flask-versie met 1080 verzoeken per seconde. Ons Starlette-script verplettert alle eerdere concurrenten met 4562 verzoeken per seconde, wat een verbetering van 6x betekent ten opzichte van pure PHP en een verbetering van 4x ten opzichte van Flask.

Als u uw WSGI Python-code nog niet naar ASGI hebt omgezet, is dit misschien een goed moment om daarmee te beginnen.

Node.js/ExpressJS

Tot nu toe hebben we alleen PHP- en Python-frameworks behandeld. Een groot deel van de wereld gebruikt echter Java, DotNet, Node.js, Ruby on Rails en andere dergelijke technologieรซn voor hun websites. Dit is absoluut geen uitgebreid overzicht van alle ecosystemen en biomen ter wereld, dus om te voorkomen dat we het programmeerequivalent van organische chemie gebruiken, kiezen we alleen de frameworks waarvoor het het gemakkelijkst is om code te typen... waarvan Java er zeker geen is.

Tenzij je je onder je exemplaar van K&R C of Knuth&#x27;s hebt verstopt De kunst van computerprogrammering de afgelopen vijftien jaar heb je waarschijnlijk gehoord van Node.js. Degenen onder ons die er al sinds het begin van JavaScript bij zijn, zijn ofwel ongelooflijk bang, verbaasd, of allebei over de staat van modern JavaScript, maar het valt niet te ontkennen dat JavaScript een kracht is geworden om rekening mee te houden op servers en browsers. We hebben nu zelfs native 64-bits integers in de taal! Dat is veel beter dan dat alles in 64-bits floats wordt opgeslagen!

ExpressJS is waarschijnlijk de makkelijkste Node.js-server om te gebruiken, dus we maken een snelle en eenvoudige Node.js/ExpressJS-app om onze teller te bedienen.

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

Deze code was eigenlijk makkelijker te schrijven dan de Python-versies, hoewel native JavaScript nogal onhandig wordt naarmate de applicaties groter worden. Pogingen om dit te corrigeren, zoals TypeScript, worden bovendien al snel omslachtiger dan Python.

Laten we eens kijken hoe dit presteert!

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

Je hebt misschien oude (oud volgens internetnormen in ieder geval...) volksverhalen gehoord over de snelheid van Node.js, en die verhalen zijn grotendeels waar dankzij het spectaculaire werk dat Google heeft verricht met de V8 JavaScript-engine. In dit geval presteert onze snelle app beter dan het Flask-script, maar de single-threaded aard ervan wordt verslagen door de vier async-processen die worden gehanteerd door de Starlette Knight die &quot;Ni!&quot; zegt.

Laten we nog wat hulp krijgen!

โ•ฐโ”€โžค  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รฉ! Nu is het een gelijkspel van vier tegen vier! Laten we de benchmark doen!

โ•ฐโ”€โžค  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

Nog steeds niet helemaal op het niveau van Starlette, maar het is niet slecht voor een snelle JavaScript-hack van vijf minuten. Uit mijn eigen tests blijkt dat dit script eigenlijk een beetje wordt tegengehouden op het niveau van de database-interface, omdat node-postgres lang niet zo efficiรซnt is als psycopg voor Python. Overschakelen naar sqlite als databasedriver levert meer dan 3000 verzoeken per seconde op voor dezelfde ExpressJS-code.

Het belangrijkste om op te merken is dat ondanks de lage uitvoeringssnelheid van Python, ASGI-frameworks voor bepaalde workloads daadwerkelijk kunnen concurreren met Node.js-oplossingen.

Roest/Actix

We komen nu dichter bij de top van de berg. En met berg bedoel ik de hoogste scores die ooit door muizen en mensen zijn gemeten.

Als je kijkt naar de meeste framework benchmarks die beschikbaar zijn op het web, zul je merken dat er twee talen zijn die de boventoon voeren: C++ en Rust. Ik werk al met C++ sinds de jaren 90 en ik had zelfs mijn eigen Win32 C++ framework voordat MFC/ATL een ding was, dus ik heb veel ervaring met de taal. Het is niet zo leuk om met iets te werken als je het al kent, dus we gaan in plaats daarvan een Rust-versie maken. ;)

Rust is relatief nieuw als het om programmeertalen gaat, maar het werd een object van nieuwsgierigheid voor mij toen Linus Torvalds aankondigde dat hij Rust zou accepteren als een Linux kernel programmeertaal. Voor ons oudere programmeurs is dat ongeveer hetzelfde als zeggen dat dit nieuwe hippie dingetje een nieuw amendement op de Amerikaanse grondwet zou worden.

Als je een ervaren programmeur bent, spring je niet zo snel op de kar als jongere mensen, anders loop je het risico dat je je verbrandt aan de snelle veranderingen in de taal of bibliotheken. (Iedereen die de eerste versie van AngularJS heeft gebruikt, weet waar ik het over heb.) Rust bevindt zich nog steeds in die experimentele ontwikkelingsfase en ik vind het grappig dat zoveel codevoorbeelden op het web niet eens meer compileren met huidige versies van pakketten.

De prestaties die Rust-applicaties laten zien, kunnen echter niet worden ontkend. Als u het nog nooit hebt geprobeerd ripgrep of fd-vinden op grote broncodebomen, moet je ze zeker eens proberen. Ze zijn zelfs beschikbaar voor de meeste Linux-distributies, gewoon via de pakketbeheerder. Je ruilt breedsprakigheid in voor prestaties met Rust... een kavel van woordrijkheid voor een kavel van prestatie.

De volledige code voor Rust is wat groot, dus we zullen hier alleen de relevante handlers bekijken:

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

Dit is veel ingewikkelder dan de Python/Node.js versies...

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

En veel performanter!

Onze Rust-server met Actix/deadpool_postgres verslaat onze vorige kampioen Starlette met gemak met +125%, ExpressJS met +362% en pure PHP met +1366%. (Ik laat het prestatieverschil bij de Laravel-versie als oefening voor de lezer.)

Ik heb ontdekt dat het leren van de Rust-taal zelf moeilijker is dan andere talen, omdat het veel meer addertjes onder het gras heeft dan alles wat ik buiten 6502 Assembly heb gezien, maar als je Rust-server 14x zoveel gebruikers aankan als je PHP-server, dan is er misschien toch iets te winnen met het wisselen van technologieรซn. Daarom zal de volgende versie van het Pafera Framework op Rust gebaseerd zijn. De leercurve is veel hoger dan bij scripttalen, maar de prestaties zullen het waard zijn. Als je geen tijd kunt steken in het leren van Rust, dan is het baseren van je tech stack op Starlette of Node.js ook geen slechte beslissing.

Technische schuld

In de afgelopen twintig jaar zijn we gegaan van goedkope statische hostingsites naar gedeelde hosting met LAMP-stacks naar het verhuren van VPS'en aan AWS, Azure en andere cloudservices. Tegenwoordig zijn veel bedrijven tevreden met het maken van ontwerpbeslissingen op basis van wie ze kunnen vinden die beschikbaar of het goedkoopst is, aangezien de komst van handige cloudservices het gemakkelijk heeft gemaakt om meer hardware op langzame servers en applicaties te gooien. Dit heeft hen grote kortetermijnwinsten opgeleverd ten koste van technische schulden op de lange termijn.

Waarschuwing van de Surgeon General van Californiรซ: dit is geen echte ruimtehond.

70 jaar geleden was er een grote ruimterace tussen de Sovjet-Unie en de Verenigde Staten. De Sovjets wonnen de meeste vroege mijlpalen. Ze hadden de eerste satelliet in Spoetnik, de eerste hond in de ruimte in Laika, het eerste maanruimteschip in Luna 2, de eerste man en vrouw in de ruimte in Yuri Gagarin en Valentina Tereshkova, enzovoort...

Maar ze bouwden langzaam maar zeker een technische schuld op.

Hoewel de Sovjets de eersten waren in elk van deze prestaties, zorgden hun engineeringprocessen en doelen ervoor dat ze zich meer richtten op uitdagingen op de korte termijn dan op haalbaarheid op de lange termijn. Ze wonnen elke keer dat ze sprongen, maar ze werden steeds vermoeider en langzamer, terwijl hun tegenstanders consistente stappen richting de finishlijn bleven zetten.

Toen Neil Armstrong zijn historische stappen op de maan live op televisie zette, namen de Amerikanen het voortouw en bleven ze daar toen het Sovjetprogramma wankelde. Dit is niet anders dan bedrijven vandaag de dag die zich hebben gericht op het volgende grote ding, de volgende grote uitbetaling of de volgende grote technologie, maar er niet in slagen om de juiste gewoontes en strategieรซn voor de lange termijn te ontwikkelen.

Als eerste op de markt zijn betekent niet dat u de dominante speler in die markt wordt. De tijd nemen om dingen goed te doen garandeert daarentegen geen succes, maar vergroot zeker uw kansen op prestaties op de lange termijn. Als u de tech lead bent voor uw bedrijf, kies dan de juiste richting en tools voor uw werklast. Laat populariteit niet de plaats innemen van prestaties en efficiรซntie.

Bronnen

Wilt u een 7z-bestand downloaden met de scripts Rust, ExpressJS, Flask, Starlette en Pure PHP?

Over de auteur

Jim programmeert al sinds hij in de jaren 90 een IBM PS/2 terugkreeg. Tot op de dag van vandaag schrijft hij HTML en SQL nog steeds het liefst met de hand, en richt hij zich op efficiรซntie en correctheid in zijn werk.