以下の内容はhttps://kazuhira-r.hatenablog.com/entry/2025/01/03/205825より取得しました。


RustでTCP Echoサーバー/クライアントを書いてみる

これは、なにをしたくて書いたもの?

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

今回はこちらを使っています。

log - Rust

env_logger - Rust

chrono - Rust

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

TcpStream in std::net - Rust

受け取った接続の扱いは、スレッドを作成してこちらに任せます。

        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-[エポック(ミリ秒)]のスレッド名に
しました。スレッド名はサーバー側のログ出力時に含めることにします。

std::thread - Rust

単にスレッドを作成するだけならthread.spawnを使えばよいみたいなのですが、この場合はスレッド名は設定されないのでこのように
しました。

なお、今回は使いませんでしたが、スレッドプールを提供するクレートもあるようです。

threadpool - Rust

参考にしたこちらのドキュメントでは、スレッドプールを自作しています。

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を使うことで、行単位で文字列を読み出すことができます。

BufReader in std::io - Rust

ここまでがサーバー側です。

次はクライアント側です。

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 in std::net - Rust

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 - Rust

logクレートはログ出力実装を持たないようなので、そちらはenv_loggerクレートを使用しています。

env_logger - Rust

サーバー側はログフォーマットをカスタマイズしていて、スレッド名を含めるようにしています。

    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クレートを使いました。

chrono - Rust

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

よさそうです。

ちなみに載せてきていませんでしたが、バイナリークレートは以下のようにserverclientの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キーワード、文字列の扱いなどがまだまだ不慣れです。

頑張って慣れていこうと思います。




以上の内容はhttps://kazuhira-r.hatenablog.com/entry/2025/01/03/205825より取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14