RustでCLI Todoアプリを作りながら学ぶ設計パターン
どうも最近Rustにハマっている初学者です🙇
いきなりですが初学者の方で、Rustの文法はある程度学習をしているけど
実際にアプリを作るとなると何から始めればいいか迷いませんか?
私も最初はそうでした。公式ドキュメントやチュートリアルで基本はある程度は理解できても
実際のコードを書く時には「この場合はどう書くのがRustらしいのか?」と悩むことが多かったです。
そこで今回は、誰もが使ったことのあるTodoアプリを題材に、Rustの基本的な書き方を学びましたので備忘録として書きました。
単純な機能追加だけでなく、Rustエコシステムの活用方法や、開発で役立つパターンも紹介していきます!
なぜTodoアプリを題材にしたのか?
Todoアプリは一見シンプルですが、実は多くの重要な概念が詰まっていると思っています!
- データ構造の設計
- ファイルI/O(データの永続化)
- エラーハンドリング
- ユーザーインターフェース(CLI)
これらを通じて、Rustの所有権システムや型安全性の恩恵を実感できます。
プロジェクトの作成とクレート選び
段階的に紹介していきますが、最後に全体のコードもありますのでご安心ください🙇
では、まずは新しいプロジェクトを作成します。
cargo new rust-todo
cd rust-todo
今回はデータの永続化にJSONを使用します。RustでJSON操作といえばserde
が定番です。
Cargo.tomlに以下の記述をします。
[package]
name = "rust-todo"
version = "0.1.0"
edition = "2024"
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
serde
は「Serialize(シリアライズ)」と「Deserialize(デシリアライズ)」を組み合わせた造語です。
features = ["derive"]
を指定することで、構造体にアトリビュートを付けるだけで自動的にシリアライゼーション機能が追加されます。
データモデルの設計
Todoアプリといえば「タスク」です!
まずはどんな情報が必要か考えてみましょう。
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::{self, Write};
#[derive(Serialize, Deserialize, Debug, Clone)]
struct Task {
id: usize,
description: String,
completed: bool,
}
impl Task {
fn new(id: usize, description: String) -> Self {
Self {
id,
description,
completed: false,
}
}
}
ここでのポイントはimpl
ブロックでコンストラクタを定義していることです。
これにより、タスク作成時のデフォルト値を確実に設定できます。
#[derive(...)]
について少し詳しく説明すると
-
Serialize
,Deserialize
: JSON変換用 -
Debug
: デバッグ出力用(開発時に助かります) -
Clone
: タスクのコピーが必要な場面で使用
アプリケーション構造体の導入
単純にベクターでタスクを管理するのではなく、アプリケーション自体を構造体として表現してみましょう。
struct TodoApp {
tasks: Vec<Task>,
next_id: usize,
}
impl TodoApp {
fn new() -> Self {
Self {
tasks: Vec::new(),
next_id: 1,
}
}
fn load() -> Self {
match fs::read_to_string("tasks.json") {
Ok(content) => {
match serde_json::from_str::<Vec<Task>>(&content) {
Ok(tasks) => {
let next_id = tasks.iter().map(|t| t.id).max().unwrap_or(0) + 1;
Self { tasks, next_id }
}
Err(_) => Self::new(),
}
}
Err(_) => Self::new(),
}
}
fn save(&self) -> Result<(), Box<dyn std::error::Error>> {
let json = serde_json::to_string_pretty(&self.tasks)?;
fs::write("tasks.json", json)?;
Ok(())
}
}
この設計で良いと思っている点は
-
ID管理が確実:
next_id
でユニークなIDを保証 - 状態管理が明確: アプリの状態がすべて構造体に含まれる
-
メソッドが直感的:
app.load()
,app.save()
のように自然に書ける
ユーザーインターフェースの実装
CLIアプリケーションでは、ユーザビリティが重要だと思います。
わかりやすいメニューと適切なフィードバックを心がけました!
impl TodoApp {
fn run(&mut self) {
loop {
self.show_menu();
match self.get_user_choice() {
1 => self.add_task(),
2 => self.list_tasks(),
3 => self.complete_task(),
4 => self.delete_task(),
5 => {
if let Err(e) = self.save() {
eprintln!("保存エラー: {}", e);
} else {
println!("タスクを保存しました。お疲れさまでした!");
}
break;
}
_ => println!("1-5の番号を入力してください。"),
}
println!(); // 見やすさのための空行
}
}
fn show_menu(&self) {
println!("=== Todo アプリ ===");
println!("1. タスクを追加");
println!("2. タスク一覧表示");
println!("3. タスクを完了にする");
println!("4. タスクを削除");
println!("5. 終了");
print!("選択 (1-5): ");
io::stdout().flush().unwrap();
}
fn get_user_choice(&self) -> u8 {
let input = self.get_input();
input.trim().parse().unwrap_or(0)
}
fn get_input(&self) -> String {
let mut input = String::new();
io::stdin().read_line(&mut input).unwrap_or(0);
input
}
}
各機能の実装
タスクの追加
impl TodoApp {
fn add_task(&mut self) {
print!("タスクの内容を入力してください: ");
io::stdout().flush().unwrap();
let description = self.get_input().trim().to_string();
if description.is_empty() {
println!("タスクの内容が空です。");
return;
}
let task = Task::new(self.next_id, description);
self.tasks.push(task);
self.next_id += 1;
println!("タスクを追加しました!");
}
}
タスクの表示
impl TodoApp {
fn list_tasks(&self) {
if self.tasks.is_empty() {
println!("タスクはありません。");
return;
}
println!("\n--- タスク一覧 ---");
for task in &self.tasks {
let status_icon = if task.completed { "✅" } else { "⏳" };
println!("{}: {} {}", task.id, status_icon, task.description);
}
let completed_count = self.tasks.iter().filter(|t| t.completed).count();
println!("\n完了: {}/{}", completed_count, self.tasks.len());
}
}
進捗表示を追加しています。
タスクの完了・削除
impl TodoApp {
fn complete_task(&mut self) {
if self.tasks.is_empty() {
println!("完了にするタスクがありません。");
return;
}
self.list_tasks();
print!("\n完了にするタスクのIDを入力: ");
io::stdout().flush().unwrap();
let input = self.get_input();
match input.trim().parse::<usize>() {
Ok(id) => {
if let Some(task) = self.tasks.iter_mut().find(|t| t.id == id) {
if task.completed {
println!("そのタスクは既に完了しています。");
} else {
task.completed = true;
println!("タスクを完了にしました! 🎉");
}
} else {
println!("指定されたIDのタスクが見つかりません。");
}
}
Err(_) => println!("正しい数字を入力してください。"),
}
}
fn delete_task(&mut self) {
if self.tasks.is_empty() {
println!("削除するタスクがありません。");
return;
}
self.list_tasks();
print!("\n削除するタスクのIDを入力: ");
io::stdout().flush().unwrap();
let input = self.get_input();
match input.trim().parse::<usize>() {
Ok(id) => {
if let Some(index) = self.tasks.iter().position(|t| t.id == id) {
let task = self.tasks.remove(index);
println!("「{}」を削除しました。", task.description);
} else {
println!("指定されたIDのタスクが見つかりません。");
}
}
Err(_) => println!("正しい数字を入力してください。"),
}
}
}
メイン関数
fn main() {
let mut app = TodoApp::load();
app.run();
}
結構シンプルになったかと思います!
構造体とメソッドを適切に分離したつもりです。
実行してみる
cargo run
アプリを起動すると、以下のようなメニューが表示されます
=== Todo アプリ ===
1. タスクを追加
2. タスク一覧表示
3. タスクを完了にする
4. タスクを削除
5. 終了
選択 (1-5):
より良いコードに向けて
現在のコードでも十分動作しますが、実際のプロダクション環境では以下の点を改善できます
エラーハンドリングの改善
use std::error::Error;
use std::fmt;
#[derive(Debug)]
enum TodoError {
InvalidInput(String),
IoError(std::io::Error),
SerializationError(serde_json::Error),
}
impl fmt::Display for TodoError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
TodoError::InvalidInput(msg) => write!(f, "入力エラー: {}", msg),
TodoError::IoError(e) => write!(f, "IO エラー: {}", e),
TodoError::SerializationError(e) => write!(f, "シリアライゼーションエラー: {}", e),
}
}
}
impl Error for TodoError {}
設定の外部化もできれば…
# config.toml
data_file = "tasks.json"
auto_save = true
テストの追加
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_task_creation() {
let task = Task::new(1, "テストタスク".to_string());
assert_eq!(task.id, 1);
assert_eq!(task.description, "テストタスク");
assert_eq!(task.completed, false);
}
#[test]
fn test_app_initialization() {
let app = TodoApp::new();
assert_eq!(app.tasks.len(), 0);
assert_eq!(app.next_id, 1);
}
}
完全なコード
これまで断片的に紹介してきたコードを、src/main.rs
に書く完全な形で示します:
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::{self, Write};
#[derive(Serialize, Deserialize, Debug, Clone)]
struct Task {
id: usize,
description: String,
completed: bool,
}
impl Task {
fn new(id: usize, description: String) -> Self {
Self {
id,
description,
completed: false,
}
}
}
struct TodoApp {
tasks: Vec<Task>,
next_id: usize,
}
impl TodoApp {
fn new() -> Self {
Self {
tasks: Vec::new(),
next_id: 1,
}
}
fn load() -> Self {
match fs::read_to_string("tasks.json") {
Ok(content) => {
match serde_json::from_str::<Vec<Task>>(&content) {
Ok(tasks) => {
let next_id = tasks.iter().map(|t| t.id).max().unwrap_or(0) + 1;
Self { tasks, next_id }
}
Err(_) => Self::new(),
}
}
Err(_) => Self::new(),
}
}
fn save(&self) -> Result<(), Box<dyn std::error::Error>> {
let json = serde_json::to_string_pretty(&self.tasks)?;
fs::write("tasks.json", json)?;
Ok(())
}
fn run(&mut self) {
loop {
self.show_menu();
match self.get_user_choice() {
1 => self.add_task(),
2 => self.list_tasks(),
3 => self.complete_task(),
4 => self.delete_task(),
5 => {
if let Err(e) = self.save() {
eprintln!("保存エラー: {}", e);
} else {
println!("タスクを保存しました。お疲れさまでした!");
}
break;
}
_ => println!("1-5の番号を入力してください。"),
}
println!(); // 見やすさのための空行
}
}
fn show_menu(&self) {
println!("=== Todo アプリ ===");
println!("1. タスクを追加");
println!("2. タスク一覧表示");
println!("3. タスクを完了にする");
println!("4. タスクを削除");
println!("5. 終了");
print!("選択 (1-5): ");
io::stdout().flush().unwrap();
}
fn get_user_choice(&self) -> u8 {
let input = self.get_input();
input.trim().parse().unwrap_or(0)
}
fn get_input(&self) -> String {
let mut input = String::new();
io::stdin().read_line(&mut input).unwrap_or(0);
input
}
fn add_task(&mut self) {
print!("タスクの内容を入力してください: ");
io::stdout().flush().unwrap();
let description = self.get_input().trim().to_string();
if description.is_empty() {
println!("タスクの内容が空です。");
return;
}
let task = Task::new(self.next_id, description);
self.tasks.push(task);
self.next_id += 1;
println!("タスクを追加しました!");
}
fn list_tasks(&self) {
if self.tasks.is_empty() {
println!("タスクはありません。");
return;
}
println!("\n--- タスク一覧 ---");
for task in &self.tasks {
let status_icon = if task.completed { "✅" } else { "⏳" };
println!("{}: {} {}", task.id, status_icon, task.description);
}
let completed_count = self.tasks.iter().filter(|t| t.completed).count();
println!("\n完了: {}/{}", completed_count, self.tasks.len());
}
fn complete_task(&mut self) {
if self.tasks.is_empty() {
println!("完了にするタスクがありません。");
return;
}
self.list_tasks();
print!("\n完了にするタスクのIDを入力: ");
io::stdout().flush().unwrap();
let input = self.get_input();
match input.trim().parse::<usize>() {
Ok(id) => {
if let Some(task) = self.tasks.iter_mut().find(|t| t.id == id) {
if task.completed {
println!("そのタスクは既に完了しています。");
} else {
task.completed = true;
println!("タスクを完了にしました! 🎉");
}
} else {
println!("指定されたIDのタスクが見つかりません。");
}
}
Err(_) => println!("正しい数字を入力してください。"),
}
}
fn delete_task(&mut self) {
if self.tasks.is_empty() {
println!("削除するタスクがありません。");
return;
}
self.list_tasks();
print!("\n削除するタスクのIDを入力: ");
io::stdout().flush().unwrap();
let input = self.get_input();
match input.trim().parse::<usize>() {
Ok(id) => {
if let Some(index) = self.tasks.iter().position(|t| t.id == id) {
let task = self.tasks.remove(index);
println!("「{}」を削除しました。", task.description);
} else {
println!("指定されたIDのタスクが見つかりません。");
}
}
Err(_) => println!("正しい数字を入力してください。"),
}
}
}
fn main() {
let mut app = TodoApp::load();
app.run();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_task_creation() {
let task = Task::new(1, "テストタスク".to_string());
assert_eq!(task.id, 1);
assert_eq!(task.description, "テストタスク");
assert_eq!(task.completed, false);
}
#[test]
fn test_app_initialization() {
let app = TodoApp::new();
assert_eq!(app.tasks.len(), 0);
assert_eq!(app.next_id, 1);
}
}
実行手順
-
プロジェクトを作成
cargo new rust-todo cd rust-todo
-
Cargo.tomlを編集
[package] name = "rust-todo" version = "0.1.0" edition = "2024" [dependencies] serde = { version = "1.0", features = ["derive"] } serde_json = "1.0"
-
依存関係をダウンロード・ビルド
cargo build
このコマンドで
serde
とserde_json
がダウンロードされ、プロジェクトがビルドされます。 -
上記のコードを
src/main.rs
に貼り付け -
実行
cargo run
-
テスト実行
cargo test
これで完全に動作するTodoアプリが完成します!
まとめ
この記事では、単純なTodoアプリを通じて以下のRustの概念を学びになれば幸いです!
- 構造体を使った適切なデータモデリング
-
serde
によるJSONシリアライゼーション -
impl
ブロックによるメソッド定義 - パターンマッチングを使った堅牢なエラーハンドリング
- CLIアプリケーションのユーザビリティ向上
重要なのは、Rustの型システムと所有権モデルを活用して、コンパイル時に多くのエラーを発見できることです。これにより、実行時エラーが大幅に減り、より安全なソフトウェアを作ることができます。
次のステップとして、以下のような機能追加にもチャレンジしてみてください!
- タスクの優先度機能
- 期限設定機能
- カテゴリ分類機能
- 検索・フィルタリング機能
Rustは開発体験が良くて、楽しい!
初学者の皆さんもぜひやってみてください!
Discussion