Jaringan di Rust: Server TCP dan Klien HTTP
Hampir semua program yang kamu pakai berbicara dengan komputer lain: aplikasi chat, game, widget cuaca. Di baliknya, program itu membuka koneksi, mengirim beberapa byte, lalu membaca jawabannya. Rust sangat jago dalam hal ini. Ia cepat, hemat memori, dan compiler-nya menangkap banyak bug yang biasanya bikin kode jaringan crash jam 3 pagi.
Di halaman ini kamu akan menulis server dan klien TCP kecil, melayani beberapa klien sekaligus (pertama dengan thread, lalu dengan tokio yang async), lalu memanggil API HTTP sungguhan dengan reqwest. Contoh TCP bisa langsung dijalankan di sini, di Rust Playground. Contoh reqwest butuh internet, jadi kamu menjalankannya di komputermu sendiri dengan cargo run. Semua contoh sudah di-build dan dijalankan, dan output yang ditampilkan adalah hasil asli.
Apa yang bisa kamu buat
Section titled “Apa yang bisa kamu buat”- Layanan jaringan dan proxy. Cloudflare membuat Pingora (bahasa Inggris), framework open-source mereka untuk proxy, dengan Rust. Service mesh Linkerd (bahasa Inggris) juga memakai proxy yang ditulis dengan Rust.
- Web API dan back end, dengan axum di atas tokio. Lihat Proyek: Membuat Web API dengan Axum.
- Klien API dan tool: tool command-line yang memanggil GitHub, bot, atau skrip yang mengecek apakah website-mu masih hidup.
- Protokol buatan sendiri: server game, server chat, perangkat yang mengirim data sensor lewat UDP.
Bagian yang lebih sulit: async Rust memang butuh waktu untuk dipelajari, dan build pertama dengan crate jaringan makan waktu satu atau dua menit. Untuk skrip 10 baris yang memanggil satu API, Python mungkin lebih cepat ditulis. Rust terasa manfaatnya saat programmu harus cepat, berjalan berbulan-bulan, atau menangani banyak koneksi.
Crate mana yang dipilih?
Section titled “Crate mana yang dipilih?”| Crate | Untuk apa | Gaya | Pilih kalau |
|---|---|---|---|
std::net |
Socket TCP dan UDP, bawaan Rust | blocking | Belajar, tool kecil, sedikit koneksi. Tanpa dependency. |
| tokio | Runtime async dengan TCP, UDP, dan timer yang async | async | Banyak koneksi sekaligus. Sebagian besar crate jaringan dibangun di atasnya. |
| reqwest | Klien HTTP yang mudah dipakai (JSON, timeout, TLS) | async (ada opsi blocking) | Kamu memanggil web API. Pilihan pertama yang paling umum. |
| ureq | Klien HTTP kecil dan sederhana | blocking | Kamu sama sekali tidak mau pakai async atau tokio. |
| hyper | Mesin HTTP tingkat rendah di bawah reqwest dan axum | async | Kamu membuat framework atau butuh kontrol penuh. |
| axum | Web framework untuk server HTTP | async | Kamu menulis web API. Lihat proyek axum. |
Semuanya sudah matang dan banyak dipakai. Jalur belajar yang enak: pahami konsepnya dengan std::net, lalu pindah ke tokio, reqwest, dan axum.
Server dan klien TCP dalam satu program
Section titled “Server dan klien TCP dalam satu program”TCP memberimu stream (aliran) byte di antara dua program, mirip panggilan telepon. Server mendengarkan (listen) di sebuah alamat dan port. Klien terhubung (connect) ke sana. Setelah itu, kedua sisi bisa membaca dan menulis.
Biasanya server dan klien adalah dua program terpisah. Di sini kita taruh keduanya dalam satu program supaya bisa jalan di Playground: server berjalan di sebuah thread, dan main berperan sebagai klien.
use std::io::{BufRead, BufReader, Write};use std::net::{TcpListener, TcpStream};use std::thread;
fn main() -> std::io::Result<()> { // Port 0 artinya "port mana saja yang bebas". Lalu kita tanya dapat port berapa. let listener = TcpListener::bind("127.0.0.1:0")?; let addr = listener.local_addr()?;
// Server: terima satu klien, baca satu baris, jawab dengan satu baris. let server = thread::spawn(move || -> std::io::Result<()> { let (stream, _) = listener.accept()?; let mut name = String::new(); BufReader::new(&stream).read_line(&mut name)?; writeln!(&stream, "Halo, {}!", name.trim())?; Ok(()) });
// Klien: terhubung, kirim satu baris, baca jawabannya. let stream = TcpStream::connect(addr)?; writeln!(&stream, "Ferris")?; let mut reply = String::new(); BufReader::new(&stream).read_line(&mut reply)?; print!("klien menerima: {reply}");
server.join().unwrap()?; println!("server selesai"); Ok(())}Output:
klien menerima: Halo, Ferris!server selesaiBeberapa hal yang perlu diperhatikan:
127.0.0.1artinya “hanya komputer ini”. Pakai0.0.0.0kalau mesin lain juga harus bisa menjangkau server-mu.- TCP tidak tahu di mana sebuah pesan berakhir. Kita pakai aturan sederhana: satu pesan = satu baris.
writeln!bekerja pada stream sepertiprintln!bekerja pada layar: ia menambahkan\n.read_linemembaca sampai karakter itu. - Setiap panggilan jaringan bisa gagal, jadi masing-masing mengembalikan
Resultdan kita pakai?. &streambisa dipakai untuk membaca dan menulis, karena&TcpStreammengimplementasikanReadsekaligusWrite.
Banyak klien dengan thread
Section titled “Banyak klien dengan thread”Server sungguhan tidak boleh membuat klien nomor 2 menunggu sampai klien nomor 1 selesai. Jawaban klasiknya: satu thread per koneksi. Loop-nya menerima klien, menyerahkannya ke thread baru, lalu langsung kembali ke accept.
Server ini menghitung jumlah kata di setiap baris yang diterimanya. Tiga klien terhubung pada waktu yang sama:
use std::io::{BufRead, BufReader, Write};use std::net::{SocketAddr, TcpListener, TcpStream};use std::thread;
// Sisi server: balas setiap baris dengan jumlah katanya.fn handle_client(stream: TcpStream) -> std::io::Result<()> { for line in BufReader::new(&stream).lines() { let words = line?.split_whitespace().count(); writeln!(&stream, "{words} kata")?; } Ok(()) // klien menutup koneksi}
// Sisi klien: kirim satu baris, kembalikan jawabannya.fn ask(addr: SocketAddr, msg: &str) -> std::io::Result<String> { let stream = TcpStream::connect(addr)?; writeln!(&stream, "{msg}")?; let mut reply = String::new(); BufReader::new(&stream).read_line(&mut reply)?; Ok(format!("{msg:?} -> {}", reply.trim()))}
fn main() -> std::io::Result<()> { let listener = TcpListener::bind("127.0.0.1:0")?; let addr = listener.local_addr()?;
thread::spawn(move || { for stream in listener.incoming() { let stream = stream.unwrap(); thread::spawn(move || handle_client(stream)); // satu thread per klien } });
// Tiga klien sekaligus, masing-masing di thread-nya sendiri. let clients = ["halo semuanya", "rust itu seru untuk dipelajari", "sampai jumpa lagi"] .map(|msg| thread::spawn(move || ask(addr, msg))); // Tunggu satu per satu sesuai urutan, supaya urutan output selalu sama. for c in clients { println!("{}", c.join().unwrap()?); } Ok(())}Output:
"halo semuanya" -> 2 kata"rust itu seru untuk dipelajari" -> 5 kata"sampai jumpa lagi" -> 3 kataSaat klien membuang (drop) TcpStream-nya, koneksi tertutup, lines() berakhir, dan thread milik klien itu selesai. Loop accept tidak pernah berhenti, dan itu normal untuk sebuah server. Di sini program cukup berhenti saat main selesai. Pola ini sederhana dan bekerja dengan baik untuk puluhan atau ratusan klien. Kalau sudah ribuan, semua thread itu memakan banyak memori. Di sinilah async berperan.
Server yang sama dengan tokio
Section titled “Server yang sama dengan tokio”Dengan tokio, setiap klien menjadi sebuah task, bukan thread. Task itu sangat kecil, jadi satu server bisa menampung ribuan koneksi. Bandingkan dengan versi thread di atas: std::net menjadi tokio::net, thread::spawn menjadi tokio::spawn, dan kamu menambahkan .await di setiap tempat program menunggu jaringan.
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};use tokio::net::{TcpListener, TcpStream};
// Server penghitung kata yang sama, tapi tiap klien adalah task yang murah, bukan thread.async fn handle_client(stream: TcpStream) -> std::io::Result<()> { let (read_half, mut write_half) = stream.into_split(); let mut lines = BufReader::new(read_half).lines(); while let Some(line) = lines.next_line().await? { let words = line.split_whitespace().count(); write_half.write_all(format!("{words} kata\n").as_bytes()).await?; } Ok(())}
async fn ask(addr: std::net::SocketAddr, msg: &str) -> std::io::Result<String> { let mut stream = TcpStream::connect(addr).await?; stream.write_all(format!("{msg}\n").as_bytes()).await?; let mut reply = String::new(); BufReader::new(stream).read_line(&mut reply).await?; Ok(format!("{msg:?} -> {}", reply.trim()))}
#[tokio::main]async fn main() -> std::io::Result<()> { let listener = TcpListener::bind("127.0.0.1:0").await?; let addr = listener.local_addr()?;
tokio::spawn(async move { loop { let (stream, _) = listener.accept().await.unwrap(); tokio::spawn(handle_client(stream)); // satu task per klien } });
// Jalankan tiga klien sekaligus, lalu tunggu masing-masing sesuai urutan. let clients = ["halo semuanya", "rust itu seru untuk dipelajari", "sampai jumpa lagi"] .map(|msg| tokio::spawn(ask(addr, msg))); for c in clients { println!("{}", c.await.unwrap()?); } Ok(())}Output:
"halo semuanya" -> 2 kata"rust itu seru untuk dipelajari" -> 5 kata"sampai jumpa lagi" -> 3 katainto_split() memberimu bagian baca dan bagian tulis, jadi proses membaca dan menulis bisa disimpan di variabel yang berbeda. Sama seperti sebelumnya, loop accept berjalan selamanya. Saat main selesai, tokio menghentikan semua task.
UDP dalam satu menit
Section titled “UDP dalam satu menit”UDP tidak punya koneksi. Kamu mengirim pesan tunggal (datagram) ke sebuah alamat, dan setiap pesan tiba utuh atau tidak tiba sama sekali. Tidak ada jaminan urutan maupun jaminan sampai. UDP dipakai untuk game, panggilan video, DNS, dan sensor, di mana kecepatan lebih penting daripada satu paket yang hilang. Di std::net, UDP cukup satu tipe, UdpSocket:
let socket = UdpSocket::bind("127.0.0.1:0")?;socket.send_to(b"temp=21.5", "127.0.0.1:9000")?; // satu pesan, dikirim ke satu alamat
let mut buf = [0u8; 1500];let (n, from) = socket.recv_from(&mut buf)?; // menunggu satu pesan dari siapa sajatokio::net::UdpSocket bekerja dengan cara yang sama, ditambah .await.
Timeout dan error jaringan
Section titled “Timeout dan error jaringan”Di jaringan, masalah terjadi setiap saat: tidak ada yang mendengarkan, Wi-Fi putus, server macet. Dua aturan menjaga programmu tetap sehat: tangani setiap error, dan jangan pernah menunggu selamanya. Secara default, operasi baca menunggu selamanya. set_read_timeout memperbaikinya:
use std::io::{ErrorKind, Read};use std::net::{TcpListener, TcpStream};use std::time::Duration;
fn main() -> std::io::Result<()> { // 1. Tidak ada yang mendengarkan: koneksi langsung gagal. let free_port = TcpListener::bind("127.0.0.1:0")?.local_addr()?; // di-drop = ditutup match TcpStream::connect(free_port) { Ok(_) => println!("terhubung?!"), Err(e) => println!("gagal terhubung: {:?}", e.kind()), }
// 2. Server yang menerima koneksi, tapi tidak pernah menjawab. let silent = TcpListener::bind("127.0.0.1:0")?; let mut stream = TcpStream::connect(silent.local_addr()?)?; stream.set_read_timeout(Some(Duration::from_millis(200)))?;
let mut buf = [0u8; 64]; match stream.read(&mut buf) { Ok(n) => println!("terbaca {n} byte"), // Linux melaporkan WouldBlock, Windows melaporkan TimedOut. Err(e) if matches!(e.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) => { println!("waktu baca habis setelah 200 ms") } Err(e) => println!("error lain: {e}"), } Ok(())}Output:
gagal terhubung: ConnectionRefusedwaktu baca habis setelah 200 mse.kind() memberimu sebuah ErrorKind (bahasa Inggris) yang bisa kamu match. Nama seperti ConnectionRefused adalah nama varian enum dari Rust sendiri, jadi tetap dalam bahasa Inggris. Ada juga TcpStream::connect_timeout untuk koneksi yang lambat. Di tokio, bungkus future apa pun dengan tokio::time::timeout:
let result = tokio::time::timeout(Duration::from_secs(2), stream.read(&mut buf)).await;// Err(_) = terlalu lama; Ok(inner) = operasi baca selesai (inner adalah Result miliknya sendiri)HTTP dan JSON dengan reqwest
Section titled “HTTP dan JSON dengan reqwest”HTTP adalah bahasa web, dan ia berjalan di atas TCP. Kamu bisa saja menulis HTTP dengan tangan lewat TcpStream, tapi crate klien mengurus detailnya untukmu: HTTPS, redirect, header, kompresi. Contoh-contoh ini butuh akses internet dan crate reqwest, jadi tidak bisa dijalankan di Playground. Jalankan di komputermu:
cargo new api-clientcd api-clientcargo add reqwest --features jsoncargo add tokio --features fullcargo add serde --features derive[dependencies]reqwest = { version = "0.13", features = ["json"] }serde = { version = "1", features = ["derive"] }tokio = { version = "1", features = ["full"] }Kita akan memakai JSONPlaceholder (bahasa Inggris), API palsu gratis yang dibuat untuk latihan. Build pertama mengunduh dan meng-compile cukup banyak crate, jadi tunggu sebentar.
GET dan POST JSON
Section titled “GET dan POST JSON”reqwest::get(url).await?.text().await? memberimu isi response sebagai String. Tapi untuk JSON ada cara yang lebih enak: dengan fitur json, reqwest memakai serde untuk mengubah response langsung menjadi struct-mu, dan mengubah struct-mu menjadi isi request:
use serde::{Deserialize, Serialize};
// Hanya field yang kamu tulis yang dibaca. Field JSON lain (seperti "userId") diabaikan.#[derive(Deserialize)]struct Todo { id: u32, title: String, completed: bool,}
#[derive(Serialize)]struct NewTodo { title: String, completed: bool,}
#[tokio::main]async fn main() -> Result<(), reqwest::Error> { let client = reqwest::Client::new();
// GET: JSON -> struct-mu let todo: Todo = client .get("https://jsonplaceholder.typicode.com/todos/1") .send() .await? .json() .await?; println!("#{}: {} (done: {})", todo.id, todo.title, todo.completed);
// POST: struct-mu -> JSON (API tes ini menjawab, tapi tidak benar-benar menyimpannya) let new = NewTodo { title: "Learn reqwest".to_string(), completed: false }; let response = client .post("https://jsonplaceholder.typicode.com/todos") .json(&new) .send() .await?; println!("status: {}", response.status()); println!("{}", response.text().await?); Ok(())}#1: delectus aut autem (done: false)status: 201 Created{ "title": "Learn reqwest", "completed": false, "id": 201}Buat satu Client saja dan pakai ulang. Ia menjaga koneksi tetap terbuka dan memakainya kembali, sehingga request berikutnya lebih cepat. .json(&new) juga otomatis memasang header Content-Type: application/json untukmu. (Teks seperti “delectus aut autem” memang berasal dari API tes ini, bukan salah ketik.)
Tidak mau pakai async? ureq adalah klien blocking yang kecil: ureq::get(url).call()?.body_mut().read_to_string()? memberimu isi response sebagai String, tanpa perlu tokio.
Timeout dan error dengan reqwest
Section titled “Timeout dan error dengan reqwest”reqwest tidak punya timeout secara default, jadi server yang macet bisa membuat programmu menunggu selamanya. Pasang timeout di client. Perhatikan juga: 404 Not Found bukan error bagi reqwest. Request-nya berhasil, hanya saja jawabannya “tidak ada”. Panggil error_for_status() untuk mengubah jawaban 4xx dan 5xx menjadi error:
use std::time::Duration;
// Jawaban 4xx/5xx juga diubah jadi error, bukan hanya masalah jaringan.async fn fetch(client: &reqwest::Client, url: &str) -> Result<String, reqwest::Error> { let response = client.get(url).send().await?.error_for_status()?; response.text().await}
#[tokio::main]async fn main() -> Result<(), reqwest::Error> { // Tanpa timeout, request ke server yang macet bisa menunggu selamanya. let client = reqwest::Client::builder() .timeout(Duration::from_secs(2)) .build()?;
// Server lokal yang menerima koneksi, tapi tidak pernah menjawab. let silent = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let stuck = format!("http://{}/", silent.local_addr().unwrap());
let urls = [ "https://jsonplaceholder.typicode.com/todos/1", "https://jsonplaceholder.typicode.com/todos/999999", "http://127.0.0.1:1/", &stuck, ]; for url in urls { match fetch(&client, url).await { Ok(body) => println!("ok, {} bytes", body.len()), Err(e) if e.is_status() => println!("server said {}", e.status().unwrap()), Err(e) if e.is_connect() => println!("could not connect: {e}"), Err(e) if e.is_timeout() => println!("gave up after 2 seconds"), Err(e) => println!("other error: {e}"), } } Ok(())}ok, 83 bytesserver said 404 Not Foundcould not connect: error sending request for url (http://127.0.0.1:1/)gave up after 2 secondsis_status(), is_connect(), dan is_timeout() memberi tahu jenis kegagalan yang terjadi, jadi kamu bisa memutuskan langkah selanjutnya: coba lagi nanti, tampilkan pesan, atau menyerah. Di program yang lebih besar, anyhow memudahkanmu menambahkan konteks pada error-error ini.
Kesalahan umum
Section titled “Kesalahan umum”- Kedua sisi saling menunggu. Klien memanggil
read_line, tapi server juga sedang membaca, karena klien lupa mengirim\n. Tidak ada yang pernah mengirim, dan program macet (hang). Sepakati di mana sebuah pesan berakhir (newline adalah aturan yang paling mudah). - Membaca “sampai habis” terlalu cepat.
read_to_stringmenunggu sampai sisi lain menutup koneksi. Pada koneksi yang tetap terbuka, fungsi itu tidak pernah selesai. Baca baris demi baris saja. - Blocking di dalam kode async. Memakai
std::netataustd::thread::sleepdi dalam task tokio akan memblokir satu worker thread penuh. Di kode async, pakaitokio::netdantokio::time::sleep. - Lupa timeout. Tes-mu lolos di jaringan lokal yang cepat. Lalu satu server yang lambat membuat semuanya membeku. Pasang timeout sejak hari pertama.
Latihan
Section titled “Latihan”1. Server kalkulator
Section titled “1. Server kalkulator”Tulis server yang membaca baris seperti add 2 3 atau mul 4 5 dan menjawab dengan hasilnya. Untuk baris yang salah, jawab dengan pesan singkat error: .... Uji dari main dengan satu koneksi dan empat pertanyaan. Petunjuk: pecah baris dengan split_whitespace(), ubah angkanya dengan parse::<i64>(), dan balas dengan writeln!.
Lihat solusi
use std::io::{BufRead, BufReader, Write};use std::net::{TcpListener, TcpStream};use std::thread;
fn calculate(line: &str) -> String { let parts: Vec<&str> = line.split_whitespace().collect(); let [op, a, b] = parts[..] else { return "error: gunakan <op> <a> <b>".to_string(); }; let (Ok(a), Ok(b)) = (a.parse::<i64>(), b.parse::<i64>()) else { return "error: bukan angka".to_string(); }; match op { "add" => (a + b).to_string(), "mul" => (a * b).to_string(), _ => format!("error: operasi tidak dikenal: {op}"), }}
fn main() -> std::io::Result<()> { let listener = TcpListener::bind("127.0.0.1:0")?; let addr = listener.local_addr()?;
let server = thread::spawn(move || -> std::io::Result<()> { let (stream, _) = listener.accept()?; for line in BufReader::new(&stream).lines() { writeln!(&stream, "{}", calculate(&line?))?; } Ok(()) });
let stream = TcpStream::connect(addr)?; let mut replies = BufReader::new(&stream); for question in ["add 2 3", "mul 4 5", "add 2 x", "div 8 2"] { writeln!(&stream, "{question}")?; let mut answer = String::new(); replies.read_line(&mut answer)?; println!("{question} = {}", answer.trim()); } drop(stream); // menutup koneksi akan mengakhiri loop di server server.join().unwrap()}Output:
add 2 3 = 5mul 4 5 = 20add 2 x = error: bukan angkadiv 8 2 = error: operasi tidak dikenal: divKarena logikanya ada di fungsi calculate biasa, kamu bisa mengujinya tanpa jaringan sama sekali. Baris let ... else langsung keluar (return) lebih awal kalau bentuk input-nya tidak sesuai.
2. Hitung to-do yang sudah selesai (di komputermu)
Section titled “2. Hitung to-do yang sudah selesai (di komputermu)”Dengan reqwest, ambil https://jsonplaceholder.typicode.com/todos?userId=1. Alamat ini mengembalikan array JSON berisi to-do. Cetak berapa jumlahnya, berapa yang sudah selesai (completed), dan judul to-do pertama yang belum selesai. Petunjuk: deserialize ke Vec<Todo>, lalu pakai method iterator seperti filter dan find.
Lihat solusi
use serde::Deserialize;
#[derive(Deserialize)]struct Todo { title: String, completed: bool,}
#[tokio::main]async fn main() -> Result<(), reqwest::Error> { let todos: Vec<Todo> = reqwest::get("https://jsonplaceholder.typicode.com/todos?userId=1") .await? .error_for_status()? .json() .await?;
let done = todos.iter().filter(|t| t.completed).count(); println!("user 1 has {} to-dos, {done} completed", todos.len());
let first_open = todos.iter().find(|t| !t.completed).unwrap(); println!("first open one: {}", first_open.title); Ok(())}user 1 has 20 to-dos, 11 completedfirst open one: delectus aut autemArray JSON dipetakan ke sebuah Vec, dan serde mengisi setiap elemennya untukmu. Struct-mu cukup berisi field yang kamu butuhkan.
Ringkasan
Section titled “Ringkasan”std::netmemberimu TCP blocking (TcpListener,TcpStream) dan UDP (UdpSocket) tanpa crate tambahan.- Bind ke port
0untuk mendapat port bebas mana saja, lalu baca nomornya denganlocal_addr(). - TCP adalah aliran byte: sepakati di mana sebuah pesan berakhir, misalnya satu pesan per baris.
- Layani banyak klien dengan satu thread per koneksi, atau dengan task tokio kalau kamu butuh ribuan.
- reqwest (async) atau ureq (blocking) memanggil API HTTP, dan serde mengubah JSON menjadi struct-mu.
- Selalu pasang timeout, dan pakai
error_for_status()supaya 404 tidak terlihat seperti sukses.
Kuis singkat
Section titled “Kuis singkat”Kuis singkat
1. Apa yang terjadi kalau TcpListener di-bind ke 127.0.0.1:0?
2. Kenapa contoh-contoh di halaman ini mengirim \n di akhir setiap pesan?
3. reqwest mendapat jawaban 404 Not Found. Apa yang dikembalikan send().await?
4. Server tokio-mu menangani setiap klien dengan std::net dan std::thread::sleep. Apa masalahnya?
Skor: 0 / 4
Bacaan lebih lanjut
Section titled “Bacaan lebih lanjut”- std::net — Rust standard library (bahasa Inggris):
TcpListener,TcpStream,UdpSocket, dan tipe alamat, lengkap dengan contoh. - Building a Single-Threaded Web Server — The Rust Book (bahasa Inggris): proyek terakhir di Book membuat server HTTP dengan
std::net, lalu menambahkan thread pool. - Tokio tutorial (bahasa Inggris): TCP async langkah demi langkah, dari spawn task sampai framing pesan.
- reqwest — docs.rs (bahasa Inggris): client, request, JSON, timeout, dan modul
blocking. - Web clients — Rust Cookbook (bahasa Inggris): resep singkat untuk request HTTP, API, dan unduhan.