Sim, Virgínia, *existe* uma Papai Noel Diferença entre Web Frameworks em 2023

A jornada de um programador desafiador para encontrar um código de servidor web de desempenho rápido antes que ele sucumba à pressão do mercado e à dívida técnica
2023-03-24 11:52:06
👁️ 779
💬 0

Conteúdo

  1. Introdução
  2. O teste
  3. PHP/Laravel
  4. PHP puro
  5. Revisitando o Laravel
  6. Django
  7. Frasco
  8. Estrela
  9. Node.js/ExpressJS
  10. Ferrugem/Actix
  11. Dívida Técnica
  12. Recursos

Introdução

Depois de uma das minhas entrevistas de emprego mais recentes, fiquei surpreso ao perceber que a empresa para a qual me candidatei ainda estava usando Laravel, um framework PHP que experimentei há cerca de uma década. Era decente para a época, mas se há uma constante na tecnologia e na moda, é a mudança contínua e o ressurgimento de estilos e conceitos. Se você é um programador JavaScript, provavelmente está familiarizado com esta velha piada

Programador 1: "Não gosto desse novo framework JavaScript!"

Programador 2: "Não precisa se preocupar. Espere apenas seis meses e haverá outro para substituí-lo!"

Por curiosidade, decidi ver exatamente o que acontece quando colocamos o antigo e o novo à prova. Claro, a web está cheia de benchmarks e claims, dos quais o mais popular é provavelmente o Benchmarks do TechEmpower Web Framework aqui . Não faremos nada tão complicado quanto eles hoje. Manteremos as coisas agradáveis e simples para que este artigo não se torne Guerra e Paz , e que você terá uma pequena chance de ficar acordado até terminar de ler. As advertências usuais se aplicam: isso pode não funcionar da mesma forma na sua máquina, versões diferentes de software podem afetar o desempenho e o gato de Schrödinger na verdade se tornou um gato zumbi que estava meio vivo e meio morto exatamente ao mesmo tempo.

O teste

Ambiente de teste

Para este teste, usarei meu laptop equipado com um pequeno i5 executando o Manjaro Linux, como mostrado aqui.

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

A tarefa em mãos

Nosso código terá três tarefas simples para cada solicitação:

  1. Leia o ID da sessão do usuário atual de um cookie
  2. Carregar informações adicionais de um banco de dados
  3. Retornar essa informação ao usuário

Que tipo de teste idiota é esse, você pode perguntar? Bem, se você olhar as requisições de rede para esta página, você notará uma chamada sessionvars.js que faz exatamente a mesma coisa.

O conteúdo de sessionvars.js

Veja, as páginas da web modernas são criaturas complicadas, e uma das tarefas mais comuns é armazenar em cache páginas complexas para evitar carga excessiva no servidor de banco de dados.

Se renderizarmos novamente uma página complexa toda vez que um usuário a solicitar, só poderemos atender cerca de 600 usuários 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

Mas se armazenarmos essa página em cache como um arquivo HTML estático e deixarmos o Nginx rapidamente jogá-la pela janela para o usuário, poderemos atender 32.000 usuários por segundo, aumentando o desempenho em um fator 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

O index.en.html estático é a parte que vai para todos, e somente as partes que diferem por usuário são enviadas em sessionvars.js. Isso não apenas reduz a carga do banco de dados e cria uma melhor experiência para nossos usuários, mas também diminui as probabilidades quânticas de que nosso servidor vaporize espontaneamente em uma violação do núcleo de dobra quando os Klingons atacarem.

Requisitos de código

O código retornado para cada framework terá um requisito simples: mostrar ao usuário quantas vezes ele atualizou a página dizendo "Count is x". Para manter as coisas simples, ficaremos longe de filas Redis, componentes Kubernetes ou AWS Lambdas por enquanto.

Mostrando quantas vezes você visitou a página

Os dados da sessão de cada usuário serão salvos em um banco de dados PostgreSQL.

A tabela usersessions

E esta tabela de banco de dados será truncada antes de cada teste.

A tabela depois de ser truncada

Simples, mas eficaz, é o lema da Pafera... pelo menos fora da linha do tempo mais sombria...

Os resultados reais do teste

PHP/Laravel

Ok, então agora podemos finalmente começar a sujar as mãos. Vamos pular a configuração do Laravel, já que é só um monte de comandos composer e artisan.

Primeiro, configuraremos as configurações do nosso banco de dados no arquivo .env

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

Em seguida, definiremos uma única rota de fallback que envia todas as solicitações ao nosso controlador.

Route::fallback(SessionController::class);

E configure o controlador para exibir a contagem. O Laravel, por padrão, armazena sessões no banco de dados. Ele também fornece o session() função para interagir com nossos dados de sessão, então tudo o que foi preciso foram algumas linhas de código para renderizar nossa página.

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

    $count  += 1;

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

    return 'Count is ' . $count;
  }
}

Depois de configurar o php-fpm e o Nginx, nossa página ficou muito boa...

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

Pelo menos até vermos os resultados dos testes...

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

Não, isso não é um erro de digitação. Nossa máquina de teste passou de 600 solicitações por segundo renderizando uma página complexa... para 21 solicitações por segundo renderizando "Count is 1".

Então o que deu errado? Há algo errado com nossa instalação do PHP? O Nginx está de alguma forma ficando lento ao interagir com o php-fpm?

PHP puro

Vamos refazer esta página em 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'];

Agora usamos 98 linhas de código para fazer o que quatro linhas de código (e um monte de trabalho de configuração) no Laravel fizeram. (Claro, se fizéssemos o tratamento de erros adequado e mensagens voltadas para o usuário, isso seria cerca de duas vezes o número de linhas.) Talvez possamos chegar a 30 solicitações 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

Uau! Parece que não há nada de errado com nossa instalação do PHP, afinal. A versão PHP pura está fazendo 700 requisições por segundo.

Se não há nada de errado com o PHP, talvez tenhamos configurado o Laravel incorretamente?

Revisitando o Laravel

Depois de vasculhar a web em busca de problemas de configuração e dicas de desempenho, duas das técnicas mais populares eram armazenar em cache os dados de configuração e rota para evitar processá-los para cada solicitação. Portanto, seguiremos os conselhos deles e tentaremos essas dicas.

╰─➤  php artisan config:cache

   INFO  Configuration cached successfully.  

╰─➤  php artisan route:cache

   INFO  Routes cached successfully.  

Tudo parece bem na linha de comando. Vamos refazer o 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

Bem, agora aumentamos o desempenho de 21,04 para 28,80 requisições por segundo, um aumento dramático de quase 37%! Isso seria bem impressionante para qualquer pacote de software... exceto pelo fato de que ainda estamos fazendo apenas 1/24 do número de requisições da versão PHP pura.

Se você está pensando que algo deve estar errado com este teste, você deve falar com o autor do framework Lucinda PHP. Em seus resultados de teste, ele tem Lucinda derrotando Laravel em 36x para solicitações HTML e 90x para solicitações JSON.

Depois de testar na minha própria máquina com Apache e Nginx, não tenho motivos para duvidar dele. Laravel é realmente apenas que lento! O PHP por si só não é tão ruim, mas quando você adiciona todo o processamento extra que o Laravel adiciona a cada solicitação, então acho muito difícil recomendar o Laravel como uma escolha em 2023.

Django

Contas PHP/Wordpress para cerca de 40% de todos os sites na web , tornando-a de longe a estrutura mais dominante. Pessoalmente, porém, acho que popularidade não se traduz necessariamente em qualidade, assim como não me encontro tendo uma súbita e incontrolável vontade de comer aquela comida gourmet extraordinária de o restaurante mais popular do mundo ... McDonald's. Como já testamos código PHP puro, não vamos testar o Wordpress em si, pois qualquer coisa envolvendo o Wordpress seria, sem dúvida, menor do que as 700 requisições por segundo que observamos com PHP puro.

Django é outro framework popular que já existe há muito tempo. Se você já o usou no passado, provavelmente está se lembrando com carinho de sua espetacular interface de administração de banco de dados, além de quão irritante era configurar tudo do jeito que você queria. Vamos ver o quão bem o Django funciona em 2023, especialmente com a nova interface ASGI que ele adicionou a partir da versão 4.0.

Configurar o Django é notavelmente similar a configurar o Laravel, pois ambos eram da época em que as arquiteturas MVC eram estilosas e corretas. Vamos pular a configuração chata e ir direto para a configuração da view.

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

Quatro linhas de código são as mesmas da versão Laravel. Vamos ver como ele se sai.

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

Nada mal, com 355 requisições por segundo. É apenas metade do desempenho da versão PHP pura, mas também é 12x maior que a versão Laravel. Django vs. Laravel parece não haver competição alguma.

Frasco

Além das estruturas maiores, tudo-incluindo-a-pia-da-cozinha, também há estruturas menores que apenas fazem alguma configuração básica, enquanto permitem que você cuide do resto. Uma das melhores para usar é o Flask e sua contraparte ASGI Quart. Minha própria Estrutura PaferaPy é construído sobre o Flask, então estou bem familiarizado com o quão fácil é fazer as coisas mantendo o desempenho.

#!/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 você pode ver, o script Flask é mais curto do que o script PHP puro. Acho que, de todas as linguagens que usei, Python é provavelmente a linguagem mais expressiva em termos de teclas digitadas. A ausência de chaves e parênteses, compreensões de lista e dicionário e bloqueio baseado em recuo em vez de ponto e vírgula tornam o Python bastante simples, mas poderoso em suas capacidades.

Infelizmente, Python também é a linguagem de propósito geral mais lenta que existe, apesar de quanto software foi escrito nela. O número de bibliotecas Python disponíveis é cerca de quatro vezes maior do que linguagens similares e cobre uma vasta quantidade de domínios, mas ninguém diria que Python é rápida ou performática fora de nichos como NumPy.

Vamos ver como nossa versão Flask se compara aos nossos frameworks 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

Nosso script Flask é realmente mais rápido que nossa versão PHP pura!

Se você está surpreso com isso, você deve perceber que nosso aplicativo Flask faz toda a sua inicialização e configuração quando iniciamos o servidor gunicorn, enquanto o PHP reexecuta o script toda vez que uma nova solicitação chega. É equivalente ao Flask ser o jovem e ansioso motorista de táxi que já ligou o carro e está esperando na beira da estrada, enquanto o PHP é o velho motorista que fica em casa esperando uma chamada para só então dirigir para buscá-lo. Sendo um cara da velha escola e vindo dos dias em que o PHP era uma mudança maravilhosa para arquivos HTML e SHTML simples, é um pouco triste perceber quanto tempo se passou, mas as diferenças de design realmente tornam difícil para o PHP competir com servidores Python, Java e Node.js que apenas ficam na memória e lidam com solicitações com a facilidade ágil de um malabarista.

Estrela

Flask pode ser nosso framework mais rápido até agora, mas na verdade é um software bem antigo. A comunidade Python mudou para os servidores ASGI assíncronos mais novos alguns anos atrás, e, claro, eu mesmo mudei junto com eles.

A versão mais recente do Pafera Framework, PaferaPyAsync , é baseado no Starlette. Embora haja uma versão ASGI do Flask chamada Quart, as diferenças de desempenho entre Quart e Starlette foram suficientes para que eu rebaseasse meu código no Starlette.

A programação assíncrona pode ser assustadora para muitas pessoas, mas na verdade não é um conceito difícil, graças aos caras do Node.js que popularizaram o conceito há mais de uma década.

Costumávamos lutar contra a simultaneidade com multithreading, multiprocessamento, computação distribuída, encadeamento de promessas e todos aqueles momentos divertidos que envelheceram prematuramente e desidrataram muitos programadores veteranos. Agora, nós apenas digitamos async em frente às nossas funções e await na frente de qualquer código que possa levar um tempo para executar. Ele é de fato mais verboso do que código regular, mas muito menos irritante de usar do que ter que lidar com primitivas de sincronização, passagem de mensagens e resolução de promessas.

Nosso arquivo Starlette se parece com isso:

#!/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 você pode ver, ele foi praticamente copiado e colado do nosso script Flask com apenas algumas alterações de roteamento e o async/await palavras-chave.

Quanta melhoria o código copiado e colado realmente pode nos proporcionar?

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

Temos um novo campeão, senhoras e senhores! Nosso recorde anterior foi nossa versão PHP pura com 704 solicitações por segundo, que foi então ultrapassada por nossa versão Flask com 1080 solicitações por segundo. Nosso script Starlette esmaga todos os concorrentes anteriores com 4562 solicitações por segundo, o que significa uma melhoria de 6x sobre PHP puro e 4x sobre Flask.

Se você ainda não alterou seu código Python WSGI para ASGI, agora pode ser um bom momento para começar.

Node.js/ExpressJS

Até agora, cobrimos apenas frameworks PHP e Python. No entanto, uma grande parte do mundo realmente usa Java, DotNet, Node.js, Ruby on Rails e outras tecnologias semelhantes para seus sites. Esta não é de forma alguma uma visão geral abrangente de todos os ecossistemas e biomas do mundo, então, para evitar fazer o equivalente de programação da química orgânica, escolheremos apenas os frameworks que são mais fáceis de digitar código... dos quais Java definitivamente não é.

A menos que você esteja se escondendo debaixo de sua cópia de K&amp;R C ou Knuth's A Arte da Programação de Computadores nos últimos quinze anos, você provavelmente ouviu falar do Node.js. Aqueles de nós que estão por aí desde o início do JavaScript estão incrivelmente assustados, surpresos ou ambos com o estado do JavaScript moderno, mas não há como negar que o JavaScript se tornou uma força a ser reconhecida em servidores e navegadores. Afinal, agora temos até inteiros nativos de 64 bits na linguagem! Isso é muito melhor do que tudo ser armazenado em floats de 64 bits, de longe!

O ExpressJS é provavelmente o servidor Node.js mais fácil de usar, então faremos um aplicativo Node.js/ExpressJS rápido e prático para atender ao nosso 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}`));

Na verdade, esse código foi mais fácil de escrever do que as versões em Python, embora o JavaScript nativo se torne um tanto complicado quando os aplicativos se tornam maiores, e todas as tentativas de corrigir isso, como o TypeScript, rapidamente se tornam mais prolixas do que o Python.

Vamos ver como isso funciona!

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

Você pode ter ouvido contos populares antigos (antigos para os padrões da Internet, pelo menos...) sobre a velocidade do Node.js, e essas histórias são, em sua maioria, verdadeiras graças ao trabalho espetacular que o Google fez com o mecanismo V8 JavaScript. Neste caso, embora nosso aplicativo rápido tenha melhor desempenho que o script Flask, sua natureza de thread único é derrotada pelos quatro processos assíncronos exercidos pelo Starlette Knight que diz &quot;Ni!&quot;.

Vamos conseguir mais ajuda!

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

Certo! Agora é uma batalha equilibrada de quatro contra quatro! Vamos fazer um 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

Ainda não está no nível do Starlette, mas não é ruim para um hack rápido de JavaScript de cinco minutos. Pelos meus próprios testes, esse script está sendo retido um pouco no nível de interface do banco de dados porque o node-postgres não é nem de longe tão eficiente quanto o psycopg é para Python. Mudar para sqlite como driver de banco de dados gera mais de 3000 solicitações por segundo para o mesmo código ExpressJS.

O principal a ser observado é que, apesar da lenta velocidade de execução do Python, as estruturas ASGI podem realmente ser competitivas com as soluções Node.js para determinadas cargas de trabalho.

Ferrugem/Actix

Então agora estamos chegando mais perto do topo da montanha, e por montanha, quero dizer as maiores pontuações de referência registradas por ratos e homens.

Se você olhar para a maioria dos benchmarks de framework disponíveis na web, você notará que há duas linguagens que tendem a dominar o topo: C++ e Rust. Eu trabalho com C++ desde os anos 90, e eu até tive meu próprio framework Win32 C++ antes do MFC/ATL ser uma coisa, então eu tenho muita experiência com a linguagem. Não é muito divertido trabalhar com algo quando você já o conhece, então vamos fazer uma versão Rust em vez disso. ;)

Rust é relativamente novo no que diz respeito a linguagens de programação, mas se tornou um objeto de curiosidade para mim quando Linus Torvalds anunciou que aceitaria Rust como uma linguagem de programação do kernel Linux. Para nós, programadores mais velhos, isso é quase o mesmo que dizer que essa coisa hippie da nova era seria uma nova emenda à Constituição dos EUA.

Agora, quando você é um programador experiente, você tende a não embarcar na onda tão rápido quanto os mais jovens, ou então você pode se queimar com mudanças rápidas na linguagem ou bibliotecas. (Qualquer um que tenha usado a primeira versão do AngularJS saberá do que estou falando.) O Rust ainda está meio que naquele estágio de desenvolvimento experimental, e eu acho engraçado que muitos exemplos de código na web nem compilam mais com versões atuais de pacotes.

No entanto, o desempenho mostrado pelos aplicativos Rust não pode ser negado. Se você nunca tentou ripgrep ou fd-encontrar em grandes árvores de código-fonte, você definitivamente deveria experimentá-los. Eles estão disponíveis até mesmo para a maioria das distribuições Linux simplesmente a partir do gerenciador de pacotes. Você está trocando verbosidade por desempenho com Rust... um muito de verbosidade para um muito de desempenho.

O código completo para Rust é um pouco grande, então vamos apenas dar uma olhada nos manipuladores relevantes aqui:

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

Isso é muito mais complicado do que as versões 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

E muito mais performático!

Nosso servidor Rust usando Actix/deadpool_postgres supera facilmente nosso campeão anterior Starlette em +125%, ExpressJS em +362% e PHP puro em +1366%. (Deixarei o delta de desempenho com a versão Laravel como um exercício para o leitor.)

Descobri que aprender a linguagem Rust em si tem sido mais difícil do que outras linguagens, pois ela tem muito mais problemas do que qualquer coisa que já vi fora do 6502 Assembly, mas se seu servidor Rust pode assumir 14x o número de usuários que seu servidor PHP, então talvez haja algo a ser ganho com a troca de tecnologias, afinal. É por isso que a próxima versão do Pafera Framework será baseada em Rust. A curva de aprendizado é muito maior do que as linguagens de script, mas o desempenho valerá a pena. Se você não pode investir tempo para aprender Rust, então basear sua pilha de tecnologia em Starlette ou Node.js também não é uma má decisão.

Dívida Técnica

Nos últimos vinte anos, passamos de sites de hospedagem estática baratos para hospedagem compartilhada com pilhas LAMP para alugar VPSes para AWS, Azure e outros serviços de nuvem. Hoje em dia, muitas empresas estão satisfeitas em tomar decisões de design com base em quem elas podem encontrar que esteja disponível ou seja mais barato, já que o advento de serviços de nuvem convenientes tornou fácil jogar mais hardware em servidores e aplicativos lentos. Isso lhes deu grandes ganhos de curto prazo ao custo de dívida técnica de longo prazo.

Aviso do Cirurgião Geral da Califórnia: Este não é um cão espacial de verdade.

Há 70 anos, houve uma grande corrida espacial entre a União Soviética e os Estados Unidos. Os soviéticos venceram a maioria dos marcos iniciais. Eles tiveram o primeiro satélite em Sputnik, o primeiro cão no espaço em Laika, a primeira nave espacial lunar em Luna 2, o primeiro homem e a primeira mulher no espaço em Yuri Gagarin e Valentina Tereshkova, e assim por diante...

Mas eles estavam lentamente acumulando dívida técnica.

Embora os soviéticos tenham sido os primeiros em cada uma dessas conquistas, seus processos e objetivos de engenharia estavam fazendo com que eles se concentrassem em desafios de curto prazo em vez de viabilidade de longo prazo. Eles venceram cada vez que saltavam, mas estavam ficando mais cansados e mais lentos enquanto seus oponentes continuavam a dar passos consistentes em direção à linha de chegada.

Depois que Neil Armstrong deu seus passos históricos na lua na televisão ao vivo, os americanos assumiram a liderança e permaneceram lá enquanto o programa soviético vacilava. Isso não é diferente das empresas de hoje que se concentraram na próxima grande coisa, na próxima grande recompensa ou na próxima grande tecnologia, enquanto falharam em desenvolver hábitos e estratégias adequados para o longo prazo.

Ser o primeiro a chegar ao mercado não significa que você se tornará o player dominante naquele mercado. Alternativamente, reservar um tempo para fazer as coisas direito não garante sucesso, mas certamente aumenta suas chances de realizações a longo prazo. Se você é o líder de tecnologia da sua empresa, escolha a direção e as ferramentas certas para sua carga de trabalho. Não deixe que a popularidade substitua o desempenho e a eficiência.

Recursos

Quer baixar um arquivo 7z contendo os scripts Rust, ExpressJS, Flask, Starlette e Pure PHP?

Sobre o autor

Jim programa desde que ganhou um IBM PS/2 nos anos 90. Até hoje, ele ainda prefere escrever HTML e SQL à mão, e foca em eficiência e correção em seu trabalho.