Sí, Virginia, *hay* una Papá Noel Diferencia entre los frameworks web en 2023

El viaje de un programador desafiante para encontrar un código de servidor web de rápido rendimiento antes de sucumbir a la presión del mercado y la deuda técnica
2023-03-24 11:52:06
👁️ 777
💬 0

Contenido

  1. Introducción
  2. La prueba
  3. PHP/Laravel
  4. PHP puro
  5. Revisando Laravel
  6. Django
  7. Matraz
  8. Estrellita
  9. Node.js/ExpressJS
  10. Óxido/Actix
  11. Deuda técnica
  12. Recursos

Introducción

Después de una de mis últimas entrevistas de trabajo, me sorprendí al darme cuenta de que la empresa a la que me presenté todavía usaba Laravel, un framework PHP que probé hace una década. Era decente para la época, pero si hay una constante tanto en la tecnología como en la moda, es el cambio continuo y el resurgimiento de estilos y conceptos. Si eres un programador de JavaScript, probablemente estés familiarizado con este viejo chiste.

Programador 1: "¡No me gusta este nuevo marco de JavaScript!"

Programador 2: "No hay de qué preocuparse. ¡Sólo hay que esperar seis meses y habrá otro que lo reemplace!"

Por curiosidad, decidí ver exactamente qué sucede cuando ponemos a prueba lo antiguo y lo nuevo. Por supuesto, la web está llena de puntos de referencia y afirmaciones, de las cuales la más popular es probablemente la Puntos de referencia del marco web TechEmpower aquí . Sin embargo, hoy no vamos a hacer nada tan complicado como ellos. Mantendremos las cosas agradables y simples para que este artículo no se convierta en algo Guerra y paz , y que tendrás una pequeña posibilidad de permanecer despierto cuando termines de leer. Se aplican las advertencias habituales: esto podría no funcionar de la misma manera en tu máquina, las diferentes versiones de software pueden afectar el rendimiento y el gato de Schrödinger en realidad se convirtió en un gato zombi que estaba medio vivo y medio muerto al mismo tiempo.

La prueba

Entorno de prueba

Para esta prueba, utilizaré mi computadora portátil equipada con un pequeño i5 que ejecuta Manjaro Linux como se muestra aquí.

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

La tarea en cuestión

Nuestro código tendrá tres tareas simples para cada solicitud:

  1. Leer el ID de sesión del usuario actual desde una cookie
  2. Cargar información adicional desde una base de datos
  3. Devolver esa información al usuario

¿Qué clase de prueba tan tonta es esa?, te preguntarás. Bueno, si miras las solicitudes de red de esta página, verás que hay una llamada sessionvars.js que hace exactamente lo mismo.

El contenido de sessionvars.js

Verá, las páginas web modernas son criaturas complicadas, y una de las tareas más comunes es almacenar en caché páginas complejas para evitar una carga excesiva en el servidor de base de datos.

Si volvemos a renderizar una página compleja cada vez que un usuario la solicita, solo podremos atender a unos 600 usuarios por segundo.

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

Pero si almacenamos en caché esta página como un archivo HTML estático y dejamos que Nginx la envíe rápidamente al usuario, entonces podemos atender a 32.000 usuarios por segundo, lo que aumenta el rendimiento en un factor de 50x.

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

El index.en.html estático es la parte que va a todos, y solo las partes que difieren según el usuario se envían en sessionvars.js. Esto no solo reduce la carga de la base de datos y crea una mejor experiencia para nuestros usuarios, sino que también disminuye las probabilidades cuánticas de que nuestro servidor se vaporice espontáneamente en una brecha en el núcleo warp cuando los klingon ataquen.

Requisitos del código

El código devuelto para cada marco tendrá un requisito simple: mostrarle al usuario cuántas veces actualizó la página diciendo "La cantidad es x". Para simplificar las cosas, nos mantendremos alejados de las colas de Redis, los componentes de Kubernetes o AWS Lambdas por ahora.

Muestra cuántas veces has visitado la página

Los datos de la sesión de cada usuario se guardarán en una base de datos PostgreSQL.

La tabla de sesiones de usuario

Y esta tabla de base de datos se truncará antes de cada prueba.

La tabla después de ser truncada

Simple pero efectivo es el lema de Pafera... fuera de la línea de tiempo más oscura, de todos modos...

Los resultados reales de la prueba

PHP/Laravel

Bien, ahora finalmente podemos empezar a ponernos manos a la obra. Nos saltearemos la configuración de Laravel, ya que se trata solo de un montón de comandos de Composer y Artisan.

Primero, configuraremos nuestra base de datos en el archivo .env

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

Luego, estableceremos una única ruta de respaldo que envía cada solicitud a nuestro controlador.

Route::fallback(SessionController::class);

Y configure el controlador para que muestre el recuento. Laravel, por defecto, almacena las sesiones en la base de datos. También proporciona la session() función para interactuar con nuestros datos de sesión, por lo que todo lo que se necesitó fueron un par de líneas de código para renderizar nuestra página.

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

    $count  += 1;

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

    return 'Count is ' . $count;
  }
}

Después de configurar php-fpm y Nginx, nuestra página se ve bastante bien...

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

Al menos hasta que veamos los resultados de la prueba...

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

No, no es un error tipográfico. Nuestra máquina de prueba ha pasado de 600 solicitudes por segundo al procesar una página compleja... a 21 solicitudes por segundo al procesar "El recuento es 1".

¿Qué salió mal? ¿Hay algún problema con nuestra instalación de PHP? ¿Nginx se está volviendo más lento al interactuar con php-fpm?

PHP puro

Rehagamos esta página en código PHP puro.

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

Hemos utilizado 98 líneas de código para hacer lo que hacían cuatro líneas de código (y un montón de trabajo de configuración) en Laravel. (Por supuesto, si hiciéramos un manejo adecuado de los errores y de los mensajes para el usuario, esto sería aproximadamente el doble de la cantidad de líneas). ¿Quizás podamos llegar a 30 solicitudes por segundo?

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

Vaya, parece que no hay ningún problema con nuestra instalación de PHP. La versión PHP pura realiza 700 solicitudes por segundo.

Si no hay nada malo con PHP, ¿quizás configuramos mal Laravel?

Revisando Laravel

Después de buscar en Internet problemas de configuración y sugerencias de rendimiento, dos de las técnicas más populares eran almacenar en caché los datos de configuración y ruta para evitar procesarlos en cada solicitud. Por lo tanto, seguiremos su consejo y probaremos estos consejos.

╰─➤  php artisan config:cache

   INFO  Configuration cached successfully.  

╰─➤  php artisan route:cache

   INFO  Routes cached successfully.  

Todo parece correcto en la línea de comandos. Rehagamos la prueba comparativa.

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

Bueno, ahora hemos aumentado el rendimiento de 21,04 a 28,80 solicitudes por segundo, ¡un aumento espectacular de casi el 37%! Esto sería bastante impresionante para cualquier paquete de software... excepto por el hecho de que todavía estamos realizando solo 1/24 de la cantidad de solicitudes de la versión PHP pura.

Si cree que algo debe estar mal con esta prueba, debería hablar con el autor del marco PHP Lucinda. En los resultados de su prueba, ha Lucinda venciendo a Laravel por 36x para solicitudes HTML y 90x para solicitudes JSON.

Después de probar en mi propia máquina tanto con Apache como con Nginx, no tengo motivos para dudar de él. Laravel es realmente igual eso ¡Lento! PHP por sí solo no es tan malo, pero una vez que se suma todo el procesamiento adicional que Laravel agrega a cada solicitud, me resulta muy difícil recomendar Laravel como una opción en 2023.

Django

Cuentas PHP/Wordpress para aproximadamente el 40% de todos los sitios web en la web , lo que lo convierte, con diferencia, en el marco más dominante. Personalmente, sin embargo, considero que la popularidad no se traduce necesariamente en calidad, al igual que no me encuentro con un deseo repentino e incontrolable de esa extraordinaria comida gourmet de El restaurante más popular del mundo ... McDonald's. Como ya hemos probado el código PHP puro, no vamos a probar Wordpress en sí, ya que cualquier cosa que involucre a Wordpress sin duda sería inferior a las 700 solicitudes por segundo que observamos con PHP puro.

Django es otro framework popular que lleva mucho tiempo en activo. Si lo has usado en el pasado, probablemente recuerdes con cariño su espectacular interfaz de administración de bases de datos y lo molesto que era configurar todo tal y como querías. Veamos qué tan bien funciona Django en 2023, especialmente con la nueva interfaz ASGI que ha agregado a partir de la versión 4.0.

La configuración de Django es muy similar a la de Laravel, ya que ambas corresponden a la época en la que las arquitecturas MVC eran elegantes y correctas. Nos saltearemos la aburrida configuración y pasaremos directamente a configurar la vista.

from django.shortcuts import render
from django.http import HttpResponse

# =====================================================================
def index(request):
  count = request.session.get('count', 0)
  count += 1
  request.session['count']  = count 
  return HttpResponse(f"Count is {count}")

Cuatro líneas de código son iguales que en la versión de Laravel. Veamos cómo funciona.

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

No está nada mal, con 355 solicitudes por segundo. Es solo la mitad del rendimiento de la versión PHP pura, pero también es 12 veces mayor que la versión Laravel. Django vs. Laravel no parecen ser competencia en absoluto.

Matraz

Además de los frameworks más grandes que incluyen todo lo necesario, también hay frameworks más pequeños que solo realizan una configuración básica y te permiten encargarte del resto. Uno de los mejores para usar es Flask y su contraparte ASGI, Quart. Marco PaferaPy está construido sobre Flask, por lo que sé muy bien lo fácil que es hacer las cosas manteniendo el rendimiento.

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

Como puede ver, el script de Flask es más corto que el script de PHP puro. Considero que, de todos los lenguajes que he utilizado, Python es probablemente el lenguaje más expresivo en términos de pulsaciones de teclas. La falta de llaves y paréntesis, la comprensión de listas y diccionarios y el bloqueo basado en sangrías en lugar de puntos y comas hacen que Python sea bastante simple pero potente en sus capacidades.

Lamentablemente, Python también es el lenguaje de propósito general más lento que existe, a pesar de la cantidad de software que se ha escrito en él. La cantidad de bibliotecas de Python disponibles es aproximadamente cuatro veces mayor que la de lenguajes similares y cubre una gran cantidad de dominios, pero nadie diría que Python es rápido ni eficiente fuera de nichos como NumPy.

Veamos cómo se compara nuestra versión de Flask con nuestros marcos anteriores.

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

¡Nuestro script Flask es en realidad más rápido que nuestra versión PHP pura!

Si esto te sorprende, debes saber que nuestra aplicación Flask realiza toda su inicialización y configuración cuando iniciamos el servidor gunicorn, mientras que PHP vuelve a ejecutar el script cada vez que llega una nueva solicitud. Es equivalente a que Flask sea el joven y ansioso taxista que ya ha encendido el auto y está esperando al costado de la carretera, mientras que PHP es el viejo conductor que se queda en su casa esperando que llegue una llamada y solo entonces se acerca a recogerte. Siendo un tipo de la vieja escuela y viniendo de los días en que PHP era un cambio maravilloso para los archivos HTML y SHTML simples, es un poco triste darse cuenta de cuánto tiempo ha pasado, pero las diferencias de diseño realmente hacen que sea difícil para PHP competir contra los servidores Python, Java y Node.js que solo se quedan en la memoria y manejan la solicitud con la ágil facilidad de un malabarista.

Estrellita

Flask puede ser nuestro framework más rápido hasta el momento, pero en realidad es un software bastante antiguo. La comunidad Python cambió a los servidores ASGI asincrónicos más nuevos hace un par de años y, por supuesto, yo mismo me cambié con ellos.

La versión más nueva del Pafera Framework, PaferaPyAsync , se basa en Starlette. Aunque existe una versión ASGI de Flask llamada Quart, las diferencias de rendimiento entre Quart y Starlette fueron suficientes para que yo reestructurara mi código en Starlette.

La programación asincrónica puede asustar a mucha gente, pero en realidad no es un concepto difícil gracias a que los chicos de Node.js popularizaron el concepto hace más de una década.

Solíamos luchar contra la concurrencia con subprocesos múltiples, multiprocesamiento, computación distribuida, encadenamiento de promesas y todas esas épocas divertidas que envejecieron y desecaron prematuramente a muchos programadores veteranos. Ahora, simplemente escribimos async Delante de nuestras funciones y await delante de cualquier código que pueda tardar un tiempo en ejecutarse. De hecho, es más detallado que el código normal, pero mucho menos molesto de usar que tener que lidiar con primitivas de sincronización, paso de mensajes y resolución de promesas.

Nuestro archivo Starlette se ve así:

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

Como puede ver, está prácticamente copiado y pegado de nuestro script Flask con solo un par de cambios de enrutamiento y el async/await Palabras clave.

¿Cuánta mejora realmente puede aportarnos el código copiado y pegado?

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

¡Tenemos un nuevo campeón, damas y caballeros! Nuestro récord anterior fue nuestra versión PHP pura con 704 solicitudes por segundo, que luego fue superada por nuestra versión Flask con 1080 solicitudes por segundo. Nuestro script Starlette supera a todos los contendientes anteriores con 4562 solicitudes por segundo, lo que significa una mejora de 6 veces sobre PHP puro y 4 veces sobre Flask.

Si aún no ha cambiado su código Python de WSGI a ASGI, ahora podría ser un buen momento para comenzar.

Node.js/ExpressJS

Hasta ahora, solo hemos cubierto los frameworks PHP y Python. Sin embargo, una gran parte del mundo usa Java, DotNet, Node.js, Ruby on Rails y otras tecnologías similares para sus sitios web. Esta no es de ninguna manera una descripción general completa de todos los ecosistemas y biomas del mundo, por lo que para evitar hacer el equivalente en programación de la química orgánica, elegiremos solo los frameworks para los que es más fácil escribir código... entre los cuales Java definitivamente no es.

A menos que hayas estado escondido debajo de tu copia de K&R C o Knuth's El arte de la programación informática Durante los últimos quince años, probablemente hayas oído hablar de Node.js. Aquellos de nosotros que hemos estado en el mundo de JavaScript desde sus inicios estamos increíblemente asustados, asombrados o ambas cosas por el estado del JavaScript moderno, pero no se puede negar que JavaScript se ha convertido en una fuerza a tener en cuenta tanto en servidores como en navegadores. Después de todo, ¡ahora tenemos incluso enteros nativos de 64 bits en el lenguaje! ¡Eso es mucho mejor que todo almacenado en números flotantes de 64 bits!

ExpressJS es probablemente el servidor Node.js más fácil de usar, por lo que haremos una aplicación Node.js/ExpressJS rápida y sencilla para servir nuestro contador.

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

En realidad, este código fue más fácil de escribir que las versiones de Python, aunque el JavaScript nativo se vuelve bastante difícil de manejar cuando las aplicaciones se vuelven más grandes, y todos los intentos de corregir esto, como TypeScript, rápidamente se vuelven más detallados que Python.

¡Veamos cómo funciona esto!

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

Es posible que hayas oído cuentos populares antiguos (antiguos según los estándares de Internet, en todo caso) sobre la velocidad de Node.js, y esas historias son en su mayoría ciertas gracias al espectacular trabajo que Google ha hecho con el motor JavaScript V8. Sin embargo, en este caso, aunque nuestra aplicación rápida supera al script Flask, su naturaleza de un solo subproceso se ve derrotada por los cuatro procesos asincrónicos manejados por el Caballero Starlette que dice "¡Ni!".

¡Consigamos más ayuda!

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

¡Muy bien! ¡Ahora es una batalla de cuatro contra cuatro! ¡Hagamos un análisis comparativo!

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

Aún no está al nivel de Starlette, pero no está mal para un hack rápido de cinco minutos de JavaScript. Según mis propias pruebas, este script está un poco retrasado en el nivel de interfaz de la base de datos porque node-postgres no es tan eficiente como psycopg para Python. Cambiar a sqlite como controlador de la base de datos produce más de 3000 solicitudes por segundo para el mismo código ExpressJS.

Lo más importante a tener en cuenta es que, a pesar de la lenta velocidad de ejecución de Python, los marcos ASGI pueden competir con las soluciones Node.js para ciertas cargas de trabajo.

Óxido/Actix

Así que ahora nos estamos acercando a la cima de la montaña, y por montaña me refiero a los puntajes de referencia más altos registrados tanto por ratones como por hombres.

Si observas la mayoría de los benchmarks de frameworks disponibles en la web, notarás que hay dos lenguajes que tienden a dominar la lista: C++ y Rust. He trabajado con C++ desde los años 90, e incluso tuve mi propio framework Win32 C++ antes de que existiera MFC/ATL, por lo que tengo mucha experiencia con el lenguaje. No es muy divertido trabajar con algo cuando ya lo conoces, así que vamos a hacer una versión de Rust en su lugar. ;)

Rust es un lenguaje de programación relativamente nuevo, pero se convirtió en un objeto de curiosidad para mí cuando Linus Torvalds anunció que aceptaría Rust como lenguaje de programación del núcleo de Linux. Para nosotros, los programadores más veteranos, eso es casi lo mismo que decir que esta nueva y moderna novedad hippie iba a ser una nueva enmienda a la Constitución de los Estados Unidos.

Ahora bien, cuando eres un programador experimentado, no sueles subirte al carro tan rápido como lo hacen los más jóvenes, o de lo contrario podrías sufrir daños por los cambios rápidos en el lenguaje o las bibliotecas. (Cualquiera que haya usado la primera versión de AngularJS sabrá de qué estoy hablando). Rust todavía se encuentra en una etapa de desarrollo experimental, y me parece gracioso que tantos ejemplos de código en la web ya ni siquiera se compilen con las versiones actuales de los paquetes.

Sin embargo, no se puede negar el rendimiento que muestran las aplicaciones Rust. Si nunca has probado ripgrep o fd-encontrar Si está buscando grandes árboles de código fuente, definitivamente debería probarlos. Incluso están disponibles para la mayoría de las distribuciones de Linux simplemente desde el administrador de paquetes. Con Rust, está intercambiando verbosidad por rendimiento... lote de verbosidad para una lote de rendimiento.

El código completo de Rust es un poco grande, por lo que simplemente echaremos un vistazo a los controladores relevantes aquí:

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

Esto es mucho más complicado que las versiones 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

¡Y mucho más eficiente!

Nuestro servidor Rust que utiliza Actix/deadpool_postgres supera cómodamente a nuestro campeón anterior Starlette en un +125%, a ExpressJS en un +362% y a PHP puro en un +1366%. (Dejaré la diferencia de rendimiento con la versión de Laravel como ejercicio para el lector).

He descubierto que aprender el lenguaje Rust en sí mismo ha sido más difícil que otros lenguajes, ya que tiene muchos más problemas que cualquier cosa que haya visto fuera de 6502 Assembly, pero si su servidor Rust puede aceptar 14 veces la cantidad de usuarios que su servidor PHP, entonces tal vez haya algo que ganar con cambiar de tecnología después de todo. Es por eso que la próxima versión de Pafera Framework se basará en Rust. La curva de aprendizaje es mucho más alta que la de los lenguajes de scripting, pero el rendimiento valdrá la pena. Si no puede dedicar tiempo a aprender Rust, entonces basar su pila tecnológica en Starlette o Node.js tampoco es una mala decisión.

Deuda técnica

En los últimos veinte años, hemos pasado de sitios de alojamiento estático baratos a alojamiento compartido con pilas LAMP y luego a alquilar VPS en AWS, Azure y otros servicios en la nube. Hoy en día, muchas empresas se conforman con tomar decisiones de diseño en función de lo que encuentren que esté disponible o sea lo más barato posible, ya que la llegada de los servicios en la nube ha hecho que sea más fácil incorporar más hardware a servidores y aplicaciones lentos. Esto les ha proporcionado grandes ganancias a corto plazo a costa de una deuda técnica a largo plazo.

Advertencia del Cirujano General de California: Este no es un perro espacial real.

Hace 70 años, hubo una gran carrera espacial entre la Unión Soviética y los Estados Unidos. Los soviéticos ganaron la mayoría de los hitos iniciales. Tuvieron el primer satélite con el Sputnik, el primer perro en el espacio con Laika, la primera nave espacial lunar con Luna 2, el primer hombre y la primera mujer en el espacio con Yuri Gagarin y Valentina Tereshkova, y así sucesivamente...

Pero poco a poco iban acumulando deuda técnica.

Aunque los soviéticos fueron los primeros en lograr cada uno de estos logros, sus procesos y objetivos de ingeniería los estaban llevando a centrarse en los desafíos a corto plazo en lugar de en la viabilidad a largo plazo. Ganaban cada vez que se lanzaban, pero cada vez estaban más cansados y eran más lentos, mientras que sus oponentes seguían dando pasos firmes hacia la línea de meta.

Una vez que Neil Armstrong dio sus históricos pasos en la Luna en directo por televisión, los estadounidenses tomaron la delantera y luego se quedaron allí mientras el programa soviético flaqueaba. Esto no es diferente de lo que sucede hoy en día con las empresas que se han centrado en el próximo gran logro, el próximo gran resultado o la próxima gran tecnología, pero no han logrado desarrollar hábitos y estrategias adecuados para el largo plazo.

Ser el primero en llegar al mercado no significa que te convertirás en el jugador dominante en ese mercado. Por otra parte, tomarte el tiempo para hacer las cosas bien no garantiza el éxito, pero ciertamente aumenta tus posibilidades de lograr logros a largo plazo. Si eres el líder tecnológico de tu empresa, elige la dirección y las herramientas adecuadas para tu carga de trabajo. No dejes que la popularidad reemplace el rendimiento y la eficiencia.

Recursos

¿Quieres descargar un archivo 7z que contenga los scripts Rust, ExpressJS, Flask, Starlette y Pure PHP?

Sobre el Autor

Jim ha estado programando desde que compró un IBM PS/2 en los años 90. Hasta el día de hoy, todavía prefiere escribir HTML y SQL a mano y se concentra en la eficiencia y la corrección en su trabajo.