Tak, Wirginio, jest Gwiazdor Różnica między frameworkami internetowymi w 2023 r.

Podróż pewnego zbuntowanego programisty w celu znalezienia szybko działającego kodu serwera WWW, zanim ulegnie presji rynku i zadłużeniu technicznemu
2023-03-24 11:52:06
👁️ 765
💬 0

Zawartość

  1. Wstęp
  2. Test
  3. PHP/Laravel
  4. Czysty PHP
  5. Ponowne odwiedzenie Laravela
  6. Django
  7. Kolba
  8. Gwiazdeczka
  9. Node.js/ExpressJS
  10. Rdza/Actix
  11. Dług techniczny
  12. Zasoby

Wstęp

Po jednej z moich ostatnich rozmów kwalifikacyjnych byłem zaskoczony, gdy zdałem sobie sprawę, że firma, do której aplikowałem, nadal używała Laravela, frameworka PHP, którego próbowałem około dekady temu. Był niezły jak na tamte czasy, ale jeśli jest coś stałego w technologii i modzie, to ciągła zmiana i ponowne pojawianie się stylów i koncepcji. Jeśli jesteś programistą JavaScript, prawdopodobnie znasz ten stary dowcip

Programista 1: „Nie podoba mi się ten nowy framework JavaScript!”

Programista 2: „Nie ma się czym martwić. Poczekaj sześć miesięcy, a pojawi się ktoś inny, kto go zastąpi!”

Z ciekawości postanowiłem sprawdzić dokładnie, co się stanie, gdy wystawimy na próbę stare i nowe. Oczywiście sieć jest pełna benchmarków i twierdzeń, z których najpopularniejszym jest prawdopodobnie Testy porównawcze TechEmpower Web Framework tutaj . Dzisiaj nie będziemy robić niczego tak skomplikowanego jak oni. Będziemy trzymać się prostych i przyjemnych rzeczy, aby ten artykuł nie zamienił się w Wojna i pokój , i że będziesz mieć niewielką szansę na pozostanie przytomnym, gdy skończysz czytać. Obowiązują zwykłe zastrzeżenia: to może nie działać tak samo na twoim komputerze, różne wersje oprogramowania mogą wpływać na wydajność, a kot Schrödingera faktycznie stał się kotem zombie, który był w połowie żywy i w połowie martwy w dokładnie tym samym czasie.

Test

Środowisko testowe

Do tego testu użyję mojego laptopa wyposażonego w słaby procesor i5, na którym działa system Manjaro Linux, jak pokazano tutaj.

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

Zadanie do wykonania

Nasz kod będzie miał trzy proste zadania dla każdego żądania:

  1. Odczytaj bieżący identyfikator sesji użytkownika z pliku cookie
  2. Załaduj dodatkowe informacje z bazy danych
  3. Zwróć te informacje użytkownikowi

Co to za idiotyczny test, możesz zapytać? Cóż, jeśli spojrzysz na żądania sieciowe dla tej strony, zauważysz jedno o nazwie sessionvars.js, które robi dokładnie to samo.

Zawartość sessionvars.js

Widzisz, współczesne strony internetowe są skomplikowanymi tworami i jednym z najczęstszych zadań jest buforowanie złożonych stron w celu uniknięcia nadmiernego obciążenia serwera bazy danych.

Jeśli będziemy renderować złożoną stronę za każdym razem, gdy użytkownik o nią poprosi, będziemy w stanie obsłużyć tylko około 600 użytkowników na 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

Jeśli jednak zapiszemy tę stronę w postaci statycznego pliku HTML i pozwolimy Nginx szybko przesłać ją użytkownikowi, wówczas będziemy mogli obsłużyć 32 000 użytkowników na sekundę, co zwiększy wydajność 50-krotnie.

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

Statyczny index.en.html to część, która trafia do wszystkich, a tylko części różniące się w zależności od użytkownika są wysyłane w sessionvars.js. To nie tylko zmniejsza obciążenie bazy danych i tworzy lepsze doświadczenie dla naszych użytkowników, ale także zmniejsza prawdopodobieństwo kwantowe, że nasz serwer spontanicznie wyparuje w wyniku naruszenia rdzenia warp, gdy zaatakują Klingoni.

Wymagania dotyczące kodu

Zwrócony kod dla każdego frameworka będzie miał jedno proste wymaganie: pokaż użytkownikowi, ile razy odświeżył stronę, mówiąc „Liczba wynosi x”. Aby zachować prostotę, na razie będziemy trzymać się z dala od kolejek Redis, komponentów Kubernetes lub AWS Lambdas.

Pokazywanie, ile razy odwiedziłeś stronę

Dane sesji każdego użytkownika zostaną zapisane w bazie danych PostgreSQL.

Tabela usersessions

A ta tabela bazy danych będzie obcinana przed każdym testem.

Tabela po obcięciu

Proste, a zarazem skuteczne – takie jest motto Pafery... przynajmniej pomijając najciemniejsze ramy czasowe...

Rzeczywiste wyniki testów

PHP/Laravel

Okej, więc teraz możemy wreszcie zacząć brudzić sobie ręce. Pominiemy konfigurację Laravela, ponieważ to tylko kilka poleceń composer i artisan.

Najpierw skonfigurujemy ustawienia bazy danych w pliku .env

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

Następnie ustawimy jedną trasę zapasową, która będzie wysyłać każde żądanie do naszego kontrolera.

Route::fallback(SessionController::class);

I ustaw kontroler tak, aby wyświetlał liczbę. Laravel domyślnie przechowuje sesje w bazie danych. Zapewnia również session() funkcję umożliwiającą komunikację z danymi sesji, więc do wyświetlenia naszej strony wystarczyło kilka linijek kodu.

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

    $count  += 1;

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

    return 'Count is ' . $count;
  }
}

Po skonfigurowaniu php-fpm i Nginx nasza strona wygląda całkiem nieźle...

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

Przynajmniej do czasu, aż zobaczymy wyniki testów...

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

Nie, to nie literówka. Nasza maszyna testowa przeszła z 600 żądań na sekundę renderujących złożoną stronę... do 21 żądań na sekundę renderujących "Liczba wynosi 1".

Co więc poszło nie tak? Czy coś jest nie tak z naszą instalacją PHP? Czy Nginx jakoś zwalnia podczas łączenia się z php-fpm?

Czysty PHP

Stwórzmy tę stronę ponownie, używając czystego kodu PHP.

<?php

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

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

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

$sessionid  = 0;

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

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

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

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

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

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

$data['count']++;

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

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

Użyliśmy już 98 linii kodu, aby zrobić to, co w Laravelu wymagało czterech linii kodu (i całej masy pracy konfiguracyjnej). (Oczywiście, gdybyśmy odpowiednio obsługiwali błędy i wyświetlali użytkownikom komunikaty, liczba linii byłaby około dwukrotnie większa). Może moglibyśmy dojść do 30 żądań na 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

Wow! Wygląda na to, że jednak nie ma nic złego w naszej instalacji PHP. Czysta wersja PHP wykonuje 700 żądań na sekundę.

Jeśli z PHP wszystko jest w porządku, to może źle skonfigurowaliśmy Laravel?

Ponowne odwiedzenie Laravela

Po przeszukaniu sieci w poszukiwaniu problemów z konfiguracją i wskazówek dotyczących wydajności, dwiema najpopularniejszymi technikami było buforowanie danych konfiguracji i trasowania, aby uniknąć przetwarzania ich przy każdym żądaniu. Dlatego skorzystamy z ich rady i wypróbujemy te wskazówki.

╰─➤  php artisan config:cache

   INFO  Configuration cached successfully.  

╰─➤  php artisan route:cache

   INFO  Routes cached successfully.  

Wszystko wygląda dobrze na wierszu poleceń. Powtórzmy test porównawczy.

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

Cóż, teraz zwiększyliśmy wydajność z 21,04 do 28,80 żądań na sekundę, co stanowi drastyczny wzrost o prawie 37%! Byłoby to całkiem imponujące dla każdego pakietu oprogramowania... z wyjątkiem faktu, że nadal wykonujemy tylko 1/24 liczby żądań w czystej wersji PHP.

Jeśli myślisz, że coś musi być nie tak z tym testem, powinieneś porozmawiać z autorem frameworka PHP Lucinda. W wynikach testu napisał: Lucinda pokonuje Laravela o 36x dla żądań HTML i 90x dla żądań JSON.

Po przetestowaniu na własnym komputerze zarówno z Apache, jak i Nginx, nie mam powodu, aby w niego wątpić. Laravel jest naprawdę po prostu To wolno! PHP samo w sobie nie jest takie złe, ale gdy dodasz do tego całe dodatkowe przetwarzanie, które Laravel dodaje do każdego żądania, to uważam, że Laravel jest bardzo trudny do polecenia jako wybór w 2023 roku.

Django

Konta PHP/Wordpress dla około 40% wszystkich stron internetowych w sieci , co czyni go zdecydowanie najbardziej dominującym frameworkiem. Osobiście jednak uważam, że popularność niekoniecznie przekłada się na jakość, tak samo jak nie odczuwam nagłej, niekontrolowanej potrzeby zjedzenia tego niezwykłego, wykwintnego jedzenia z najpopularniejsza restauracja na świecie ... McDonald's. Ponieważ testowaliśmy już czysty kod PHP, nie będziemy testować samego WordPressa, ponieważ wszystko, co dotyczy WordPressa, byłoby niewątpliwie niższe niż 700 żądań na sekundę, które zaobserwowaliśmy w czystym PHP.

Django to kolejny popularny framework, który istnieje od dawna. Jeśli korzystałeś z niego w przeszłości, prawdopodobnie z rozrzewnieniem wspominasz jego spektakularny interfejs administracyjny bazy danych, a także to, jak irytujące było konfigurowanie wszystkiego dokładnie tak, jak chciałeś. Zobaczmy, jak dobrze Django działa w 2023 r., zwłaszcza z nowym interfejsem ASGI, który dodano od wersji 4.0.

Konfigurowanie Django jest zadziwiająco podobne do konfigurowania Laravel, ponieważ oba pochodzą z czasów, gdy architektury MVC były stylowe i poprawne. Pominiemy nudną konfigurację i przejdziemy bezpośrednio do konfigurowania widoku.

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

Cztery linijki kodu są takie same jak w wersji Laravel. Zobaczmy, jak to działa.

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

Wcale nie jest źle przy 355 żądaniach na sekundę. To tylko połowa wydajności wersji czystego PHP, ale jest też 12 razy większa niż w przypadku wersji Laravel. Django kontra Laravel nie wydaje się być wcale konkurencją.

Kolba

Oprócz większych frameworków typu everything-including-the-kitchen-sink, istnieją również mniejsze frameworki, które wykonują tylko podstawowe ustawienia, pozwalając ci zająć się resztą. Jednym z najlepszych jest Flask i jego odpowiednik ASGI Quart. Mój własny Struktura PaferaPy jest zbudowany na bazie Flask, więc doskonale wiem, jak łatwo można w nim wykonywać zadania, zachowując przy tym wydajność.

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

Jak widać, skrypt Flask jest krótszy niż czysty skrypt PHP. Uważam, że ze wszystkich języków, których używałem, Python jest prawdopodobnie najbardziej ekspresyjnym językiem pod względem naciśnięć klawiszy. Brak nawiasów klamrowych i okrągłych, list i dict comprehensions oraz blokowanie oparte na wcięciach, a nie średnikach, sprawiają, że Python jest dość prosty, ale ma potężne możliwości.

Niestety, Python jest również najwolniejszym językiem ogólnego przeznaczenia, pomimo tego, ile oprogramowania zostało w nim napisane. Liczba dostępnych bibliotek Pythona jest około cztery razy większa niż w podobnych językach i obejmuje ogromną liczbę domen, ale nikt nie powiedziałby, że Python jest szybki ani wydajny poza niszami takimi jak NumPy.

Porównajmy naszą wersję Flask z naszymi poprzednimi rozwiązaniami.

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

Nasz skrypt Flask jest faktycznie szybszy niż nasza wersja napisana w czystym PHP!

Jeśli to Cię zaskoczyło, powinieneś zdać sobie sprawę, że nasza aplikacja Flask wykonuje całą swoją inicjalizację i konfigurację, gdy uruchamiamy serwer gunicorn, podczas gdy PHP ponownie uruchamia skrypt za każdym razem, gdy pojawia się nowe żądanie. To odpowiednik Flaska jako młodego, chętnego taksówkarza, który już uruchomił samochód i czeka przy drodze, podczas gdy PHP jest starym kierowcą, który zostaje w domu, czekając na telefon, a dopiero potem przyjeżdża, aby Cię odebrać. Będąc starym facetem i pochodzącym z czasów, gdy PHP było wspaniałą zmianą w stosunku do zwykłych plików HTML i SHTML, jest trochę smutno zdać sobie sprawę, ile czasu minęło, ale różnice w projekcie naprawdę utrudniają PHP konkurowanie z serwerami Python, Java i Node.js, które po prostu pozostają w pamięci i obsługują żądania z zwinnością żonglera.

Gwiazdeczka

Flask może być naszym najszybszym frameworkiem do tej pory, ale tak naprawdę jest to dość stare oprogramowanie. Społeczność Pythona przeszła na nowsze, asymetryczne serwery ASGI kilka lat temu, a ja oczywiście przeszedłem wraz z nimi.

Najnowsza wersja Pafera Framework, PaferaPyAsync , jest oparty na Starlette. Chociaż istnieje wersja ASGI Flask o nazwie Quart, różnice w wydajności między Quart i Starlette były wystarczające, abym ponownie oparł swój kod na Starlette.

Programowanie asynchroniczne może przerażać wiele osób, ale tak naprawdę nie jest to trudna koncepcja dzięki ludziom pracującym nad Node.js, którzy spopularyzowali ją ponad dekadę temu.

Kiedyś walczyliśmy ze współbieżnością za pomocą wielowątkowości, przetwarzania wieloprocesowego, przetwarzania rozproszonego, łańcuchowania obietnic i wszystkich tych zabawnych czasów, które przedwcześnie starzeją i wysuszają wielu doświadczonych programistów. Teraz po prostu wpisujemy async przed naszymi funkcjami i await przed jakimkolwiek kodem, którego wykonanie może zająć trochę czasu. Jest to rzeczywiście bardziej rozwlekłe niż zwykły kod, ale znacznie mniej irytujące w użyciu niż konieczność radzenia sobie z prymitywami synchronizacji, przekazywaniem wiadomości i rozwiązywaniem obietnic.

Nasz plik Starlette wygląda następująco:

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

Jak widać, jest to w zasadzie skopiowane i wklejone z naszego skryptu Flask, z kilkoma zmianami w routingu i async/await słowa kluczowe.

Jak wiele ulepszeń może nam dać kopiowanie i wklejanie kodu?

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

Mamy nowego mistrza, panie i panowie! Nasz poprzedni rekord to czysta wersja PHP z 704 żądaniami na sekundę, którą następnie prześcignęła nasza wersja Flask z 1080 żądaniami na sekundę. Nasz skrypt Starlette miażdży wszystkich poprzednich konkurentów z 4562 żądaniami na sekundę, co oznacza 6-krotną poprawę w stosunku do czystego PHP i 4-krotną poprawę w stosunku do Flask.

Jeśli jeszcze nie zmieniłeś swojego kodu WSGI Python na ASGI, teraz może być dobry moment, aby to zrobić.

Node.js/ExpressJS

Do tej pory omawialiśmy tylko frameworki PHP i Python. Jednak duża część świata faktycznie używa Java, DotNet, Node.js, Ruby on Rails i innych podobnych technologii na swoich stronach internetowych. To w żadnym wypadku nie jest kompleksowy przegląd wszystkich ekosystemów i biomów świata, więc aby uniknąć programowania równoważnego chemii organicznej, wybierzemy tylko te frameworki, dla których najłatwiej jest pisać kod... a Java zdecydowanie do nich nie należy.

Chyba że ukrywałeś się pod swoim egzemplarzem K&R C lub Knutha Sztuka programowania komputerowego przez ostatnie piętnaście lat, prawdopodobnie słyszałeś o Node.js. Ci z nas, którzy są w pobliżu od początku JavaScript, są albo niesamowicie przestraszeni, albo zdumieni, albo jedno i drugie stanem nowoczesnego JavaScript, ale nie da się zaprzeczyć, że JavaScript stał się siłą, z którą trzeba się liczyć na serwerach, jak i przeglądarkach. W końcu mamy teraz w języku natywne 64-bitowe liczby całkowite! To o wiele lepsze niż wszystko przechowywane w 64-bitowych liczbach zmiennoprzecinkowych!

ExpressJS jest prawdopodobnie najłatwiejszym w użyciu serwerem Node.js, więc szybko i sprawnie stworzymy aplikację Node.js/ExpressJS do obsługi naszego licznika.

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

Tak naprawdę napisanie tego kodu było łatwiejsze niż napisanie wersji w Pythonie, choć natywny JavaScript staje się dość nieporęczny, gdy aplikacje stają się większe, a wszelkie próby skorygowania tego problemu, takie jak w przypadku TypeScript, szybko stają się bardziej rozwlekłe niż w przypadku Pythona.

Zobaczmy, jak to działa!

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

Być może słyszałeś starożytne (starożytne według standardów internetowych...) opowieści ludowe o szybkości Node.js, a historie te są w większości prawdziwe dzięki spektakularnej pracy, jaką Google wykonało z silnikiem JavaScript V8. W tym przypadku jednak, chociaż nasza szybka aplikacja przewyższa skrypt Flask, jej jednowątkowa natura zostaje pokonana przez cztery procesy asynchroniczne obsługiwane przez Starlette Knight, która mówi „Ni!”.

Potrzebna nam dodatkowa pomoc!

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

Okej! Teraz to wyrównana walka czterech na czterech! Zróbmy benchmark!

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

Nadal nie jest to poziom Starlette, ale nie jest źle jak na szybki, pięciominutowy hack JavaScript. Z moich własnych testów wynika, że ten skrypt jest trochę powstrzymywany na poziomie interfejsu bazy danych, ponieważ node-postgres nie jest wcale tak wydajny jak psycopg dla Pythona. Przejście na sqlite jako sterownik bazy danych generuje ponad 3000 żądań na sekundę dla tego samego kodu ExpressJS.

Należy przede wszystkim zauważyć, że pomimo powolnej prędkości wykonywania kodu w Pythonie, frameworki ASGI mogą w przypadku niektórych obciążeń dorównywać rozwiązaniom Node.js.

Rdza/Actix

Zbliżamy się więc do szczytu góry, a przez górę mam na myśli najwyższe wyniki testów laboratoryjnych odnotowane zarówno przez myszy, jak i ludzi.

Jeśli przyjrzysz się większości dostępnych w sieci testów porównawczych frameworków, zauważysz, że na szczycie są dwa języki: C++ i Rust. Pracowałem z C++ od lat 90. i miałem nawet własny framework Win32 C++, zanim jeszcze MFC/ATL stało się czymś powszechnym, więc mam duże doświadczenie z tym językiem. Praca z czymś, co już znasz, nie jest zbyt przyjemna, więc zamiast tego zrobimy wersję Rust. ;)

Rust jest stosunkowo nowym językiem programowania, ale stał się obiektem mojej ciekawości, gdy Linus Torvalds ogłosił, że zaakceptuje Rust jako język programowania jądra Linuksa. Dla nas, starszych programistów, to mniej więcej tak, jakby powiedzieć, że ta nowomodna, hipisowska nowość będzie nową poprawką do Konstytucji Stanów Zjednoczonych.

Teraz, gdy jesteś doświadczonym programistą, nie wskakujesz do pociągu tak szybko, jak robią to młodsi, bo możesz się poparzyć szybkimi zmianami w języku lub bibliotekach. (Każdy, kto używał pierwszej wersji AngularJS, wie, o czym mówię.) Rust jest wciąż w fazie eksperymentalnego rozwoju i uważam za zabawne, że tak wiele przykładów kodu w sieci nie kompiluje się już nawet z obecnymi wersjami pakietów.

Jednakże wydajność aplikacji Rust nie może być zaprzeczona. Jeśli nigdy nie próbowałeś ripgrep Lub fd-znajdź na dużych drzewach kodu źródłowego, zdecydowanie powinieneś je wypróbować. Są nawet dostępne dla większości dystrybucji Linuksa po prostu z menedżera pakietów. Wymieniasz gadatliwość na wydajność z Rust... działka wielosłowności dla działka wydajności.

Cały kod dla Rust jest trochę długi, więc przyjrzymy się tylko odpowiednim procedurom obsługi:

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

To jest o wiele bardziej skomplikowane niż wersje Python/Node.js...

Rust/Actix

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

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

I o wiele bardziej wydajny!

Nasz serwer Rust wykorzystujący Actix/deadpool_postgres z łatwością pokonuje naszego poprzedniego mistrza Starlette o +125%, ExpressJS o +362% i czysty PHP o +1366%. (Deltę wydajności pozostawię na przykładzie wersji Laravel jako ćwiczenie dla czytelnika.)

Odkryłem, że nauka samego języka Rust jest trudniejsza niż innych języków, ponieważ ma o wiele więcej pułapek niż cokolwiek, co widziałem poza 6502 Assembly, ale jeśli twój serwer Rust może obsłużyć 14 razy więcej użytkowników niż twój serwer PHP, to być może jest coś do zyskania ze zmiany technologii. Dlatego kolejna wersja Pafera Framework będzie oparta na Rust. Krzywa uczenia się jest o wiele wyższa niż w przypadku języków skryptowych, ale wydajność będzie tego warta. Jeśli nie możesz poświęcić czasu na naukę Rust, to oparcie swojego stosu technologicznego na Starlette lub Node.js również nie jest złą decyzją.

Dług techniczny

W ciągu ostatnich dwudziestu lat przeszliśmy od tanich statycznych witryn hostingowych do hostingu współdzielonego ze stosami LAMP, do wynajmu VPS-ów do AWS, Azure i innych usług w chmurze. Obecnie wiele firm zadowala się podejmowaniem decyzji projektowych na podstawie tego, kogo mogą znaleźć, kto jest dostępny lub najtańszy, ponieważ pojawienie się wygodnych usług w chmurze ułatwiło wrzucanie większej ilości sprzętu do wolnych serwerów i aplikacji. Dało im to duże krótkoterminowe zyski kosztem długoterminowego długu technicznego.

Ostrzeżenie Głównego Lekarza Kalifornii: To nie jest prawdziwy pies kosmiczny.

70 lat temu miał miejsce wielki wyścig kosmiczny między Związkiem Radzieckim a Stanami Zjednoczonymi. Sowieci odnieśli większość wczesnych kamieni milowych. Mieli pierwszego satelitę w Sputniku, pierwszego psa w kosmosie w Łajce, pierwszy statek kosmiczny na Księżyc w Łunie 2, pierwszego mężczyznę i kobietę w kosmosie w Juriju Gagarinie i Walentynie Tierieszkowej i tak dalej...

Jednak powoli gromadzili dług techniczny.

Chociaż Sowieci byli pierwsi w każdym z tych osiągnięć, ich procesy inżynieryjne i cele powodowały, że koncentrowali się na krótkoterminowych wyzwaniach, a nie na długoterminowej wykonalności. Wygrywali za każdym razem, gdy skakali, ale byli coraz bardziej zmęczeni i wolniejsi, podczas gdy ich przeciwnicy nadal stawiali stałe kroki w kierunku mety.

Kiedy Neil Armstrong postawił swoje historyczne kroki na księżycu w telewizji na żywo, Amerykanie objęli prowadzenie, a następnie pozostali tam, gdy radziecki program się załamał. Nie różni się to niczym od dzisiejszych firm, które skupiają się na kolejnej wielkiej rzeczy, kolejnej wielkiej wypłacie lub kolejnej wielkiej technologii, nie rozwijając jednocześnie właściwych nawyków i strategii na dłuższą metę.

Bycie pierwszym na rynku nie oznacza, że staniesz się dominującym graczem na tym rynku. Z drugiej strony poświęcenie czasu na robienie rzeczy właściwie nie gwarantuje sukcesu, ale z pewnością zwiększa szanse na długoterminowe osiągnięcia. Jeśli jesteś liderem technicznym w swojej firmie, wybierz właściwy kierunek i narzędzia do swojego obciążenia pracą. Nie pozwól, aby popularność zastąpiła wydajność i efektywność.

Zasoby

Chcesz pobrać plik 7z zawierający skrypty Rust, ExpressJS, Flask, Starlette i Pure PHP?

O autorze

Jim programuje odkąd dostał IBM PS/2 w latach 90. Do dziś woli pisać HTML i SQL ręcznie, a w swojej pracy skupia się na wydajności i poprawności.