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を契約して実装の見直し、リファクタリング、レビューなどを行いたいと思っています。