Rustのネットワーク:TCPサーバーとHTTPクライアント
ふだん使っているプログラムのほとんどは、ほかのコンピュータと通信しています。チャットアプリも、ゲームも、天気のウィジェットもそうです。裏側では接続(connection)を開き、バイトを送り、返ってきた答えを読んでいます。Rustはこの分野がとても得意です。速く、メモリをあまり使わず、深夜3時にネットワークのコードを落とすようなバグの多くをコンパイラが見つけてくれます。
このページでは、小さなTCPサーバーとクライアントを書き、複数のクライアントを同時にさばき(まずスレッドで、次にasyncのtokioで)、そのあとreqwestで本物のHTTP APIを呼び出します。TCPの例はこのページからそのままRust Playgroundで動かせます。reqwestの例はインターネット接続が必要なので、自分のパソコンで cargo run して試してください。どの例も実際にビルドして実行したもので、載せている実行結果は本物です。
何が作れるのか
Section titled “何が作れるのか”- ネットワークサービスとプロキシ。 Cloudflareは、プロキシを作るためのオープンソースのフレームワークPingoraをRustで作りました。サービスメッシュのLinkerd(英語)もRust製のプロキシを使っています。
- Web APIとバックエンド。 tokioの上に乗ったaxumで作れます。プロジェクト:Web APIを作るを見てください。
- APIクライアントとツール。 GitHubを呼び出すコマンドラインツール、ボット、自分のサイトが動いているか確かめるスクリプトなどです。
- 独自のプロトコル。 ゲームサーバー、チャットサーバー、センサーの値をUDPで送る機器などです。
難しいところもあります。非同期のRustは、慣れるまでそれなりに時間がかかります。また、ネットワーク系のクレートを使った最初のビルドには1〜2分かかります。APIを1つ呼ぶだけの10行のスクリプトなら、Pythonのほうが早く書けるかもしれません。Rustが本領を発揮するのは、プログラムが速くなければならないとき、何か月も動き続けなければならないとき、たくさんの接続をさばかなければならないときです。
どのクレートを使う?
Section titled “どのクレートを使う?”| クレート | 用途 | スタイル | こんなときに |
|---|---|---|---|
std::net |
TCPとUDPのソケット。Rustに標準で入っている | ブロッキング | 学習用、小さなツール、接続が少ないとき。依存クレートなし。 |
| tokio | 非同期TCP・UDP・タイマーを備えた非同期ランタイム | 非同期 | 同時にたくさんの接続をさばくとき。ほとんどのネットワーク系クレートがこれの上に作られている。 |
| reqwest | 使いやすいHTTPクライアント(JSON、タイムアウト、TLS) | 非同期(ブロッキングも選べる) | Web APIを呼ぶとき。ふつうはまずこれ。 |
| ureq | 小さくてシンプルなHTTPクライアント | ブロッキング | asyncやtokioをまったく使いたくないとき。 |
| hyper | reqwestやaxumの下で動く低レベルのHTTPエンジン | 非同期 | フレームワークを作るときや、細かく制御したいとき。 |
| axum | HTTPサーバーのためのWebフレームワーク | 非同期 | Web APIを書くとき。axumのプロジェクトを参照。 |
どれも成熟していて、広く使われています。おすすめの順番は、まず std::net で考え方を学び、それからtokio、reqwest、axumへ進むことです。
1つのプログラムでTCPサーバーとクライアント
Section titled “1つのプログラムでTCPサーバーとクライアント”TCPは、2つのプログラムの間にバイトのストリーム(stream)をつなぎます。電話のようなものです。サーバーはアドレスとポートで待ち受け(listen)、クライアントはそこへ接続(connect)します。あとはどちらの側も読み書きできます。
ふつうはサーバーとクライアントは別々のプログラムです。ここではPlaygroundで動かせるように、両方を1つのプログラムに入れています。サーバーはスレッドで動かし、main がクライアントです。
use std::io::{BufRead, BufReader, Write};use std::net::{TcpListener, TcpStream};use std::thread;
fn main() -> std::io::Result<()> { // ポート0は「空いているポートならどれでも」という意味。あとで実際のポートを聞く。 let listener = TcpListener::bind("127.0.0.1:0")?; let addr = listener.local_addr()?;
// サーバー:クライアントを1つ受け入れ、1行読み、1行で答える。 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, "こんにちは、{}さん!", name.trim())?; Ok(()) });
// クライアント:接続し、1行送り、答えを読む。 let stream = TcpStream::connect(addr)?; writeln!(&stream, "Ferris")?; let mut reply = String::new(); BufReader::new(&stream).read_line(&mut reply)?; print!("クライアントが受け取った: {reply}");
server.join().unwrap()?; println!("サーバーが終了しました"); Ok(())}実行結果:
クライアントが受け取った: こんにちは、Ferrisさん!サーバーが終了しましたいくつか注目してほしい点があります。
127.0.0.1は「このコンピュータだけ」という意味です。ほかのマシンからサーバーに接続させたいときは0.0.0.0を使います。- TCPは、1つのメッセージがどこで終わるのかを知りません。そこで、1つのメッセージ=1行という簡単なルールを使っています。
writeln!は、println!が画面に書くのと同じようにストリームに書き、最後に\nを付けます。read_lineはそこまで読みます。 - ネットワークの呼び出しはどれも失敗する可能性があるので、それぞれが
Resultを返し、?を使っています。 &streamは読み込みにも書き込みにも使えます。&TcpStreamがReadとWriteの両方を実装しているからです。
スレッドでたくさんのクライアントをさばく
Section titled “スレッドでたくさんのクライアントをさばく”本物のサーバーは、2番目のクライアントを1番目のクライアントが終わるまで待たせてはいけません。昔からある答えは、接続ごとに1つのスレッドです。ループでクライアントを受け入れたら、新しいスレッドに渡し、すぐに accept へ戻ります。
このサーバーは、受け取った各行の単語数を数えます。3つのクライアントが同時に接続します。split_whitespace() は空白で単語を区切りますが、日本語の文には単語の間に空白がないので、ここではメッセージを英語のままにしています。
use std::io::{BufRead, BufReader, Write};use std::net::{SocketAddr, TcpListener, TcpStream};use std::thread;
// サーバー側:各行に、その単語数で答える。fn handle_client(stream: TcpStream) -> std::io::Result<()> { for line in BufReader::new(&stream).lines() { let words = line?.split_whitespace().count(); writeln!(&stream, "{words} words")?; } Ok(()) // クライアントが接続を閉じた}
// クライアント側:1行送り、答えを返す。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)); // クライアントごとに1スレッド } });
// 3つのクライアントを同時に。それぞれが自分のスレッドで動く。 let clients = ["hello there", "rust is fun to learn", "see you soon"] .map(|msg| thread::spawn(move || ask(addr, msg))); // 順番に待つので、出力の順番はいつも同じになる。 for c in clients { println!("{}", c.join().unwrap()?); } Ok(())}実行結果:
"hello there" -> 2 words"rust is fun to learn" -> 5 words"see you soon" -> 3 wordsクライアントが自分の TcpStream を破棄すると接続が閉じ、lines() が終わり、そのクライアントのスレッドも終了します。受け入れのループは終わりませんが、サーバーとしてはそれがふつうです。この例では、main が戻った時点でプログラム全体が止まります。このパターンはシンプルで、クライアントが数十から数百くらいまでならよく動きます。数千になると、スレッドがたくさんのメモリを使ってしまいます。そこで出番になるのがasyncです。
同じサーバーをtokioで
Section titled “同じサーバーをtokioで”tokioでは、各クライアントはスレッドではなくタスク(task)になります。タスクはとても小さいので、1つのサーバーで何千もの接続を抱えられます。上のスレッド版と比べてみてください。std::net が tokio::net に、thread::spawn が tokio::spawn に変わり、プログラムがネットワークを待つところには .await が付きます。
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};use tokio::net::{TcpListener, TcpStream};
// 同じ単語数サーバー。ただし各クライアントはスレッドではなく軽いタスク。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} words\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)); // クライアントごとに1タスク } });
// 3つのクライアントを一度に始め、順番に待つ。 let clients = ["hello there", "rust is fun to learn", "see you soon"] .map(|msg| tokio::spawn(ask(addr, msg))); for c in clients { println!("{}", c.await.unwrap()?); } Ok(())}実行結果:
"hello there" -> 2 words"rust is fun to learn" -> 5 words"see you soon" -> 3 wordsinto_split() は読み込み用の半分と書き込み用の半分を返すので、読む処理と書く処理を別々の変数に持たせられます。先ほどと同じく、受け入れのループはずっと回り続けます。main が戻ると、tokioがすべてのタスクを止めます。
1分でわかるUDP
Section titled “1分でわかるUDP”UDPには接続がありません。1つずつのメッセージ(データグラム、datagram)をアドレスに送り、それぞれが丸ごと届くか、まったく届かないかのどちらかです。順番も、届くことも保証されません。パケットが1つ失われるより速さのほうが大事な場面、たとえばゲーム、ビデオ通話、DNS、センサーなどで使われます。std::net では UdpSocket という1つの型だけです。
let socket = UdpSocket::bind("127.0.0.1:0")?;socket.send_to(b"temp=21.5", "127.0.0.1:9000")?; // 1つのメッセージを1つのアドレスへ送る
let mut buf = [0u8; 1500];let (n, from) = socket.recv_from(&mut buf)?; // 誰かからのメッセージを1つ待つtokio::net::UdpSocket も同じように使えます。.await が付くだけです。
タイムアウトとネットワークエラー
Section titled “タイムアウトとネットワークエラー”ネットワークでは、うまくいかないことがしょっちゅう起きます。誰も待ち受けていない、Wi-Fiが切れる、サーバーが固まる。プログラムを健全に保つためのルールは2つです。すべてのエラーを処理することと、決して永遠に待たないことです。何もしなければ、読み込みは永遠に待ち続けます。set_read_timeout でそれを防げます。
use std::io::{ErrorKind, Read};use std::net::{TcpListener, TcpStream};use std::time::Duration;
fn main() -> std::io::Result<()> { // 1. 誰も待ち受けていない:接続はすぐに失敗する。 let free_port = TcpListener::bind("127.0.0.1:0")?.local_addr()?; // 破棄=閉じる match TcpStream::connect(free_port) { Ok(_) => println!("接続できた?!"), Err(e) => println!("接続に失敗: {:?}", e.kind()), }
// 2. 接続は受け入れるが、決して答えないサーバー。 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!("{n}バイト読み込みました"), // LinuxはWouldBlock、WindowsはTimedOutを返す。 Err(e) if matches!(e.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) => { println!("200ミリ秒で読み込みがタイムアウトしました") } Err(e) => println!("その他のエラー: {e}"), } Ok(())}実行結果:
接続に失敗: ConnectionRefused200ミリ秒で読み込みがタイムアウトしましたe.kind() はErrorKind(英語)を返すので、matchで場合分けできます。接続そのものが遅いときのために TcpStream::connect_timeout もあります。tokioでは、どんなFutureでも tokio::time::timeout で包めます。
let result = tokio::time::timeout(Duration::from_secs(2), stream.read(&mut buf)).await;// Err(_) = 時間がかかりすぎた。Ok(inner) = 読み込みが終わった(inner はそれ自身の Result)reqwestでHTTPとJSON
Section titled “reqwestでHTTPとJSON”HTTPはWebの言葉で、TCPの上で動いています。TcpStream の上でHTTPを手書きすることもできますが、クライアント用のクレートを使えば、HTTPS、リダイレクト、ヘッダー、圧縮といった細かいことを任せられます。ここからの例はインターネット接続とreqwestクレートが必要なので、Playgroundでは動きません。自分のパソコンで実行してください。
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"] }練習用に作られた無料のダミーAPI、JSONPlaceholder(英語)を使います。最初のビルドではかなりの数のクレートをダウンロードしてコンパイルするので、少し待ってください。
JSONをGETとPOSTする
Section titled “JSONをGETとPOSTする”reqwest::get(url).await?.text().await? で、レスポンスの本文を String として受け取れます。でもJSONなら、もっと良い方法があります。json フィーチャーを有効にすると、reqwestはserdeを使って、レスポンスを直接あなたの構造体に変換し、あなたの構造体をリクエストの本文に変換してくれます。
use serde::{Deserialize, Serialize};
// 読まれるのは書いたフィールドだけ。JSONのほかのフィールド("userId" など)は無視される。#[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 -> 自分の構造体 let todo: Todo = client .get("https://jsonplaceholder.typicode.com/todos/1") .send() .await? .json() .await?; println!("#{}: {} (done: {})", todo.id, todo.title, todo.completed);
// POST:自分の構造体 -> JSON(このテスト用APIは答えを返すが、実際には保存しない) 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}Client は1つだけ作って使い回しましょう。接続を開いたままにして再利用するので、2回目以降のリクエストが速くなります。.json(&new) は Content-Type: application/json ヘッダーも自動で付けてくれます。
asyncを使いたくない? それなら小さなブロッキングクライアントの ureq があります。ureq::get(url).call()?.body_mut().read_to_string()? で本文を String として受け取れます。tokioは要りません。
reqwestのタイムアウトとエラー
Section titled “reqwestのタイムアウトとエラー”reqwestにはデフォルトのタイムアウトがありません。そのため、固まったサーバーがあると、プログラムが永遠に待ち続けることがあります。クライアントにタイムアウトを設定しましょう。もう1つ注意点があります。404 Not Found は、reqwestにとってはエラーではありません。リクエスト自体は成功していて、答えが「ありません」だっただけだからです。4xxや5xxの答えをエラーに変えるには、error_for_status() を呼びます。
use std::time::Duration;
// ネットワークの問題だけでなく、4xx/5xx の答えもエラーにする。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> { // タイムアウトがないと、固まったサーバーへのリクエストは永遠に待つことがある。 let client = reqwest::Client::builder() .timeout(Duration::from_secs(2)) .build()?;
// 接続は受け入れるが、決して答えないローカルのサーバー。 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()、is_timeout() を使うと、どんな種類の失敗が起きたのかがわかります。それに応じて、あとでやり直す、メッセージを表示する、あきらめる、といった対応を決められます。大きなプログラムでは、anyhowを使うと、こうしたエラーにかんたんにコンテキストを付けられます。
よくある間違い
Section titled “よくある間違い”- 両方の側がお互いを待ってしまう。 クライアントが
read_lineを呼んでいるのに、サーバーも読み込み中。クライアントが\nを付け忘れたせいです。誰も送らないので、プログラムは止まったままになります。メッセージがどこで終わるのかを決めておきましょう(改行がいちばん簡単なルールです)。 - 「最後まで」読むのが早すぎる。
read_to_stringは、相手が接続を閉じるまで待ちます。開いたままの接続では、いつまでも戻ってきません。代わりに1行ずつ読みましょう。 - asyncのコードの中でブロックする。 tokioのタスクの中で
std::netやstd::thread::sleepを使うと、ワーカースレッドが丸ごと止まってしまいます。asyncのコードではtokio::netとtokio::time::sleepを使います。 - タイムアウトを忘れる。 速いローカルネットワークではテストが通ります。ところが、遅いサーバーが1つあるだけで全体が固まります。タイムアウトは最初の日から設定しましょう。
1. 計算サーバー
Section titled “1. 計算サーバー”add 2 3 や mul 4 5 のような行を読んで、結果を答えるサーバーを書いてください。おかしな行には、エラー: ... という短いメッセージで答えます。main から1つの接続で4つの質問をしてテストしましょう。ヒント: 行は split_whitespace() で分け、数は parse::<i64>() で読み取り、writeln! で答えます。
解答を見る
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 "エラー: <op> <a> <b> の形で送ってください".to_string(); }; let (Ok(a), Ok(b)) = (a.parse::<i64>(), b.parse::<i64>()) else { return "エラー: 数値ではありません".to_string(); }; match op { "add" => (a + b).to_string(), "mul" => (a * b).to_string(), _ => format!("エラー: 知らない演算です({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); // 接続を閉じると、サーバーのループが終わる server.join().unwrap()}実行結果:
add 2 3 = 5mul 4 5 = 20add 2 x = エラー: 数値ではありませんdiv 8 2 = エラー: 知らない演算です(div)計算の処理をふつうの関数 calculate に分けておけば、ネットワークをまったく使わずにテストできます。let ... else の行は、入力が正しい形でないときに早めに戻ります。
2. 完了したToDoを数える(自分のパソコンで)
Section titled “2. 完了したToDoを数える(自分のパソコンで)”reqwestを使って https://jsonplaceholder.typicode.com/todos?userId=1 を取得してください。ToDoのJSON配列が返ってきます。ToDoが全部でいくつあるか、そのうちいくつが完了しているか、そして完了していない最初のToDoのタイトルを表示しましょう。ヒント: Vec<Todo> にデシリアライズしてから、filter や find のようなイテレータのメソッドを使います。
解答を見る
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 autemJSONの配列は Vec に対応していて、各要素はserdeが埋めてくれます。構造体には、必要なフィールドだけを書けば大丈夫です。
std::netには、ブロッキングのTCP(TcpListener、TcpStream)とUDP(UdpSocket)があり、追加のクレートは要りません。- ポート
0にバインドすると空いているポートがどれか割り当てられ、local_addr()でその番号を読み取れます。 - TCPはバイトのストリームです。たとえば「1行に1メッセージ」のように、メッセージがどこで終わるのかを決めておきます。
- たくさんのクライアントは、接続ごとに1スレッドでさばきます。数千の接続が必要なら、tokioのタスクを使います。
- HTTP APIはreqwest(非同期)やureq(ブロッキング)で呼び出し、serdeがJSONを構造体に変換します。
- タイムアウトはいつも設定し、404が成功に見えないように
error_for_status()を使いましょう。
理解度チェック
Section titled “理解度チェック”理解度チェック
1. TcpListener を 127.0.0.1:0 にバインドすると、どうなりますか?
2. このページの例で、すべてのメッセージの最後に \n を送っているのはなぜですか?
3. reqwestが 404 Not Found の答えを受け取りました。send().await は何を返しますか?
4. tokioのサーバーで、各クライアントを std::net と std::thread::sleep で処理しています。何が問題ですか?
スコア: 0 / 4
さらに詳しく
Section titled “さらに詳しく”- std::net — Rust標準ライブラリ(英語):
TcpListener、TcpStream、UdpSocketとアドレスについて、例付きで載っています。 - シングルスレッドのWebサーバを構築する — The Rust Programming Language 日本語版:Rust Bookの最後のプロジェクトです。
std::netでHTTPサーバーを作り、そのあとスレッドプールを加えます。 - Tokio チュートリアル(英語):タスクの生成からメッセージの区切り方(フレーミング)まで、非同期TCPを一歩ずつ学べます。
- reqwest — docs.rs(英語):クライアント、リクエスト、JSON、タイムアウト、
blockingモジュールについて載っています。 - Web clients — Rust Cookbook(英語):HTTPリクエスト、API、ダウンロードのための短いレシピ集です。