Featured image of post PerlとMooでWebページ変更ハンターを作ろう

PerlとMooでWebページ変更ハンターを作ろう

競合サイトの価格変動、ニュースの更新を自動監視!Observer・Memento・Iteratorパターンを組み合わせて、PerlとMooで実用的なWebページ変更ハンターを作ろう。

「競合サイトの価格が気になる」「政府の公開情報が更新されたらすぐ知りたい」「お気に入りのニュースサイトに新着記事が出たら通知してほしい」──そんな経験、ありませんか?

毎日手動でブラウザを開いてチェックするのは面倒です。かといって、月額課金のWebモニタリングSaaSに頼るのももったいない。そこで、Perlで自作してしまいましょう。

この記事では、「Webページ変更ハンター」を段階的に構築していきます。URLリストを登録し、定期的にWebページをチェック、変更があれば複数の通知先(メール、Slack、ログ)に通知。過去のスナップショットと比較して「何が」変わったかを可視化する──そんなツールを、デザインパターンを学びながら作っていきます。

対象読者と使用技術

対象読者

  • Perlの基礎は知っているが、オブジェクト指向は初めて
  • デザインパターンを「理論」ではなく「実践」で学びたい
  • 実際に動くツールを作りながら理解を深めたい

使用技術

  • Perl: v5.36以降(signatures、postfix dereferenceを使用)
  • OOPフレームワーク: Moo(軽量で高速)
  • デザインパターン: Observer、Memento、Iterator
  • 依存モジュール: LWP::UserAgent、Storable、Text::Diff、YAML::PP

第1章:URLの内容を取得してみよう

まず、「Webページの内容を取得する」という基礎から。これができなければ何も始まりません。

HTTP通信の基礎

WebページをPerlで取得するには、HTTP通信を行う必要があります。Perlには定番のモジュール「LWP::UserAgent」があります(“LWP"は"Library for WWW in Perl"の略)。

基本的な流れはこうです:

	sequenceDiagram
    participant Script as Perlスクリプト
    participant UA as LWP::UserAgent
    participant Web as Webサーバー

    Script->>UA: URLを指定してGETリクエスト
    UA->>Web: HTTP GETリクエスト送信
    Web-->>UA: HTMLレスポンス
    UA-->>Script: レスポンス内容を返す
    Script->>Script: 内容を処理

シンプルな取得スクリプト

まずは最小限のコードで、指定したURLの内容を取得してみましょう:

 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
#!/usr/bin/env perl
use v5.36;
use utf8;
use LWP::UserAgent;

# UserAgentを作成
my $ua = LWP::UserAgent->new(
    timeout => 10,
    agent   => 'WebHunter/0.1',
);

# URLを取得
my $url = 'https://example.com';
my $response = $ua->get($url);

# 成功したか確認
if ($response->is_success) {
    say "取得成功!";
    say "Content-Type: ", $response->header('Content-Type');
    say "文字数: ", length($response->decoded_content);
    say "\n--- 内容 ---";
    say substr($response->decoded_content, 0, 500);  # 最初の500文字だけ表示
} else {
    die "取得失敗: ", $response->status_line;
}

v5.36 を使うことで、say(末尾に改行を自動追加するprint)やその他のモダン機能が使えます。decoded_content は、文字コードを自動判定してデコードしてくれる便利なメソッドです。

Mooでクラス化してみる

さて、これを「何度も使える部品」にするため、Mooでクラス化してみましょう:

 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
package WebHunter::Fetcher {
    use Moo;
    use LWP::UserAgent;
    use namespace::clean;

    has ua => (
        is      => 'lazy',
        builder => sub {
            LWP::UserAgent->new(
                timeout => 10,
                agent   => 'WebHunter/0.1',
            );
        },
    );

    has timeout => (
        is      => 'ro',
        default => 10,
    );

    sub fetch ($self, $url) {
        my $response = $self->ua->get($url);
        
        return {
            success => $response->is_success,
            status  => $response->status_line,
            content => $response->is_success ? $response->decoded_content : undef,
            headers => { $response->headers->flatten },
        };
    }
}

ポイント:

  • has ua => (is => 'lazy', builder => sub {...}): ua属性は最初にアクセスされたときに自動生成されます(遅延初期化)
  • sub fetch ($self, $url): Perl v5.36以降の「signatures」機能を使用
  • namespace::clean: クラス定義内でuseしたモジュールの関数がメソッドとして見えないようにします
  • 戻り値はハッシュリファレンス: 成功/失敗、ステータス、内容、ヘッダーをまとめて返します

第2章:変更を検知する仕組み

URLの内容を取得できるようになりました。しかし、これだけでは「監視」にはなりません。「前回見たとき」と「今回見たとき」を比較して、「変わったかどうか」を判定する必要があります。

ハッシュ値で変更を検知

MD5ハッシュを使いましょう。Perlのコアモジュール「Digest::MD5」で簡単に計算できます:

1
2
3
4
5
6
7
8
use Digest::MD5 qw(md5_hex);

my $text1 = "Hello, World!";
my $text2 = "Hello, World!";
my $text3 = "Hello, Perl!";

say "text1とtext2は同じ? ", md5_hex($text1) eq md5_hex($text2) ? "YES" : "NO";
say "text1とtext3は同じ? ", md5_hex($text1) eq md5_hex($text3) ? "YES" : "NO";

同じテキストなら同じハッシュ値、1文字でも違えば全く異なるハッシュ値になります。

ChangeDetectorクラスを作る

変更検知機能をクラス化しましょう:

 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
package WebHunter::ChangeDetector {
    use Moo;
    use Digest::MD5 qw(md5_hex);
    use namespace::clean;

    has previous_hash => (
        is      => 'rw',
        default => '',
    );

    sub detect ($self, $content) {
        my $current_hash = md5_hex($content);
        
        # 初回チェック
        if (!$self->previous_hash) {
            $self->previous_hash($current_hash);
            return {
                changed      => 0,
                first_check  => 1,
                current_hash => $current_hash,
            };
        }

        # 変更チェック
        my $changed = ($current_hash ne $self->previous_hash);
        
        my $result = {
            changed       => $changed,
            first_check   => 0,
            current_hash  => $current_hash,
            previous_hash => $self->previous_hash,
        };

        $self->previous_hash($current_hash) if $changed;

        return $result;
    }
}
	flowchart TD
    A[前回の内容を保存] --> B[今回の内容を取得]
    B --> C{ハッシュ値を比較}
    C -->|同じ| D[変更なし]
    C -->|異なる| E[変更あり!]
    E --> F[差分を計算]
    F --> G[通知]

第3章:変更を通知したい!── Observerパターン

Observer Pattern

変更を検知できるようになりました。しかし、検知だけでは意味がありません。「変更があった」という事実を、誰か(あるいは何か)に伝える必要があります。

通知先が増えると、コードが破綻します:

1
2
3
4
5
6
7
# ❌ こうなると悲惨
sub on_change ($self, $url, $content) {
    $self->send_email($url, $content);
    $self->send_slack($url, $content);
    $self->write_log($url, $content);
    # LINE通知も...Discord通知も...
}

通知先が増えるたびに、この関数を修正しなければなりません。これは「Open-Closed Principle(開放閉鎖原則)」違反です。

Observerパターンの登場

	classDiagram
    class Subject {
        +attach(observer)
        +detach(observer)
        +notify()
    }
    class Observer {
        <<interface>>
        +update(event)
    }
    class EmailNotifier {
        +update(event)
    }
    class SlackNotifier {
        +update(event)
    }
    
    Subject --> Observer : notifies
    Observer <|.. EmailNotifier
    Observer <|.. SlackNotifier

「Subject(主体)」は複数のObserverを登録でき、変更があったときに全員にupdate()メソッドを呼びます。新しい通知先を追加するときは、新しいObserverクラスを作ってattach()で登録するだけ。既存コードを一切変更しません。

Observerを実装

まず、Observerの「役割」を定義します:

1
2
3
4
5
6
package WebHunter::Observer {
    use Moo::Role;
    use namespace::clean;

    requires 'update';  # このRoleを使うクラスは必ずupdateメソッドを実装
}

具体的なObserverたち:

 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
package WebHunter::Observer::Email {
    use Moo;
    use namespace::clean;
    
    with 'WebHunter::Observer';

    has to => (
        is       => 'ro',
        required => 1,
    );

    sub update ($self, $event) {
        say "[EMAIL] 宛先: ", $self->to;
        say "  URL: $event->{url}";
        say "  変更検知時刻: $event->{timestamp}";
    }
}

package WebHunter::Observer::Slack {
    use Moo;
    use namespace::clean;
    
    with 'WebHunter::Observer';

    has webhook_url => (
        is       => 'ro',
        required => 1,
    );

    sub update ($self, $event) {
        say "[SLACK] Webhook: ", substr($self->webhook_url, 0, 30), "...";
        say "  URL: $event->{url}";
    }
}

package WebHunter::Observer::Log {
    use Moo;
    use namespace::clean;
    
    with 'WebHunter::Observer';

    has logfile => (
        is      => 'ro',
        default => 'webhunter.log',
    );

    sub update ($self, $event) {
        say "[LOG] ログファイル: ", $self->logfile;
        say "  URL: $event->{url}";
    }
}

Subject(監視主体)を作る

 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
package WebHunter::Monitor {
    use Moo;
    use namespace::clean;

    has observers => (
        is      => 'ro',
        default => sub { [] },
    );

    sub attach ($self, $observer) {
        push $self->observers->@*, $observer;
    }

    sub detach ($self, $observer) {
        $self->observers->@* = grep { $_ != $observer } $self->observers->@*;
    }

    sub notify ($self, $event) {
        for my $observer ($self->observers->@*) {
            $observer->update($event);
        }
    }

    sub on_change ($self, $url, $content) {
        my $event = {
            url       => $url,
            content   => $content,
            timestamp => time,
        };
        $self->notify($event);
    }
}
	sequenceDiagram
    participant Monitor as WebHunter::Monitor
    participant Email as EmailNotifier
    participant Slack as SlackNotifier
    participant Log as LogWriter

    Note over Monitor: 変更を検知
    Monitor->>Email: update(event)
    Monitor->>Slack: update(event)
    Monitor->>Log: update(event)
    
    Note over Email,Log: 各々が自分のやり方で通知

第4章:過去の状態を残しておく ── Mementoパターン

Memento Pattern

通知の仕組みができました。しかし、「昨日の状態と比較したい」「先週のスナップショットを見たい」という要求が出てきます。

ここで登場するのが「Mementoパターン」です。オブジェクトの内部状態をスナップショットとして保存し、後で復元できるようにする仕組みです。

Mementoパターンの仕組み

	classDiagram
    class Originator {
        -state
        +createMemento()
        +restore(memento)
    }
    class Memento {
        -state
        -timestamp
        +getState()
    }
    class Caretaker {
        -mementos[]
        +save(memento)
        +get(index)
    }
    
    Originator --> Memento : creates
    Caretaker --> Memento : stores
  • Originator(作成者): 状態を持つオブジェクト。スナップショットを作成する。
  • Memento(記念品): 状態のスナップショット。不変オブジェクト。
  • Caretaker(管理者): Mementoを保存・管理する。

WebPageMementoを作る

 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
package WebHunter::Memento {
    use Moo;
    use Digest::MD5 qw(md5_hex);
    use namespace::clean;

    has url => (
        is       => 'ro',
        required => 1,
    );

    has content => (
        is       => 'ro',
        required => 1,
    );

    has timestamp => (
        is      => 'ro',
        default => sub { time },
    );

    has hash => (
        is      => 'lazy',
        builder => sub ($self) {
            md5_hex($self->content);
        },
    );

    sub to_string ($self) {
        my $time = localtime($self->timestamp);
        my $size = length($self->content);
        return sprintf("[%s] %s (hash: %s, size: %d bytes)",
            $time, $self->url, substr($self->hash, 0, 8), $size);
    }
}

Caretaker(管理者)を作る

 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
package WebHunter::History {
    use Moo;
    use Storable qw(store retrieve);
    use namespace::clean;

    has snapshots => (
        is      => 'ro',
        default => sub { [] },
    );

    has storage_file => (
        is      => 'ro',
        default => 'webhunter_history.dat',
    );

    sub save ($self, $memento) {
        push $self->snapshots->@*, $memento;
    }

    sub latest ($self) {
        return undef if !$self->snapshots->@*;
        return $self->snapshots->[-1];
    }

    sub get ($self, $index) {
        return $self->snapshots->[$index];
    }

    sub count ($self) {
        return scalar $self->snapshots->@*;
    }

    sub persist ($self) {
        store($self->snapshots, $self->storage_file);
    }

    sub load ($self) {
        return unless -e $self->storage_file;
        my $data = retrieve($self->storage_file);
        $self->snapshots->@* = $data->@*;
    }
}

StorableはPerlのコアモジュールで、Perlのデータ構造をバイナリ形式でファイルに保存・復元できます。


第5章:履歴を順番に見る ── Iteratorパターン

過去のスナップショットを複数保存できるようになりました。しかし、これらを「順番に見る」とき、素朴に書くと内部構造に依存してしまいます:

1
2
3
4
5
# ❌ 内部構造に依存している
for my $i (0 .. $history->count - 1) {
    my $snapshot = $history->snapshots->[$i];
    say $snapshot->to_string;
}

もし履歴の保存方法が「配列」から「データベース」に変わったら、このコードは壊れます。

Iteratorパターンの仕組み

	classDiagram
    class Iterator {
        <<interface>>
        +has_next() bool
        +next() Element
        +reset()
    }
    class ConcreteIterator {
        -position
        -collection
        +has_next() bool
        +next() Element
    }
    class Aggregate {
        <<interface>>
        +iterator() Iterator
    }
    
    Aggregate --> Iterator : creates

HistoryIteratorを作る

 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
package WebHunter::HistoryIterator {
    use Moo;
    use namespace::clean;

    has history => (
        is       => 'ro',
        required => 1,
    );

    has position => (
        is      => 'rw',
        default => 0,
    );

    sub has_next ($self) {
        return $self->position < $self->history->count;
    }

    sub next ($self) {
        return undef unless $self->has_next;
        my $item = $self->history->get($self->position);
        $self->position($self->position + 1);
        return $item;
    }

    sub reset ($self) {
        $self->position(0);
    }
}

逆順Iteratorも:

 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
package WebHunter::HistoryReverseIterator {
    use Moo;
    use namespace::clean;

    has history => (
        is       => 'ro',
        required => 1,
    );

    has position => (
        is      => 'lazy',
        builder => sub ($self) {
            $self->history->count - 1;
        },
    );

    sub has_next ($self) {
        return $self->position >= 0;
    }

    sub next ($self) {
        return undef unless $self->has_next;
        my $item = $self->history->get($self->position);
        $self->position($self->position - 1);
        return $item;
    }

    sub reset ($self) {
        $self->position($self->history->count - 1);
    }
}
	sequenceDiagram
    participant Client
    participant Iterator
    participant History

    Client->>History: iterator()
    History-->>Client: Iterator
    loop while has_next()
        Client->>Iterator: has_next()
        Iterator-->>Client: true
        Client->>Iterator: next()
        Iterator->>History: get(position)
        History-->>Iterator: Memento
        Iterator-->>Client: Memento
    end

第6章:複数サイトを同時監視

これまで1つのURLを監視する仕組みを作ってきました。実用的なツールにするため、複数のサイトを同時監視できるようにしましょう。

設定ファイルを作る

YAML形式で監視対象を管理:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# webhunter_config.yaml
urls:
  - url: https://example.com/price
    name: 競合A社価格ページ
    check_interval: 3600

  - url: https://news.example.com/
    name: ニュースサイト
    check_interval: 1800

observers:
  - type: email
    to: admin@example.com

  - type: slack
    webhook_url: https://hooks.slack.com/services/YOUR/WEBHOOK/URL

  - type: log
    logfile: webhunter.log

MonitorManagerクラス

  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
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
package WebHunter::MonitorManager {
    use Moo;
    use YAML::PP;
    use WebHunter::WebMonitor;
    use WebHunter::Fetcher;
    use WebHunter::Observer::Email;
    use WebHunter::Observer::Slack;
    use WebHunter::Observer::Log;
    use namespace::clean;

    has config_file => (
        is      => 'ro',
        default => 'webhunter_config.yaml',
    );

    has monitors => (
        is      => 'ro',
        default => sub { {} },
    );

    has observers => (
        is      => 'ro',
        default => sub { [] },
    );

    has fetcher => (
        is      => 'lazy',
        builder => sub { WebHunter::Fetcher->new },
    );

    sub BUILD ($self, $args) {
        $self->load_config;
    }

    sub load_config ($self) {
        return unless -e $self->config_file;

        my $yp = YAML::PP->new;
        my $config = $yp->load_file($self->config_file);

        for my $url_config ($config->{urls}->@*) {
            my $monitor = WebHunter::WebMonitor->new(
                url => $url_config->{url},
            );
            $self->monitors->{$url_config->{url}} = {
                monitor        => $monitor,
                name           => $url_config->{name} // $url_config->{url},
                check_interval => $url_config->{check_interval} // 3600,
            };
        }

        for my $obs_config ($config->{observers}->@*) {
            my $observer;
            if ($obs_config->{type} eq 'email') {
                $observer = WebHunter::Observer::Email->new(
                    to => $obs_config->{to}
                );
            } elsif ($obs_config->{type} eq 'slack') {
                $observer = WebHunter::Observer::Slack->new(
                    webhook_url => $obs_config->{webhook_url}
                );
            } elsif ($obs_config->{type} eq 'log') {
                $observer = WebHunter::Observer::Log->new(
                    logfile => $obs_config->{logfile}
                );
            }
            push $self->observers->@*, $observer if $observer;
        }
    }

    sub run_check_all ($self) {
        my @results;

        for my $url (sort keys $self->monitors->%*) {
            my $info    = $self->monitors->{$url};
            my $monitor = $info->{monitor};
            my $name    = $info->{name};

            say "チェック中: $name ($url)";

            my $result = $self->fetcher->fetch($url);
            unless ($result->{success}) {
                warn "取得失敗: $url - $result->{status}";
                next;
            }

            my $check = $monitor->check_change($result->{content});
            
            if ($check->{changed}) {
                say "  ✓ 変更検知!";
                
                my $event = {
                    url       => $url,
                    name      => $name,
                    content   => $result->{content},
                    previous  => $check->{previous},
                    current   => $check->{current},
                    timestamp => time,
                };
                
                $self->notify_observers($event);
                
                push @results, { url => $url, changed => 1 };
            } else {
                say "  変更なし";
                push @results, { url => $url, changed => 0 };
            }
        }

        return \@results;
    }

    sub notify_observers ($self, $event) {
        for my $observer ($self->observers->@*) {
            $observer->update($event);
        }
    }

    sub count ($self) {
        return scalar keys $self->monitors->%*;
    }
}

cronで定期実行

1
0 * * * * cd /path/to/webhunter && perl webhunter.pl >> logs/webhunter.log 2>&1
	sequenceDiagram
    participant Cron
    participant Manager as MonitorManager
    participant Monitor as WebMonitor
    participant Fetcher
    participant Observer

    Cron->>Manager: webhunter.pl 実行
    Manager->>Manager: 設定ファイル読み込み
    loop 各URL
        Manager->>Fetcher: fetch(url)
        Fetcher-->>Manager: content
        Manager->>Monitor: check_change(content)
        Monitor-->>Manager: changed?
        alt 変更あり
            Manager->>Observer: notify(event)
        end
    end

第7章:差分を見やすく表示する

複数サイトを同時監視できるようになりました。しかし、「変わった」だけでは困ります。「どこが変わったの?」

Text::Diffで差分を計算

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
use Text::Diff;

my $old = <<'EOF';
価格: 1,000円
在庫: あり
EOF

my $new = <<'EOF';
価格: 1,200円
在庫: あり
EOF

my $diff = diff(\$old, \$new);
say $diff;

# 出力:
# @@ -1,2 +1,2 @@
# -価格: 1,000円
# +価格: 1,200円
#  在庫: あり

DiffFormatterクラス

 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
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
package WebHunter::DiffFormatter {
    use Moo;
    use Text::Diff;
    use HTML::Entities qw(encode_entities);
    use namespace::clean;

    has use_color => (
        is      => 'ro',
        default => 0,
    );

    sub format_diff ($self, $old, $new, %options) {
        my $diff = diff(
            \$old,
            \$new,
            {
                STYLE => $options{style} // 'Unified',
            }
        );
        
        return $diff unless $self->use_color;
        return $self->colorize($diff);
    }

    sub colorize ($self, $diff) {
        my @lines = split /\n/, $diff;
        my @colored;
        
        for my $line (@lines) {
            if ($line =~ /^-/) {
                push @colored, "\e[31m$line\e[0m";  # 赤
            } elsif ($line =~ /^\+/) {
                push @colored, "\e[32m$line\e[0m";  # 緑
            } elsif ($line =~ /^@@/) {
                push @colored, "\e[36m$line\e[0m";  # シアン
            } else {
                push @colored, $line;
            }
        }
        
        return join("\n", @colored);
    }

    sub format_html_diff ($self, $old, $new) {
        my $diff = diff(\$old, \$new, { STYLE => 'Unified' });
        my @lines = split /\n/, $diff;
        my @html;
        
        push @html, '<pre style="background: #f5f5f5; padding: 10px;">';
        
        for my $line (@lines) {
            my $escaped = encode_entities($line);
            
            if ($line =~ /^-/) {
                push @html, qq{<span style="color: #c00;">$escaped</span>};
            } elsif ($line =~ /^\+/) {
                push @html, qq{<span style="color: #0c0;">$escaped</span>};
            } elsif ($line =~ /^@@/) {
                push @html, qq{<span style="color: #06c;">$escaped</span>};
            } else {
                push @html, $escaped;
            }
        }
        
        push @html, '</pre>';
        return join("\n", @html);
    }

    sub get_summary ($self, $old, $new) {
        my $diff = diff(\$old, \$new, { STYLE => 'Unified' });
        my @lines = split /\n/, $diff;
        
        my $added   = grep { /^\+/ && !/^\+\+\+/ } @lines;
        my $deleted = grep { /^-/  && !/^---/    } @lines;
        
        return {
            added   => $added,
            deleted => $deleted,
            total   => $added + $deleted,
        };
    }
}

第8章:3つのパターンの正体

全7章で「WebHunter」を作ってきました。最終章では、Observer、Memento、Iteratorの3つのパターンがどう協調し、どんな原則に基づいているかを解説します。

3つのパターンの役割

	graph TD
    A[WebMonitor<br/>監視主体] -->|変更検知| B[Observer<br/>通知先]
    A -->|状態保存| C[Memento<br/>スナップショット]
    C -->|履歴管理| D[History<br/>コレクション]
    D -->|巡回| E[Iterator<br/>履歴巡回]
    
    style A fill:#e1f5ff
    style B fill:#ffe1e1
    style C fill:#e1ffe1
    style D fill:#ffe1f5
    style E fill:#f5e1ff
  • Observer: 変更の通知(疎結合な1対多の依存関係)
  • Memento: 状態の保存(カプセル化を守る履歴管理)
  • Iterator: コレクションの巡回(内部構造の隠蔽)

SOLID原則との対応

原則対応
S (Single Responsibility)各クラスが1つの責任のみ(Fetcher=通信、History=履歴、Iterator=巡回)
O (Open-Closed)新しいObserverを追加しても既存コード変更不要
L (Liskov Substitution)どのObserver実装も同じインターフェースで置換可能
I (Interface Segregation)Observerはupdate()だけを要求
D (Dependency Inversion)具象ではなく抽象(Role)に依存

パターン協調のシーケンス

	sequenceDiagram
    participant Manager as MonitorManager
    participant Monitor as WebMonitor
    participant Memento
    participant History
    participant Iterator
    participant Observer

    Manager->>Monitor: check_change(content)
    Monitor->>Memento: create(content)
    Monitor->>History: latest()
    History->>Monitor: 前回のMemento
    Monitor->>Monitor: 比較
    
    alt 変更あり
        Monitor->>History: save(new Memento)
        History->>History: 永続化
        Monitor->>Manager: 変更あり
        Manager->>Observer: notify(event)
    end
    
    Note over History,Iterator: 別の場面で
    Manager->>History: iterator()
    History-->>Manager: Iterator
    loop while has_next()
        Manager->>Iterator: next()
        Iterator->>History: get(position)
        History-->>Iterator: Memento
        Iterator-->>Manager: Memento
    end

まとめ:完成したツールの機能

この記事を通じて、以下の機能を持つツールが完成しました:

  1. 複数URLの監視: 設定ファイルで管理
  2. 変更検知: ハッシュ値による効率的な比較
  3. 複数の通知先: メール、Slack、ログファイル(拡張可能)
  4. 履歴保存: 過去のスナップショットを永続化
  5. 履歴巡回: 時系列で過去の状態を確認
  6. 差分表示: どこが変わったかを視覚化
  7. 定期実行: cronで自動化

デザインパターンは「銀の弾丸」ではありませんが、適材適所で使う「道具」として強力です。Perlの表現力とMooの使いやすさを活かして、美しいコードを書いていきましょう。

Happy Hacking!

comments powered by Disqus
Hugo で構築されています。
テーマ StackJimmy によって設計されています。