0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Rustポートフォリオ開発記 #5】テスト追加・定期送信・sysinfo実機化・環境設定から最終テストまで!実際の開発手順とコードの全変遷

0
Posted at

Note
本開発プロセスおよび技術記事の作成にあたっては、開発効率の最大化とアウトプットの継続を目的としてAI(Gemini)をパートナーとして活用しています。コードや構成案の生成後、著者自身による動作検証およびレビューを行った上で掲載しています。

はじめに

自作の超軽量システムモニター(Agent)開発の第5弾です。
前回(#4)までに非同期通信の骨組みを作成しましたが、今回はそこから実際に手を動かして機能を拡張し、テストや環境整備を行っていった全プロセスを、実施した順番通りにコードの変遷とともに記録します。


1. テストモジュールの追加・テスト実行・GitHub Push

まず、これまで作成したデータ構造やTraitに対して、単体テストを行えるよう src/main.rs の最下部に #[cfg(test)] モジュールを追加しました。

追加したテストコード (src/main.rs)

#[cfg(test)]
mod tests {
    use super::*;

    // 1. テスト専用のモックコレクター(実機の環境情報に依存しない)
    pub struct DummyCollector;

    impl MetricsCollector for DummyCollector {
        fn collect_host_info(&self) -> HostInfo {
            HostInfo {
                hostname: "test-host".to_string(),
                os_type: OsType::MacOS,
                agent_version: "0.1.0".to_string(),
            }
        }

        fn collect_metrics(&self) -> SystemMetrics {
            SystemMetrics {
                cpu_usage_percent: 12.5,
                memory: MemoryMetrics {
                    total_bytes: 1000,
                    used_bytes: 500,
                    usage_percent: 50.0,
                },
                disk: DiskMetrics {
                    total_bytes: 2000,
                    used_bytes: 1000,
                    usage_percent: 50.0,
                },
                uptime_seconds: 1234,
            }
        }

        fn build_payload(&self) -> MonitorPayload {
            MonitorPayload {
                host_info: self.collect_host_info(),
                metrics: self.collect_metrics(),
                timestamp_unix: 1700000000,
            }
        }
    }

    // 2. OsType の serde シリアライズ/デシリアライズ検証(lowercase化の動作確認)
    #[test]
    fn test_os_type_serde() {
        let os = OsType::MacOS;
        let json_str = serde_json::to_string(&os).expect("Failed to serialize OsType");
        assert_eq!(json_str, "\"macos\"");

        let deserialized: OsType = serde_json::from_str("\"macos\"").expect("Failed to deserialize OsType");
        assert_eq!(deserialized, OsType::MacOS);
    }

    // 3. DummyCollector を用いた MetricsCollector トレイティの実装検証
    #[test]
    fn test_dummy_collector_payload() {
        let collector = DummyCollector;
        let payload = collector.build_payload();

        assert_eq!(payload.host_info.hostname, "test-host");
        assert_eq!(payload.host_info.os_type, OsType::MacOS);
        assert_eq!(payload.metrics.cpu_usage_percent, 12.5);
        assert_eq!(payload.metrics.memory.used_bytes, 500);
        assert_eq!(payload.timestamp_unix, 1700000000);
    }

    // 4. MonitorPayload 全体が正しい JSON キー構造へ変換されるか検証
    #[test]
    fn test_payload_json_structure() {
        let collector = DummyCollector;
        let payload = collector.build_payload();

        let json_value = serde_json::to_value(&payload).expect("Failed to convert payload to serde_json::Value");

        assert_eq!(json_value["host_info"]["hostname"], "test-host");
        assert_eq!(json_value["host_info"]["os_type"], "macos");
        assert_eq!(json_value["metrics"]["cpu_usage_percent"], 12.5);
        assert_eq!(json_value["metrics"]["memory"]["usage_percent"], 50.0);
    }
}

動作確認 (cargo test)

running 3 tests
test tests::test_os_type_serde ... ok
test tests::test_dummy_collector_payload ... ok
test tests::test_payload_json_structure ... ok

test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

GitHub Push

git status
git add .
git commit -m "test: add unit tests for monitor payload and serde"
git push origin main

2. 10秒ごとの定期収集と HTTP POST 送信処理の実装

常駐Agentとして機能させるため、tokio と reqwest を導入し、10秒周期で外部へ非同期送信するループ処理を実装しました。

改修したコード

Cargo.toml の [dependencies] に以下を追加:

[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
# 非同期処理・タイマー用
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }
# HTTPリクエスト用 (JSON自動シリアライズ機能つき)
reqwest = { version = "0.11", features = ["json"] }

src/main.rs のメイン処理部分を改修:

use serde::{Deserialize, Serialize};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tokio::time::interval;

// (構造体・Trait定義は省略)

#[tokio::main]
async fn main() {
    println!("--- 軽量モニターAgent 起動 (10秒周期送信モード) ---");

    let collector = DummyCollector;
    // リクエスト間で接続を再利用するため Client を1つ生成
    let client = reqwest::Client::new();

    // 動作確認用テストエンドポイント
    let target_url = "[https://httpbin.org/post](https://httpbin.org/post)";

    // 10秒間隔のタイマーを作成
    let mut ticker = interval(Duration::from_secs(10));

    loop {
        // 10秒の経過を待機
        ticker.tick().await;

        let payload = collector.build_payload();
        println!("[Unix: {}] メトリクスを送信中...", payload.timestamp_unix);

        // HTTP POST リクエスト送信 (JSON自動シリアライズ)
        match client.post(target_url).json(&payload).send().await {
            Ok(response) => {
                if response.status().is_success() {
                    println!(" -> 送信成功: ステータスコード {}", response.status());
                } else {
                    eprintln!(" -> 送信エラー: ステータスコード {}", response.status());
                }
            }
            Err(err) => {
                eprintln!(" -> 通信エラーが発生しました: {}", err);
            }
        }
    }
}

動作確認 (cargo run)

cargo run
--- 軽量モニターAgent 起動 (10秒周期送信モード) ---
[Unix: 1789313308] メトリクスを送信中...
 -> 送信成功: ステータスコード 200 OK
[Unix: 1789313318] メトリクスを送信中...
 -> 送信成功: ステータスコード 200 OK

※ターミナル上で Ctrl + C を押して停止。

GitHub Push

git status
git add .
git commit -m "feat: implement periodic metric collection and HTTP POST transmission using tokio and reqwest"
git push origin main

3. 送信テスト先「httpbin.org」についての整理

定期送信のテスト先として利用した https://httpbin.org/post について理解を深めました。

  • httpbin.org とは: 送信したHTTPリクエスト(ヘッダーやJSONボディ)をそのままレスポンスとして返してくれるWebテスト用サービス。
  • 利点: 受信用の独自サーバーを用意する前段階として、Rust側のリクエスト構成やJSONシリアライズが正常に機能しているかを簡便に検証できます。

4. sysinfo クレート導入:ダミーデータから実機(Mac)のリアルタイム取得へ

ダミーデータでの送信成功後、実際のMacのリソース情報を取得するため sysinfo(v0.30)クレートを導入し、SystemCollector を実装しました。

コードの改修

Cargo.toml に追加:

[dependencies]
# 実機メトリクス取得用クレート
sysinfo = "0.30"

src/main.rs に SystemCollector を実装:

use sysinfo::{Disks, System};
use std::sync::{Arc, Mutex};

pub struct SystemCollector {
    // CPU計算のために前回の状態を保持する System インスタンス
    sys: Arc<Mutex<System>>,
}

impl SystemCollector {
    pub fn new() -> Self {
        let mut sys = System::new_all();
        // 初回のCPU計測ベースラインを作成
        sys.refresh_all();
        Self {
            sys: Arc::new(Mutex::new(sys)),
        }
    }
}

impl MetricsCollector for SystemCollector {
    fn collect_host_info(&self) -> HostInfo {
        let hostname = System::host_name().unwrap_or_else(|| "unknown-host".to_string());
        let os_name = System::name().unwrap_or_default().to_lowercase();

        let os_type = if os_name.contains("darwin") || os_name.contains("mac") {
            OsType::MacOS
        } else if os_name.contains("linux") {
            OsType::Linux
        } else if os_name.contains("windows") {
            OsType::Windows
        } else {
            OsType::Unknown
        };

        HostInfo {
            hostname,
            os_type,
            agent_version: env!("CARGO_PKG_VERSION").to_string(),
        }
    }

    fn collect_metrics(&self) -> SystemMetrics {
        let mut sys = self.sys.lock().unwrap();
        sys.refresh_all();

        let cpu_usage_percent = sys.global_cpu_usage();
        let total_mem = sys.total_memory();
        let used_mem = sys.used_memory();
        let mem_percent = if total_mem > 0 {
            (used_mem as f32 / total_mem as f32) * 100.0
        } else {
            0.0
        };

        let disks = Disks::new_with_refreshed_list();
        let mut total_disk: u64 = 0;
        let mut used_disk: u64 = 0;

        for disk in &disks {
            total_disk += disk.total_space();
            used_disk += disk.total_space() - disk.available_space();
        }

        let disk_percent = if total_disk > 0 {
            (used_disk as f32 / total_disk as f32) * 100.0
        } else {
            0.0
        };

        SystemMetrics {
            cpu_usage_percent,
            memory: MemoryMetrics {
                total_bytes: total_mem,
                used_bytes: used_mem,
                usage_percent: mem_percent,
            },
            disk: DiskMetrics {
                total_bytes: total_disk,
                used_bytes: used_disk,
                usage_percent: disk_percent,
            },
            uptime_seconds: System::uptime(),
        }
    }

    fn build_payload(&self) -> MonitorPayload {
        let now_unix = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap_or_default()
            .as_secs();

        MonitorPayload {
            host_info: self.collect_host_info(),
            metrics: self.collect_metrics(),
            timestamp_unix: now_unix,
        }
    }
}

※この段階では動作確認およびGitHub Pushは行わず、次の環境整理とテスト拡充へ進みました。


5. 横道①:開発環境設定「ファイルの末尾は必ず改行で終える」

開発の途中で、Rustのコード標準作法およびGitでの警告(No newline at end of file)対策として、ファイルの最終行に改行を入れる設定を行いました。

VS Codeの .vscode/settings.json に以下を追記して自動化しました。

{
  "files.insertFinalNewline": true,
  "files.trimFinalNewlines": true
}
  • files.insertFinalNewline: ファイル保存時に末尾へ自動で改行を挿入する。
  • files.trimFinalNewlines: 最終改行の後に続く無駄な空行をカットする。

6. 横道②:cargo fmt の対象範囲の確認

コードフォーマッターである cargo fmt の挙動について整理しました。

  • 結論: cargo fmt は単体ファイルではなく、プロジェクト配下(src/ 内の全 .rs ファイル)を一括で整形します。コマンド一発で全ファイルのスタイリングを統一できます。

7. ユニットテストの不足発覚・追加指示・最終コード完成

実機化まで実装が進んだ段階でコードを見直すと、sysinfo 0.30 固有の仕様変更(CPU使用率取得)や、実機化に伴う境界値対策(ゼロ除算防止)、モックを用いたテストが不足していることに気づきました。

そこでAI(Gemini)に追加指示を出し、テストと本番ロジックを修正・拡充しました。

完成した全ソースコード (src/main.rs)

use serde::{Deserialize, Serialize};
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use sysinfo::{Disks, System};
use tokio::time::interval;

// ==========================================
// 1. Enum & Struct 定義
// ==========================================

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum OsType {
    MacOS,
    Linux,
    Windows,
    Unknown,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HostInfo {
    pub hostname: String,
    pub os_type: OsType,
    pub agent_version: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MemoryMetrics {
    pub total_bytes: u64,
    pub used_bytes: u64,
    pub usage_percent: f32,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiskMetrics {
    pub total_bytes: u64,
    pub used_bytes: u64,
    pub usage_percent: f32,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SystemMetrics {
    pub cpu_usage_percent: f32,
    pub memory: MemoryMetrics,
    pub disk: DiskMetrics,
    pub uptime_seconds: u64,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MonitorPayload {
    pub host_info: HostInfo,
    pub metrics: SystemMetrics,
    pub timestamp_unix: u64,
}

// ==========================================
// 2. Trait & System Collector 実装
// ==========================================

pub trait MetricsCollector {
    fn collect_host_info(&self) -> HostInfo;
    fn collect_metrics(&self) -> SystemMetrics;
    fn build_payload(&self) -> MonitorPayload;
}

pub struct SystemCollector {
    sys: Arc<Mutex<System>>,
}

impl SystemCollector {
    pub fn new() -> Self {
        let mut sys = System::new_all();
        sys.refresh_all();
        Self {
            sys: Arc::new(Mutex::new(sys)),
        }
    }
}

impl MetricsCollector for SystemCollector {
    fn collect_host_info(&self) -> HostInfo {
        let hostname = System::host_name().unwrap_or_else(|| "unknown-host".to_string());
        let os_name = System::name().unwrap_or_default().to_lowercase();

        let os_type = if os_name.contains("darwin") || os_name.contains("mac") {
            OsType::MacOS
        } else if os_name.contains("linux") {
            OsType::Linux
        } else if os_name.contains("windows") {
            OsType::Windows
        } else {
            OsType::Unknown
        };

        HostInfo {
            hostname,
            os_type,
            agent_version: env!("CARGO_PKG_VERSION").to_string(),
        }
    }

    fn collect_metrics(&self) -> SystemMetrics {
        let mut sys = self.sys.lock().unwrap();
        sys.refresh_all();

        // CPU使用率 (%) - sysinfo 0.30 用の修正
        let cpu_usage_percent = sys.global_cpu_info().cpu_usage();

        // メモリ情報 (バイト単位)
        let total_mem = sys.total_memory();
        let used_mem = sys.used_memory();
        let mem_percent = if total_mem > 0 {
            (used_mem as f32 / total_mem as f32) * 100.0
        } else {
            0.0
        };

        // ディスク情報 (全マウントポイントの合算)
        let disks = Disks::new_with_refreshed_list();
        let mut total_disk: u64 = 0;
        let mut used_disk: u64 = 0;

        for disk in &disks {
            total_disk += disk.total_space();
            used_disk += disk.total_space() - disk.available_space();
        }

        let disk_percent = if total_disk > 0 {
            (used_disk as f32 / total_disk as f32) * 100.0
        } else {
            0.0
        };

        let uptime_seconds = System::uptime();

        SystemMetrics {
            cpu_usage_percent,
            memory: MemoryMetrics {
                total_bytes: total_mem,
                used_bytes: used_mem,
                usage_percent: mem_percent,
            },
            disk: DiskMetrics {
                total_bytes: total_disk,
                used_bytes: used_disk,
                usage_percent: disk_percent,
            },
            uptime_seconds,
        }
    }

    fn build_payload(&self) -> MonitorPayload {
        let now_unix = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap_or_default()
            .as_secs();

        MonitorPayload {
            host_info: self.collect_host_info(),
            metrics: self.collect_metrics(),
            timestamp_unix: now_unix,
        }
    }
}

// ==========================================
// 3. メイン処理 & 送信ループ
// ==========================================

#[tokio::main]
async fn main() {
    println!("--- 軽量モニターAgent 起動 (実機メトリクス収集モード) ---");

    let collector = SystemCollector::new();
    let client = reqwest::Client::new();
    let target_url = "[https://httpbin.org/post](https://httpbin.org/post)";

    let mut ticker = interval(Duration::from_secs(10));

    loop {
        ticker.tick().await;

        let payload = collector.build_payload();
        println!(
            "[Unix: {}] Host: {} ({:?}) | CPU: {:.1}% | Mem: {:.1}% | Disk: {:.1}%",
            payload.timestamp_unix,
            payload.host_info.hostname,
            payload.host_info.os_type,
            payload.metrics.cpu_usage_percent,
            payload.metrics.memory.usage_percent,
            payload.metrics.disk.usage_percent
        );

        match client.post(target_url).json(&payload).send().await {
            Ok(response) => {
                if response.status().is_success() {
                    println!(" -> 送信成功: ステータスコード {}", response.status());
                } else {
                    eprintln!(" -> 送信エラー: ステータスコード {}", response.status());
                }
            }
            Err(err) => {
                eprintln!(" -> 通信エラーが発生しました: {}", err);
            }
        }
    }
}

// ==========================================
// 4. ユニットテスト モジュール
// ==========================================

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_os_type_serde() {
        let os = OsType::MacOS;
        let json = serde_json::to_string(&os).unwrap();
        assert_eq!(json, "\"macos\"");

        let deserialized: OsType = serde_json::from_str("\"linux\"").unwrap();
        assert_eq!(deserialized, OsType::Linux);
    }

    #[test]
    fn test_zero_total_memory_handling() {
        let total_mem = 0;
        let used_mem = 0;
        let mem_percent = if total_mem > 0 {
            (used_mem as f32 / total_mem as f32) * 100.0
        } else {
            0.0
        };
        assert_eq!(mem_percent, 0.0);
    }

    struct MockCollector;
    impl MetricsCollector for MockCollector {
        fn collect_host_info(&self) -> HostInfo {
            HostInfo {
                hostname: "test-host".to_string(),
                os_type: OsType::Linux,
                agent_version: "0.1.0".to_string(),
            }
        }
        fn collect_metrics(&self) -> SystemMetrics {
            SystemMetrics {
                cpu_usage_percent: 45.0,
                memory: MemoryMetrics {
                    total_bytes: 100,
                    used_bytes: 50,
                    usage_percent: 50.0,
                },
                disk: DiskMetrics {
                    total_bytes: 200,
                    used_bytes: 100,
                    usage_percent: 50.0,
                },
                uptime_seconds: 3600,
            }
        }
        fn build_payload(&self) -> MonitorPayload {
            MonitorPayload {
                host_info: self.collect_host_info(),
                metrics: self.collect_metrics(),
                timestamp_unix: 1700000000,
            }
        }
    }

    #[test]
    fn test_build_payload_with_mock() {
        let collector = MockCollector;
        let payload = collector.build_payload();

        assert_eq!(payload.host_info.hostname, "test-host");
        assert_eq!(payload.host_info.os_type, OsType::Linux);
        assert_eq!(payload.metrics.cpu_usage_percent, 45.0);
        assert_eq!(payload.timestamp_unix, 1700000000);
    }
}

最終動作確認

cargo test の実行結果:

running 3 tests
test tests::test_zero_total_memory_handling ... ok
test tests::test_build_payload_with_mock ... ok
test tests::test_os_type_serde ... ok

test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

cargo run による実機リアルタイム動作ログ:

$ cargo run
   Compiling sysmon-agent v0.1.0 (/Users/[xxx]/lightweight-monitor)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.51s
     Running `target/debug/sysmon-agent`
--- 軽量モニターAgent 起動 (実機メトリクス収集モード) ---
[Unix: 1789746711] Host: [xxx].local (MacOS) | CPU: 16.2% | Mem: 79.6% | Disk: 91.3%
 -> 送信成功: ステータスコード 200 OK
[Unix: 1789746721] Host: [xxx].local (MacOS) | CPU: 39.8% | Mem: 77.1% | Disk: 91.3%
 -> 送信成功: ステータスコード 200 OK
[Unix: 1789746731] Host: [xxx].local (MacOS) | CPU: 10.8% | Mem: 76.9% | Disk: 91.3%
 -> 送信成功: ステータスコード 200 OK
[Unix: 1789746742] Host: [xxx].local (MacOS) | CPU: 9.7% | Mem: 77.8% | Disk: 91.3%
 -> 送信成功: ステータスコード 200 OK
^C

最終 GitHub Push

git status
git add .
git commit -m "feat: 実機メトリクス自動取得・HTTP POST送信処理およびユニットテストの実装"
git push origin main

まとめ・次のステップ

今回は最初に追加したテストから始まり、10秒周期の非同期通信処理、sysinfo による実機化、VS Codeや cargo fmt の環境設定、そして最後のテスト拡充まで、実際の開発手順に沿って着実にコードを進めることができました。

子機Agent(Rust側)の実装・単体テスト・GitHub管理が完了したため、次回からは親機サーバー(Cloudflare Workers)の環境構築およびデータ受信APIの実装へ進みます!


AIでは無い追加部分

今回、Geminiには間々でHarnessの質問など色々と挟んでしまった為、Qiita投稿の出力が意図したものでは無く、最終結果のみを出すだけとなってしまい、結果的に1つ1つ項目を指示し、かつ更新ソース、コマンドなども明示する形となってしまいました。
対策としてはこまめな投稿、GitHubとの連携など、検討しています。

また、今はGoogle AI PLUSプランでGeminiの3.6 Flash 拡張に助けてもらっていますが、流れとしては、一旦全て実装した後に、ChatGPT PlusかClaude Proを契約して実装の見直し、リファクタリング、レビューなどを行いたいと思っています。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?