Featured image of post 第4回:コマンドをオブジェクトにしよう【PerlでSlackボット指令センターを作る】

第4回:コマンドをオブジェクトにしよう【PerlでSlackボット指令センターを作る】

前回は、コマンド分岐が増えすぎて巨大な if 文の塊(神オブジェクト)になってしまった惨劇を見ました。 今回は、この状況を解決するデザインパターン「Commandパターン」を導入します。

Commandパターンの本質は、「要求をオブジェクトとしてカプセル化すること」 です。 これにより、コマンドの追加が「既存コードへの追記」から「新しいクラスの作成」に変わり、コードの独立性が高まります。

整然としたカプセル

Commandの役割を定義する

まず、すべてのコマンドが共通して持つべき振る舞いを Moo::Role で定義します。 今回のボットでは、以下の3つの機能が必要そうです。

  1. マッチング: メッセージが自分宛てか判断し、引数を抽出する (match)
  2. 実行: 実際に処理を行い、結果を返す (execute)
  3. 説明: ヘルプ表示用に自分の役割を説明する (description)
1
2
3
4
5
6
7
8
9
package Bot::Command::Role;
use Moo::Role;
use Types::Standard qw(Str);

requires 'match';       # 引数解釈(戻り値: 引数ハッシュリファレンス or undef)
requires 'execute';     # 処理実行(戻り値: 結果文字列)
requires 'description'; # ヘルプ用説明

1;

具体的なコマンドを作る

このロールを使って、各コマンドを独立したクラスとして実装します。

デプロイコマンド

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package Bot::Command::Deploy;
use Moo;
with 'Bot::Command::Role';

sub match {
    my ($self, $text) = @_;
    if ($text =~ m{^/deploy\s+(\w+)(?:\s+(--force))?}) {
        return { target => $1, force => $2 };
    }
    return undef;
}

sub execute {
    my ($self, $args) = @_;
    my $target = $args->{target};
    my $force  = $args->{force} ? "(強制)" : "";
    
    return "🚀 $target 環境へのデプロイを開始しました$force...";
}

sub description { "/deploy <env> [--force] : 指定環境へデプロイします" }

1;

ログ取得コマンド

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
package Bot::Command::Log;
use Moo;
with 'Bot::Command::Role';

sub match {
    my ($self, $text) = @_;
    if ($text =~ m{^/log\s+(\w+)(?:\s+--lines\s+(\d+))?}) {
        return { level => $1, lines => $2 // 10 };
    }
    return undef;
}

sub execute {
    my ($self, $args) = @_;
    return "📋 $args->{level} ログを直近 $args->{lines} 行取得しました...";
}

sub description { "/log <level> [--lines N] : ログを取得します" }

1;

このように、コマンドごとにファイル(クラス)が分かれます。正規表現やロジックが各クラスに閉じ込められ、お互いに干渉しなくなりました。

メインループの修正

さて、呼び出し側(ボット本体)はどう変わるでしょうか? 巨大な if-elsif は、すっきりとしたループに置き換わります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
use Bot::Command::Deploy;
use Bot::Command::Log;

my @commands = (
    Bot::Command::Deploy->new,
    Bot::Command::Log->new,
    # 新しいコマンドはここに追加するだけ!
);

sub handle_message {
    my ($text) = @_;
    
    for my $cmd (@commands) {
        if (my $args = $cmd->match($text)) {
            return $cmd->execute($args);
        }
    }
    
    return "不明なコマンドです。";
}

何が嬉しくなったのか?

  1. SRP(単一責任の原則)の遵守: パース処理と実行ロジックが各コマンドクラスに集約されました。
  2. OCP(開放閉鎖の原則)の遵守: 新しいコマンドを追加したいときは、新しいクラスを作って配列に追加するだけ。handle_message のロジック自体をいじる必要はありません。
  3. テスト容易性: Bot::Command::Deploy だけを単体テストすることが簡単になりました。

これでコードの見通しは劇的に良くなりました。 しかし、まだ課題が残っています。「管理者のみ実行可能」といった共通の権限チェックロジックや、コマンドが増えすぎた場合の検索効率の問題です。

次回は、これらのコマンドを賢く管理し、適切な場所に振り分ける「司令塔」として Mediatorパターン を導入します。

今回の完成コード

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
# Bot/Command/Role.pm
package Bot::Command::Role;
use Moo::Role;
requires 'match';
requires 'execute';
requires 'description';
1;

# Bot/Command/Deploy.pm
package Bot::Command::Deploy;
use Moo;
with 'Bot::Command::Role';
sub match {
    my ($self, $text) = @_;
    if ($text =~ m{^/deploy\s+(\w+)(?:\s+(--force))?}) {
        return { target => $1, force => $2 };
    }
    return undef;
}
sub execute {
    my ($self, $args) = @_;
    my $force = $args->{force} ? "(強制)" : "";
    return "🚀 $args->{target} 環境へのデプロイを開始しました$force...";
}
sub description { "/deploy <env> [--force]" }
1;

# Bot/Command/Log.pm
package Bot::Command::Log;
use Moo;
with 'Bot::Command::Role';
sub match {
    my ($self, $text) = @_;
    if ($text =~ m{^/log\s+(\w+)(?:\s+(--lines\s+(\d+))?)?}) { # 正規表現修正
        return { level => $1, lines => $3 // 10 };
    }
    return undef;
}
sub execute {
    my ($self, $args) = @_;
    return "📋 $args->{level} ログを直近 $args->{lines} 行取得しました...";
}
sub description { "/log <level> [--lines N]" }
1;

# main.pl (抜粋)
use Bot::Command::Deploy;
use Bot::Command::Log;

my @commands = (
    Bot::Command::Deploy->new,
    Bot::Command::Log->new,
);

sub handle_message {
    my ($text) = @_;
    for my $cmd (@commands) {
        if (my $args = $cmd->match($text)) {
            return $cmd->execute($args);
        }
    }
    return "不明なコマンドです。";
}
comments powered by Disqus
Hugo で構築されています。
テーマ StackJimmy によって設計されています。