Rust w backendzie: Actix Web czy Axum i jak dobrać framework do wydajnego API?

0
7
Rate this post

Nawigacja:

Pytanie nie brzmi „który jest najszybszy?”, tylko „który zmniejszy ryzyko w moim API?”

Realne pytania, które zwykle stoją za wyborem Actix Web vs Axum

Jeśli rozważasz Rust w backendzie, to decyzja Actix Web czy Axum rzadko jest tylko o surowej przepustowości. W praktyce pojawiają się bardziej przyziemne pytania: czy zespół łatwo dowiezie spójne middleware (timeouty, limity, tracing), czy testy endpointów będą szybkie i czytelne, czy integracje z bibliotekami z ekosystemu Tokio nie będą „na siłę”, oraz czy po 12 miesiącach ktoś nowy wejdzie w projekt bez tygodnia walki z abstrakcjami.

W tle jest jeszcze jedno: „wydajne API” w Rust oznacza zwykle stabilną latencję (p95/p99), sensowną kontrolę backpressure i brak patologii typu nieograniczone request body lub brak limitów współbieżności. Framework ma tu znaczenie, ale często mniejsze niż: dobór klienta bazy, sposób serializacji, rozmiary payloadów, polityka cache, oraz to, czy w ogóle masz poprawnie skonfigurowane timeouty na całej ścieżce (klient → proxy/gateway → aplikacja → DB).

Co w tym porównaniu jest „twarde”, a co bywa złudzeniem

Porównania oparte o benchmarki z internetu kuszą prostą odpowiedzią, ale bywają złudne z trzech powodów. Po pierwsze, benchmarki często mierzą idealny scenariusz: minimalny handler, brak autoryzacji, brak walidacji, brak realnego ruchu do bazy. Po drugie, różnice w p99 potrafią wynikać z rzeczy „obok”: alokacje w serializacji JSON, logowanie synchroniczne, zbyt agresywna instrumentacja lub brak limitów. Po trzecie, nawet jeśli framework A ma niższy narzut, to i tak możesz go „zjeść” w 5 minut, dokładając kosztowną walidację albo nieuważnie mapując błędy.

Wybór frameworka do wydajnego API w Rust jest więc w dużej mierze wyborem modelu integracji (Tokio/tower vs idiomy Actix) i konsekwencji utrzymaniowych: jak będziesz budować pipeline requestu, jak testować, jak włączać obserwowalność, jak wprowadzać limity i polityki bezpieczeństwa.

Szybka mapa filozofii: Actix Web i Axum w jednym zdaniu

Axum jest mocno osadzony w ekosystemie Tokio i tower: oś stanowi tower::Service i warstwy (layers), co zwykle ułatwia „składanie klocków” operacyjnych. Actix Web ma własne, dojrzałe idiomy i narzędzia, a całość jest spójna wewnątrz frameworka; bywa to bardzo ergonomiczne, choć nie zawsze „plug-and-play” z towerowym światem.

Architektura i model wykonania: Actix i Axum patrzą na pipeline inaczej

Runtime i współbieżność: podobne fundamenty, inny punkt ciężkości

Co do zasady oba podejścia działają na Tokio, więc „asynchroniczność” i wydajność IO to wspólny mianownik. Różnica leży w tym, jak framework każe Ci myśleć o przepływie requestu. W Axum naturalną jednostką kompozycji jest router i usługa towerowa; middleware to kolejne warstwy, a ich kolejność jest częścią architektury. W Actix Web budujesz aplikację z App/Scope, podpinasz middleware w stylu actixowym i korzystasz z dojrzałych ekstraktorów oraz data injection.

To ma konsekwencje praktyczne. Jeśli już używasz tower/hyper w innych częściach systemu (np. wspólna bramka, klient HTTP, własne usługi w tle), Axum zwykle obniża koszt poznawczy i ułatwia wspólne komponenty. Jeśli zespół zna Actix Web i ma wypracowane wzorce, to „koszt zmiany mentalnego modelu” przy Axum może zjeść potencjalne zyski.

Backpressure i limity: gdzie framework pomaga, a gdzie wymaga dyscypliny

Wydajne API w Rust rzadko przegrywa przez sam framework. Częściej przegrywa przez brak ograniczeń: zbyt duże body, brak limitu współbieżności, brak timeoutów na upstreamie, niekontrolowane streamowanie. Axum, przez tower, naturalnie zachęca do dołożenia warstw typu concurrency limit, timeout, request body limit. W Actix Web również da się to zrobić, tylko narzędzia i „typowy sposób” są inne.

W praktyce kluczowe jest, żeby limity nie były rozproszone po losowych miejscach. Jeśli masz reverse proxy, ustawiasz tam limit rozmiaru body i timeouty, ale aplikacja też powinna mieć własne bezpieczniki. Inaczej kończysz z sytuacją, gdzie proxy przyjmuje 50 MB, a serwer próbuje to wczytać do pamięci, bo nikt nie dodał limitu po stronie aplikacji.

Stabilność mentalnego modelu w zespole

To może brzmieć „miękko”, ale zwykle jest twardym kryterium. Axum ma mocno „biblioteczny” charakter: sporo wynika z tower i hyper, a router jest wartością, którą składasz. Actix Web jest bardziej „frameworkowy”: daje spójny zestaw elementów i narzędzi, które często prowadzą Cię za rękę do rozwiązania. Jeśli zespół często rotuje, przewidywalność i łatwość onboardingu potrafią być ważniejsze niż różnice w narzucie.

Ergonomia API: routing, ekstraktory, walidacja i błędy na konkretnych wzorcach

Ten sam endpoint w dwóch stylach: POST /users

Poniższe przykłady są celowo krótkie. Chodzi o „dotyk” API: jak wyglądają sygnatury handlerów, gdzie trafia stan, jak zwracasz odpowiedź i jak trzymasz walidację w ryzach.

Axum: czytelna sygnatura i IntoResponse

use axum::{
    extract::{State, Json},
    http::StatusCode,
    response::IntoResponse,
    routing::post,
    Router,
};
use serde::{Deserialize, Serialize};

#[derive(Clone)]
struct AppState {
    // np. pool do bazy, klient do innej usługi, konfiguracja
}

#[derive(Deserialize)]
struct CreateUserReq {
    email: String,
}

#[derive(Serialize)]
struct UserDto {
    id: String,
    email: String,
}

async fn create_user(
    State(state): State<AppState>,
    Json(req): Json<CreateUserReq>,
) -> Result<impl IntoResponse, ApiError> {
    if !req.email.contains('@') {
        return Err(ApiError::bad_request("invalid_email"));
    }

    // ...logika domenowa / aplikacyjna...
    let user = UserDto { id: "u_123".into(), email: req.email };

    Ok((StatusCode::CREATED, Json(user)))
}

fn app(state: AppState) -> Router {
    Router::new()
        .route("/users", post(create_user))
        .with_state(state)
}

#[derive(Debug)]
struct ApiError {
    code: &'static str,
    status: StatusCode,
}

impl ApiError {
    fn bad_request(code: &'static str) -> Self {
        Self { code, status: StatusCode::BAD_REQUEST }
    }
}

impl IntoResponse for ApiError {
    fn into_response(self) -> axum::response::Response {
        let body = axum::Json(serde_json::json!({
            "error": { "code": self.code }
        }));
        (self.status, body).into_response()
    }
}

Actix Web: web::Json, web::Data i ResponseError

use actix_web::{
    web, App, HttpResponse, ResponseError,
    http::StatusCode,
};
use serde::{Deserialize, Serialize};

#[derive(Clone)]
struct AppState {
    // np. pool, konfiguracja
}

#[derive(Deserialize)]
struct CreateUserReq {
    email: String,
}

#[derive(Serialize)]
struct UserDto {
    id: String,
    email: String,
}

async fn create_user(
    state: web::Data<AppState>,
    req: web::Json<CreateUserReq>,
) -> Result<HttpResponse, ApiError> {
    if !req.email.contains('@') {
        return Err(ApiError::bad_request("invalid_email"));
    }

    let user = UserDto { id: "u_123".into(), email: req.email.clone() };

    Ok(HttpResponse::Created().json(user))
}

fn app(state: AppState) -> App<impl actix_web::dev::ServiceFactory> {
    App::new()
        .app_data(web::Data::new(state))
        .route("/users", web::post().to(create_user))
}

#[derive(Debug)]
struct ApiError {
    code: &'static str,
    status: StatusCode,
}

impl ApiError {
    fn bad_request(code: &'static str) -> Self {
        Self { code, status: StatusCode::BAD_REQUEST }
    }
}

impl ResponseError for ApiError {
    fn status_code(&self) -> StatusCode {
        self.status
    }

    fn error_response(&self) -> HttpResponse {
        HttpResponse::build(self.status).json(serde_json::json!({
            "error": { "code": self.code }
        }))
    }
}

Ekstraktory i walidacja: gdzie trzymać granicę HTTP → domena

W obu frameworkach ekstraktory są wygodne, ale łatwo przesadzić i wpakować w nie zbyt dużo logiki. Sensowny kompromis w API produkcyjnym to: ekstraktor dba o dekodowanie (JSON/Path/Query), a walidacja „biznesowa” i mapowanie błędów pozostają w warstwie aplikacyjnej. Jeśli chcesz mieć walidację stricte wejściową (np. format email, minimalna długość), to często lepiej trzymać ją tuż po ekstrakcji, zanim dotkniesz domeny.

Axum daje naturalne, czytelne sygnatury typu State, Json, Path. Actix Web ma analogiczne elementy w web::Data, web::Json, web::Path. Różnice częściej wynikają z tego, jak składasz routing i jaką konwencję przyjmiesz na błędy, niż z „możliwości” samych ekstraktorów.

Błędy domenowe → HTTP: konsekwencje ResponseError vs IntoResponse

Najbardziej praktyczny wzorzec, niezależnie od wyboru, wygląda tak: wewnątrz aplikacji posługujesz się własnym typem błędu (np. enum), a na krawędzi HTTP mapujesz go na status i ciało odpowiedzi. Actix Web formalizuje to przez ResponseError, Axum często przez IntoResponse. Oba podejścia dają podobny efekt końcowy, ale Axum zwykle sprzyja temu, by „odpowiedź HTTP” była składana jako krotka (status, nagłówki, body) i konwertowana, natomiast Actix zachęca do budowania HttpResponse wprost.

Różnica wychodzi w utrzymaniu: jeśli chcesz mieć jeden spójny format błędów i konsekwentne mapowanie, to zyskujesz, gdy masz jedno miejsce, które robi transformację. Niezależnie od frameworka, unikaj rozlewania HttpResponse::BadRequest() po całym kodzie domeny, bo wtedy migracja lub zmiana formatu odpowiedzi staje się kosztowna.

Middleware i sprawy „przekrojowe”: tu najłatwiej o produkcyjne wpadki

Axum: warstwy tower jako domyślny język polityk

Jeśli Twoje API ma działać stabilnie, potrzebujesz polityk: timeouty, limit rozmiaru body, ograniczenie równoległości, CORS, request-id, kompresja, czasem rate limiting. W Axum typowe jest składanie tego przez tower-http i kolejne warstwy. To jest wygodne, ale ma też „hak”: kolejność warstw ma znaczenie, a niektóre warstwy zmieniają semantykę błędów i czas propagacji odpowiedzi. To nie jest wada Axum, tylko cena kompozycji.

Przykład: tracing + timeout w Axum (styl tower-http)

use axum::{routing::get, Router};
use std::time::Duration;
use tower::ServiceBuilder;
use tower_http::trace::TraceLayer;
use tower_http::timeout::TimeoutLayer;

async fn health() -> &'static str {
    "ok"
}

fn app() -> Router {
    let middleware = ServiceBuilder::new()
        .layer(TraceLayer::new_for_http())
        .layer(TimeoutLayer::new(Duration::from_secs(2)));

    Router::new()
        .route("/health", get(health))
        .layer(middleware)
}

Actix Web: middleware „frameworkowe” i prosta konfiguracja na App/Scope

Actix Web ma dojrzałe middleware i konfigurację, którą często podpina się wprost na App lub Scope. Zwykle jest to mniej „abstrakcyjne” niż warstwy tower, ale też mniej przenośne, jeśli chcesz współdzielić dokładnie ten sam pipeline w kilku usługach opartych o różne komponenty. W zamian dostajesz idiomy, które są mocno osadzone w dokumentacji i społeczności Actix.

W praktyce największa różnica nie leży w „czy jest middleware”, tylko w tym, jak łatwo utrzymać spójną politykę na granicy usługi. Actix ma czytelny model: middleware są częścią świata Actix i zwykle działają bez dodatkowych adapterów. Jeśli robisz typowe rzeczy (CORS, logger, kompresja, limit payloadu), konfiguracja jest prosta, a ścieżka debugowania krótka — masz mniej warstw pośrednich, więc łatwiej prześledzić, gdzie dokładnie zgubił się nagłówek albo czemu odpowiedź została wysłana przed końcem handlera.

Jednocześnie „frameworkowość” bywa pułapką. Gdy chcesz wpiąć komponenty pisane pod Tower (albo współdzielić tę samą politykę między HTTP i np. gRPC), w Axum składasz to naturalnie, w Actix częściej kończy się na dopasowywaniu świata Service do świata Actix i pilnowaniu, by nie rozjechały się typy, błędy i lifetimes. To nie jest dramat, ale jest to koszt, który wychodzi dopiero przy bardziej przekrojowych wymaganiach: globalny rate limiting, wspólny request-id propagowany do kilku klientów, jednolite time-outy per upstream, albo „circuit breaker” na wybranych ścieżkach.

Na tym tle istotna staje się kolejność i zakres stosowania polityk. W Axum łatwo założyć warstwę na cały router, a potem odkryć, że timeout obejmuje też endpoint streamujący (SSE) i ucina dłuższe połączenia. W Actix analogicznie można zbyt szeroko zastosować limit rozmiaru payloadu albo middleware autoryzacyjny, który blokuje healthcheck. Co do zasady oba frameworki pozwalają to rozwiązać przez rozdział na Scope/Router per obszar, ale wymaga to dyscypliny: oddzielny pipeline dla publicznych endpointów, oddzielny dla administracyjnych, a osobny dla rzeczy, które mają inną charakterystykę czasu życia połączenia.

Jeśli priorytetem jest wielokrotne użycie „tej samej polityki” w wielu usługach i zgodność z ekosystemem Tower (warstwy z tower-http, integracje z innymi komponentami), Axum zwykle wygrywa prostotą kompozycji. Jeśli ważniejsze są gotowe, stabilne idiomy i mniejsza liczba abstrakcji między handlerem a socketem, Actix często daje bardziej bezpośrednią kontrolę i mniej zaskoczeń przy klasycznym REST.

Decyzja sprowadza się do tego, gdzie ma leżeć ciężar: w Axum inwestujesz w spójny model Service i łatwiejszą wymienialność warstw, w Actix częściej stawiasz na „jedno narzędzie do HTTP”, z którego korzysta się szybko i konsekwentnie. Gdy zespół ma mocne nawyki wokół Tower i zależy Ci na współdzieleniu cross‑cutting concerns, Axum bywa bezpieczniejszym wyborem; gdy liczy się przewidywalne API frameworka i szybkie dowożenie bez budowania własnego rusztowania, Actix zwykle będzie bardziej pragmatyczny.

Observability, testy i gotowość operacyjna: różnice, które widać po pierwszym incydencie

Zbliżenie na kolorowe linie kodu CSS na ekranie komputera
Źródło: Pexels | Autor: Pixabay

Tracing i korelacja żądań: spójność jest ważniejsza niż „ładne logi”

W API produkcyjnym logowanie to nie jest estetyka, tylko możliwość odpowiedzi na pytania: „które żądanie spowodowało błąd?”, „ile trwała rozmowa z DB?”, „czy to regresja po deployu?”. W Rust najczęściej kończy się to na tracing i konwencji: request-id (z nagłówka lub generowany), span per request, a w środku spany per kluczowe operacje (DB, HTTP do upstreamu, serializacja większych payloadów).

Axum ma naturalną ścieżkę przez tower-http (TraceLayer) i składanie warstw, dzięki czemu łatwo uzyskać spójny baseline dla całego routera. Trzeba jednak pilnować szczegółów: jak logujesz błędy, czy nie wyciekają w logach dane wrażliwe (nagłówki auth, fragmenty body), i czy masz w ogóle miejsce, w którym normalizujesz format błędów. TraceLayer da Ci „request start/end”, ale nie wymusi sensownej polityki dla kodów błędów domenowych.

W Actix Web obserwowalność częściej buduje się przez middleware Actix i integracje z tracing-actix-web albo klasyczny logger. Zwykle jest to bardziej „wprost”: mniej abstrakcji, mniej miejsc, w których mogą zniknąć metadane. Z drugiej strony, jeśli Twoja organizacja standardy observability trzyma w Tower (i ma na to gotowe warstwy), w Axum zyskujesz możliwość wpięcia ich bez przepisywania.

Przykład: podstawowy span per request w Axum (TraceLayer + request-id)

use axum::{routing::get, Router};
use http::{HeaderName, HeaderValue, Request};
use tower::ServiceBuilder;
use tower_http::{
    request_id::{MakeRequestId, PropagateRequestIdLayer, SetRequestIdLayer},
    trace::TraceLayer,
};
use uuid::Uuid;

static REQUEST_ID: HeaderName = HeaderName::from_static("x-request-id");

#[derive(Clone, Copy)]
struct UuidRequestId;

impl MakeRequestId for UuidRequestId {
    fn make_request_id<B>(&mut self, _request: &Request<B>) -> Option<HeaderValue> {
        let id = Uuid::new_v4().to_string();
        HeaderValue::from_str(&id).ok()
    }
}

async fn health() -> &'static str { "ok" }

fn app() -> Router {
    let middleware = ServiceBuilder::new()
        .layer(SetRequestIdLayer::new(REQUEST_ID.clone(), UuidRequestId))
        .layer(PropagateRequestIdLayer::new(REQUEST_ID.clone()))
        .layer(TraceLayer::new_for_http());

    Router::new()
        .route("/health", get(health))
        .layer(middleware)
}

Ten przykład działa dobrze jako szkielet, ale w praktyce i tak kończysz z dopracowaniem: które nagłówki logować, jak mapować błędy na poziomie HTTP, jak oznaczać „klienta” (app-id, user-id) bez ujawniania PII. Framework da Ci tylko haczyki.

Timeouty, limity i kontrola „rozlania się” zasobów

Wydajne API to zwykle takie, które dobrze zachowuje się pod presją. Praktyczne problemy rzadko biorą się z wolnego routera; częściej z tego, że endpointy bez limitów pozwalają klientom otwierać długie połączenia, wysyłać za duże body albo inicjować zbyt wiele równoległych operacji na DB. W Axum, przez Tower, łatwiej składać takie polityki jak concurrency limit, load shedding, retry/circuit breaker dla upstreamu. W Actix też da się to zrobić, ale częściej wymaga to przyjęcia idiomów Actix lub dopięcia zewnętrznych komponentów w mniej „naturalny” sposób.

Istotny detal: timeout w warstwie HTTP nie rozwiązuje wszystkiego. Jeżeli handler odpalił zapytanie do bazy bez sensownego timeoutu po stronie drivera, to przerwanie odpowiedzi po stronie HTTP może nie przerwać pracy w tle. Dlatego polityka czasu powinna być spójna „end-to-end”: runtime → handler → DB/upstream. To jest niezależne od wyboru Actix/Axum, ale łatwiej o spójność, gdy masz jedno miejsce, w którym budujesz klienty i wymuszasz timeouts.

Testowanie routera i handlerów: izolacja warstwy HTTP robi różnicę

Tu wychodzi perspektywa „koszt utrzymania”. Jeżeli logika aplikacyjna jest wpleciona w typy HTTP, testy szybko zamieniają się w testy integracyjne wszystkiego naraz. Zwykle lepiej działa układ, w którym handler jest cienkim adapterem: bierze wejście, woła serwis domenowy, mapuje wynik na odpowiedź. Wtedy większość testów to zwykłe testy modułowe serwisów, a testy HTTP zostają na krytyczne ścieżki (routing, middleware, autoryzacja, format odpowiedzi).

Axum pasuje do tego stylu, bo jego ekstraktory i odpowiedzi mają czytelne sygnatury i łatwo „przełączyć” zależności przez State. Testy HTTP można robić bez realnego nasłuchiwania na porcie, używając tower::ServiceExt i wywołując router jak usługę. Actix też ma dobrą historię testów (własne narzędzia testowe), ale często naturalny tor myślenia pcha w stronę „testujmy App” zamiast „testujmy serwisy”, a to zwiększa koszt, jeśli aplikacja rośnie.

Przykład: test „bez sieci” w Axum przez wywołanie Router jako Service

use axum::{routing::get, Router};
use http::{Request, StatusCode};
use tower::ServiceExt;

async fn health() -> &'static str { "ok" }

#[tokio::test]
async fn health_is_ok() {
    let app = Router::new().route("/health", get(health));

    let res = app
        .oneshot(Request::builder().uri("/health").body(axum::body::Body::empty()).unwrap())
        .await
        .unwrap();

    assert_eq!(res.status(), StatusCode::OK);
}

Analogiczny test w Actix jest równie osiągalny, tylko idzie przez ekosystem testowy Actix. Różnica w praktyce bywa taka, że w Axum testowanie „przez Service” zachęca do myślenia o całym pipeline (warstwy, limity, tracing) jak o kompozycji, którą też da się testować. W Actix łatwiej zostać przy testach „App jako czarna skrzynka”, co jest OK, dopóki nie próbujesz rozdzielać polityk per scope i utrzymywać kilku wariantów konfiguracji.

Streaming, WebSockety, SSE i duże payloady: tam kończą się proste benchmarki

Duże body i alokacje: kontrola kosztu zanim dotkniesz JSON

Jeżeli endpoint przyjmuje duże JSON-y, „wydajność frameworka” jest daleko na liście problemów. Najpierw liczy się to, czy masz limit rozmiaru body, czy parsujesz streamingowo (gdy ma to sens), jak radzisz sobie z odrzucaniem niepoprawnych żądań i czy nie logujesz całego body w razie błędu. Axum i Actix oferują mechanizmy limitów, ale różnią się ergonomią podpinania tego „globalnie” i „lokalnie” (na wybranych trasach). W Axum typowo robisz to warstwą, w Actix częściej konfiguracją na scope lub middleware.

Druga rzecz to serializacja. W wielu usługach największą pracę wykonuje serde_json (lub inny format), a nie router. Jeśli Twoje API jest bardzo „JSON-heavy”, sensownie jest mierzyć i optymalizować struktury danych, unikać niepotrzebnych klonów i świadomie dobierać typy (np. Bytes dla binariów, zamiast próbować przepychać wszystko przez String).

SSE i odpowiedzi strumieniowane: zderzenie z timeoutami i reverse proxy

SSE i streaming odpowiedzi to częsty punkt, w którym „ładnie złożone middleware” zaczyna przeszkadzać. Globalny timeout na routerze potrafi uciąć połączenia, które z definicji mają żyć długo. Podobnie limity równoległości, jeśli nie rozróżniasz zwykłych requestów od połączeń utrzymywanych. Niezależnie od frameworka, trzeba świadomie wydzielić trasy strumieniowane do osobnego pipeline’u: inne timeouty (albo brak), inne limity, często też inne bufory i strategię backpressure.

Axum, przez Tower, daje dobrą kontrolę kompozycji warstw per pod-router, ale wymaga dyscypliny: osobne Router dla SSE/WebSocket i dopiero potem złożenie całości. Actix ma podobną możliwość przez scope, a jego „frameworkowość” bywa zaletą, bo łatwiej utrzymać jedną konwencję konfiguracji per obszar API. W obu przypadkach krytyczne są testy z realnym reverse proxy (Nginx/Envoy) i obserwacja: czy pakiety są flushowane, czy po drodze coś buforuje, czy nie gubisz heartbeatów.

WebSockety: integracja z resztą stosu i polityki bezpieczeństwa

WebSockety zwykle wymuszają decyzje, które nie mają nic wspólnego z tym, czy handler jest „ładniejszy” w Axum czy Actix. Liczy się: jak zarządzasz stanem połączeń, jak egzekwujesz auth, jak ograniczasz liczbę połączeń na klienta, jak monitorujesz rozłączania i opóźnienia. Actix ma długą historię podejść actorowych, które dobrze pasują do modelu „połączenie = jednostka stanu i zdarzeń”. Axum (Tokio + Tower) też sobie z tym radzi, ale częściej kończy się na jawnych taskach, kanałach i strukturach współdzielonych.

To jest dobry punkt, by spojrzeć na swój zespół: jeśli macie doświadczenie w projektowaniu asynchronicznych przepływów i backpressure w Tokio, Axum jest naturalny. Jeśli wolicie model, w którym „połączenie jest obiektem z własną logiką” i jest to spójne z resztą aplikacji, Actix może zredukować liczbę własnych wynalazków.

Kryteria wyboru pod ryzyko: kiedy Actix Web, kiedy Axum

Gdy Axum zwykle redukuje ryzyko długoterminowe

Axum pasuje szczególnie wtedy, gdy zależy Ci na spójności z ekosystemem Tokio/Tower i na możliwości dokładania lub wymieniania warstw bez wchodzenia w „świat frameworka”. To obejmuje sytuacje, w których masz firmowe standardy middleware (request-id, tracing, limity, retry), współdzielone komponenty (klienci HTTP, polityki timeouts), albo usługę, która będzie ewoluować w stronę kilku protokołów (HTTP + gRPC) i chcesz ujednolicić cross-cutting concerns.

Axum sprzyja też testowalności pipeline’u jako całości (router jako Service) i konsekwentnemu stylowi handlerów, gdzie typy wejścia/wyjścia są czytelne, a mapping błędów da się centralizować. Ceną jest to, że zespół musi rozumieć kompozycję warstw i mieć na to „operacyjny refleks”: kolejność warstw, wpływ timeoutów na streaming, sposób propagacji błędów i nagłówków.

Gdy Actix Web bywa bardziej pragmatyczny

Actix Web często wygrywa, gdy chcesz szybko i stabilnie dowieźć klasyczne API HTTP, a zespół woli mniej abstrakcji między handlerem a serwerem. Jeżeli nie planujesz intensywnego współdzielenia polityk Tower między usługami, nie potrzebujesz skomplikowanych warstw (load shedding, circuit breaker, zaawansowany rate limiting), a priorytetem jest „mamy sprawdzony frameworkowy sposób na middleware, routing i konfigurację” — Actix bywa po prostu krótszą ścieżką.

Drugi typowy przypadek to aplikacje, w których model zdarzeń i stanów jest istotny (np. WebSockety, koordynacja wielu zadań), a zespół chce oprzeć się o idiomy Actix i jego dojrzałe wzorce. To nie znaczy, że Axum się nie nadaje; raczej tyle, że w Axum częściej dopisujesz własne konstrukcje wokół tasków i kanałów, a w Actix łatwiej utrzymać je w jednym, konsekwentnym modelu.

Minimalizowanie kosztu przyszłej zmiany: architektura „adapter na krawędzi”

Jeśli decyzja jest politycznie trudna albo projekt ma niepewną przyszłość, rozsądne jest takie ułożenie kodu, żeby wybór frameworka był możliwie odwracalny. Co do zasady działa tu prosta zasada: HTTP ma być adapterem, a nie rdzeniem. Serwisy aplikacyjne nie powinny zwracać HttpResponse ani znać ekstraktorów. Zamiast tego zwracają własne typy (DTO) i własne błędy, które mapujesz na krawędzi.

Taki układ nie jest „czystą architekturą dla idei”. W praktyce zmniejsza liczbę miejsc, w których przecieka framework: ekstraktory, typy odpowiedzi, specyficzne mechanizmy błędów. Handler HTTP staje się cienką funkcją, która: (1) zamienia wejście na typ domenowy, (2) wywołuje serwis, (3) mapuje rezultat na HTTP. Reszta logiki — autoryzacja na poziomie use‑case, walidacja reguł biznesowych, transakcje, idempotencja — żyje w kodzie, który da się testować bez runtime’u sieciowego.

W Axum ta separacja jest naturalna, bo i tak składasz aplikację z warstw; łatwo więc traktować HTTP jako jedną z krawędzi, obok np. konsumenta kolejki. W Actix również da się to zrobić, tylko trzeba świadomie oprzeć się pokusie „wrzućmy wszystko do handlera, bo jest wygodnie”. Gdy serwis zaczyna rosnąć, te drobne kompromisy wracają w postaci trudniejszych refactorów: np. endpointy, które zwracają bezpośrednio HttpResponse, zaczynają duplikować zasady mapowania błędów i kodów statusu w wielu miejscach.

Najprostszy, a skuteczny wzorzec to własny typ błędu aplikacyjnego + jedna funkcja mapująca na HTTP. Dzięki temu zmiana frameworka jest zwykle ograniczona do warstwy adaptera, a nie całego kodu. Podobnie z DTO: jeśli dziś używasz serde_json, a jutro część endpointów ma przejść na inny format, to przenosisz ciężar w jedno miejsce. W praktyce to także ułatwia spójność kontraktu: w jednym miejscu kontrolujesz, czy błąd walidacji jest 400, czy 422, czy zwracasz problem+json, i jak wygląda pole trace_id.

Dobrym testem „odwracalności” jest mała zmiana, którą zespoły robią regularnie: dołożenie nowej polityki timeoutów albo limitów tylko dla fragmentu API (np. import danych albo SSE). Jeśli musisz przepisać połowę handlerów, bo timeout był „wszyty” w ich logikę, to adapter przestał być adapterem. Jeśli ogranicza się to do przestawienia konfiguracji warstw/scope i ewentualnie jednej mapy błędów — jesteś w dobrym miejscu, niezależnie od tego, czy na brzegu stoi Actix czy Axum.

Jeśli kluczowa jest kompatybilność z Tower i kompozycja polityk jako warstw, zwykle bezpieczniej iść w Axum; jeśli priorytetem jest pragmatyczna szybkość dostarczania klasycznego HTTP i spójne, „frameworkowe” idiomy (zwłaszcza przy WS i podejściu actorowym), Actix Web bywa krótszą ścieżką. W obu przypadkach największą dźwignią jakości jest trzymanie logiki poza handlerami i pilnowanie, żeby HTTP pozostał wymienialną krawędzią, a nie miejscem, w którym zapadają decyzje o całej aplikacji.

Najważniejsze punkty

  • Decyzja „Actix Web czy Axum” rzadko rozbija się o surową przepustowość; częściej chodzi o ograniczenie ryzyk w API: spójne middleware, sensowne testowanie, łatwe integracje i utrzymanie po roku, gdy do projektu dołącza nowa osoba.
  • „Wydajne API” to zwykle stabilna latencja (p95/p99), kontrola backpressure i bezpieczniki (limity body, limity współbieżności, timeouty) — a na te parametry często mocniej wpływa DB client, serializacja JSON, payloady i cache niż sam framework.
  • Benchmarki z internetu potrafią mylić: mierzą idealne handlery bez autoryzacji i walidacji, a różnice w p99 często robią „rzeczy obok” (alokacje, synchroniczne logowanie, zbyt ciężka instrumentacja, brak limitów), które łatwo przykryją narzut frameworka.
  • Axum jest naturalnie „tokio/tower-first”: pipeline składa się z usług i warstw (layers), co zwykle ułatwia dokładanie elementów operacyjnych typu timeout, concurrency limit czy request body limit i trzymanie ich w jednym miejscu.
  • Actix Web jest bardziej „frameworkowy” i spójny we własnych idiomach (App/Scope, middleware actixowe, ekstraktory, data injection) — bywa bardzo ergonomiczny, ale integracje z towerowym światem nie zawsze są tak „plug-and-play” jak w Axum.
  • Backpressure i limity nie „robią się same”: nawet przy reverse proxy aplikacja powinna mieć własne zabezpieczenia; w przeciwnym razie proxy przyjmie duże body, a serwer spróbuje wczytać je do pamięci, bo limitu nie ustawiono w środku.
  • Źródła informacji

  • Actix Web Documentation. Actix Web – API frameworka, middleware, ekstraktory, konfiguracja serwera i limity.
  • axum Documentation. Tokio – Routing, extractors, state, IntoResponse oraz integracja z tower.
  • tower Documentation. Tower – Model Service/Layer i gotowe warstwy: timeout, limit współbieżności, load shed.
  • Hyper Documentation. Hyper – Niskopoziomowy HTTP w Rust; serwer/klient, wydajność i zachowanie I/O.
  • Tokio Documentation. Tokio Project – Runtime async, zadania, I/O, timeouts; fundament współbieżności dla Axum/Actix.
  • The Rust Programming Language (2nd edition). No Starch Press (2018) – Podstawy Rust: własność, błędy, async; kontekst dla decyzji architektonicznych.
  • Rust API Guidelines. Rust Lang – Zalecenia projektowania API w Rust; spójność błędów i ergonomia interfejsów.
  • RFC 9110: HTTP Semantics. IETF (2022) – Semantyka HTTP: metody, kody statusu, nagłówki; podstawa poprawnych API.