Ja, Virginia, es *gibt* eine Weihnachtsmann Unterschied zwischen Web-Frameworks im Jahr 2023

Die Suche eines trotzigen Programmierers nach schnellem Webserver-Code, bevor er dem Marktdruck und der technischen Schuld erliegt
2023-03-24 11:52:06
👁️ 776
💬 0

Inhalt

  1. Einführung
  2. Der Test
  3. PHP/Laravel
  4. Reines PHP
  5. Laravel erneut besuchen
  6. Django
  7. Flasche
  8. Sternchen
  9. Node.js/ExpressJS
  10. Rost/Actix
  11. Technische Schulden
  12. Ressourcen

Einführung

Nach einem meiner letzten Vorstellungsgespräche war ich überrascht, dass das Unternehmen, bei dem ich mich beworben hatte, immer noch Laravel verwendete, ein PHP-Framework, das ich vor etwa einem Jahrzehnt ausprobiert hatte. Es war für die damalige Zeit anständig, aber wenn es eine Konstante in Technologie und Mode gibt, dann ist es die ständige Veränderung und Wiederauferstehung von Stilen und Konzepten. Wenn Sie JavaScript-Programmierer sind, kennen Sie wahrscheinlich diesen alten Witz

Programmierer 1: „Mir gefällt dieses neue JavaScript-Framework nicht!“

Programmierer 2: „Kein Grund zur Sorge. Warten Sie einfach sechs Monate und es wird ein neuer kommen, der es ersetzt!“

Aus Neugierde beschloss ich, genau zu sehen, was passiert, wenn wir Altes und Neues auf die Probe stellen. Natürlich ist das Internet voll von Benchmarks und Behauptungen, von denen die beliebteste wahrscheinlich die ist TechEmpower Web Framework Benchmarks hier . Wir werden heute allerdings nichts annähernd so Kompliziertes machen. Wir werden die Dinge schön und einfach halten, damit dieser Artikel nicht zu einem Krieg und Frieden , und dass Sie eine geringe Chance haben, wach zu bleiben, wenn Sie mit dem Lesen fertig sind. Es gelten die üblichen Vorbehalte: Dies funktioniert auf Ihrem Computer möglicherweise nicht auf die gleiche Weise, unterschiedliche Softwareversionen können die Leistung beeinträchtigen und Schrödingers Katze wurde tatsächlich zu einer Zombiekatze, die gleichzeitig halb lebendig und halb tot war.

Der Test

Testumgebung

Für diesen Test verwende ich meinen Laptop, der mit einem mickrigen i5 ausgestattet ist und auf dem Manjaro Linux läuft, wie hier gezeigt.

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

Die vorliegende Aufgabe

Unser Code umfasst für jede Anfrage drei einfache Aufgaben:

  1. Lesen Sie die Sitzungs-ID des aktuellen Benutzers aus einem Cookie
  2. Zusätzliche Informationen aus einer Datenbank laden
  3. Geben Sie diese Informationen an den Benutzer zurück

Was ist das für ein idiotischer Test, fragen Sie sich vielleicht? Wenn Sie sich die Netzwerkanforderungen für diese Seite ansehen, werden Sie feststellen, dass einer mit dem Namen sessionvars.js genau dasselbe tut.

Der Inhalt von sessionvars.js

Moderne Webseiten sind komplizierte Gebilde und eine der häufigsten Aufgaben besteht darin, komplexe Seiten zwischenzuspeichern, um eine übermäßige Belastung des Datenbankservers zu vermeiden.

Wenn wir eine komplexe Seite bei jeder Benutzeranforderung neu rendern, können wir nur etwa 600 Benutzer pro Sekunde 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

Wenn wir diese Seite jedoch als statische HTML-Datei zwischenspeichern und sie von Nginx schnell an den Benutzer ausgeben lassen, können wir 32.000 Benutzer pro Sekunde bedienen und so die Leistung um den Faktor 50 steigern.

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

Die statische Datei index.en.html ist der Teil, der an alle geht, und nur die Teile, die sich je nach Benutzer unterscheiden, werden in sessionvars.js gesendet. Dies reduziert nicht nur die Datenbanklast und sorgt für ein besseres Benutzererlebnis, sondern verringert auch die Quantenwahrscheinlichkeit, dass unser Server bei einem Angriff der Klingonen bei einem Warpkernbruch spontan verdampft.

Codeanforderungen

Der zurückgegebene Code für jedes Framework hat eine einfache Anforderung: Zeigen Sie dem Benutzer, wie oft er die Seite aktualisiert hat, indem Sie sagen: „Anzahl ist x“. Um die Dinge einfach zu halten, verzichten wir vorerst auf Redis-Warteschlangen, Kubernetes-Komponenten oder AWS-Lambdas.

Zeigt an, wie oft Sie die Seite besucht haben.

Die Sitzungsdaten jedes Benutzers werden in einer PostgreSQL-Datenbank gespeichert.

Die Usersessions-Tabelle

Und diese Datenbanktabelle wird vor jedem Test abgeschnitten.

Die Tabelle nach dem Abschneiden

Einfach, aber wirkungsvoll lautet das Motto von Pafera ... zumindest außerhalb der dunkelsten Zeitlinie ...

Die tatsächlichen Testergebnisse

PHP/Laravel

Okay, jetzt können wir endlich anfangen, uns die Hände schmutzig zu machen. Wir überspringen das Setup für Laravel, da es sich nur um eine Reihe von Composer- und Artisan-Befehlen handelt.

Zuerst richten wir unsere Datenbankeinstellungen in der .env-Datei ein

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

Dann legen wir eine einzelne Fallback-Route fest, die jede Anfrage an unseren Controller sendet.

Route::fallback(SessionController::class);

Und stellen Sie den Controller so ein, dass die Anzahl angezeigt wird. Laravel speichert Sitzungen standardmäßig in der Datenbank. Es bietet auch die session() Funktion zur Schnittstelle mit unseren Sitzungsdaten, sodass zum Rendern unserer Seite nur ein paar Codezeilen erforderlich waren.

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

    $count  += 1;

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

    return 'Count is ' . $count;
  }
}

Nachdem wir php-fpm und Nginx eingerichtet haben, sieht unsere Seite ziemlich gut aus ...

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

Zumindest bis wir die Testergebnisse tatsächlich sehen …

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

Nein, das ist kein Tippfehler. Unsere Testmaschine hat von 600 Anfragen pro Sekunde beim Rendern einer komplexen Seite auf 21 Anfragen pro Sekunde beim Rendern von „Anzahl ist 1“ gewechselt.

Was ist also schiefgelaufen? Stimmt etwas mit unserer PHP-Installation nicht? Wird Nginx bei der Interaktion mit php-fpm irgendwie langsamer?

Reines PHP

Lassen Sie uns diese Seite in reinem PHP-Code neu erstellen.

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

Wir haben jetzt 98 Codezeilen verwendet, um das zu tun, was in Laravel mit vier Codezeilen (und einer ganzen Menge Konfigurationsarbeit) möglich war. (Natürlich wäre dies etwa die doppelte Zeilenzahl, wenn wir eine ordnungsgemäße Fehlerbehandlung und benutzerorientierte Nachrichten hätten.) Vielleicht können wir es auf 30 Anfragen pro Sekunde schaffen?

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

Wow! Es sieht so aus, als ob mit unserer PHP-Installation alles in Ordnung ist. Die reine PHP-Version bearbeitet 700 Anfragen pro Sekunde.

Wenn mit PHP alles in Ordnung ist, haben wir Laravel vielleicht falsch konfiguriert?

Laravel erneut besuchen

Nachdem wir das Internet nach Konfigurationsproblemen und Leistungstipps durchforstet hatten, waren zwei der beliebtesten Techniken das Zwischenspeichern der Konfigurations- und Routendaten, um sie nicht bei jeder Anfrage verarbeiten zu müssen. Daher werden wir ihren Rat befolgen und diese Tipps ausprobieren.

╰─➤  php artisan config:cache

   INFO  Configuration cached successfully.  

╰─➤  php artisan route:cache

   INFO  Routes cached successfully.  

Auf der Befehlszeile sieht alles gut aus. Lassen Sie uns den Benchmark wiederholen.

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

Nun, wir haben die Leistung von 21,04 auf 28,80 Anfragen pro Sekunde gesteigert, eine dramatische Steigerung von fast 37 %! Das wäre für jedes Softwarepaket ziemlich beeindruckend ... abgesehen von der Tatsache, dass wir immer noch nur 1/24 der Anfragen der reinen PHP-Version bearbeiten.

Wenn Sie denken, dass bei diesem Test etwas nicht stimmt, sollten Sie mit dem Autor des Lucinda PHP-Frameworks sprechen. In seinen Testergebnissen hat er Lucinda schlägt Laravel um das 36-fache für HTML-Anfragen und das 90-fache für JSON-Anfragen.

Nachdem ich auf meiner eigenen Maschine sowohl Apache als auch Nginx getestet habe, habe ich keinen Grund, an ihm zu zweifeln. Laravel ist wirklich nur Das langsam! PHP an sich ist nicht so schlecht, aber wenn man die ganze zusätzliche Verarbeitung hinzurechnet, die Laravel jeder Anfrage hinzufügt, fällt es mir sehr schwer, Laravel im Jahr 2023 als Wahl zu empfehlen.

Django

PHP/Wordpress-Konten für etwa 40 % aller Websites im Internet , was es zum bei weitem dominantesten Rahmen macht. Persönlich finde ich jedoch, dass Popularität nicht unbedingt Qualität bedeutet, genauso wenig wie ich plötzlich ein unkontrollierbares Verlangen nach diesem außergewöhnlichen Gourmet-Essen aus das beliebteste Restaurant der Welt ... McDonald's. Da wir bereits reinen PHP-Code getestet haben, werden wir Wordpress selbst nicht testen, da alles, was Wordpress betrifft, zweifellos unter den 700 Anfragen pro Sekunde liegen würde, die wir mit reinem PHP beobachtet haben.

Django ist ein weiteres beliebtes Framework, das es schon seit langer Zeit gibt. Wenn Sie es in der Vergangenheit verwendet haben, erinnern Sie sich wahrscheinlich gerne an seine spektakuläre Datenbankverwaltungsoberfläche und daran, wie mühsam es war, alles genau so zu konfigurieren, wie Sie es wollten. Mal sehen, wie gut Django im Jahr 2023 funktioniert, insbesondere mit der neuen ASGI-Schnittstelle, die ab Version 4.0 hinzugefügt wurde.

Die Einrichtung von Django ist der von Laravel bemerkenswert ähnlich, da beide aus der Zeit stammen, in der MVC-Architekturen stilvoll und korrekt waren. Wir überspringen die langweilige Konfiguration und gehen direkt zur Einrichtung der Ansicht über.

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 Codezeilen sind dieselben wie bei der Laravel-Version. Sehen wir uns an, wie es funktioniert.

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

Gar nicht schlecht bei 355 Anfragen pro Sekunde. Das ist nur die Hälfte der Leistung der reinen PHP-Version, aber auch 12x so viel wie die der Laravel-Version. Django vs. Laravel scheint überhaupt kein Wettbewerb zu sein.

Flasche

Neben den größeren Frameworks, die alles inklusive der Küchenspüle abdecken, gibt es auch kleinere Frameworks, die nur einige grundlegende Einstellungen vornehmen und Sie den Rest erledigen lassen. Eines der am besten zu verwendenden Frameworks ist Flask und sein ASGI-Gegenstück Quart. Mein eigenes PaferaPy-Rahmenwerk basiert auf Flask, daher weiß ich genau, wie einfach es ist, Dinge zu erledigen und gleichzeitig die Leistung aufrechtzuerhalten.

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

Wie Sie sehen, ist das Flask-Skript kürzer als das reine PHP-Skript. Ich finde, dass Python von allen Sprachen, die ich verwendet habe, in Bezug auf die Anzahl der eingegebenen Tastenanschläge wahrscheinlich die ausdrucksstärkste Sprache ist. Das Fehlen von Klammern und Klammern, Listen- und Dict-Verständnisse und Blockierungen basierend auf Einrückungen statt Semikolons machen Python ziemlich einfach und dennoch leistungsstark in seinen Funktionen.

Leider ist Python auch die langsamste Allzwecksprache, egal wie viel Software darin geschrieben wurde. Die Anzahl der verfügbaren Python-Bibliotheken ist etwa viermal höher als bei ähnlichen Sprachen und deckt eine Vielzahl von Bereichen ab. Trotzdem würde niemand sagen, dass Python außerhalb von Nischen wie NumPy schnell oder leistungsfähig ist.

Sehen wir uns an, wie unsere Flask-Version im Vergleich zu unseren vorherigen Frameworks abschneidet.

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

Unser Flask-Skript ist tatsächlich schneller als unsere reine PHP-Version!

Wenn Sie das überrascht, sollten Sie sich bewusst machen, dass unsere Flask-App die gesamte Initialisierung und Konfiguration durchführt, wenn wir den Gunicorn-Server starten, während PHP das Skript jedes Mal erneut ausführt, wenn eine neue Anfrage eingeht. Das ist so, als wäre Flask der junge, eifrige Taxifahrer, der das Auto bereits gestartet hat und am Straßenrand wartet, während PHP der alte Fahrer ist, der zu Hause bleibt, auf einen Anruf wartet und erst dann herüberfährt, um Sie abzuholen. Als Typ der alten Schule und aus der Zeit, als PHP eine wunderbare Abwechslung zu einfachen HTML- und SHTML-Dateien war, ist es ein bisschen traurig zu sehen, wie viel Zeit vergangen ist, aber die Designunterschiede machen es PHP wirklich schwer, mit Python-, Java- und Node.js-Servern zu konkurrieren, die einfach im Speicher bleiben und Anfragen mit der flinken Leichtigkeit eines Jongleurs bearbeiten.

Sternchen

Flask ist vielleicht unser bisher schnellstes Framework, aber eigentlich ist es ziemlich alte Software. Die Python-Community ist vor ein paar Jahren auf die neueren asynchronen ASGI-Server umgestiegen, und natürlich bin ich selbst mit ihnen umgestiegen.

Die neueste Version des Pafera Frameworks, PaferaPyAsync , basiert auf Starlette. Obwohl es eine ASGI-Version von Flask namens Quart gibt, waren die Leistungsunterschiede zwischen Quart und Starlette für mich Grund genug, meinen Code stattdessen auf Starlette umzustellen.

Asynchrone Programmierung kann auf viele Leute Angst machen, aber eigentlich ist es kein schwieriges Konzept, da die Leute von Node.js es vor über einem Jahrzehnt populär gemacht haben.

Früher haben wir Parallelität mit Multithreading, Multiprocessing, verteiltem Rechnen, Promise Chaining und all diesen lustigen Zeiten bekämpft, die viele erfahrene Programmierer vorzeitig altern und austrocknen ließen. Jetzt tippen wir einfach async vor unseren Funktionen und await vor jedem Code, dessen Ausführung eine Weile dauern könnte. Es ist zwar ausführlicher als normaler Code, aber viel weniger lästig in der Anwendung, als sich mit Synchronisierungsprimitiven, Nachrichtenübermittlung und dem Einlösen von Promises herumschlagen zu müssen.

Unsere Starlette-Datei sieht folgendermaßen aus:

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

Wie Sie sehen, wurde es größtenteils aus unserem Flask-Skript kopiert und eingefügt, mit nur ein paar Routing-Änderungen und dem async/await Schlüsselwörter.

Wie viel Verbesserung können wir durch kopierten und eingefügten Code wirklich erreichen?

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

Wir haben einen neuen Champion, meine Damen und Herren! Unser bisheriger Höchstwert war unsere reine PHP-Version mit 704 Anfragen pro Sekunde, die dann von unserer Flask-Version mit 1080 Anfragen pro Sekunde überholt wurde. Unser Starlette-Skript schlägt alle bisherigen Konkurrenten mit 4562 Anfragen pro Sekunde, was eine 6-fache Verbesserung gegenüber reinem PHP und eine 4-fache Verbesserung gegenüber Flask bedeutet.

Wenn Sie Ihren WSGI-Python-Code noch nicht auf ASGI umgestellt haben, ist jetzt möglicherweise ein guter Zeitpunkt, damit zu beginnen.

Node.js/ExpressJS

Bisher haben wir nur PHP- und Python-Frameworks behandelt. Ein großer Teil der Welt verwendet jedoch tatsächlich Java, DotNet, Node.js, Ruby on Rails und andere derartige Technologien für seine Websites. Dies ist keineswegs ein umfassender Überblick über alle Ökosysteme und Biome der Welt. Um also nicht das Programmieräquivalent der organischen Chemie durchzuführen, wählen wir nur die Frameworks aus, für die sich Code am einfachsten eingeben lässt ... und dazu gehört Java definitiv nicht.

Sofern Sie sich nicht unter Ihrem Exemplar von K&R C oder Knuths Die Kunst der Computerprogrammierung in den letzten fünfzehn Jahren haben Sie wahrscheinlich von Node.js gehört. Diejenigen von uns, die seit den Anfängen von JavaScript dabei sind, sind entweder unglaublich verängstigt, erstaunt oder beides über den Zustand des modernen JavaScript, aber es lässt sich nicht leugnen, dass JavaScript sowohl auf Servern als auch in Browsern zu einer Macht geworden ist, mit der man rechnen muss. Immerhin haben wir jetzt sogar native 64-Bit-Ganzzahlen in der Sprache! Das ist bei weitem besser, als wenn alles in 64-Bit-Floats gespeichert wäre!

ExpressJS ist wahrscheinlich der am einfachsten zu verwendende Node.js-Server, daher erstellen wir schnell und unkompliziert eine Node.js/ExpressJS-App, um unseren Zähler bereitzustellen.

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

Dieser Code war tatsächlich einfacher zu schreiben als die Python-Versionen, obwohl natives JavaScript ziemlich unhandlich wird, wenn Anwendungen größer werden, und alle Versuche, dies zu korrigieren, wie etwa TypeScript, schnell ausführlicher werden als Python.

Mal sehen, wie es funktioniert!

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

Sie haben vielleicht uralte (jedenfalls uralte, wenn man das Internet als Maßstab nimmt...) Volksmärchen über die Geschwindigkeit von Node.js gehört, und diese Geschichten sind größtenteils wahr, dank der spektakulären Arbeit, die Google mit der V8-JavaScript-Engine geleistet hat. In diesem Fall jedoch übertrifft unsere schnelle App zwar das Flask-Skript, aber ihre Single-Thread-Natur wird durch die vier asynchronen Prozesse des Starlette Knight besiegt, der „Ni!“ sagt.

Holen wir uns noch mehr Hilfe!

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

Okay! Jetzt ist es ein ausgeglichener Kampf 4 gegen 4! Lasst uns einen Vergleich anstellen!

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

Immer noch nicht ganz auf dem Niveau von Starlette, aber für einen schnellen fünfminütigen JavaScript-Hack ist es nicht schlecht. Nach meinen eigenen Tests wird dieses Skript auf der Ebene der Datenbankschnittstelle tatsächlich etwas zurückgehalten, da node-postgres bei weitem nicht so effizient ist wie psycopg für Python. Der Wechsel zu sqlite als Datenbanktreiber führt zu über 3000 Anfragen pro Sekunde für denselben ExpressJS-Code.

Das Wichtigste ist, dass ASGI-Frameworks trotz der langsamen Ausführungsgeschwindigkeit von Python bei bestimmten Workloads tatsächlich mit Node.js-Lösungen konkurrieren können.

Rost/Actix

Wir nähern uns jetzt also dem Gipfel des Berges, und mit Berg meine ich die höchsten Benchmark-Ergebnisse, die sowohl von Mäusen als auch von Menschen erzielt wurden.

Wenn Sie sich die meisten Framework-Benchmarks im Internet ansehen, werden Sie feststellen, dass zwei Sprachen die Spitzenplätze dominieren: C++ und Rust. Ich arbeite seit den 90er Jahren mit C++ und hatte sogar mein eigenes Win32-C++-Framework, bevor MFC/ATL aufkam. Ich habe also viel Erfahrung mit der Sprache. Es macht nicht viel Spaß, mit etwas zu arbeiten, das man bereits kennt, also machen wir stattdessen eine Rust-Version. ;)

Rust ist eine relativ neue Programmiersprache, aber als Linus Torvalds bekannt gab, dass er Rust als Programmiersprache für den Linux-Kernel akzeptieren würde, wurde ich neugierig. Für uns ältere Programmierer ist das ungefähr so, als würden wir sagen, dass dieses neumodische Hippie-Dings aus der New-Age-Ära ein neuer Zusatzartikel zur US-Verfassung werden würde.

Als erfahrener Programmierer springt man nicht so schnell auf den Zug auf wie die jüngeren Leute, sonst kann es passieren, dass man sich an den schnellen Änderungen an der Sprache oder den Bibliotheken die Finger verbrennt. (Jeder, der die erste Version von AngularJS verwendet hat, weiß, wovon ich spreche.) Rust befindet sich noch in einem experimentellen Entwicklungsstadium und ich finde es lustig, dass sich so viele Codebeispiele im Web mit den aktuellen Paketversionen nicht einmal mehr kompilieren lassen.

Die Leistung von Rust-Anwendungen kann jedoch nicht bestritten werden. Wenn Sie noch nie versucht haben Abonnieren oder fd-finden Wenn Sie große Quellcodebäume ausprobieren, sollten Sie sie unbedingt ausprobieren. Sie sind sogar für die meisten Linux-Distributionen einfach über den Paketmanager verfügbar. Mit Rust tauschen Sie Ausführlichkeit gegen Leistung aus ... ein viel der Ausführlichkeit für eine viel der Leistung.

Der vollständige Code für Rust ist etwas umfangreich, deshalb schauen wir uns hier nur die relevanten Handler an:

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

Dies ist viel komplizierter als die Python/Node.js-Versionen ...

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

Und viel leistungsstärker!

Unser Rust-Server mit Actix/deadpool_postgres schlägt unseren bisherigen Champion Starlette mühelos um +125 %, ExpressJS um +362 % und reines PHP um +1366 %. (Die Leistungsdifferenz zur Laravel-Version überlasse ich dem Leser als Übung.)

Ich habe festgestellt, dass das Erlernen der Sprache Rust selbst schwieriger ist als bei anderen Sprachen, da sie viel mehr Fallstricke hat als alles, was ich außer 6502 Assembly gesehen habe, aber wenn Ihr Rust-Server 14-mal so viele Benutzer aufnehmen kann wie Ihr PHP-Server, dann ist es vielleicht doch von Vorteil, die Technologie zu wechseln. Aus diesem Grund wird die nächste Version des Pafera Frameworks auf Rust basieren. Die Lernkurve ist viel höher als bei Skriptsprachen, aber die Leistung wird es wert sein. Wenn Sie nicht die Zeit investieren können, um Rust zu lernen, dann ist es auch keine schlechte Entscheidung, Ihren Tech-Stack auf Starlette oder Node.js aufzubauen.

Technische Schulden

In den letzten zwanzig Jahren haben wir uns von billigen statischen Hosting-Sites zu Shared Hosting mit LAMP-Stacks und zur Vermietung von VPSes an AWS, Azure und andere Cloud-Dienste entwickelt. Heutzutage geben sich viele Unternehmen damit zufrieden, Designentscheidungen auf der Grundlage dessen zu treffen, was sie finden können, was verfügbar oder am günstigsten ist, da es durch das Aufkommen praktischer Cloud-Dienste einfach ist, mehr Hardware für langsame Server und Anwendungen einzusetzen. Dies hat ihnen große kurzfristige Gewinne auf Kosten langfristiger technischer Schulden beschert.

Warnung des kalifornischen Surgeon General: Dies ist kein echter Weltraumhund.

Vor 70 Jahren gab es ein großes Wettrennen im Weltraum zwischen der Sowjetunion und den Vereinigten Staaten. Die Sowjets gewannen die meisten der frühen Meilensteine. Sie hatten den ersten Satelliten Sputnik, den ersten Hund im Weltraum Laika, das erste Mondraumschiff Luna 2, den ersten Mann und die erste Frau im Weltraum Juri Gagarin und Walentina Tereschkowa und so weiter...

Doch langsam häuften sich technische Schulden an.

Obwohl die Sowjets bei all diesen Errungenschaften die Ersten waren, führten ihre technischen Prozesse und Ziele dazu, dass sie sich auf kurzfristige Herausforderungen konzentrierten und nicht auf die langfristige Machbarkeit. Sie gewannen jedes Mal, wenn sie einen Sprung machten, aber sie wurden immer müder und langsamer, während ihre Gegner weiterhin stetige Fortschritte in Richtung Ziel machten.

Nachdem Neil Armstrong live im Fernsehen seine historischen Schritte auf dem Mond machte, übernahmen die Amerikaner die Führung und blieben dort, als das sowjetische Programm ins Stocken geriet. Das ist nicht anders als bei heutigen Unternehmen, die sich auf die nächste große Sache, den nächsten großen Gewinn oder die nächste große Technologie konzentrieren, ohne es jedoch zu schaffen, die richtigen Gewohnheiten und Strategien für die langfristige Entwicklung zu entwickeln.

Als Erster auf dem Markt zu sein, bedeutet nicht, dass Sie der dominierende Akteur auf diesem Markt werden. Sich die Zeit zu nehmen, die Dinge richtig zu machen, garantiert zwar keinen Erfolg, erhöht aber sicherlich Ihre Chancen auf langfristige Erfolge. Wenn Sie der technische Leiter Ihres Unternehmens sind, wählen Sie die richtige Richtung und die richtigen Werkzeuge für Ihre Arbeitslast. Lassen Sie nicht zu, dass Popularität Leistung und Effizienz ersetzt.

Ressourcen

Möchten Sie eine 7z-Datei mit den Skripten Rust, ExpressJS, Flask, Starlette und Pure PHP herunterladen?

Über den Autor

Jim programmiert, seit er in den 90er Jahren einen IBM PS/2 bekam. Bis heute schreibt er HTML und SQL lieber von Hand und legt bei seiner Arbeit Wert auf Effizienz und Korrektheit.