Actix #
Actix-web adalah framework web Rust yang secara konsisten berada di puncak benchmark web framework dunia — melampaui Go, Java, dan Node.js dalam throughput dan latensi. Di balik performa ini bukan sihir: Actix-web dibangun di atas actix-rt (tokio-based runtime) dan menggunakan worker thread per CPU core. Setiap worker menjalankan satu event loop, menghindari overhead koordinasi antar thread. Artikel ini membahas Actix-web 4 secara menyeluruh: routing dengan macro, semua jenis extractor, middleware, error handling, WebSocket, dan testing.
Instalasi #
[dependencies]
actix-web = "4"
actix-files = "0.6"
actix-ws = "0.3"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sqlx = { version = "0.7", features = ["runtime-tokio-native-tls", "postgres"] }
Aplikasi Dasar #
use actix_web::{get, post, web, App, HttpResponse, HttpServer, Responder};
use serde::{Deserialize, Serialize};
#[get("/")]
async fn index() -> impl Responder {
HttpResponse::Ok().body("Selamat datang di Actix-web!")
}
#[get("/halo/{nama}")]
async fn halo(path: web::Path<String>) -> impl Responder {
let nama = path.into_inner();
HttpResponse::Ok().body(format!("Halo, {}!", nama))
}
#[derive(Serialize)]
struct InfoAplikasi {
nama: &'static str,
versi: &'static str,
aktif: bool,
}
#[get("/info")]
async fn info() -> web::Json<InfoAplikasi> {
web::Json(InfoAplikasi {
nama: "API Server",
versi: "1.0.0",
aktif: true,
})
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
HttpServer::new(|| {
App::new()
.service(index)
.service(halo)
.service(info)
// Route tanpa macro
.route("/ping", web::get().to(|| async { "pong" }))
})
.bind("0.0.0.0:8080")?
.run()
.await
}
Routing Lanjutan #
use actix_web::{delete, get, post, put, web, HttpResponse, Responder, Scope};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, Clone)]
struct Pengguna {
pub id: u64,
pub nama: String,
pub email: String,
}
#[derive(Deserialize)]
struct InputPengguna {
pub nama: String,
pub email: String,
}
// Path parameter tunggal
#[get("/pengguna/{id}")]
async fn ambil_pengguna(path: web::Path<u64>) -> impl Responder {
let id = path.into_inner();
web::Json(Pengguna { id, nama: format!("User {}", id), email: format!("user{}@test.com", id) })
}
// Multiple path parameters
#[get("/pengguna/{id}/artikel/{slug}")]
async fn artikel_pengguna(path: web::Path<(u64, String)>) -> impl Responder {
let (user_id, slug) = path.into_inner();
HttpResponse::Ok().json(serde_json::json!({
"pengguna_id": user_id,
"slug": slug
}))
}
// Query string
#[derive(Deserialize)]
struct ParameterDaftar {
halaman: Option<u32>,
per_halaman: Option<u32>,
cari: Option<String>,
aktif: Option<bool>,
}
#[get("/pengguna")]
async fn daftar_pengguna(query: web::Query<ParameterDaftar>) -> impl Responder {
let halaman = query.halaman.unwrap_or(1);
let per_halaman = query.per_halaman.unwrap_or(20);
HttpResponse::Ok().json(serde_json::json!({
"halaman": halaman,
"per_halaman": per_halaman,
"cari": query.cari,
"aktif": query.aktif,
"data": []
}))
}
// JSON body
#[post("/pengguna")]
async fn buat_pengguna(body: web::Json<InputPengguna>) -> impl Responder {
let pengguna = Pengguna {
id: rand_id(),
nama: body.nama.clone(),
email: body.email.clone(),
};
HttpResponse::Created().json(pengguna)
}
fn rand_id() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.subsec_nanos() as u64
}
#[put("/pengguna/{id}")]
async fn perbarui_pengguna(
path: web::Path<u64>,
body: web::Json<InputPengguna>,
) -> impl Responder {
let id = path.into_inner();
HttpResponse::Ok().json(Pengguna {
id,
nama: body.nama.clone(),
email: body.email.clone(),
})
}
#[delete("/pengguna/{id}")]
async fn hapus_pengguna(path: web::Path<u64>) -> impl Responder {
let id = path.into_inner();
HttpResponse::Ok().json(serde_json::json!({"dihapus": id}))
}
// Scope: kelompokkan route dengan prefix
fn rute_pengguna() -> Scope {
web::scope("/api/v1")
.service(daftar_pengguna)
.service(buat_pengguna)
.service(ambil_pengguna)
.service(perbarui_pengguna)
.service(hapus_pengguna)
.service(artikel_pengguna)
}
Shared State dengan Data<T>
#
use actix_web::{get, post, web, App, HttpServer};
use std::sync::{Arc, Mutex, atomic::{AtomicU64, Ordering}};
use serde::Serialize;
#[derive(Clone)]
struct AppState {
nama_aplikasi: String,
request_count: Arc<AtomicU64>,
db_pool: sqlx::PgPool,
}
#[derive(Serialize)]
struct StatusAplikasi {
nama: String,
total_request: u64,
}
#[get("/status")]
async fn status(state: web::Data<AppState>) -> web::Json<StatusAplikasi> {
let count = state.request_count.fetch_add(1, Ordering::Relaxed);
web::Json(StatusAplikasi {
nama: state.nama_aplikasi.clone(),
total_request: count,
})
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
let pool = sqlx::PgPool::connect("postgres://localhost/db")
.await
.expect("Gagal koneksi database");
let state = web::Data::new(AppState {
nama_aplikasi: String::from("Rust API"),
request_count: Arc::new(AtomicU64::new(0)),
db_pool: pool,
});
HttpServer::new(move || {
App::new()
.app_data(state.clone()) // inject state ke semua handler
.service(status)
})
.bind("0.0.0.0:8080")?
.run()
.await
}
Error Handling #
use actix_web::{
error, get,
http::{header::ContentType, StatusCode},
web, App, HttpResponse,
};
use serde::Serialize;
use std::fmt;
#[derive(Debug)]
enum ApiError {
TidakDitemukan(String),
BadRequest(String),
Database(String),
Internal(String),
}
impl fmt::Display for ApiError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
ApiError::TidakDitemukan(msg) => write!(f, "Tidak ditemukan: {}", msg),
ApiError::BadRequest(msg) => write!(f, "Bad request: {}", msg),
ApiError::Database(msg) => write!(f, "Database error: {}", msg),
ApiError::Internal(msg) => write!(f, "Internal error: {}", msg),
}
}
}
#[derive(Serialize)]
struct ErrorResponse {
error: String,
kode: u16,
}
// Implementasikan ResponseError agar bisa dikembalikan langsung dari handler
impl error::ResponseError for ApiError {
fn error_response(&self) -> HttpResponse {
let status = self.status_code();
HttpResponse::build(status)
.content_type(ContentType::json())
.json(ErrorResponse {
error: self.to_string(),
kode: status.as_u16(),
})
}
fn status_code(&self) -> StatusCode {
match self {
ApiError::TidakDitemukan(_) => StatusCode::NOT_FOUND,
ApiError::BadRequest(_) => StatusCode::BAD_REQUEST,
ApiError::Database(_) => StatusCode::INTERNAL_SERVER_ERROR,
ApiError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
}
// Konversi otomatis dari sqlx::Error ke ApiError
impl From<sqlx::Error> for ApiError {
fn from(e: sqlx::Error) -> Self {
match e {
sqlx::Error::RowNotFound => ApiError::TidakDitemukan("Data tidak ada".into()),
_ => ApiError::Database(e.to_string()),
}
}
}
// Handler bisa return Result<T, ApiError>
#[get("/produk/{id}")]
async fn ambil_produk(
path: web::Path<u64>,
db: web::Data<sqlx::PgPool>,
) -> Result<web::Json<serde_json::Value>, ApiError> {
let id = path.into_inner();
if id == 0 {
return Err(ApiError::BadRequest("ID tidak boleh 0".into()));
}
// Simulasi DB query
if id > 1000 {
return Err(ApiError::TidakDitemukan(format!("Produk {} tidak ada", id)));
}
Ok(web::Json(serde_json::json!({
"id": id,
"nama": format!("Produk {}", id),
"harga": id * 1000
})))
}
Middleware #
use actix_web::{
dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform},
Error, HttpMessage,
};
use futures::future::{ready, LocalBoxFuture, Ready};
use std::time::Instant;
use actix_web::middleware::Logger;
use actix_cors::Cors;
use actix_web::http::header;
// Middleware kustom: logging waktu 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 = LocalBoxFuture<'static, 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?;
let elapsed = mulai.elapsed();
println!("{} {} → {} dalam {:?}", metode, path, resp.status(), elapsed);
Ok(resp)
})
}
}
// Setup middleware di App
fn buat_app() -> App<
impl actix_web::dev::ServiceFactory<
ServiceRequest,
Config = (),
Response = ServiceResponse,
Error = Error,
InitError = (),
>,
> {
let cors = Cors::default()
.allowed_origin("https://app.contoh.com")
.allowed_methods(vec!["GET", "POST", "PUT", "DELETE"])
.allowed_headers(vec![header::CONTENT_TYPE, header::AUTHORIZATION])
.max_age(3600);
App::new()
.wrap(TimingMiddleware)
.wrap(Logger::default()) // log format: "[IP] "METHOD /path" STATUS SIZE"
.wrap(cors)
.service(index)
}
#[get("/")]
async fn index() -> &'static str { "OK" }
Form Data dan File Upload #
use actix_web::{post, web, HttpResponse};
use actix_multipart::Multipart;
use futures::StreamExt;
#[derive(serde::Deserialize)]
struct FormLogin {
username: String,
password: String,
}
// Form URL-encoded
#[post("/login")]
async fn login(form: web::Form<FormLogin>) -> HttpResponse {
if form.username == "admin" && form.password == "rahasia" {
HttpResponse::Ok().json(serde_json::json!({"token": "jwt-token-123"}))
} else {
HttpResponse::Unauthorized().json(serde_json::json!({"error": "Kredensial salah"}))
}
}
// File upload dengan multipart
#[post("/upload")]
async fn upload(mut payload: Multipart) -> HttpResponse {
let mut file_count = 0;
let mut total_bytes = 0;
while let Some(Ok(mut field)) = payload.next().await {
let nama_file = field.content_disposition()
.get_filename()
.unwrap_or("unknown")
.to_string();
let mut ukuran = 0;
while let Some(Ok(chunk)) = field.next().await {
ukuran += chunk.len();
total_bytes += chunk.len();
// Proses chunk: simpan ke disk, upload ke S3, dll.
}
println!("File: {}, ukuran: {} byte", nama_file, ukuran);
file_count += 1;
}
HttpResponse::Ok().json(serde_json::json!({
"jumlah_file": file_count,
"total_bytes": total_bytes
}))
}
WebSocket #
use actix_web::{get, web, HttpRequest, HttpResponse};
use actix_ws::Message;
use futures::StreamExt;
#[get("/ws")]
async fn ws_handler(
req: HttpRequest,
stream: web::Payload,
) -> Result<HttpResponse, actix_web::Error> {
let (respons, mut session, mut stream) = actix_ws::handle(&req, stream)?;
tokio::spawn(async move {
while let Some(Ok(msg)) = stream.recv().await {
match msg {
Message::Text(teks) => {
println!("Diterima: {}", teks);
// Echo kembali
if session.text(format!("Echo: {}", teks)).await.is_err() {
break;
}
}
Message::Ping(data) => {
if session.pong(&data).await.is_err() {
break;
}
}
Message::Close(_) => break,
_ => {}
}
}
});
Ok(respons)
}
Testing Handler #
#[cfg(test)]
mod tests {
use actix_web::{test, App};
use super::*;
#[actix_web::test]
async fn test_index() {
let app = test::init_service(
App::new().service(index)
).await;
let req = test::TestRequest::get()
.uri("/")
.to_request();
let resp = test::call_service(&app, req).await;
assert!(resp.status().is_success());
let body = test::read_body(resp).await;
assert_eq!(body, "Selamat datang di Actix-web!");
}
#[actix_web::test]
async fn test_json_response() {
let app = test::init_service(
App::new().service(info)
).await;
let req = test::TestRequest::get()
.uri("/info")
.to_request();
let resp: InfoAplikasi = test::call_and_read_body_json(&app, req).await;
assert_eq!(resp.nama, "API Server");
assert!(resp.aktif);
}
#[actix_web::test]
async fn test_post_json() {
let app = test::init_service(
App::new().service(buat_pengguna)
).await;
let req = test::TestRequest::post()
.uri("/pengguna")
.set_json(InputPengguna {
nama: "Test User".to_string(),
email: "[email protected]".to_string(),
})
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), 201);
let pengguna: Pengguna = test::read_body_json(resp).await;
assert_eq!(pengguna.nama, "Test User");
}
#[actix_web::test]
async fn test_error_handler() {
let pool = sqlx::PgPool::connect("postgres://localhost/test")
.await
.unwrap();
let app = test::init_service(
App::new()
.app_data(web::Data::new(pool))
.service(ambil_produk)
).await;
// Test 400 Bad Request (ID = 0)
let req = test::TestRequest::get()
.uri("/produk/0")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), 400);
// Test 404 Not Found (ID > 1000)
let req = test::TestRequest::get()
.uri("/produk/9999")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), 404);
}
}
Konfigurasi Server Produksi #
use actix_web::{middleware, web, App, HttpServer};
use std::time::Duration;
#[actix_web::main]
async fn main() -> std::io::Result<()> {
// Logging
env_logger::init_from_env(
env_logger::Env::new().default_filter_or("info")
);
let jumlah_worker = num_cpus::get();
println!("Menjalankan {} worker thread", jumlah_worker);
HttpServer::new(move || {
App::new()
.wrap(middleware::Logger::default())
.wrap(middleware::Compress::default()) // kompresi gzip/brotli otomatis
.wrap(middleware::NormalizePath::trim()) // hapus trailing slash
.service(
web::scope("/api/v1")
.service(rute_pengguna())
)
// Static files
.service(
actix_files::Files::new("/static", "./static")
.show_files_listing()
.use_last_modified(true)
)
})
.workers(jumlah_worker) // satu worker per CPU core
.keep_alive(Duration::from_secs(75))
.client_request_timeout(Duration::from_secs(60))
.bind("0.0.0.0:8080")?
.run()
.await
}
fn rute_pengguna() -> actix_web::Scope {
web::scope("/pengguna")
.service(ambil_pengguna)
.service(daftar_pengguna)
}
Ringkasan #
- Macro
#[get],#[post], dll. mendefinisikan route dan HTTP method sekaligus. Daftarkan ke App dengan.service().- Extractor sebagai parameter handler —
web::Path<T>,web::Query<T>,web::Json<T>,web::Form<T>,web::Data<T>. Actix-web otomatis menangani deserialisasi dan error 400 jika format tidak cocok.web::Data<T>untuk shared state — inject state ke semua handler dengan.app_data().Data<T>adalahArc<T>— bisa di-clone dan thread-safe.- Handler return
Result<T, ApiError>— implementasikanResponseErrorpada tipe error untuk mapping otomatis ke HTTP status code dan JSON error body.- Scope untuk versioning API —
web::scope("/api/v1")mengelompokkan route dengan prefix. Berguna untuk versioning (/api/v1/...,/api/v2/...)..workers(num_cpus::get())— default satu worker per CPU core. Setiap worker menjalankan event loop independen; tidak ada koordinasi antar worker untuk request biasa.middleware::Compress::default()— kompresi gzip/brotli otomatis berdasarkan headerAccept-Encodingdari client.actix_web::testuntuk testing —test::init_service,test::TestRequest,test::call_service,test::read_body_jsonmembuat testing handler menjadi mudah dan idiomatis.- WebSocket dengan
actix_ws— lebih ringan dariactix-web-actors; tidak perlu model aktor, cukup spawn task async yang membaca stream.