Lewati ke konten

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.

  • 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 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.

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 selesai
▶ Coba sendiri

Beberapa hal yang perlu diperhatikan:

  • 127.0.0.1 artinya “hanya komputer ini”. Pakai 0.0.0.0 kalau 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 seperti println! bekerja pada layar: ia menambahkan \n. read_line membaca sampai karakter itu.
  • Setiap panggilan jaringan bisa gagal, jadi masing-masing mengembalikan Result dan kita pakai ?.
  • &stream bisa dipakai untuk membaca dan menulis, karena &TcpStream mengimplementasikan Read sekaligus Write.

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 kata
▶ Coba sendiri

Saat 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.

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 kata
▶ Coba sendiri

into_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 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 saja

tokio::net::UdpSocket bekerja dengan cara yang sama, ditambah .await.

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: ConnectionRefused
waktu baca habis setelah 200 ms
▶ Coba sendiri

e.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 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:

Terminal window
cargo new api-client
cd api-client
cargo add reqwest --features json
cargo add tokio --features full
cargo 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.

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.

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 bytes
server said 404 Not Found
could not connect: error sending request for url (http://127.0.0.1:1/)
gave up after 2 seconds

is_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.

  • 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_string menunggu 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::net atau std::thread::sleep di dalam task tokio akan memblokir satu worker thread penuh. Di kode async, pakai tokio::net dan tokio::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.

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 = 5
mul 4 5 = 20
add 2 x = error: bukan angka
div 8 2 = error: operasi tidak dikenal: div
▶ Coba sendiri

Karena 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 completed
first open one: delectus aut autem

Array JSON dipetakan ke sebuah Vec, dan serde mengisi setiap elemennya untukmu. Struct-mu cukup berisi field yang kamu butuhkan.

  • std::net memberimu TCP blocking (TcpListener, TcpStream) dan UDP (UdpSocket) tanpa crate tambahan.
  • Bind ke port 0 untuk mendapat port bebas mana saja, lalu baca nomornya dengan local_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

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