Go (fiber) vs Rust (axum) JWT&DB
На medium.com есть ряд статей со сравнением простых web-сервисов, написанных на разных языках. Одна из них Go vs Rust: Performance comparison for JWT verify and MySQL query и судя по ней, Go на 42% быстрее чем Rust. Я решил перепроверить и заодно поменять Gin на Fiber, Axis на Axum и MySQL на PostgreSQL.
Web-сервис будет принимать запрос с аутентификацией по токену JWT, искать в БД пользователя с данными email из JWT и возвращать его в виде json. Так как подобная аутентификация используется повсеместно, то тест актуальный.
Сперва готовим тестовую БД. Это будет PostgreSQL и разворачивать ее будем в Docker через compose. В папке, в которой будет наша БД, создаем файл init.sql. В нем мы создаем новую базу и в ней таблицу users:
CREATE DATABASE testbench;
\connect testbench;
CREATE TABLE users(
email VARCHAR(255) NOT NULL PRIMARY KEY,
first VARCHAR(255),
last VARCHAR(255),
county VARCHAR(255),
city VARCHAR(255),
age int
);
Далее там же создаем папку db и файл docker-compose.yaml следующего содержания:
services:
postgres:
image: postgres:alpine
environment:
- POSTGRES_PASSWORD=123456
volumes:
- ./db:/var/lib/postgresql/data
# скрипт ниже выполнится при первом создании базы
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
ports:
- "5432:5432"
Создаем и запускаем контейнер:
$ docker-compose up
Далее нам нужно наполнить базу 100 000 записями. Для этого нужен генератор данных, в качестве которого используем Synth. Создаем файл с настройками генератора (обратите внимание, что email у нас первичный уникальный ключ):
{
"type": "array",
"length": {
"type": "number",
"constant": 1
},
"content": {
"type": "object",
"email": {
"type": "unique",
"content": {
"type": "string",
"faker": {
"generator": "free_email"
}
}
},
"first": {
"type": "string",
"faker": {
"generator": "first_name"
}
},
"last": {
"type": "string",
"faker": {
"generator": "last_name"
}
},
"city": {
"type": "string",
"faker": {
"generator": "city_name"
}
},
"county": {
"type": "string",
"faker": {
"generator": "country_name"
}
},
"age": {
"type": "number",
"subtype": "i32",
"range": {
"low": 18,
"high": 55,
"step": 1
}
}
}
}
Запускаем генератор:
$ synth generate ./ --to postgres://postgres:123456@localhost:5432/testbench --size 100000
БД готова, пишем сами веб-сервисы.
Сначала на Go:
package main
import (
"bufio"
"database/sql"
"log"
"os"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"github.com/golang-jwt/jwt/v5"
_ "github.com/lib/pq"
)
type MyCustomClaims struct {
Email string `json:"email"`
jwt.RegisteredClaims
}
type User struct {
Email string
First string
Last string
City string
County string
Age int
}
var jwtSecret = "mysuperPUPERsecret100500security"
func getToken(c *fiber.Ctx) string {
hdr := c.Get("Authorization")
if hdr == "" {
return ""
}
token := strings.Split(hdr, "Bearer ")[1]
return token
}
func main() {
app := fiber.New()
db, err := sql.Open("postgres", "user=postgres password=123456 dbname=testbench sslmode=disable")
if err != nil {
return
}
defer db.Close()
db.SetMaxOpenConns(10)
db.SetMaxIdleConns(10)
app.Get("/", func(c *fiber.Ctx) error {
tokenString := getToken(c)
if tokenString == "" {
return c.SendStatus(fiber.StatusUnauthorized)
}
token, err := jwt.ParseWithClaims(tokenString, &MyCustomClaims{}, func(token *jwt.Token) (interface{}, error) {
return []byte(jwtSecret), nil
})
if err != nil {
log.Println(err)
return c.SendStatus(fiber.StatusUnauthorized)
}
claims := token.Claims.(*MyCustomClaims)
query := "SELECT * FROM users WHERE email=$1"
row := db.QueryRow(query, claims.Email)
var user User = User{}
err2 := row.Scan(&user.Email, &user.First, &user.Last, &user.County, &user.City, &user.Age)
if err2 == sql.ErrNoRows {
log.Println("ErrNoRows")
}
if err2 != nil {
log.Println(err2)
return c.SendStatus(fiber.StatusNotFound)
}
return c.JSON(user)
})
//вспомогательная ручка
app.Get("/randomtoken", func(c *fiber.Ctx) error {
file, err := os.Create("tokens.txt")
if err != nil {
log.Println(err)
return c.SendStatus(fiber.StatusInternalServerError)
}
writer := bufio.NewWriter(file)
rows, err := db.Query("SELECT * FROM USERS OFFSET floor(random() * 100000) LIMIT 10")
if err != nil {
return c.SendStatus(fiber.StatusInternalServerError)
}
for rows.Next() {
var user User
err = rows.Scan(&user.Email, &user.First, &user.Last, &user.County, &user.City, &user.Age)
if err != nil {
log.Println(err)
return c.SendStatus(fiber.StatusInternalServerError)
}
claims := MyCustomClaims{
user.Email,
jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
ss, err := token.SignedString([]byte(jwtSecret))
if err != nil {
log.Println(err)
return c.SendStatus(fiber.StatusInternalServerError)
}
_, err = writer.WriteString(ss + "\n")
if err != nil {
file.Close()
log.Println(err)
return c.SendStatus(fiber.StatusInternalServerError)
}
}
writer.Flush()
file.Close()
return c.SendFile(file.Name())
})
log.Fatal(app.Listen(":3000"))
}
Теперь на Rust
use axum::{
extract::State,
http::{header::AUTHORIZATION, HeaderMap, StatusCode},
response::IntoResponse,
routing::get,
Json, Router,
};
use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
use serde::{Deserialize, Serialize};
use sqlx::{postgres::PgPoolOptions, Pool, Postgres};
#[derive(Debug, Serialize, Deserialize)]
struct Claims {
email: String,
exp: usize,
}
#[derive(Debug, Deserialize, Serialize, sqlx::FromRow)]
pub struct User {
pub email: String,
pub first: Option,
pub last: Option,
pub city: Option,
pub county: Option,
pub age: Option,
}
type ConnectionPool = Pool;
async fn root(headers: HeaderMap, State(pool): State) -> impl IntoResponse {
let jwt_secret = "mysuperPUPERsecret100500security";
let validation = Validation::new(Algorithm::HS256);
let auth_header = headers.get(AUTHORIZATION).expect("no authorization header");
let mut auth_hdr: &str = auth_header.to_str().unwrap();
auth_hdr = &auth_hdr.strip_prefix("Bearer ").unwrap();
let token = match decode::(
&auth_hdr,
&DecodingKey::from_secret(jwt_secret.as_ref()),
&validation,
) {
Ok(c) => c,
Err(e) => {
eprintln!("Application error: {e}");
return (StatusCode::INTERNAL_SERVER_ERROR, "invalid token").into_response();
}
};
let email = token.claims.email;
let query_result: Result =
sqlx::query_as(r#"SELECT * FROM USERS WHERE email=$1"#)
.bind(email)
.fetch_one(&pool)
.await;
match query_result {
Ok(user) => {
return (StatusCode::ACCEPTED, Json(user)).into_response();
}
Err(sqlx::Error::RowNotFound) => {
return (StatusCode::NOT_FOUND, "user not found").into_response();
}
Err(_e) => {
println!("error: {}", _e.to_string());
return (StatusCode::INTERNAL_SERVER_ERROR, "error").into_response();
}
};
}
async fn alltoken() -> StatusCode {
StatusCode::CREATED
}
#[tokio::main]
async fn main() -> Result<(), Box> {
let database_url = String::from("postgres://postgres:123456@localhost/testbench");
let pool = PgPoolOptions::new()
.max_connections(10)
.connect(&database_url)
.await
.expect("can't connect to database");
println!("DB connect success");
let app = Router::new()
.route("/", get(root))
.route("/alltoken", get(alltoken))
.with_state(pool);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
axum::serve(listener, app).await?;
Ok(())
}
// NOTE ----
// Rust code has been built in release mode for all performance tests
Для Go собираем исполняемый файл командой: go build, для Rust: cargo build --release
Далее собственно тестирование
ПК: Windows 11 22H2, Intel® Core™ i5–10400 CPU @ 2.90GHz, 32 Гб.
Go 1.21.4
Rust 1.74.0
Делать будем 100К запросов при 10,50 и 100 одновременных подключениях.
Используем Cassowary. Получаем 10 «случайных» токенов запустив веб-сервис на Go: http://127.0.0.1:3000/randomtoken
Выбираем любой и запускаем тесты (подставив свой токен):
$ cassowary.exe run -u http://127.0.0.1:3000 -c 10 -n 100000 -H "Authorization:Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImFsZWphbmRyaW5fZG9sb3JlbXF1ZUBnbWFpbC5jb20iLCJleHAiOjE3MDEzMDYyMTl9.5mT3KVV9Q69yd5gx-z97LVr6tgNA1yVJxpeJEXSq6U0"
Делаем по 3 запуска и берем максимальный результат.
Результаты Go:
Go 10
Go 50
Go 100
Результаты Rust:
Rust 10
Rust 50
Rust 100
Разница между 10,50 и 100 одновременными подключениями в Go небольшая, а в Rust вообще нет. Это связанно с тем, что количество соединений в пуле БД ограничено 10-тью. Так было в изначальном сравнении и я не стал это менять (все равно соединений в пуле всегда в разы меньше чем запросов).
Итог
Go быстрее Rust на 35% в данном сценарии.
В Rust я пробовал менять sqlx на tokio-postgres, убирал расшифровку JWT, результат тот же.
Ссылка на Github для желающих проверить на своих серверах.