これは、なにをしたくて書いたもの?
Rustの勉強をするのに次はなにをしようかなと思ったのですが、こういう時に恒例的に書いているのは簡単なgrepか
TCP Echoサーバー/クライアントです。
grepはこちらで1度やることになりました。
Getting started - Command Line Applications in Rust
RustのGetting Started「Command line apps in Rust」を試す - CLOVER🍀
TCPのサーバー、クライアントを書くにはちょっとRustに対する知識が足りない気がするのですが、テーマを設けて書かないと進まない
気がしてきたので思いきって書いてみたいと思います。
お題と元ネタ
タイトルどおり、TCP Echoサーバーとクライアントを書きます。
以下のお題でやってみたいと思います。
- サーバーは、送られてきたメッセージに対して「★★★」で装飾したメッセージを返す
- クライアントは、返ってきたメッセージを表示する
- サーバー、クライアントそれぞれバイナリークレートとして作成する(どちらも実行可能ファイルを作成する)
- ぼちぼちログ出力する
- サーバー側はできればマルチスレッドにしてみる
またできれば標準ライブラリーに含まれる機能のみで書こうと思ったのですが、Rustは標準ライブラリーにありそうな機能も
クレートになっているものが多いようなので、サードパーティー製のクレートも使おうと思います。
元ネタですが、Rustのドキュメントの「最終プロジェクト」にマルチスレッドで動作するWebサーバーのサンプルがあるので、
こちらをベースに書いていこうと思います。
Final Project: Building a Multithreaded Web Server - The Rust Programming Language
結果としては、そこまで参照しませんでしたが(笑)。
環境
今回の環境はこちら。
$ rustup --version rustup 1.27.1 (54dd3d00f 2024-04-24) info: This is the version for the rustup toolchain manager, not the rustc compiler. info: The currently active `rustc` version is `rustc 1.83.0 (90b35a623 2024-11-26)`
準備
Cargoパッケージの作成。
$ cargo new --vcs none tcp-echo $ cd tcp-echo
クレートの追加。
$ cargo add log $ cargo add env_logger $ cargo add chrono
今回はこちらを使っています。
Cargo.toml
[package] name = "tcp-echo" version = "0.1.0" edition = "2021" [dependencies] chrono = "0.4.39" env_logger = "0.11.6" log = "0.4.22"
バイナリークレートで使用するライブラリークレートを作成する
サーバー、クライアントともにバイナリークレートとして作成するという方針でした。
これらのバイナリークレートから使用するコードは、ライブラリークレートとして作成することにします。
完成形はこうなりました。
src/lib.rs
use std::{ error::Error, io::{BufRead, BufReader, Write}, net::{TcpListener, TcpStream}, thread, time::SystemTime, }; use ::log::{error, info}; pub fn start_server(address: &str, port: i32) -> Result<(), Box<dyn Error>> { let listener = TcpListener::bind(format!("{address}:{port}"))?; info!("starting tcp echo server[{}:{}]...", address, port); for stream in listener.incoming() { let stream = stream.unwrap(); let remote_address = stream.peer_addr().unwrap().ip(); let remote_port = stream.peer_addr().unwrap().port(); info!("accept new client[{}:{}]", remote_address, remote_port); let builder = thread::Builder::new().name(format!( "worker-{}", SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) .unwrap() .as_millis() )); let _ = builder.spawn(|| { let result = reply(stream); match result { Ok(_) => {} Err(error) => { error!("{}", error); } } }); } Ok(()) } fn reply(mut stream: TcpStream) -> Result<(), Box<dyn Error>> { let mut reader = BufReader::new(&stream); let mut line = String::new(); reader.read_line(&mut line)?; let message = line.trim(); info!("received message = {}", message); let response_message = format!("★★★{}★★★", message); stream.write_all(response_message.as_bytes())?; stream.flush()?; Ok(()) } pub struct Client { stream: TcpStream, } impl Client { pub fn send(&mut self, message: &str) -> Result<String, Box<dyn Error>> { self.stream.write_all(message.as_bytes())?; self.stream.write_all("\r\n".as_bytes())?; self.stream.flush()?; let mut reader = BufReader::new(&self.stream); let mut line = String::new(); reader.read_line(&mut line)?; let received_message = line.trim(); Ok(String::from(received_message)) } } pub fn connect(address: &str, port: i32) -> Result<Client, Box<dyn Error>> { let stream = TcpStream::connect(format!("{}:{}", address, port))?; Ok(Client { stream }) }
順を追って説明します。
こちらはTCPサーバーを起動する関数です。
pub fn start_server(address: &str, port: i32) -> Result<(), Box<dyn Error>> { let listener = TcpListener::bind(format!("{address}:{port}"))?; info!("starting tcp echo server[{}:{}]...", address, port); for stream in listener.incoming() { let stream = stream.unwrap(); let remote_address = stream.peer_addr().unwrap().ip(); let remote_port = stream.peer_addr().unwrap().port(); info!("accept new client[{}:{}]", remote_address, remote_port); let builder = thread::Builder::new().name(format!( "worker-{}", SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) .unwrap() .as_millis() )); let _ = builder.spawn(|| { let result = reply(stream); match result { Ok(_) => {} Err(error) => { error!("{}", error); } } }); } Ok(()) }
TCPサーバーとしてリッスンするにはTcpListenerを使うようです。
let listener = TcpListener::bind(format!("{address}:{port}"))?;
TcpListener in std::net - Rust
クライアントからの接続は、TcpListener.incomingで受け付けます。これはTcpStreamとして表現されます。
for stream in listener.incoming() { let stream = stream.unwrap();
受け取った接続の扱いは、スレッドを作成してこちらに任せます。
let builder = thread::Builder::new().name(format!( "worker-{}", SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) .unwrap() .as_millis() )); let _ = builder.spawn(|| { let result = reply(stream); match result { Ok(_) => {} Err(error) => { error!("{}", error); } } });
スレッドを作成するのにBuilderを使っているのは、スレッド名を設定するためです。今回はworker-[エポック(ミリ秒)]のスレッド名に
しました。スレッド名はサーバー側のログ出力時に含めることにします。
単にスレッドを作成するだけならthread.spawnを使えばよいみたいなのですが、この場合はスレッド名は設定されないのでこのように
しました。
なお、今回は使いませんでしたが、スレッドプールを提供するクレートもあるようです。
参考にしたこちらのドキュメントでは、スレッドプールを自作しています。
Turning Our Single-Threaded Server into a Multithreaded Server - The Rust Programming Language
クライアントから送信されたメッセージを読み出し、装飾して送り返す部分はこちら。
fn reply(mut stream: TcpStream) -> Result<(), Box<dyn Error>> { let mut reader = BufReader::new(&stream); let mut line = String::new(); reader.read_line(&mut line)?; let message = line.trim(); info!("received message = {}", message); let response_message = format!("★★★{}★★★", message); stream.write_all(response_message.as_bytes())?; stream.flush()?; Ok(()) }
BufReaderを使うことで、行単位で文字列を読み出すことができます。
ここまでがサーバー側です。
次はクライアント側です。
pub struct Client { stream: TcpStream, } impl Client { pub fn send(&mut self, message: &str) -> Result<String, Box<dyn Error>> { self.stream.write_all(message.as_bytes())?; self.stream.write_all("\r\n".as_bytes())?; self.stream.flush()?; let mut reader = BufReader::new(&self.stream); let mut line = String::new(); reader.read_line(&mut line)?; let received_message = line.trim(); Ok(String::from(received_message)) } } pub fn connect(address: &str, port: i32) -> Result<Client, Box<dyn Error>> { let stream = TcpStream::connect(format!("{}:{}", address, port))?; Ok(Client { stream }) }
クライアント側は、クライアントを表す構造体を作成することにしました。
pub struct Client { stream: TcpStream, } impl Client { pub fn send(&mut self, message: &str) -> Result<String, Box<dyn Error>> { self.stream.write_all(message.as_bytes())?; self.stream.write_all("\r\n".as_bytes())?; self.stream.flush()?; let mut reader = BufReader::new(&self.stream); let mut line = String::new(); reader.read_line(&mut line)?; let received_message = line.trim(); Ok(String::from(received_message)) } }
サーバーへの接続は、TcpStream::connectで行います。
pub fn connect(address: &str, port: i32) -> Result<Client, Box<dyn Error>> { let stream = TcpStream::connect(format!("{}:{}", address, port))?; Ok(Client { stream }) }
TcpStreamの使い方自体はサーバー側で扱った時と同じです。
サーバー側のバイナリークレートを作成する
では、これらを使ってサーバー側のバイナリークレートを作成します。src/bin配下に作成します。
src/bin/server.rs
use std::error::Error; use std::io::Write; use std::{env::args, thread}; use chrono::Local; use log::info; use tcp_echo::start_server; fn main() -> Result<(), Box<dyn Error>> { env_logger::builder() .format(|buf, record| { writeln!( buf, "[{} {} server] {} - {}", Local::now().format("%Y-%m-%d %H:%M:%S"), record.level(), thread::current().name().unwrap(), record.args() ) }) .init(); let address = args().nth(1).unwrap(); let port = args().nth(2).unwrap().parse::<i32>().unwrap(); start_server(&address, port)?; info!("tcp server startup."); Ok(()) }
ここまで説明していませんでしたが、info!のようなマクロはlogクレートによるものです。
logクレートはログ出力実装を持たないようなので、そちらはenv_loggerクレートを使用しています。
サーバー側はログフォーマットをカスタマイズしていて、スレッド名を含めるようにしています。
env_logger::builder() .format(|buf, record| { writeln!( buf, "[{} {} server] {} - {}", Local::now().format("%Y-%m-%d %H:%M:%S"), record.level(), thread::current().name().unwrap(), record.args() ) }) .init();
この時、ログの出力日時は自分で作成する必要があるのでchronoクレートを使いました。
Rustの標準ライブラリーだと、このあたりの日時の書式化などの機能がないみたいなんですよね。このあたりもクレートとして提供する
思想みたいです。
あとはコマンドライン引数にバインドするアドレスとポートが指定される前提で、サーバーを起動します。
let address = args().nth(1).unwrap(); let port = args().nth(2).unwrap().parse::<i32>().unwrap(); start_server(&address, port)?;
コマンドライン引数が指定されなかった場合はパニックになりますが、今回はまあ…。
起動はこんな感じで、env_loggerにRUST_LOG環境変数で有効にするログレベルを指定して起動します。
またsrc/main.rsを持たないので、--bin serverで起動するバイナリークレートを指定する必要があります。
$ RUST_LOG=info cargo run --bin server localhost 5000
環境変数RUST_LOGでレベルを指定しない場合、ログが出力されません…。
起動時のログはこんな感じです。
[2025-01-03 20:49:59 INFO server] main - starting tcp echo server[localhost:5000]...
ncで動作確認。
$ echo hello | nc localhost 5000 ★★★hello★★★ $ echo こんにちは、世界 | nc localhost 5000 ★★★こんにちは、世界★★★
よさそうです。
この時のサーバー側のログはこんな感じです。
[2025-01-03 20:51:21 INFO server] main - accept new client[127.0.0.1:35058] [2025-01-03 20:51:21 INFO server] worker-1735905081496 - received message = hello [2025-01-03 20:51:27 INFO server] main - accept new client[127.0.0.1:34680] [2025-01-03 20:51:27 INFO server] worker-1735905087449 - received message = こんにちは、世界
クライアント側のバイナリークレートを作成する
最後はクライアント側のバイナリークレートを作成します。
src/bin/client.rs
use std::{env::args, error::Error}; use log::info; use tcp_echo::connect; fn main() -> Result<(), Box<dyn Error>> { env_logger::init(); let address = args().nth(1).unwrap(); let port = args().nth(2).unwrap().parse::<i32>().unwrap(); let message = args().nth(3).unwrap(); let mut client = connect(&address, port).unwrap(); info!("connected tcp server[{}:{}]", address, port); info!("send message = {}", message); let received_message = client.send(&message)?; info!("received message = {}", received_message); info!("disconnect"); Ok(()) }
こちらは接続先のアドレス、ポート、送信するメッセージをコマンドライン引数として受け取ります。
env_loggerの設定はデフォルトですが、env_logger::initの呼び出しは必要です。
実行はこんな感じですね。
$ RUST_LOG=info cargo run --bin client localhost 5000 hello
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.03s
Running `target/debug/client localhost 5000 hello`
[2025-01-03T11:54:47Z INFO client] connected tcp server[localhost:5000]
[2025-01-03T11:54:47Z INFO client] send message = hello
[2025-01-03T11:54:47Z INFO client] received message = ★★★hello★★★
[2025-01-03T11:54:47Z INFO client] disconnect
$ RUST_LOG=info cargo run --bin client localhost 5000 こんにちは、世界
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.03s
Running `target/debug/client localhost 5000 'こんにちは、世界'`
[2025-01-03T11:54:52Z INFO client] connected tcp server[localhost:5000]
[2025-01-03T11:54:52Z INFO client] send message = こんにちは、世界
[2025-01-03T11:54:52Z INFO client] received message = ★★★こんにちは、世界★★★
[2025-01-03T11:54:52Z INFO client] disconnect
よさそうです。
ちなみに載せてきていませんでしたが、バイナリークレートは以下のようにserverとclientの2つが実行可能ファイルとしてできあがります。
$ ll target/debug 合計 43540 drwxrwxr-x 7 xxxxx xxxxx 4096 1月 3 20:55 ./ drwxrwxr-x 3 xxxxx xxxxx 4096 1月 3 17:59 ../ -rw-rw-r-- 1 xxxxx xxxxx 0 1月 3 17:59 .cargo-lock drwxrwxr-x 55 xxxxx xxxxx 4096 1月 3 18:49 .fingerprint/ drwxrwxr-x 4 xxxxx xxxxx 4096 1月 3 17:59 build/ -rwxrwxr-x 2 xxxxx xxxxx 20529424 1月 3 20:31 client* -rw-rw-r-- 1 xxxxx xxxxx 176 1月 3 20:54 client.d drwxrwxr-x 2 xxxxx xxxxx 20480 1月 3 20:49 deps/ drwxrwxr-x 2 xxxxx xxxxx 4096 1月 3 17:59 examples/ drwxrwxr-x 14 xxxxx xxxxx 4096 1月 3 18:49 incremental/ -rw-rw-r-- 1 xxxxx xxxxx 126 1月 3 20:55 libtcp_echo.d -rw-rw-r-- 2 xxxxx xxxxx 1381956 1月 3 20:30 libtcp_echo.rlib -rwxrwxr-x 2 xxxxx xxxxx 22593624 1月 3 20:49 server* -rw-rw-r-- 1 xxxxx xxxxx 176 1月 3 20:31 server.d
おわりに
RustでTCP Echoサーバー/クライアントを書いてみました。
けっこう難しかったですが、いろいろ勉強になりました…。
Rustに慣れないとなかなか思ったようにプログラムが書けないですね。所有権やmutキーワード、文字列の扱いなどがまだまだ不慣れです。
頑張って慣れていこうと思います。