コンテンツにスキップ

Rustのネットワーク:TCPサーバーとHTTPクライアント

ふだん使っているプログラムのほとんどは、ほかのコンピュータと通信しています。チャットアプリも、ゲームも、天気のウィジェットもそうです。裏側では接続(connection)を開き、バイトを送り、返ってきた答えを読んでいます。Rustはこの分野がとても得意です。速く、メモリをあまり使わず、深夜3時にネットワークのコードを落とすようなバグの多くをコンパイラが見つけてくれます。

このページでは、小さなTCPサーバーとクライアントを書き、複数のクライアントを同時にさばき(まずスレッドで、次にasyncのtokioで)、そのあとreqwestで本物のHTTP APIを呼び出します。TCPの例はこのページからそのままRust Playgroundで動かせます。reqwestの例はインターネット接続が必要なので、自分のパソコンで cargo run して試してください。どの例も実際にビルドして実行したもので、載せている実行結果は本物です。

  • ネットワークサービスとプロキシ。 Cloudflareは、プロキシを作るためのオープンソースのフレームワークPingoraをRustで作りました。サービスメッシュのLinkerd(英語)もRust製のプロキシを使っています。
  • Web APIとバックエンド。 tokioの上に乗ったaxumで作れます。プロジェクト:Web APIを作るを見てください。
  • APIクライアントとツール。 GitHubを呼び出すコマンドラインツール、ボット、自分のサイトが動いているか確かめるスクリプトなどです。
  • 独自のプロトコル。 ゲームサーバー、チャットサーバー、センサーの値をUDPで送る機器などです。

難しいところもあります。非同期のRustは、慣れるまでそれなりに時間がかかります。また、ネットワーク系のクレートを使った最初のビルドには1〜2分かかります。APIを1つ呼ぶだけの10行のスクリプトなら、Pythonのほうが早く書けるかもしれません。Rustが本領を発揮するのは、プログラムが速くなければならないとき、何か月も動き続けなければならないとき、たくさんの接続をさばかなければならないときです。

クレート 用途 スタイル こんなときに
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では、各クライアントはスレッドではなくタスク(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 words
▶ 実際に試してみよう

into_split() は読み込み用の半分と書き込み用の半分を返すので、読む処理と書く処理を別々の変数に持たせられます。先ほどと同じく、受け入れのループはずっと回り続けます。main が戻ると、tokioがすべてのタスクを止めます。

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(())
}

実行結果:

接続に失敗: ConnectionRefused
200ミリ秒で読み込みがタイムアウトしました
▶ 実際に試してみよう

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)

HTTPはWebの言葉で、TCPの上で動いています。TcpStream の上でHTTPを手書きすることもできますが、クライアント用のクレートを使えば、HTTPS、リダイレクト、ヘッダー、圧縮といった細かいことを任せられます。ここからの例はインターネット接続とreqwestクレートが必要なので、Playgroundでは動きません。自分のパソコンで実行してください。

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"] }

練習用に作られた無料のダミーAPI、JSONPlaceholder(英語)を使います。最初のビルドではかなりの数のクレートをダウンロードしてコンパイルするので、少し待ってください。

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にはデフォルトのタイムアウトがありません。そのため、固まったサーバーがあると、プログラムが永遠に待ち続けることがあります。クライアントにタイムアウトを設定しましょう。もう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 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()、is_timeout() を使うと、どんな種類の失敗が起きたのかがわかります。それに応じて、あとでやり直す、メッセージを表示する、あきらめる、といった対応を決められます。大きなプログラムでは、anyhowを使うと、こうしたエラーにかんたんにコンテキストを付けられます。

  • 両方の側がお互いを待ってしまう。 クライアントが read_line を呼んでいるのに、サーバーも読み込み中。クライアントが \n を付け忘れたせいです。誰も送らないので、プログラムは止まったままになります。メッセージがどこで終わるのかを決めておきましょう(改行がいちばん簡単なルールです)。
  • 「最後まで」読むのが早すぎる。 read_to_string は、相手が接続を閉じるまで待ちます。開いたままの接続では、いつまでも戻ってきません。代わりに1行ずつ読みましょう。
  • asyncのコードの中でブロックする。 tokioのタスクの中で std::net や std::thread::sleep を使うと、ワーカースレッドが丸ごと止まってしまいます。asyncのコードでは tokio::net と tokio::time::sleep を使います。
  • タイムアウトを忘れる。 速いローカルネットワークではテストが通ります。ところが、遅いサーバーが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 = 5
mul 4 5 = 20
add 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 completed
first open one: delectus aut autem

JSONの配列は 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() を使いましょう。

理解度チェック

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