@nqounetです。
シリーズ「シンプルなTodo CLIアプリ」の第6回です。
前回の振り返り
前回は、InMemoryRepositoryを実装してテスト容易性を高めました。
TaskRepository::InMemory でメモリ上にタスクを保存- ファイルI/Oなしでテストが可能に
- 同じインターフェースで実装を切り替え可能
Repositoryパターンの真価を体感しました。今回は、メイン処理の if-elsif分岐 を整理し、Commandパターン を導入します。
if-elsif分岐の問題点
現状のコードを見てみましょう
現在のメイン処理は以下のようになっています。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
| my $command = shift @ARGV // 'help';
if ($command eq 'add') {
my $title = shift @ARGV;
die "Usage: $0 add <task>\n" unless defined $title && $title ne '';
my $task = Task->new(title => $title);
$repository->save($task);
print "Added: $title (ID: " . $task->id . ")\n";
}
elsif ($command eq 'list') {
my @tasks = $repository->all;
if (@tasks == 0) {
print "No tasks.\n";
exit;
}
for my $task (sort { $a->id <=> $b->id } @tasks) {
my $status = $task->is_done ? '[x]' : '[ ]';
printf "%d. %s %s\n", $task->id, $status, $task->title;
}
}
elsif ($command eq 'complete') {
my $id = shift @ARGV;
die "Usage: $0 complete <id>\n" unless defined $id && $id =~ /^\d+$/;
my $task = $repository->find($id);
die "Task $id not found.\n" unless $task;
$task->mark_done();
$repository->save($task);
print "Completed: " . $task->title . "\n";
}
else {
print "Usage: $0 <command> [args]\n";
print "Commands:\n";
print " add <task> - Add a new task\n";
print " list - List all tasks\n";
print " complete <id> - Complete a task by ID\n";
}
|
3つのコマンドだけでこの長さです。
コマンドが増えるとどうなるか
新しいコマンド(delete, edit, search など)を追加するたびに、if-elsif分岐が長くなります。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| if ($command eq 'add') {
# ...
}
elsif ($command eq 'list') {
# ...
}
elsif ($command eq 'complete') {
# ...
}
elsif ($command eq 'delete') { # 追加
# ...
}
elsif ($command eq 'edit') { # 追加
# ...
}
elsif ($command eq 'search') { # 追加
# ...
}
# どんどん長くなる...
|
問題点:
- 見通しが悪くなる
- 修正箇所が分かりにくい
- 1つのファイルが巨大になる
- 各コマンドの単体テストが困難
Commandパターンとは
Commandパターンの構造を図で確認しましょう。
classDiagram
class Command_Role {
<<Role>>
+execute()*
+description()*
}
class Command_Add {
-repository
-title
+execute()
+description()
}
class Command_List {
-repository
+execute()
+description()
}
class TaskRepository_Role {
<<Role>>
}
Command_Role <|.. Command_Add : with
Command_Role <|.. Command_List : with
Command_Add --> TaskRepository_Role : uses
Command_List --> TaskRepository_Role : uses
この図は、Commandパターンの基本構造を示しています。Command::Roleが共通インターフェース(execute, description)を定義し、各コマンドクラスがそれを実装します。各Commandは依存性注入(DI)でRepositoryを受け取るため、FileでもInMemoryでも動作します。
操作をオブジェクトにする
Commandパターンは、各操作をオブジェクトとしてカプセル化する デザインパターンです。
flowchart LR
subgraph After
direction LR
C[コマンド名を判定] --> D[Commandオブジェクト] --> E[処理を実行]
end
subgraph Before
direction LR
A[if-elsif 分岐の嵐] --> B[直接処理を実行]
end
各コマンドは独立したクラスになります。
Command::Add - タスク追加Command::List - 一覧表示Command::Complete - タスク完了
メリット
| メリット | 説明 |
|---|
| 分離 | 各コマンドが独立したクラスになる |
| 拡張性 | 新しいコマンドはクラスを追加するだけ |
| テスト容易 | コマンドごとに単体テストが書ける |
| 可読性 | 責務が明確で理解しやすい |
Command::Role の定義
インターフェースを決める
まず、すべてのCommandクラスが持つべきメソッドをMoo::Roleで定義します。
1
2
3
4
5
6
| package Command::Role {
use Moo::Role;
requires 'execute'; # コマンドを実行する
requires 'description'; # コマンドの説明を返す
}
|
requires で宣言されたメソッドは、このRoleを適用するクラスが必ず実装しなければなりません。
なぜdescriptionメソッドが必要か
description メソッドを用意しておくと、ヘルプ表示を自動生成できます。
1
2
3
4
5
| # 各コマンドにdescriptionがあれば
for my $name (sort keys %commands) {
my $cmd = $commands{$name};
printf " %-12s - %s\n", $name, $cmd->description;
}
|
後で動的にヘルプを生成する仕組みに発展できます。
Command::Add の実装
タスク追加コマンド
「タスクを追加する」操作をクラスにまとめます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| package Command::Add {
use Moo;
with 'Command::Role';
has repository => (is => 'ro', required => 1);
has title => (is => 'ro', required => 1);
sub execute {
my $self = shift;
my $task = Task->new(title => $self->title);
$self->repository->save($task);
print "Added: " . $self->title . " (ID: " . $task->id . ")\n";
}
sub description {
return 'Add a new task';
}
}
|
属性の説明
| 属性 | 説明 |
|---|
repository | タスクを保存するRepository(DI) |
title | 追加するタスクのタイトル |
repository を外部から注入することで、FileでもInMemoryでも使えます。これが依存性注入(DI)の力です。
使い方
1
2
3
4
5
| my $cmd = Command::Add->new(
repository => $repository,
title => '牛乳を買う',
);
$cmd->execute; # Added: 牛乳を買う (ID: 1)
|
if-elsif内の処理が、オブジェクトの execute メソッド呼び出しに変わりました。
Command::List の実装
一覧表示コマンド
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
| package Command::List {
use Moo;
with 'Command::Role';
has repository => (is => 'ro', required => 1);
sub execute {
my $self = shift;
my @tasks = $self->repository->all;
if (@tasks == 0) {
print "No tasks.\n";
return;
}
for my $task (sort { $a->id <=> $b->id } @tasks) {
my $status = $task->is_done ? '[x]' : '[ ]';
printf "%d. %s %s\n", $task->id, $status, $task->title;
}
}
sub description {
return 'List all tasks';
}
}
|
使い方
1
2
3
4
| my $cmd = Command::List->new(repository => $repository);
$cmd->execute;
# 1. [ ] 牛乳を買う
# 2. [ ] メールを返信する
|
引数を必要としないコマンドはシンプルです。
メイン処理の書き換え
Commandオブジェクトを生成して実行
if-elsif分岐を、Commandオブジェクトの生成と実行に置き換えます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| my $cmd_name = shift @ARGV // 'help';
my $command;
if ($cmd_name eq 'add') {
my $title = shift @ARGV;
die "Usage: $0 add <task>\n" unless defined $title && $title ne '';
$command = Command::Add->new(
repository => $repository,
title => $title,
);
}
elsif ($cmd_name eq 'list') {
$command = Command::List->new(repository => $repository);
}
else {
print "Usage: $0 <command> [args]\n";
print "Commands:\n";
print " add <task> - Add a new task\n";
print " list - List all tasks\n";
exit;
}
$command->execute;
|
まだif-elsif分岐がある?
確かに、コマンド名の判定にはまだif-elsifが残っています。しかし、重要な違いがあります。
Before:
- if-elsif内に「処理の全て」が書かれていた
- 分岐が長大で見通しが悪い
After:
- if-elsifは「どのCommandオブジェクトを作るか」だけを決める
- 実際の処理はCommandクラス内にカプセル化
- 各Commandクラスは独立してテスト可能
「コマンドの生成」と「コマンドの実行」が分離されたのです。
ハッシュでディスパッチを簡潔に
コマンド名とクラスのマッピング
if-elsif分岐をさらに減らすには、ハッシュを使ってコマンド名とクラスをマッピングします。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| my %command_map = (
add => sub {
my $title = shift @ARGV;
die "Usage: $0 add <task>\n" unless defined $title && $title ne '';
return Command::Add->new(repository => $repository, title => $title);
},
list => sub {
return Command::List->new(repository => $repository);
},
);
my $cmd_name = shift @ARGV // 'help';
if (exists $command_map{$cmd_name}) {
my $command = $command_map{$cmd_name}->();
$command->execute;
}
else {
print "Unknown command: $cmd_name\n";
# ヘルプ表示...
}
|
この手法は「ディスパッチテーブル」と呼ばれます。新しいコマンドを追加するには、ハッシュにエントリを追加するだけです。
Commandパターンの効果
比較:Before vs After
| 観点 | Before | After |
|---|
| 処理の場所 | if-elsif内に直書き | Commandクラス内 |
| 新コマンド追加 | if-elsifに分岐追加 | クラスを追加 |
| 単体テスト | 困難 | Commandごとに可能 |
| コードの見通し | 悪い | 良い |
拡張性の向上
新しいコマンド Command::Complete を追加する場合:
Command::Complete クラスを実装- ハッシュにエントリを追加
既存のコードへの影響は最小限です。オープン・クローズド原則(拡張に対して開いている、変更に対して閉じている)を実現しています。
まとめ
今回は、Commandパターンを導入してif-elsif分岐を解消しました。
Command::Role でインターフェースを定義Command::Add, Command::List を実装- 各コマンドが独立したクラスになった
- 拡張性とテスト容易性が向上
Repositoryパターンに続き、2つ目のデザインパターンを学びました。「操作をオブジェクトにする」という発想は、多くの場面で応用できます。
次回は、Command::Complete を追加し、新機能追加の容易さを体験します。Commandパターンの真価を実感しましょう!
お楽しみに!