はじめに
こんにちは、aptpod Advent Calendar 2023 12月19日担当のアプトポッドの組み込みソフトエンジニアの影山です。
弊社ではエッジデバイスで動くソフトウェアにRustを採用して、開発を進めております。 私もアプトポッドに入社してからRustを本格的に勉強していくつかの開発にも携わってきました。
一般的に何かのWebサービスを使おうとした場合、開発時ははCLIで利用できるだけで基本的には事足りますが、とっつきやすさだったり、情報の閲覧性を考えるとGUIがあった方がうれしいケースも多々あるかと思います。
これまでの経験でRustも多少使えるようになったので、今回は、Rust SDKを使って自社サービスに接続してみようと思います。
弊社では、コアのサービスであるintdashに接続するためのクライアントライブラリを、色々な言語で提供 *1しており、その中にRustも含まれています。
RustでGUIを作ることができるライブラリはいくつか提供されています。今回はその中でもeguiというライブラリを利用して、デスクトップアプリを作ってみたいと思います。
今回作成するアプリ
本記事では、弊社のintdashサーバに接続して、ダミーデータを送信するアプリを作ります。
コマンドラインで実行するアプリケーションの設定値は設定ファイルに書いたりすることが多いかと思いますが、その設定をGUIから編集できるようにしてみます。
今回は、上のキャプチャのような、接続に必要な設定を記入して、送信ボタンを押すと、それが所定の文字列がintdashサーバに届くというとてもシンプルなものです。
開発環境は、Windows11上のWSL2です。あらかじめRustやgitをインストールお願いします。
eguiとは
eguiは、Rustで書かれたGUIライブラリです。 クロスプラットフォーム対応で、Windows、MacOS、Linux、ブラウザ上で動作します。
リポジトリのReadmeによれば、
egui aims to be the easiest-to-use Rust GUI library, and the simplest way to make a web app in Rust.
とうたっています。一番簡単につかえるRustのGUIライブラリを目指しているそうです。 まさにちょっとGUIがほしいユースケースにぴったりそうです。
eguiの特徴
即時モードUIであることが他の一般的なGUIライブラリとは異なっている点と言われています。
即時モードとは、一定の周期でアプリの全体を描画し直すような動作をすることを指します。パーツごとの書き換えのために必要な状態の管理などが不要になり、構造がシンプルになる代わりに、描画量が増えるので、その分の負荷が高まると言われています。 イメージとしてはゲームのように特定の周期で画面をリフレッシュするようなアプリケーションと同じような仕組みで動いているGUIです。
ゲームエンジンとの相性もよいので、eguiは、bevyというRustのゲームエンジンにも対応しています。 bevyについては弊社テックブログでも過去に触れておりますので、ご興味があれば参照ください。
GUI画面作成
では実際にeguiを利用して、GUI画面を実装してみましょう。 アプリには、パラメータを入力するためのテキストボックスと、送信ボタンが必要になります。
egui template の利用
今回のアプリのベースには、eguiのテンプレートをgit cloneして使ってみます。
src/
以下に、app.rs
、lib.rs
、main.rs
が入っており、GUIに関わるコードは、app.rsに入っていますので、そちらを改造していきます。
例えばcloneした状態でcargo run
で実行すると次のようなウィンドウのアプリが立ち上がります。
アプリ構造体の修正
まず最初に接続に使用するサーバのアドレスとエッジのUUIDとSecretの情報を格納するためにTemplateApp
構造体を以下のように修正します。
pub struct TemplateApp { server: String, uuid: String, secret: String, } impl Default for TemplateApp { fn default() -> Self { Self { server: String::new(), uuid: String::new(), secret: String::new(), } } }
GUIパーツの追加
次にテキストボックスとボタンを追加していきます。
GUIパーツを編集するには、eframe::Appトレイトのupdate関数を修正する必要があります。 update関数はアプリ画面のリフレッシュ時に毎回呼ばれています。
テンプレートで元々あったサンプルコードに手を加えて試行錯誤してみると、GUIパーツをどのように使えばよいのかすぐ理解できるかと思います。
CentralPanelの部分には元々スライダーなどが入っていますが、それらを削除して、編集可能なテキストボックス(text_edit_singleline
)を追加して、server
、uuid
とsecret
に紐づけます。
送信開始のボタンは ui.button("Send").clicked()
というコードに対応しています。こちらのクリックの条件が真になったときに処理したいコードを挿入します。
今回は、このイベントをトリガーにデータの送信を行う関数(send_indash_message
)を呼び出します。
send_indash_message
関数の中身はまだ空にしておきます。
impl eframe::App for TemplateApp { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { (中略) egui::CentralPanel::default().show(ctx, |ui| { ui.heading("intdash sample"); ui.horizontal(|ui| { ui.label("Server: "); ui.text_edit_singleline(&mut self.server); }); ui.horizontal(|ui| { ui.label("Edge UUID: "); ui.text_edit_singleline(&mut self.uuid); }); ui.horizontal(|ui| { ui.label("Edge Secret: "); ui.text_edit_singleline(&mut self.secret); }); if ui.button("Send").clicked() { send_indash_message(&self.server, &self.uuid, &self.secret); } (中略) fn send_indash_message(server: &String, uuid: &String, secret: &String) { }
修正した結果の画面は以下のようになります。
データ送信処理の組み込み
それではアプリのスケルトンができたので、こちらをベースに弊社のintdashサーバへ接続してデータを送信するコードを作って行きましょう。
Cargo.tomlの編集
Cargo.tomlの[dependencies]
に以下の依存関係を追加します。
[dependencies] iscp-rs = "0.10.2" chrono = "0.4.31" async-trait = "0.1.50" oauth2 = "4.0" tokio-test = "0.4" tokio = { version = "1.17", features = ["full"] } uuid = { version = "1.2", features = ["serde", "v4"] }
サービスに接続するためのコード追加
サービス(ここでは弊社のintdash)に接続するために、iscp-rsのサンプルを参考にして、次のようなコードを追加します。
ほぼサンプルコードをコピーした形になります。
詳細の説明は割愛しますが、intdashサーバへ接続して、greeting
というData IDで、hello
というデータを送信する処理になっています。
非同期処理なので、送信関数を呼び出す際は、TokioのRuntimeを利用しています。
use oauth2::basic::BasicClient; use oauth2::reqwest::async_http_client; use oauth2::{AuthType, AuthUrl, ClientId, ClientSecret, TokenResponse, TokenUrl}; use std::{sync::Arc, time::Duration}; fn send_indash_message(server: &String, uuid: &String, secret: &String) { tokio::runtime::Runtime::new().unwrap().block_on(async { let _ = send_msg(server, uuid, secret).await; }); } #[derive(Clone)] struct TokenSource { access_token: String, } #[async_trait::async_trait] impl iscp::TokenSource for TokenSource { async fn token(&self) -> iscp::error::Result<String> { Ok(self.access_token.clone()) } } async fn get_token(node_id: &String, client_secret: &String, url_api: &String) -> Result<String,String> { let client = BasicClient::new( ClientId::new(node_id.to_string()), Some(ClientSecret::new(client_secret.to_string())), AuthUrl::new(format!("{}/api/auth/oauth2/authorization", url_api)).unwrap(), Some(TokenUrl::new(format!("{}/api/auth/oauth2/token", url_api)).unwrap()), ); let client = client.set_auth_type(AuthType::RequestBody); let token_result = client .exchange_client_credentials() .request_async(async_http_client) .await; Ok(token_result.unwrap().access_token().secret().clone()) } async fn send_msg(host: &String, node_id: &String, node_secret: &String)-> Result<(), String>{ let url_api = format!("https://{}",host); let iscp_addr = format!("{}:11443",host); //Tokenの取得 let api_token = get_token(&node_id, &node_secret, &url_api).await.map_err(|e| e.to_string())?; let token_source = Arc::new(TokenSource { access_token: api_token, }); //intdashサーバへ接続 let builder = iscp::ConnBuilder::new(&iscp_addr, iscp::TransportKind::Quic) .quic_config(Some(iscp::tr::QuicConfig { host: host.clone(), mtu: 1000, ..Default::default() })) .encoding(iscp::enc::EncodingKind::Proto) .token_source(Some(token_source)) .node_id(node_id); let conn = builder.connect().await.unwrap(); let session_id = uuid::Uuid::new_v4().to_string(); let base_time = chrono::Utc::now(); let up = conn .upstream_builder(&session_id) .flush_policy(iscp::FlushPolicy::IntervalOnly { interval: std::time::Duration::from_millis(5), }) .ack_interval(chrono::Duration::milliseconds(1000)) .persist(true) .close_timeout(Some(Duration::new(1, 0))) .build() .await .unwrap(); // 基準時刻をiSCPサーバーへ送信します。 conn.send_base_time( iscp::message::BaseTime { elapsed_time: chrono::Duration::zero(), name: "edge_rtc".to_string(), base_time, priority: 20, session_id, }, iscp::SendMetadataOptions { persist: true }, ) .await .unwrap(); tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; // データポイントをiSCPサーバーへ送信します。 up.write_data_points(iscp::DataPointGroup { id: iscp::DataId::new("greeting", "string"), data_points: vec![iscp::DataPoint { payload: "hello".into(), elapsed_time: chrono::Utc::now() - base_time, }], }) .await .unwrap(); up.close(Some(iscp::UpstreamCloseOptions { close_session: true, })) .await .unwrap(); conn.close().await.unwrap(); Ok(()) }
GUIアプリの動作確認
次のコマンドで起動します。
$ cargo run
起動したら接続対象のサーバのアドレス、エッジのUUIDとSecretを入力します。
そしてSendボタンを押すことで、サーバへの接続、データの送信が行われます。
では、Edge Finderという、デバイスからデータ受信状態を表示する弊社のWebアプリで、実際にデータを受信していることを確認してみます。
上の図はSendボタンを押して何秒か待ったあとの画面です。このようにアプリ上のボタンを押すことで、実際にサーバにhello
というテキストデータが届いていることが確認できました。
デスクトップアプリとしての利用
単体で動作するデスクトップアプリとして配布するためには、まずリリース向けにビルドします。
cargo build --release
そしてtarget/release
フォルダに生成された実行ファイルが、単体で動作するアプリとして利用できます。
まとめ
本記事ではeguiを使って、お手軽に仕事でつかえるデスクトップアプリを作る方法をご紹介しました。
純粋なRustで、ちょっとコードを書くだけで簡単にGUIアプリが作れることが伝われば幸いです。
綺麗なGUIデザインを作ることは難しいかもしれませんが、特定の用途で使うアプリなどを作る際には適したものだと思います。
Rustに関しては、開発チームによる、日々の開発にも役に立つテックブログもあるのでご興味ある方はぜひご覧ください。
*1:Python、TypeScript、Swift、C#、Go、Rust