Ja, Virginia, det *er* en Julenissen Forskjellen mellom nettrammer i 2023

En trassig programmerers reise for å finne hurtigytende webserverkode før han bukker under for markedspress og teknisk gjeld
2023-03-24 11:52:06
👁️ 768
💬 0

Innhold

  1. Innledning
  2. Testen
  3. PHP/Laravel
  4. Ren PHP
  5. Laravel på nytt
  6. Django
  7. Tønne
  8. Starlette
  9. Node.js/ExpressJS
  10. Rust/Actix
  11. Teknisk gjeld
  12. Ressurser

Innledning

Etter et av de siste jobbintervjuene mine ble jeg overrasket over at selskapet jeg søkte jobb hos fortsatt brukte Laravel, et PHP-rammeverk som jeg prøvde for omtrent ti år siden. Det var greit for den tiden, men hvis det er noe som er konstant i både teknologi og mote, så er det kontinuerlige endringer og nye stiler og konsepter. Hvis du'er en JavaScript-programmerer, er du sannsynligvis kjent med denne gamle vitsen

Programmerer 1: "Jeg liker ikke dette nye JavaScript-rammeverket!

Programmerer 2: "Ingen grunn til bekymring. Bare vent et halvt år, så kommer det en ny som kan erstatte den!

Av ren nysgjerrighet bestemte jeg meg for å se nøyaktig hva som skjer når vi setter gammelt og nytt på prøve. Nettet er selvfølgelig fylt med benchmarks og påstander, og den mest populære av dem er nok TechEmpower Web Framework Benchmarks her . Vi skal imidlertid ikke gjøre noe på langt nær så komplisert som dem i dag. Vi skal holde det enkelt og greit, både for at denne artikkelen ikke skal bli til en Krig og fred , og at du har en liten sjanse til å holde deg våken når du er ferdig med å lese. De vanlige forbeholdene gjelder: det er ikke sikkert at dette fungerer på samme måte på din maskin, ulike programvareversjoner kan påvirke ytelsen, og Schrödingers katt ble faktisk en zombiekatt som var halvt levende og halvt død på nøyaktig samme tid.

Testen

Testmiljø

Til denne testen bruker jeg den bærbare datamaskinen min med en liten i5 som kjører Manjaro Linux, som vist her.

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

Oppgaven som skal løses

Koden vår vil ha tre enkle oppgaver for hver forespørsel:

  1. Les den aktuelle brukerens økt-ID fra en informasjonskapsel
  2. Last inn tilleggsinformasjon fra en database
  3. Returner denne informasjonen til brukeren

Hva slags idiotisk test er det, spør du kanskje? Vel, hvis du ser på nettverksforespørslene for denne siden, vil du legge merke til en som heter sessionvars.js som gjør nøyaktig det samme.

Innholdet i sessionvars.js

Moderne nettsider er nemlig kompliserte skapninger, og en av de vanligste oppgavene er å mellomlagre komplekse sider for å unngå overbelastning på databaseserveren.

Hvis vi skal rendere en kompleks side på nytt hver gang en bruker ber om den, kan vi bare betjene rundt 600 brukere per sekund.

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

Men hvis vi bufrer denne siden som en statisk HTML-fil og lar Nginx raskt kaste den ut av vinduet til brukeren, kan vi betjene 32 000 brukere per sekund, noe som øker ytelsen med en faktor på 50 ganger.

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

Den statiske index.en.html er den delen som går til alle, og bare de delene som er forskjellige fra bruker til bruker, sendes i sessionvars.js. Dette reduserer ikke bare databasebelastningen og skaper en bedre opplevelse for brukerne våre, men reduserer også kvantesannsynligheten for at serveren vår spontant vil fordampe i et warpkjernebrudd når klingonene angriper.

Krav til kodeks

Den returnerte koden for hvert rammeverk vil ha ett enkelt krav: Vis brukeren hvor mange ganger de har oppdatert siden ved å si "Count is x". For å holde ting enkelt, holder vi oss unna Redis-køer, Kubernetes-komponenter eller AWS Lambdas inntil videre.

Viser hvor mange ganger du har besøkt siden

Hver brukers øktdata lagres i en PostgreSQL-database.

Brukersessions-tabellen

Og denne databasetabellen vil bli trunkert før hver test.

Tabellen etter å ha blitt trunkert

Enkelt, men effektivt er Paferas motto... i alle fall utenfor den mørkeste tidslinjen...

De faktiske testresultatene

PHP/Laravel

Ok, så nå kan vi endelig begynne å skitne til hendene våre. Vi hopper over oppsettet for Laravel, siden det bare er en haug med composer- og artisan kommandoer.

Først setter vi opp databaseinnstillingene våre i .env-filen

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

Deretter setter vi opp én enkelt fallback-rute som sender alle forespørsler til kontrolleren vår.

Route::fallback(SessionController::class);

Og sett kontrolleren til å vise antallet. Laravel lagrer som standard økter i databasen. Den tilbyr også session() funksjon for å grensesnitt med våre sesjonsdata, så alt som trengtes var et par linjer med kode for å gjengi siden vår.

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

    $count  += 1;

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

    return 'Count is ' . $count;
  }
}

Etter å ha satt opp php-fpm og Nginx, ser siden vår ganske bra ut...

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

I hvert fall til vi faktisk får se testresultatene...

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

Nei, det er ikke en skrivefeil. Testmaskinen vår har gått fra 600 forespørsler per sekund ved gjengivelse av en kompleks side ... til 21 forespørsler per sekund ved gjengivelse av "Count is 1 ".

Så hva gikk galt? Er det noe galt med PHP-installasjonen vår? Er Nginx på en eller annen måte tregere når den kobles til php-fpm?

Ren PHP

La oss gjøre om denne siden i ren PHP-kode.

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

Vi har nå brukt 98 kodelinjer til å gjøre det som fire kodelinjer (og en hel haug med konfigurasjonsarbeid) i Laravel gjorde. (Hvis vi hadde gjort skikkelig feilhåndtering og brukervendte meldinger, ville dette selvfølgelig blitt omtrent dobbelt så mange linjer). Kanskje vi kan komme opp i 30 forespørsler per sekund?

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

Jøss! Det ser ut til at det ikke er noe galt med PHP-installasjonen vår likevel. Den rene PHP-versjonen gjør 700 forespørsler per sekund.

Hvis det ikke er noe galt med PHP, har vi kanskje feilkonfigurert Laravel?

Laravel på nytt

Etter å ha søkt på nettet etter konfigurasjonsproblemer og ytelsestips, var to av de mest populære teknikkene å mellomlagre konfigurasjons- og rutedataene for å unngå å behandle dem for hver forespørsel. Derfor vil vi følge rådene deres og prøve ut disse tipsene.

╰─➤  php artisan config:cache

   INFO  Configuration cached successfully.  

╰─➤  php artisan route:cache

   INFO  Routes cached successfully.  

Alt ser bra ut på kommandolinjen. La oss gjøre en ny 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

Vel, vi har nå økt ytelsen fra 21,04 til 28,80 forespørsler per sekund, en dramatisk økning på nesten 37 %! Dette ville vært ganske imponerende for enhver programvarepakke ... bortsett fra det faktum at vi fremdeles bare gjør 1/24 av antall forespørsler av den rene PHP-versjonen.

Hvis du tenker at noe må være galt med denne testen, bør du snakke med forfatteren av Lucinda PHP-rammeverket. I testresultatene hans har han Lucinda slår Laravel med 36x for HTML-forespørsler og 90x for JSON-forespørsler.

Etter å ha testet på min egen maskin med både Apache og Nginx, har jeg ingen grunn til å tvile på ham. Laravel er egentlig bare at treg! PHP i seg selv er ikke så ille, men når du legger til all den ekstra behandlingen som Laravel legger til hver forespørsel, så synes jeg det er veldig vanskelig å anbefale Laravel som et valg i 2023.

Django

PHP/Wordpress-kontoer for omtrent 40 % av alle nettsteder på nettet og er dermed det klart mest dominerende rammeverket. Personlig mener jeg imidlertid at popularitet ikke nødvendigvis betyr kvalitet, like lite som at jeg plutselig får en ukontrollerbar trang til den ekstraordinære gourmetmaten fra den mest populære restauranten i verden ... McDonald&#x27;s. Siden vi allerede har testet ren PHP-kode, kommer vi ikke til å teste Wordpress selv, da alt som involverer Wordpress utvilsomt vil være lavere enn de 700 forespørslene per sekund som vi observerte med ren PHP.

Django er et annet populært rammeverk som har eksistert lenge. Hvis du har brukt det tidligere, husker du sikkert med glede det spektakulære grensesnittet for databaseadministrasjon og hvor irriterende det var å konfigurere alt akkurat slik du ville ha det. La oss se hvor godt Django fungerer i 2023, spesielt med det nye ASGI-grensesnittet som er lagt til fra og med versjon 4.0.

Oppsettet av Django er bemerkelsesverdig likt oppsettet av Laravel, siden de begge er fra en tid da MVC-arkitekturer var stilige og korrekte. Vi hopper over den kjedelige konfigurasjonen og går rett til å sette opp visningen.

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

Fire linjer med kode er det samme som med Laravel-versjonen. La oss se hvordan det fungerer.

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

Ikke dårlig i det hele tatt med 355 forespørsler per sekund. Det er bare halvparten av ytelsen til den rene PHP-versjonen, men det er også 12 ganger så mye som Laravel-versjonen. Django vs. Laravel ser ikke ut til å være noen konkurranse i det hele tatt.

Tønne

I tillegg til de større rammeverkene som inneholder alt, inkludert kjøkkenvasken, finnes det også mindre rammeverk som bare gjør noen grunnleggende oppsett og lar deg håndtere resten. Et av de beste er Flask og dets ASGI-motstykke Quart. Min egen PaferaPy-rammeverket er bygget på toppen av Flask, så jeg er godt kjent med hvor enkelt det er å få ting gjort samtidig som ytelsen opprettholdes.

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

Som du kan se, er Flask-skriptet kortere enn det rene PHP-skriptet. Av alle språkene jeg har brukt, er Python sannsynligvis det mest uttrykksfulle språket når det gjelder antall tastetrykk. Mangel på klammer og parenteser, liste- og diktforståelser og blokkering basert på innrykk i stedet for semikolon gjør Python ganske enkelt, men likevel kraftfullt i sine muligheter.

Dessverre er Python også det tregeste allmennspråket der ute, til tross for hvor mye programvare som er skrevet i det. Det finnes omtrent fire ganger så mange Python-biblioteker som i lignende språk, og de dekker et stort antall domener, men likevel er det ingen som vil si at Python er raskt eller ytelsessterkt utenfor nisjer som NumPy.

La oss se hvordan Flask-versjonen vår er sammenlignet med de tidligere rammeverkene våre.

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

Flask-skriptet vårt er faktisk raskere enn vår rene PHP-versjon!

Hvis du&#x27;r overrasket over dette, bør du innse at Flask-appen vår gjør all initialisering og konfigurasjon når vi starter opp gunicorn-serveren, mens PHP kjører skriptet på nytt hver gang en ny forespørsel kommer inn. Det kan sammenlignes med at Flask er den unge, ivrige taxisjåføren som allerede har startet bilen og venter ved siden av veien, mens PHP er den gamle sjåføren som blir hjemme og venter på at en samtale skal komme inn og først da kjører over for å plukke deg opp. Som en old school-fyr som kommer fra den tiden da PHP var en fantastisk forandring til vanlige HTML- og SHTML-filer, er det litt trist å innse hvor mye tid som har gått, men designforskjellene gjør det virkelig vanskelig for PHP å konkurrere mot Python-, Java- og Node.js-servere som bare holder seg i minnet og håndterer forespørsler med en sjonglørs smidige letthet.

Starlette

Flask er kanskje vårt raskeste rammeverk så langt, men det er faktisk ganske gammel programvare. Python-miljøet byttet til de nyere asychronous ASGI-serverne for et par år siden, og jeg har selvfølgelig byttet sammen med dem.

Den nyeste versjonen av Pafera Framework, PaferaPyAsync er basert på Starlette. Selv om det finnes en ASGI-versjon av Flask som heter Quart, var ytelsesforskjellene mellom Quart og Starlette nok til at jeg valgte å basere koden min på Starlette i stedet.

Asykron programmering kan virke skremmende på mange, men det er faktisk ikke et vanskelig konsept, takket være Node.js-folkene som populariserte konseptet for over ti år siden.

Før kjempet vi mot samtidighet med multithreading, multiprosessering, distribuert databehandling, løftekjeding og alle disse morsomme tingene som gjorde mange erfarne programmerere for gamle og uttørkede. Nå skriver vi bare async foran våre funksjoner og await foran all kode som kan ta en stund å utføre. Det er riktignok mer omstendelig enn vanlig kode, men mye mindre irriterende å bruke enn å måtte forholde seg til synkroniseringsprimitiver, meldingspassering og løsning av løfter.

Starlette-filen vår ser slik ut:

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

Som du kan se, er det stort sett kopiert og limt inn fra Flask-skriptet vårt, med bare et par endringer i rutingen og async/await nøkkelord.

Hvor mye forbedring kan faktisk kopiert og limt inn kode gi oss?

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

Vi har en ny mester, mine damer og herrer! Vår tidligere toppnotering var vår rene PHP-versjon med 704 forespørsler per sekund, som deretter ble forbigått av vår Flask-versjon med 1080 forespørsler per sekund. Vårt Starlette-skript knuser alle tidligere utfordrere med 4562 forespørsler per sekund, noe som betyr en forbedring på 6 ganger i forhold til ren PHP og 4 ganger i forhold til Flask.

Hvis du ikke har endret WSGI Python-koden din til ASGI ennå, kan det være en god tid å begynne nå.

Node.js/ExpressJS

Så langt har vi bare dekket PHP- og Python-rammeverk. En stor del av verden bruker imidlertid Java, DotNet, Node.js, Ruby on Rails og andre slike teknologier til sine nettsteder. Dette er på ingen måte en omfattende oversikt over alle verdens økosystemer og biomer, så for å unngå å gjøre programmeringen ekvivalent med organisk kjemi, velger vi bare de rammeverkene som er enklest å skrive kode for ... og Java er definitivt ikke det.

Med mindre du har gjemt deg under ditt eksemplar av K&amp;R C eller Knuth&#x27;s Kunsten å programmere datamaskiner de siste femten årene, har du sannsynligvis hørt om Node.js. De av oss som har vært med siden begynnelsen av JavaScript, er enten utrolig skremt, forbløffet eller begge deler over tilstanden til moderne JavaScript, men det kan ikke benektes at JavaScript har blitt en kraft å regne med på servere så vel som i nettlesere. Tross alt har vi til og med 64-biters heltall i språket nå! Det er langt bedre enn at alt lagres i 64 bit floats!

ExpressJS er sannsynligvis den enkleste Node.js-serveren å bruke, så vi skal lage en rask og skitten Node.js/ExpressJS-app for å betjene telleren vår.

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

Denne koden var faktisk enklere å skrive enn Python-versjonene, selv om native JavaScript blir ganske uhåndterlig når applikasjonene blir større, og alle forsøk på å rette opp dette, for eksempel TypeScript, blir raskt mer omstendelige enn Python.

La oss se hvordan dette fungerer!

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

Du har kanskje hørt gamle (gamle etter Internett-standarder i alle fall...) folkeeventyr om Node.js&#x27; hastighet, og disse historiene er for det meste sanne takket være det spektakulære arbeidet som Google har gjort med V8 JavaScript-motoren. I dette tilfellet, selv om vår raske app utkonkurrerer Flask-skriptet, blir dens enkelttrådede natur beseiret av de fire asynkrone prosessene som utøves av Starlette Knight som sier &quot;Ni!&quot;.

La oss få litt mer hjelp!

╰─➤  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! Nå er det en jevn kamp fire mot fire! La oss måle!

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

Det er fortsatt ikke helt på nivå med Starlette, men det er ikke dårlig for et raskt JavaScript-hack på fem minutter. Ut fra mine egne tester blir dette skriptet faktisk holdt litt tilbake på databasegrensesnittnivået fordi node-postgres ikke er i nærheten av så effektiv som psycopg er for Python. Ved å bytte til sqlite som databasedriver får man over 3000 forespørsler per sekund for den samme ExpressJS-koden.

Det viktigste å merke seg er at til tross for den langsomme kjøringshastigheten til Python, kan ASGI-rammeverk faktisk være konkurransedyktige med Node.js-løsninger for visse arbeidsbelastninger.

Rust/Actix

Så nå nærmer vi oss toppen av fjellet, og med fjell mener jeg de høyeste referansescorene som er registrert av både mus og mennesker.

Hvis du ser på de fleste benchmarks for rammeverk som er tilgjengelige på nettet, vil du legge merke til at det er to språk som har en tendens til å dominere toppen: C++ og Rust. Jeg har jobbet med C++ siden 90-tallet, og jeg hadde til og med mitt eget Win32 C++-rammeverk før MFC/ATL var en greie, så jeg har mye erfaring med språket. Det er ikke så gøy å jobbe med noe når du allerede kan det, så vi kommer til å lage en Rust-versjon i stedet ;)

Rust er relativt nytt når det gjelder programmeringsspråk, men jeg ble nysgjerrig på det da Linus Torvalds kunngjorde at han ville akseptere Rust som programmeringsspråk i Linux-kjernen. For oss eldre programmerere er det omtrent det samme som å si at denne nymotens hippiegreia skulle bli et nytt tillegg til den amerikanske grunnloven.

Når du er en erfaren programmerer, har du en tendens til ikke å hoppe på vognen like fort som de yngre, ellers kan du bli brent av raske endringer i språket eller bibliotekene. (Alle som har brukt den første versjonen av AngularJS, vet hva jeg snakker om). Rust er fortsatt på et eksperimentelt utviklingsstadium, og jeg synes det er morsomt at mange kodeeksempler på nettet ikke engang kompilerer lenger med dagens versjoner av pakkene.

Rust-applikasjonenes ytelse kan imidlertid ikke benektes. Hvis du aldri har prøvd ripgrep eller fd-find på store kildekodetrær, bør du absolutt prøve dem. De er til og med tilgjengelige for de fleste Linux-distribusjoner, ganske enkelt fra pakkebehandleren. Du bytter ut ordrikdom med ytelse med Rust ... a mye av ordrikhet for en mye av ytelse.

Den komplette koden for Rust er litt omfattende, så vi nøyer oss med å se på de relevante håndtererne her:

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

Dette er mye mer komplisert enn Python/Node.js-versjonene...

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

Og mye bedre ytelse!

Rust-serveren vår som bruker Actix/deadpool_postgres, slår vår tidligere mester Starlette med +125 %, ExpressJS med +362 % og ren PHP med +1366 %. (Jeg lar ytelsesdeltaet med Laravel-versjonen være en øvelse for leseren).

Jeg har funnet ut at det har vært vanskeligere å lære seg Rust-språket i seg selv enn andre språk, siden det har mange flere problemer enn noe annet jeg har sett utenfor 6502 Assembly, men hvis Rust-serveren din kan ta imot 14 ganger så mange brukere som PHP-serveren din, er det kanskje noe å vinne på å bytte teknologi likevel. Derfor vil neste versjon av Pafera-rammeverket være basert på Rust. Læringskurven er mye høyere enn for skriptspråk, men ytelsen vil være verdt det. Hvis du ikke kan bruke tid på å lære deg Rust, er det heller ikke en dårlig beslutning å basere teknologistakken din på Starlette eller Node.js.

Teknisk gjeld

I løpet av de siste tjue årene har vi gått fra billige statiske hostingsider til delt hosting med LAMP-stabler til leie av VPS-er til AWS, Azure og andre skytjenester. I dag nøyer mange selskaper seg med å ta designbeslutninger basert på hvem som er tilgjengelig eller billigst, siden fremveksten av praktiske skytjenester har gjort det enkelt å kaste mer maskinvare på trege servere og applikasjoner. Dette har gitt dem store kortsiktige gevinster på bekostning av langsiktig teknisk gjeld.

Californias kirurgiske general advarer: Dette er ikke en ekte romhund.

For 70 år siden foregikk det et stort romkappløp mellom Sovjetunionen og USA. Sovjetunionen vant de fleste av de første milepælene. De hadde den første satellitten i Sputnik, den første hunden i rommet i Laika, det første måneromfartøyet i Luna 2, den første mannen og kvinnen i rommet i Jurij Gagarin og Valentina Teresjkova, og så videre...

Men de opparbeidet seg langsomt teknisk gjeld.

Selv om sovjeterne var først til hver av disse prestasjonene, fikk ingeniørprosessene og målene deres dem til å fokusere på kortsiktige utfordringer i stedet for langsiktig gjennomførbarhet. De vant hver gang de hoppet, men de ble stadig mer slitne og langsommere, mens motstanderne fortsatte å ta stadige skritt mot målstreken.

Da Neil Armstrong tok sine historiske skritt på månen på direktesendt TV, tok amerikanerne ledelsen, og der ble de værende mens det sovjetiske programmet vaklet. Dette er ikke annerledes enn selskaper som i dag har fokusert på den neste store tingen, den neste store gevinsten eller den neste store teknologien, uten å utvikle gode vaner og strategier på lang sikt.

Å være først ute på markedet betyr ikke at du blir den dominerende aktøren i det aktuelle markedet. Hvis du tar deg tid til å gjøre ting riktig, er det heller ingen garanti for suksess, men det øker helt klart sjansene for langsiktige resultater. Hvis du er teknologileder i bedriften din, bør du velge riktig retning og verktøy for arbeidsmengden din. Ikke la popularitet erstatte ytelse og effektivitet.

Ressurser

Vil du laste ned en 7z-fil som inneholder Rust-, ExpressJS-, Flask-, Starlette- og Pure PHP-skript?

Om forfatteren

Jim har programmert siden han fikk en IBM PS/2 på 90-tallet. Den dag i dag foretrekker han fortsatt å skrive HTML og SQL for hånd, og fokuserer på effektivitet og korrekthet i arbeidet sitt.