Да, Вирджиния, есть *там* Санта Клаус Разница между веб-фреймворками в 2023 году

Один непокорный программист пытается найти быстродействующий код веб-сервера, прежде чем он поддастся давлению рынка и техническому долгу
2023-03-24 11:52:06
👁️ 792
💬 0

Содержание

  1. Введение
  2. Тест
  3. PHP/Laravel
  4. Чистый PHP
  5. Возвращаясь к Laravel
  6. Джанго
  7. Фляга
  8. Старлетт
  9. Node.js/ExpressJS
  10. Rust/Actix
  11. Технический долг
  12. Ресурсы

Введение

После одного из последних собеседований я с удивлением обнаружил, что компания, в которую я подавал заявку, все еще использует Laravel, PHP-фреймворк, который я пробовал около десяти лет назад. Он был приличным для того времени, но если есть что-то постоянное в технологиях и моде, так это постоянные изменения и обновление стилей и концепций. Если вы программист JavaScript, вы, вероятно, знакомы с этой старой шуткой

Программист 1: «Мне не нравится этот новый фреймворк JavaScript!»

Программист 2: «Не стоит беспокоиться. Подождите полгода, и на смену ему придет другой!»

Из любопытства я решил посмотреть, что именно произойдет, если мы проверим старое и новое. Конечно, в сети полно бенчмарков и заявлений, из которых самым популярным, вероятно, является Тесты TechEmpower Web Framework здесь . Мы сегодня не будем делать ничего столь же сложного, как они. Мы сделаем все просто и красиво, чтобы эта статья не превратилась в Война и мир , и что у вас будет небольшой шанс не заснуть к тому времени, как вы закончите читать. Применимы обычные предостережения: это может работать не так на вашем компьютере, разные версии программного обеспечения могут влиять на производительность, и кот Шредингера фактически стал котом-зомби, который был полуживым и полумертвым в одно и то же время.

Тест

Тестовая среда

Для этого теста я буду использовать свой ноутбук, оснащенный слабеньким i5, работающим под управлением Manjaro Linux, как показано здесь.

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

Задача на данный момент

Наш код будет иметь три простые задачи для каждого запроса:

  1. Прочитать идентификатор сеанса текущего пользователя из файла cookie
  2. Загрузить дополнительную информацию из базы данных
  3. Верните эту информацию пользователю.

Что это за идиотский тест, спросите вы? Ну, если вы посмотрите на сетевые запросы для этой страницы, вы заметите один под названием sessionvars.js, который делает то же самое.

Содержимое sessionvars.js

Видите ли, современные веб-страницы — сложные создания, и одной из наиболее распространенных задач является кэширование сложных страниц, чтобы избежать избыточной нагрузки на сервер базы данных.

Если мы будем повторно отображать сложную страницу каждый раз, когда ее запрашивает пользователь, то мы сможем обслуживать только около 600 пользователей в секунду.

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

Но если мы кэшируем эту страницу как статический HTML-файл и позволим Nginx быстро вывести ее пользователю, то мы сможем обслуживать 32 000 пользователей в секунду, увеличив производительность в 50 раз.

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

Статический index.en.html — это часть, которая отправляется всем, и только те части, которые отличаются у разных пользователей, отправляются в sessionvars.js. Это не только снижает нагрузку на базу данных и создает лучший опыт для наших пользователей, но и снижает квантовые вероятности того, что наш сервер спонтанно испарится при прорыве варп-ядра, когда атакуют клингоны.

Требования к кодексу

Возвращаемый код для каждого фреймворка будет иметь одно простое требование: показать пользователю, сколько раз он обновил страницу, сказав «Count is x». Чтобы не усложнять, мы пока воздержимся от очередей Redis, компонентов Kubernetes или AWS Lambdas.

Показывает, сколько раз вы посещали страницу

Данные сеанса каждого пользователя будут сохранены в базе данных PostgreSQL.

Таблица сеансов пользователей

И эта таблица базы данных будет усекаться перед каждым тестом.

Таблица после усечения

Простой, но эффективный девиз Pafera... во всяком случае, за пределами самой темной временной линии...

Фактические результаты теста

PHP/Laravel

Хорошо, теперь мы наконец можем начать пачкать руки. Мы пропустим настройку Laravel, так как это просто куча команд composer и artisan.

Сначала мы настроим параметры нашей базы данных в файле .env.

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

Затем мы установим один резервный маршрут, который будет отправлять все запросы нашему контроллеру.

Route::fallback(SessionController::class);

И настройте контроллер на отображение количества. Laravel по умолчанию хранит сессии в базе данных. Он также предоставляет session() функция для взаимодействия с данными нашего сеанса, поэтому для отображения нашей страницы потребовалось всего несколько строк кода.

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

    $count  += 1;

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

    return 'Count is ' . $count;
  }
}

После настройки php-fpm и Nginx наша страница выглядит довольно хорошо...

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

По крайней мере, пока мы не увидим результаты теста...

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

Нет, это не опечатка. Наша тестовая машина перешла от 600 запросов в секунду при рендеринге сложной страницы... к 21 запросу в секунду при рендеринге «Count is 1».

Так что же пошло не так? Что-то не так с нашей установкой PHP? Nginx как-то тормозит при взаимодействии с php-fpm?

Чистый PHP

Давайте переделаем эту страницу на чистом 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'];

Теперь мы использовали 98 строк кода, чтобы сделать то, что сделали четыре строки кода (и целый ряд работы по настройке) в Laravel. (Конечно, если бы мы правильно обрабатывали ошибки и сообщения, выводимые пользователю, это было бы примерно в два раза больше строк.) Возможно, мы сможем достичь 30 запросов в секунду?

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

Ого! Похоже, с нашей установкой PHP все в порядке. Чистая версия PHP выполняет 700 запросов в секунду.

Если с PHP все в порядке, возможно, мы неправильно настроили Laravel?

Возвращаясь к Laravel

После прочёсывания интернета на предмет проблем с конфигурацией и советов по производительности, два самых популярных метода заключались в кэшировании данных конфигурации и маршрутизации, чтобы избежать их обработки для каждого запроса. Поэтому мы воспользуемся их советом и попробуем эти советы.

╰─➤  php artisan config:cache

   INFO  Configuration cached successfully.  

╰─➤  php artisan route:cache

   INFO  Routes cached successfully.  

В командной строке все выглядит хорошо. Давайте повторим бенчмарк.

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

Ну, теперь мы увеличили производительность с 21,04 до 28,80 запросов в секунду, резкий рост почти на 37%! Это было бы весьма впечатляюще для любого программного пакета... если бы не тот факт, что мы все еще делаем только 1/24 от количества запросов чистой версии PHP.

Если вы думаете, что что-то не так с этим тестом, вам следует поговорить с автором фреймворка Lucinda PHP. В результатах своего теста он Люсинда побеждает Ларавеля в 36 раз для HTML-запросов и в 90 раз для JSON-запросов.

После тестирования на моей собственной машине с Apache и Nginx у меня нет причин сомневаться в нем. Laravel на самом деле просто что медленно! PHP сам по себе не так уж и плох, но если добавить всю дополнительную обработку, которую Laravel добавляет к каждому запросу, то мне будет очень сложно рекомендовать Laravel в качестве выбора в 2023 году.

Джанго

Учетные записи PHP/Wordpress для около 40% всех веб-сайтов в Интернете , что делает его самым доминирующим фреймворком. Лично я считаю, что популярность не обязательно переходит в качество, так же как я не обнаруживаю у себя внезапного неконтролируемого желания получить эту необычную изысканную еду из самый популярный ресторан в мире ... McDonald's. Поскольку мы уже протестировали чистый PHP-код, мы не будем тестировать сам Wordpress, поскольку все, что касается Wordpress, несомненно, будет ниже 700 запросов в секунду, которые мы наблюдали с чистым PHP.

Django — еще один популярный фреймворк, который существует уже долгое время. Если вы использовали его в прошлом, вы, вероятно, с теплотой вспоминаете его впечатляющий интерфейс администрирования баз данных, а также то, как раздражало настраивать все именно так, как вам хотелось. Давайте посмотрим, насколько хорошо Django работает в 2023 году, особенно с новым интерфейсом ASGI, который он добавил в версии 4.0.

Настройка Django удивительно похожа на настройку Laravel, поскольку они оба из эпохи, когда архитектура MVC была стильной и правильной. Мы пропустим скучную конфигурацию и перейдем сразу к настройке представления.

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

Четыре строки кода такие же, как и в версии Laravel. Посмотрим, как это работает.

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

Совсем неплохо, 355 запросов в секунду. Это всего лишь половина производительности версии на чистом PHP, но и в 12 раз больше, чем у версии Laravel. Django против Laravel, похоже, вообще не конкуренты.

Фляга

Помимо больших фреймворков «все, включая кухонную раковину», есть также меньшие фреймворки, которые просто выполняют некоторые базовые настройки, позволяя вам заниматься всем остальным. Один из лучших для использования — Flask и его аналог ASGI Quart. Мой собственный Фреймворк PaferaPy построен на основе Flask, поэтому я хорошо знаю, насколько легко с его помощью выполнять задачи, сохраняя при этом производительность.

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

Как вы видите, скрипт Flask короче чистого скрипта PHP. Я обнаружил, что из всех языков, которые я использовал, Python, вероятно, является самым выразительным языком с точки зрения набранных нажатий клавиш. Отсутствие фигурных скобок и скобок, списочных и словарных включений и блокировка на основе отступов, а не точек с запятой, делают Python довольно простым, но мощным по своим возможностям.

К сожалению, Python также является самым медленным языком общего назначения, несмотря на то, сколько программного обеспечения было написано на нем. Число доступных библиотек Python примерно в четыре раза больше, чем у подобных языков, и охватывает огромное количество доменов, однако никто не скажет, что Python быстрый или производительный за пределами таких ниш, как NumPy.

Давайте сравним нашу версию Flask с нашими предыдущими фреймворками.

Python/Flask

╰─➤  gunicorn --access-logfile - -w 4 flasksite:app
[2023-03-21 15:32:49 +0800] [2856296] [INFO] Starting gunicorn 20.1.0

╰─➤  wrk -d 10s -t 4 -c 100 http://127.0.0.1:8000
Running 10s test @ http://127.0.0.1:8000
  4 threads and 100 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency    91.84ms   11.97ms 149.63ms   86.18%
    Req/Sec   272.04     39.05   380.00     74.50%
  10842 requests in 10.04s, 3.27MB read
Requests/sec:   1080.28
Transfer/sec:    333.37KB

Наш скрипт Flask на самом деле быстрее, чем наша версия на чистом PHP!

Если вас это удивляет, вы должны понимать, что наше приложение Flask выполняет всю свою инициализацию и настройку, когда мы запускаем сервер gunicorn, в то время как PHP повторно выполняет скрипт каждый раз, когда поступает новый запрос. Это эквивалентно тому, что Flask — это молодой, энергичный таксист, который уже завел машину и ждет на обочине дороги, в то время как PHP — это старый водитель, который остается дома в ожидании звонка и только потом едет за вами. Будучи парнем старой школы и выходцем из тех времен, когда PHP был прекрасным изменением для простых файлов HTML и SHTML, немного грустно осознавать, как много времени прошло, но различия в дизайне действительно затрудняют для PHP конкуренцию с серверами Python, Java и Node.js, которые просто остаются в памяти и обрабатывают запросы с ловкой легкостью жонглера.

Старлетт

Flask, возможно, наш самый быстрый фреймворк на данный момент, но на самом деле это довольно старое ПО. Сообщество Python перешло на новые асинхронные серверы ASGI пару лет назад, и, конечно, я сам перешел вместе с ними.

Новейшая версия Pafera Framework, PaferaPyAsync , основан на Starlette. Хотя существует ASGI-версия Flask под названием Quart, различий в производительности между Quart и Starlette оказалось достаточно, чтобы я перенес свой код на Starlette.

Асинхронное программирование может пугать многих людей, но на самом деле это не такая уж сложная концепция благодаря разработчикам Node.js, популяризировавшим ее более десяти лет назад.

Мы боролись с параллелизмом с помощью многопоточности, многопроцессорности, распределенных вычислений, цепочки обещаний и всех тех забавных моментов, которые преждевременно состарили и иссушили многих ветеранов-программистов. Теперь мы просто печатаем async перед нашими функциями и await перед любым кодом, выполнение которого может занять некоторое время. Он действительно более многословен, чем обычный код, но гораздо менее раздражает в использовании, чем необходимость иметь дело с примитивами синхронизации, передачей сообщений и разрешением обещаний.

Наш файл Starlette выглядит так:

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

Как вы можете видеть, он в значительной степени скопирован и вставлен из нашего скрипта Flask с несколькими изменениями в маршрутизации и async/await ключевые слова.

Насколько реальное улучшение может дать нам копирование и вставка кода?

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

У нас новый чемпион, дамы и господа! Нашим предыдущим рекордом была наша чистая версия PHP в 704 запроса в секунду, которую затем обогнала наша версия Flask в 1080 запросов в секунду. Наш скрипт Starlette сокрушает всех предыдущих соперников в 4562 запроса в секунду, что означает 6-кратное улучшение по сравнению с чистым PHP и 4-кратное улучшение по сравнению с Flask.

Если вы еще не изменили свой код Python WSGI на ASGI, сейчас самое время начать.

Node.js/ExpressJS

До сих пор мы рассматривали только фреймворки PHP и Python. Однако большая часть мира на самом деле использует Java, DotNet, Node.js, Ruby on Rails и другие подобные технологии для своих веб-сайтов. Это ни в коем случае не всеобъемлющий обзор всех мировых экосистем и биомов, поэтому, чтобы избежать программирования, эквивалентного органической химии, мы выберем только фреймворки, для которых проще всего набирать код... к которым Java определенно не относится.

Если только вы не прятались под своей копией K&amp;R C или Кнута Искусство программирования за последние пятнадцать лет вы, вероятно, слышали о Node.js. Те из нас, кто был с самого начала JavaScript, либо невероятно напуганы, либо поражены, либо и то, и другое состоянием современного JavaScript, но нельзя отрицать, что JavaScript стал силой, с которой приходится считаться как на серверах, так и в браузерах. В конце концов, теперь в языке есть даже собственные 64-битные целые числа! Это намного лучше, чем все, что хранится в 64-битных числах с плавающей точкой!

ExpressJS, вероятно, является самым простым в использовании сервером Node.js, поэтому мы сделаем быстрое и грубое приложение Node.js/ExpressJS для обслуживания нашего счетчика.

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

На самом деле этот код было проще писать, чем версии на Python, хотя собственный JavaScript становится довольно громоздким, когда приложения становятся больше, и все попытки исправить это, такие как TypeScript, быстро становятся более многословными, чем Python.

Давайте посмотрим, как это сработает!

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

Вы, возможно, слышали древние (древние по меркам Интернета, во всяком случае...) сказки о скорости Node.js, и эти истории в основном правдивы благодаря впечатляющей работе, которую Google проделала с движком JavaScript V8. Однако в этом случае, хотя наше быстрое приложение превосходит скрипт Flask, его однопоточная природа терпит поражение из-за четырех асинхронных процессов, которыми владеет рыцарь-звездочка, говорящий «Ни!».

Давайте позовем еще немного помощи!

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

Ладно! Теперь это равный бой четыре на четыре! Давайте сравним!

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

Все еще не на уровне Starlette, но для быстрого пятиминутного хака JavaScript это неплохо. По моим собственным тестам, этот скрипт на самом деле немного задерживается на уровне интерфейса базы данных, потому что node-postgres далеко не так эффективен, как psycopg для Python. Переход на sqlite в качестве драйвера базы данных дает более 3000 запросов в секунду для того же кода ExpressJS.

Главное, что следует отметить, это то, что, несмотря на низкую скорость выполнения Python, фреймворки ASGI на самом деле могут конкурировать с решениями Node.js для определенных рабочих нагрузок.

Rust/Actix

Итак, теперь мы приближаемся к вершине горы, а под горой я подразумеваю самые высокие результаты тестов, зафиксированные как мышами, так и людьми.

Если вы посмотрите на большинство тестов фреймворков, доступных в сети, вы заметите, что есть два языка, которые, как правило, доминируют в топе: C++ и Rust. Я работаю с C++ с 90-х годов, и у меня даже был свой собственный фреймворк Win32 C++ еще до того, как MFC/ATL стали чем-то, так что у меня большой опыт работы с этим языком. Не очень-то весело работать с чем-то, что ты уже знаешь, поэтому мы собираемся вместо этого сделать версию Rust. ;)

Rust относительно новый язык программирования, но он стал для меня предметом любопытства, когда Линус Торвальдс объявил, что примет Rust в качестве языка программирования ядра Linux. Для нас, программистов постарше, это примерно то же самое, что сказать, что эта новомодная хиппи-штучка нового века станет новой поправкой к Конституции США.

Теперь, когда вы опытный программист, вы, как правило, не спешит присоединяться к моде так быстро, как это делают молодые ребята, иначе вы можете обжечься из-за быстрых изменений в языке или библиотеках. (Любой, кто использовал первую версию AngularJS, поймет, о чем я говорю.) Rust все еще находится в некоторой степени на экспериментальной стадии разработки, и мне кажется забавным, что многие примеры кода в Интернете даже больше не компилируются с текущими версиями пакетов.

Однако производительность, которую демонстрируют приложения Rust, отрицать нельзя. Если вы никогда не пробовали рипгреп или fd-найти на больших исходных кодах, вам определенно стоит их попробовать. Они даже доступны для большинства дистрибутивов Linux просто из менеджера пакетов. Вы обмениваете многословие на производительность с Rust... много многословия для много производительности.

Полный код для Rust немного велик, поэтому мы просто рассмотрим соответствующие обработчики:

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

Это гораздо сложнее, чем версии 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

И гораздо более производительный!

Наш сервер Rust, использующий Actix/deadpool_postgres, легко обходит нашего предыдущего чемпиона Starlette на +125%, ExpressJS на +362% и чистый PHP на +1366%. (Я оставлю разницу в производительности с версией Laravel в качестве упражнения для читателя.)

Я обнаружил, что изучение самого языка Rust оказалось сложнее, чем изучение других языков, поскольку в нем гораздо больше подводных камней, чем во всем, что я видел за пределами 6502 Assembly, но если ваш сервер Rust может принять в 14 раз больше пользователей, чем ваш сервер PHP, то, возможно, все-таки есть смысл переключаться на новые технологии. Вот почему следующая версия Pafera Framework будет основана на Rust. Кривая обучения намного выше, чем у скриптовых языков, но производительность того стоит. Если вы не можете потратить время на изучение Rust, то создание своего технического стека на Starlette или Node.js тоже не будет плохим решением.

Технический долг

За последние двадцать лет мы перешли от дешевых статических хостинговых сайтов к общему хостингу со стеками LAMP, к аренде VPS в AWS, Azure и других облачных сервисах. В настоящее время многие компании довольствуются принятием проектных решений на основе того, кого они могут найти, кто доступен или дешевле, поскольку появление удобных облачных сервисов позволило легко загружать больше оборудования на медленные серверы и приложения. Это дало им большие краткосрочные выгоды за счет долгосрочного технического долга.

Предупреждение главного хирурга Калифорнии: это не настоящая космическая собака.

70 лет назад между Советским Союзом и Соединенными Штатами была великая космическая гонка. Советы выиграли большинство ранних этапов. У них был первый спутник Спутник, первая собака в космосе Лайка, первый лунный космический корабль Луна-2, первые мужчина и женщина в космосе Юрий Гагарин и Валентина Терешкова и так далее...

Но они медленно накапливали технический долг.

Хотя Советы были первыми в каждом из этих достижений, их инженерные процессы и цели заставляли их фокусироваться на краткосрочных задачах, а не на долгосрочной осуществимости. Они побеждали каждый раз, когда прыгали, но они становились все более уставшими и медленными, в то время как их противники продолжали делать последовательные шаги к финишной черте.

После того, как Нил Армстронг сделал свои исторические шаги на Луне в прямом эфире, американцы взяли на себя инициативу и остались там, пока советская программа дала сбой. Это ничем не отличается от компаний сегодня, которые сосредоточились на следующем большом деле, следующем большом выигрыше или следующей большой технологии, не сумев выработать правильные привычки и стратегии на долгосрочную перспективу.

Быть первым на рынке не означает, что вы станете доминирующим игроком на этом рынке. С другой стороны, выделение времени на то, чтобы делать все правильно, не гарантирует успеха, но, безусловно, увеличивает ваши шансы на долгосрочные достижения. Если вы технический руководитель в своей компании, выберите правильное направление и инструменты для своей рабочей нагрузки. Не позволяйте популярности заменить производительность и эффективность.

Ресурсы

Хотите загрузить файл 7z, содержащий скрипты Rust, ExpressJS, Flask, Starlette и Pure PHP?

об авторе

Джим программирует с тех пор, как в 90-х у него появился IBM PS/2. По сей день он предпочитает писать HTML и SQL вручную и фокусируется на эффективности и корректности в своей работе.