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 prioritas —
axumuntuk API modern yang ergonomis dan terintegrasi baik dengan ekosistem tower,actix-webuntuk performa maksimal,rocketuntuk kemudahan pengembangan.- Semua framework mendukung JSON via serde —
Json<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 viaState(axum) atauData(actix-web) atauStaterocket. Untuk akses baca banyak-tulis sedikit, pertimbangkanRwLock.- Handler di axum mengembalikan
impl IntoResponse— berbagai tipe bisa dikembalikan: string, StatusCode, tuple, Json, dan tipe kustom yang mengimplementasikanIntoResponse.- Error handling di axum via
Result<T, E>— buat enum error dan implementasikanIntoResponse, lalu handler bisa returnResult<Json<T>, AppError>.- Middleware di axum via tower — gunakan
tower-httpuntuk 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:slimatau bahkanscratch. 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.