aptpod Advent Calendar 2020の2日目を担当します、研究開発グループの大久保です。
弊社では、新しいプロトコルであるQUICの利用法を調査しています。そこで今回は、RustのQUIC実装の1つであるQuinnを用いて、受け取ったリクエストをそのままクライアントへ返送するechoサーバを実装してみます。RustのQUIC実装には、他にquicheというものもありますが、Quinnはtokioの上に実装されているため、Rustのasync機能を活用して楽に書くことができます。
構成
quinn-echo-server
とquinn-echo-client
という2つのクレートを作り、それぞれのCargo.toml
に以下の依存関係を追記します。
[dependencies] anyhow = "1" clap = "3.0.0-beta.2" futures = "0.3" quinn = "0.6" tokio = { version = "0.2", features = ["full"] }
QUICを使うのにquinn
、async周りの機能のためにfutures
とtokio
が必要です。また、エラー処理には、最近おなじみになったanyhow
を、CLI引数のパースにはclap
を入れておきます。
サーバ側
quinn-echo-server
は以下のようになります。
use anyhow::*; use clap::Clap; use futures::StreamExt; use quinn::{ Certificate, CertificateChain, Connecting, Endpoint, NewConnection, PrivateKey, ServerConfig, ServerConfigBuilder, TransportConfig, }; use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::path::PathBuf; #[derive(Clap, Debug)] #[clap(version = "0.1.0")] struct Opts { #[clap(short, long)] port: u16, #[clap(short, long)] ca: PathBuf, #[clap(long)] privkey: PathBuf, } #[tokio::main] async fn main() -> Result<(), Error> { // コマンドライン引数のパース let opts: Opts = Opts::parse(); // QUICの設定 let mut transport_config = TransportConfig::default(); transport_config.stream_window_uni(0xFF); let mut server_config = ServerConfig::default(); server_config.transport = std::sync::Arc::new(transport_config); let mut server_config = ServerConfigBuilder::new(server_config); // 証明書の設定 let cert = Certificate::from_der(&std::fs::read(opts.ca)?)?; server_config.certificate( CertificateChain::from_certs(vec![cert]), PrivateKey::from_der(&std::fs::read(opts.privkey)?)?, )?; // QUICを開く let mut endpoint = Endpoint::builder(); endpoint.listen(server_config.build()); let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), opts.port); let (endpoint, mut incoming) = endpoint.bind(&addr)?; println!("listeing on {}", endpoint.local_addr()?); // クライアントからの接続を扱う while let Some(conn) = incoming.next().await { tokio::spawn(async { // クライアントとの処理を行い、エラーが起きたら表示 match handle_connection(conn).await { Ok(_) => (), Err(e) => { eprintln!("{}", e); } } }); } Ok(()) } // echoの処理をする関数 async fn handle_connection(conn: Connecting) -> Result<(), Error> { let NewConnection { connection, mut uni_streams, .. } = conn.await?; println!("connected from {}", connection.remote_address()); // 受信用のストリームを開く if let Some(uni_stream) = uni_streams.next().await { let uni_stream = uni_stream?; // ストリームを読み出す let data = uni_stream.read_to_end(0xFF).await?; println!("received \"{}\"", String::from_utf8_lossy(&data)); // 送信用のストリームを開く let mut send_stream = connection.open_uni().await?; // 返信を書き込む send_stream.write(&data).await?; send_stream.finish().await?; connection.close(0u8.into(), &[]); } else { bail!("cannot open uni stream"); } println!("closed"); Ok(()) }
下準備と接続を開くまでmain
関数で行っています。コマンドライン引数で渡されたポート番号と、証明書ファイルのパスを基にサーバを立ち上げ、クライアントとの接続が起きたらhandle_connection
関数に渡します。tokioランタイムの上で動作するので、main
関数には#[tokio::main]
属性を追加しておきます。
handle_connection
関数では、受信用に単方向ストリームを開き、内容を全て読み出します。その後、送信用の単方向ストリームを開き、受け取った内容をそのまま書き出したら接続を終了します。
クライアント側
quinn-echo-client
は以下のようになります。
use anyhow::*; use clap::Clap; use futures::StreamExt; use quinn::{Certificate, ClientConfigBuilder, Endpoint, NewConnection}; use std::net::SocketAddr; use std::path::PathBuf; #[derive(Clap, Debug)] #[clap(version = "0.1.0")] struct Opts { #[clap(short, long)] ipaddr: SocketAddr, #[clap(short, long)] ca: PathBuf, } #[tokio::main] async fn main() -> Result<(), Error> { // コマンドライン引数のパース let opts: Opts = Opts::parse(); // QUICの設定 let mut client_config = ClientConfigBuilder::default(); client_config.add_certificate_authority(Certificate::from_der(&std::fs::read(&opts.ca)?)?)?; let mut endpoint_builder = Endpoint::builder(); endpoint_builder.default_client_config(client_config.build()); let (endpoint, _incoming) = endpoint_builder.bind(&"0.0.0.0:0".parse().unwrap())?; // サーバへ接続 let NewConnection { connection, mut uni_streams, .. } = endpoint.connect(&opts.ipaddr, "localhost")?.await?; println!("connected: addr={}", connection.remote_address()); // メッセージの書き込み let msg = "hello"; let mut send_stream = connection.open_uni().await?; send_stream.write(msg.as_bytes()).await?; send_stream.finish().await?; println!("sent \"{}\"", msg); // 返信の読み込み if let Some(uni_stream) = uni_streams.next().await { let uni_stream = uni_stream?; let data = uni_stream.read_to_end(0xFF).await?; println!("received \"{}\"", String::from_utf8_lossy(&data)); } else { bail!("cannot open uni stream"); } // 終了 endpoint.wait_idle().await; Ok(()) }
main
関数で接続、書き込み、返信の読み込みまで行います。QUICの接続を確立したら、送信用の単方向ストリームを開き、文字列"hello"
を書き込みます。その後、受信用の単方向ストリームを開き、文字列に変換して表示したら終了します。
実行
適当なDER形式の証明書ファイルcert.der
と秘密鍵priv.der
を用意した場合、サーバを次のように立ち上げます。
quinn-echo-server --port 33333 --ca cert.der --privkey priv.der
このサーバに接続するクライアントは、次のように実行します。
quinn-echo-client --ipaddr 127.0.0.1:33333 --ca cert.der
すると次のような出力が得られます。
サーバ側
listeing on 0.0.0.0:33333 connected from 127.0.0.1:32820 received "hello" closed
クライアント側
connected: addr=127.0.0.1:33333 sent "hello" received "hello"
これでechoが返ってくることを確認できました。
最後に
今回は、tokioをベースとして構築されたQuinnをつかって、簡単なQUICのechoサーバを構築してみました。別のQUIC実装であるquicheの方は、自分でイベントを扱うループを書く必要があったりしますが、Quinnの方はRustのasync/await機能の基本が分かっていれば、比較的簡単に使いこなすことができます。コネクションやストリームを開いたり、ストリームの読み書きも、とてもRustらしい書き方で行えます。今回実装したのはechoサーバですが、これでQUICの特徴の1つである ストリームの使い方がわかるので、ここから応用することもできるでしょう。
新しいプロトコルであるQUICも、Rustの知識がある程度あればQuinnで簡単に使えるので、ぜひお試し下さい。