きっかけ
TCP エコーサーバーは自明に見える。接続を受け、データを読み、送り返す。Python なら15行で書ける。しかし実用的な要件を足し始めると、非同期システム設計の良い演習になる。同時接続数の制限、アイドルタイムアウト、変換パイプライン、構造化ログ。
tcpecho-rs は Rust + Tokio で作った設定可能な TCP エコーサーバー。面白いのはエコーそのものではなく、セマフォベースの接続制限、合成可能な変換パイプライン、リソースリークを防ぐタイムアウト処理。
🔗 GitHub: https://github.com/sen-ltd/tcpecho-rs
なぜエコーサーバーか
エコーサーバーはネットワークプログラミングの printf。プロトコルのデバッグ、ロードバランサのテスト、ファイアウォール規則の検証、プロキシのベンチマーク。予測可能なエンドポイントが必要な場面は多い。
変換フラグはそれぞれ実際のテストシナリオに対応する。
-
--uppercase: クライアントが大文字小文字を正しく処理しているか -
--hex: バイナリデータの送受信が正しいか -
--prefix: プロトコル固有のレスポンスプレフィックスを処理できるか -
--delay: クライアントが遅いサーバーに対してクラッシュしないか
アーキテクチャ
lib.rs に純粋ロジック、main.rs にワイヤリング。テスト容易性のためにこの分離を徹底している。
変換パイプライン
変換関数は固定パイプラインを各行に適用する。
pub fn transform_line(input: &str, config: &Config) -> String {
let trimmed = input.trim_end_matches(|c| c == '\n' || c == '\r');
let after_hex = if config.hex {
hex_encode(trimmed.as_bytes())
} else {
trimmed.to_string()
};
let after_upper = if config.uppercase {
after_hex.to_uppercase()
} else {
after_hex
};
let after_prefix = match &config.prefix {
Some(pfx) => format!("{pfx}{after_upper}"),
None => after_upper,
};
format!("{after_prefix}\n")
}
順序は意図的に固定。hex が uppercase の前に走るので、hex 出力の a-f も大文字化される。prefix は最後なのでプレフィックス自体は変換されない。各ステージは独立しており、if hex && uppercase のような条件分岐は不要。新しい変換を足すには1ステージ追加するだけ。
Hex エンコード(依存なし)
pub fn hex_encode(bytes: &[u8]) -> String {
bytes.iter()
.map(|b| format!("{b:02x}"))
.collect::<Vec<_>>()
.join(" ")
}
スペース区切りにして可読性を優先。"Hi" は "48 69" になる。
セマフォベースの接続制限
非同期サーバーのデフォルトの罠は無制限の並行性。攻撃や暴走クライアントがファイルディスクリプタやメモリを食い尽くす。
Tokio のセマフォで同時接続数をキャップする。
let semaphore = if config.max_conn > 0 {
Some(Arc::new(Semaphore::new(config.max_conn)))
} else {
None
};
新しい接続が来たら try_acquire() でパーミットを取得する。acquire().await ではなく try_acquire() を使う点が重要。前者は空きが出るまでブロックし接続をキューイングするが、後者は即座に失敗するのでクライアントにすぐ拒否を通知できる。テストサーバーとしてはこの挙動が正しい。
パーミットは接続ハンドラの生存期間中保持され、ハンドラが return すると drop でスロットが解放される。Rust の所有権システムで自動的にクリーンアップされるので、手動の管理が不要。
アイドルタイムアウト
データを送らなくなった長寿命接続はリソースリーク。--timeout フラグで接続ごとのアイドルタイムアウトを設定する。
let read_result = if let Some(timeout_dur) = config.timeout {
match tokio::time::timeout(timeout_dur, buf_reader.read_line(&mut line)).await {
Ok(r) => r,
Err(_) => {
let _ = writer.write_all(b"ERROR: idle timeout\n").await;
return;
}
}
} else {
buf_reader.read_line(&mut line).await
};
tokio::time::timeout がタイマーと read を競わせる。タイマーが先に発火したらクライアントにメッセージを送って切断。タイムアウトは1行ごとにリセットされるので、30秒タイムアウトでも29秒ごとにデータを送り続ければ切断されない。「接続全体」のタイムアウトではなく「アイドル」タイムアウト。
タイムスタンプ(chrono なし)
ログにはタイムスタンプが必要だが、datetime クレートを足すほどではない。SystemTime から手動で UTC 変換する。閏年の処理には Howard Hinnant の civil calendar アルゴリズムを使う。
テスト
テストは36件、3層のピラミッド構造。
ユニットテスト(22件): 変換ロジック、hex エンコード、duration パース、タイムスタンプ、カラーパレット、カレンダー計算。純粋関数のみ、I/O なし。
インテグレーションテスト(9件): 実際の TCP サーバーを 127.0.0.1:0 で起動し、TcpStream で接続してレスポンスを検証する。
#[tokio::test]
async fn echo_uppercase() {
let cfg = Config { uppercase: true, ..Config::default() };
let (addr, handle) = spawn_echo_server(cfg).await;
let resp = send_line(addr, "hello").await;
assert_eq!(resp, "HELLO\n");
handle.abort();
}
bind("127.0.0.1:0") でポートを OS に割り当てさせ、テストの並列実行でも競合しない。
特筆すべきインテグレーションテスト:
-
delay_adds_latency:--delay 100msが実際にレイテンシを足すことを確認 -
idle_timeout_disconnects: 接続して何も送らず、タイムアウト後に切断されることを確認 -
max_conn_rejects_excess: 上限まで接続を張った後、追加の接続が拒否されることを確認
CLI テスト(5件): assert_cmd でビルド済みバイナリを実行し、ヘルプ・バージョン・不正引数を検証。
おわりに
TCP エコーサーバーという単純な題材でも、セマフォ、タイムアウト、変換パイプライン、カラー出力など非同期サーバー設計のパターンを一通り実装できる。
ランタイム依存は clap(引数パース)と tokio(非同期ランタイム)の2つだけ。変換、hex エンコード、タイムスタンプ、カラー出力はすべて手書き。
ソースは GitHub で公開している。
