Web Server

Web Server #

Ekosistem web Rust telah berkembang pesat dan kini punya beberapa framework matang yang siap produksi. Tiga yang paling populer adalah axum (dibangun oleh tim tokio, ergonomis dan modular), actix-web (performa tertinggi di benchmark, berbasis aktor), dan rocket (paling ramah pemula, banyak fitur bawaan). Artikel ini membahas ketiganya dengan contoh yang cukup dalam untuk memulai — routing, JSON, shared state, middleware, dan error handling — lalu diakhiri dengan panduan deployment dengan Docker.

Perbandingan Framework #

flowchart TD
    Q{Prioritas utama?}
    Q --> P["Performa\nabsolut tertinggi"]
    Q --> E["Ergonomis\ndan modular"]
    Q --> B["Ramah pemula\nfitur built-in"]

    P --> AW["actix-web\nBerbasis aktor\nPerforma benchmark tertinggi\nEkosistem sangat kaya"]
    E --> AX["axum\nDibuat tim tokio\nExtractor pattern elegan\nMiddleware via tower"]
    B --> RK["rocket\nMakro yang ekspresif\nValidasi request otomatis\nAsync native di v0.5"]
Aspek axum actix-web rocket
Versi stabil 0.7 4.x 0.5
Async runtime tokio tokio (custom) tokio
Performa Sangat tinggi Tertinggi Tinggi
Kurva belajar Sedang Sedang Rendah
Middleware Tower ecosystem Actix middleware Fairing
Cocok untuk API, microservice High-performance API Fullstack web

Axum — Ergonomis dan Modular #

Instalasi #

[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tower = "0.4"
tower-http = { version = "0.5", features = ["cors", "trace"] }

Server Dasar dengan Routing #

use axum::{
    extract::{Path, Query},
    http::StatusCode,
    response::Json,
    routing::{delete, get, post, put},
    Router,
};
use serde::{Deserialize, Serialize};

#[derive(Serialize)]
struct Respons {
    pesan: String,
    status: u16,
}

// Handler sederhana
async fn root() -> &'static str {
    "Selamat datang di API Rust!"
}

// Path parameter
async fn salam(Path(nama): Path<String>) -> String {
    format!("Halo, {}!", nama)
}

// Query string: GET /cari?kata=rust&halaman=2
#[derive(Deserialize)]
struct ParameterCari {
    kata: String,
    halaman: Option<u32>,
}

async fn cari(Query(params): Query<ParameterCari>) -> Json<Respons> {
    let pesan = format!(
        "Mencari '{}' di halaman {}",
        params.kata,
        params.halaman.unwrap_or(1)
    );
    Json(Respons { pesan, status: 200 })
}

// Multiple path parameters
async fn artikel_berdasarkan_kategori(
    Path((kategori, id)): Path<(String, u64)>,
) -> String {
    format!("Kategori: {}, ID artikel: {}", kategori, id)
}

#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/", get(root))
        .route("/salam/:nama", get(salam))
        .route("/cari", get(cari))
        .route("/artikel/:kategori/:id", get(artikel_berdasarkan_kategori));

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    println!("Server di http://localhost:3000");
    axum::serve(listener, app).await.unwrap();
}

JSON Request dan Response #

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

#[derive(Deserialize)]
struct RequestBuatPengguna {
    nama: String,
    email: String,
    usia: u8,
}

#[derive(Serialize)]
struct ResponsPengguna {
    id: u64,
    nama: String,
    email: String,
}

async fn buat_pengguna(
    Json(body): Json<RequestBuatPengguna>,
) -> (StatusCode, Json<ResponsPengguna>) {
    // Validasi sederhana
    if body.nama.is_empty() || body.email.is_empty() {
        return (
            StatusCode::BAD_REQUEST,
            Json(ResponsPengguna {
                id: 0,
                nama: String::from("error"),
                email: String::from("nama dan email wajib diisi"),
            }),
        );
    }

    // Simulasi simpan ke database
    let pengguna = ResponsPengguna {
        id: 1001,
        nama: body.nama,
        email: body.email,
    };

    (StatusCode::CREATED, Json(pengguna))
}

#[tokio::main]
async fn main() {
    let app = Router::new().route("/pengguna", post(buat_pengguna));

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

Shared State di Axum #

use axum::{extract::State, routing::get, Router};
use std::sync::{Arc, Mutex};

// State aplikasi yang dibagi ke semua handler
#[derive(Clone)]
struct AppState {
    counter: Arc<Mutex<u64>>,
    nama_app: String,
}

async fn ambil_counter(State(state): State<AppState>) -> String {
    let c = state.counter.lock().unwrap();
    format!("{}: counter = {}", state.nama_app, *c)
}

async fn increment(State(state): State<AppState>) -> String {
    let mut c = state.counter.lock().unwrap();
    *c += 1;
    format!("Counter sekarang: {}", *c)
}

#[tokio::main]
async fn main() {
    let state = AppState {
        counter: Arc::new(Mutex::new(0)),
        nama_app: String::from("Aplikasi Rust"),
    };

    let app = Router::new()
        .route("/counter", get(ambil_counter))
        .route("/increment", get(increment))
        .with_state(state);  // injeksi state ke semua route

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

Error Handling di Axum #

use axum::{
    http::StatusCode,
    response::{IntoResponse, Response},
    Json,
};
use serde_json::json;

// Tipe error kustom
enum AppError {
    TidakDitemukan(String),
    ServerError(String),
    BadRequest(String),
}

// Implementasikan IntoResponse agar bisa dikembalikan dari handler
impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, pesan) = match self {
            AppError::TidakDitemukan(msg) => (StatusCode::NOT_FOUND, msg),
            AppError::ServerError(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg),
            AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg),
        };

        let body = Json(json!({
            "error": pesan,
            "status": status.as_u16()
        }));

        (status, body).into_response()
    }
}

async fn cari_pengguna(axum::extract::Path(id): axum::extract::Path<u64>)
    -> Result<Json<serde_json::Value>, AppError>
{
    if id == 0 {
        return Err(AppError::BadRequest("ID tidak boleh 0".into()));
    }

    // Simulasi: hanya ID 1–100 yang ada
    if id > 100 {
        return Err(AppError::TidakDitemukan(format!("Pengguna ID {} tidak ada", id)));
    }

    Ok(Json(json!({
        "id": id,
        "nama": format!("Pengguna {}", id),
        "email": format!("user{}@contoh.com", id)
    })))
}

Actix-web — Performa Tertinggi #

Instalasi #

[dependencies]
actix-web = "4"
serde = { version = "1", features = ["derive"] }
serde_json = "1"

Server dengan Routing dan JSON #

use actix_web::{
    delete, get, post, put,
    web::{self, Data, Json, Path, Query},
    App, HttpResponse, HttpServer, Responder,
};
use serde::{Deserialize, Serialize};
use std::sync::Mutex;

#[derive(Serialize, Deserialize, Clone)]
struct Produk {
    id: u64,
    nama: String,
    harga: f64,
}

struct AppState {
    produk: Mutex<Vec<Produk>>,
}

// GET /produk — ambil semua produk
#[get("/produk")]
async fn daftar_produk(state: Data<AppState>) -> impl Responder {
    let produk = state.produk.lock().unwrap();
    HttpResponse::Ok().json(produk.clone())
}

// GET /produk/{id}
#[get("/produk/{id}")]
async fn ambil_produk(path: Path<u64>, state: Data<AppState>) -> impl Responder {
    let id = path.into_inner();
    let produk = state.produk.lock().unwrap();

    match produk.iter().find(|p| p.id == id) {
        Some(p) => HttpResponse::Ok().json(p),
        None => HttpResponse::NotFound().json(serde_json::json!({
            "error": format!("Produk {} tidak ditemukan", id)
        })),
    }
}

// POST /produk — buat produk baru
#[post("/produk")]
async fn buat_produk(body: Json<Produk>, state: Data<AppState>) -> impl Responder {
    let mut produk = state.produk.lock().unwrap();
    produk.push(body.into_inner());
    HttpResponse::Created().json(produk.last().unwrap())
}

// DELETE /produk/{id}
#[delete("/produk/{id}")]
async fn hapus_produk(path: Path<u64>, state: Data<AppState>) -> impl Responder {
    let id = path.into_inner();
    let mut produk = state.produk.lock().unwrap();
    let panjang_awal = produk.len();
    produk.retain(|p| p.id != id);

    if produk.len() < panjang_awal {
        HttpResponse::Ok().json(serde_json::json!({"dihapus": id}))
    } else {
        HttpResponse::NotFound().body("Tidak ditemukan")
    }
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    let state = Data::new(AppState {
        produk: Mutex::new(vec![
            Produk { id: 1, nama: "Laptop".into(), harga: 15_000_000.0 },
            Produk { id: 2, nama: "Mouse".into(), harga: 250_000.0 },
        ]),
    });

    println!("Server actix-web di http://localhost:8080");
    HttpServer::new(move || {
        App::new()
            .app_data(state.clone())
            .service(daftar_produk)
            .service(ambil_produk)
            .service(buat_produk)
            .service(hapus_produk)
    })
    .bind("0.0.0.0:8080")?
    .run()
    .await
}

Middleware di Actix-web #

use actix_web::{
    dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform},
    middleware::Logger,
    web, App, Error, HttpServer,
};
use std::future::{ready, Future, Ready};
use std::pin::Pin;
use std::time::Instant;

// Middleware logging waktu proses request
pub struct TimingMiddleware;

impl<S, B> Transform<S, ServiceRequest> for TimingMiddleware
where
    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
    S::Future: 'static,
    B: 'static,
{
    type Response = ServiceResponse<B>;
    type Error = Error;
    type InitError = ();
    type Transform = TimingService<S>;
    type Future = Ready<Result<Self::Transform, Self::InitError>>;

    fn new_transform(&self, service: S) -> Self::Future {
        ready(Ok(TimingService { service }))
    }
}

pub struct TimingService<S> {
    service: S,
}

impl<S, B> Service<ServiceRequest> for TimingService<S>
where
    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
    S::Future: 'static,
    B: 'static,
{
    type Response = ServiceResponse<B>;
    type Error = Error;
    type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>>>>;

    forward_ready!(service);

    fn call(&self, req: ServiceRequest) -> Self::Future {
        let mulai = Instant::now();
        let metode = req.method().to_string();
        let path = req.path().to_string();
        let fut = self.service.call(req);

        Box::pin(async move {
            let resp = fut.await?;
            println!("{} {} selesai dalam {:?}", metode, path, mulai.elapsed());
            Ok(resp)
        })
    }
}

Rocket — Ramah Pemula #

Instalasi #

[dependencies]
rocket = { version = "0.5", features = ["json"] }
serde = { version = "1", features = ["derive"] }

Server dengan Routing, JSON, dan State #

#[macro_use]
extern crate rocket;

use rocket::{
    serde::{json::Json, Deserialize, Serialize},
    State,
};
use std::sync::Mutex;

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(crate = "rocket::serde")]
struct Catatan {
    id: u64,
    judul: String,
    isi: String,
}

struct DbCatatan {
    daftar: Mutex<Vec<Catatan>>,
    id_berikutnya: Mutex<u64>,
}

// GET / — halaman utama
#[get("/")]
fn index() -> &'static str {
    "API Catatan dengan Rocket"
}

// GET /catatan — semua catatan
#[get("/catatan")]
fn daftar_catatan(db: &State<DbCatatan>) -> Json<Vec<Catatan>> {
    Json(db.daftar.lock().unwrap().clone())
}

// GET /catatan/<id>
#[get("/catatan/<id>")]
fn ambil_catatan(id: u64, db: &State<DbCatatan>) -> Option<Json<Catatan>> {
    let daftar = db.daftar.lock().unwrap();
    daftar.iter().find(|c| c.id == id).cloned().map(Json)
}

// POST /catatan
#[post("/catatan", data = "<catatan>")]
fn buat_catatan(catatan: Json<Catatan>, db: &State<DbCatatan>) -> Json<Catatan> {
    let mut id_counter = db.id_berikutnya.lock().unwrap();
    let mut daftar = db.daftar.lock().unwrap();

    let baru = Catatan {
        id: *id_counter,
        judul: catatan.judul.clone(),
        isi: catatan.isi.clone(),
    };
    *id_counter += 1;
    daftar.push(baru.clone());
    Json(baru)
}

// DELETE /catatan/<id>
#[delete("/catatan/<id>")]
fn hapus_catatan(id: u64, db: &State<DbCatatan>) -> &'static str {
    let mut daftar = db.daftar.lock().unwrap();
    let panjang = daftar.len();
    daftar.retain(|c| c.id != id);
    if daftar.len() < panjang { "Berhasil dihapus" } else { "Tidak ditemukan" }
}

#[launch]
fn rocket() -> _ {
    rocket::build()
        .manage(DbCatatan {
            daftar: Mutex::new(Vec::new()),
            id_berikutnya: Mutex::new(1),
        })
        .mount("/", routes![index, daftar_catatan, ambil_catatan, buat_catatan, hapus_catatan])
}

Deployment dengan Docker #

Dockerfile multi-stage untuk binary Rust yang ringan:

# Stage 1: Build
FROM rust:1.77-slim as builder

WORKDIR /app
COPY Cargo.toml Cargo.lock ./
# Build dependensi dulu (di-cache) agar rebuild lebih cepat
RUN mkdir src && echo "fn main() {}" > src/main.rs
RUN cargo build --release
RUN rm src/main.rs

# Copy source dan build ulang hanya source
COPY src ./src
RUN touch src/main.rs && cargo build --release

# Stage 2: Runtime — image sangat kecil
FROM debian:bookworm-slim

RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*

COPY --from=builder /app/target/release/nama-aplikasi /usr/local/bin/

EXPOSE 3000
CMD ["nama-aplikasi"]
# Build image
docker build -t rust-webserver .

# Jalankan container
docker run -p 3000:3000 rust-webserver

# Dengan environment variable
docker run -p 3000:3000 -e DATABASE_URL=postgres://... rust-webserver

Binary Rust yang di-compile dengan --release dan strip = true (di Cargo.toml) bisa sekecil 2–5 MB, membuat container akhir sangat ringan.


Ringkasan #

  • Pilih framework berdasarkan prioritasaxum untuk API modern yang ergonomis dan terintegrasi baik dengan ekosistem tower, actix-web untuk performa maksimal, rocket untuk kemudahan pengembangan.
  • Semua framework mendukung JSON via serdeJson<T> sebagai extractor otomatis deserialize request body; Json<T> sebagai return type otomatis serialize response.
  • Shared state dengan Arc<Mutex<T>> — inject ke semua handler via State (axum) atau Data (actix-web) atau State rocket. Untuk akses baca banyak-tulis sedikit, pertimbangkan RwLock.
  • Handler di axum mengembalikan impl IntoResponse — berbagai tipe bisa dikembalikan: string, StatusCode, tuple, Json, dan tipe kustom yang mengimplementasikan IntoResponse.
  • Error handling di axum via Result<T, E> — buat enum error dan implementasikan IntoResponse, lalu handler bisa return Result<Json<T>, AppError>.
  • Middleware di axum via tower — gunakan tower-http untuk CORS, request logging, timeout, dan kompresi dengan mudah: .layer(CorsLayer::permissive()).
  • Docker multi-stage untuk binary kecil — build di image Rust lengkap, copy binary ke debian:slim atau bahkan scratch. Hasil akhir 5–20 MB vs 1+ GB image builder.
  • Actix-web #[get], #[post], dll. adalah proc-macro yang mendefinisikan route dan method HTTP sekaligus — lebih ringkas dari registrasi manual.

← Sebelumnya: Web Socket   Berikutnya: Unit Test →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact