Featured image of post 第4回-誰が覗いたか記録せよ - Mooで作るゴーストギャラリー・ビューワ

第4回-誰が覗いたか記録せよ - Mooで作るゴーストギャラリー・ビューワ

ギャラリーへのアクセスに監査要件が追加された。誰がいつどの絵を見たかを記録するLogging Proxyで、監査ログを一箇所に集約する方法を学びます。

@nqounetです。

「Mooで作るゴーストギャラリー・ビューワ」シリーズの第4回です。前回はCaching Proxyを使って高速化を実装しました。

今回は「誰が覗いたか記録せよ」というテーマです。呪いの絵を見たユーザーを追跡できるように、監査ログを実装します。

今回のゴール

ギャラリーへのアクセスを監査ログとして記録します。誰が、いつ、どの絵を見たかを追跡できるようにします。Logging Proxy(ロギングプロキシ)を使って、ログ出力の責務を分離します。

新しい要件

ギャラリーのオーナーから監査要件が追加されました。

  • 呪いの絵を見たユーザーを記録したい
  • 万が一の呪い被害に備えて、閲覧履歴を保全する
  • ログは集約して保管したい

ログ散乱パターン(破綻例)

まず、各クラスに直接ログを追加する方法を試してみます。

 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
#!/usr/bin/env perl
# 言語: perl
# バージョン: 5.36以上
# 依存: Moo(cpanmでインストール)

use v5.36;

package GhostImage {
    use Moo;
    use Time::HiRes qw(time);

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

    sub render ($self, $user) {
        # ログを直接出力(良くない設計)
        my $timestamp = localtime(time);
        say "[LOG] $timestamp - User: $user->{name} viewed: " . $self->name;

        return "🖼️ " . $self->name;
    }
}

package ImageProxy {
    use Moo;
    use Time::HiRes qw(time);

    has name => ( is => 'ro', required => 1 );
    has _real_image => (
        is       => 'lazy',
        builder  => '_build_real_image',
    );

    sub _build_real_image ($self) {
        GhostImage->new(name => $self->name);
    }

    sub render ($self, $user) {
        # ここにもログを追加(散乱)
        say "[PROXY LOG] Accessing " . $self->name;
        return $self->_real_image->render($user);
    }
}

package main {
    my $proxy = ImageProxy->new(name => '叫ぶ亡霊');
    my $user = { name => '田中太郎' };

    $proxy->render($user);
}

出力は以下のようになります。

1
2
[PROXY LOG] Accessing 叫ぶ亡霊
[LOG] Tue Jan 17 23:10:44 2026 - User: 田中太郎 viewed: 叫ぶ亡霊

破綻のポイント

  • ログのコードが複数のクラスに散乱している(カオス)
  • ログフォーマットが統一されていない([LOG][PROXY LOG]、どっちが正式?)
  • ログ出力先を変えたい場合、全クラスを変更する必要がある(OCP違反)
  • renderメソッドの引数に$userが必要になる(インターフェースが変わって後方互換崩壊)
  • テスト時にログを無効化するのが困難(テストが地獄に)

これもまた、単一責任の原則(SRP)違反です。画像クラスは画像、プロキシはプロキシ、そしてログはログ専門に任せるべき。

Logging Proxyで解決する

ログの責務を専用のProxyに分離します。

Logging Proxyの構造

	classDiagram
    class AuditProxy {
        +inner_proxy
        +current_user
        +log_storage[]
        +render()
        +render_full()
        -_log(action)
        +export_logs()
    }

    class ImageProxy {
        +name
        +render()
        +render_full()
    }

    class LogStorage {
        +entries[]
        +add(entry)
        +export()
    }

    AuditProxy --> ImageProxy : wraps
    AuditProxy --> LogStorage : writes to

    note for AuditProxy "全てのアクセスを\nログに記録"
    note for LogStorage "複数のProxyで\n共有可能"

ロギングのシーケンス

	sequenceDiagram
    participant UserA as ユーザーA
    participant UserB as ユーザーB
    participant Audit as AuditProxy
    participant Storage as LogStorage
    participant Proxy as ImageProxy

    UserA->>Audit: render_full()
    Audit->>Storage: _log("VIEW_FULL", UserA)
    Audit->>Proxy: render_full()
    Proxy-->>Audit: 画像データ
    Audit-->>UserA: 🖼️ 画像

    UserB->>Audit: render_full()
    Audit->>Storage: _log("VIEW_FULL", UserB)
    Audit->>Proxy: render_full()
    Proxy-->>Audit: 画像データ
    Audit-->>UserB: 🖼️ 画像

    Note over Storage: 監査ログ<br/>UserA: VIEW_FULL<br/>UserB: VIEW_FULL
  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
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
#!/usr/bin/env perl
# 言語: perl
# バージョン: 5.36以上
# 依存: Moo(cpanmでインストール)

use v5.36;

# === 高解像度アート画像クラス(RealSubject) ===
package GhostImage {
    use Moo;
    use Time::HiRes qw(sleep);

    has name => ( is => 'ro', required => 1 );
    has resolution => ( is => 'ro', default => sub { '8K' } );

    sub BUILD ($self, $args) {
        sleep(0.2);
    }

    sub render ($self) {
        return "🖼️ " . $self->name . " [" . $self->resolution . "]";
    }
}

# === Virtual Proxy ===
package ImageProxy {
    use Moo;

    has name => ( is => 'ro', required => 1 );
    has resolution => ( is => 'ro', default => sub { '8K' } );
    has _real_image => (
        is       => 'lazy',
        init_arg => undef,
        builder  => '_build_real_image',
    );

    sub _build_real_image ($self) {
        GhostImage->new(name => $self->name, resolution => $self->resolution);
    }

    sub render ($self) { "👻 " . $self->name . " [プレビュー]" }
    sub render_full ($self) { $self->_real_image->render }
}

# === Audit Proxy (Logging Proxy) ===
package AuditProxy {
    use Moo;

    has inner_proxy => ( is => 'ro', required => 1 );
    has current_user => ( is => 'rw', default => sub { { name => 'anonymous' } } );
    has log_storage => ( is => 'ro', default => sub { [] } );

    sub name ($self) { $self->inner_proxy->name }

    sub _log ($self, $action, $details = '') {
        my ($sec, $min, $hour, $mday, $mon, $year) = localtime;
        my $timestamp = sprintf("%04d-%02d-%02d %02d:%02d:%02d",
            $year + 1900, $mon + 1, $mday, $hour, $min, $sec);

        my $entry = {
            timestamp => $timestamp,
            user      => $self->current_user->{name},
            action    => $action,
            target    => $self->name,
            details   => $details,
        };

        push @{$self->log_storage}, $entry;

        say sprintf("[AUDIT] %s | %-12s | %-10s | %s%s",
            $timestamp,
            $entry->{user},
            $entry->{action},
            $entry->{target},
            $details ? " ($details)" : "",
        );
    }

    sub render ($self) {
        $self->_log('PREVIEW');
        return $self->inner_proxy->render;
    }

    sub render_full ($self) {
        $self->_log('VIEW_FULL');
        return $self->inner_proxy->render_full;
    }

    sub get_logs ($self) {
        return @{$self->log_storage};
    }

    sub export_logs ($self) {
        say "\n📋 監査ログエクスポート";
        say "=" x 70;
        for my $entry (@{$self->log_storage}) {
            say sprintf("%s | %-12s | %-10s | %s",
                $entry->{timestamp},
                $entry->{user},
                $entry->{action},
                $entry->{target},
            );
        }
        say "=" x 70;
        say "Total entries: " . scalar(@{$self->log_storage});
    }
}

# === ギャラリークラス ===
package GhostGallery {
    use Moo;

    has images => ( is => 'ro', default => sub { [] } );
    has current_user => ( is => 'rw', default => sub { { name => 'ゲスト' } } );
    has audit_log => ( is => 'ro', default => sub { [] } );

    sub add_image ($self, $img) {
        # 共有の監査ログストレージを設定
        if ($img->isa('AuditProxy')) {
            $img = AuditProxy->new(
                inner_proxy => $img->inner_proxy,
                log_storage => $self->audit_log,
            );
        }
        push @{$self->images}, $img;
    }

    sub set_user ($self, $user) {
        $self->current_user($user);
        for my $img (@{$self->images}) {
            if ($img->isa('AuditProxy')) {
                $img->current_user($user);
            }
        }
    }

    sub show_gallery ($self) {
        say "\n=== 👻 ゴーストギャラリー ===";
        say "ログイン: " . $self->current_user->{name} . "\n";
        my $i = 1;
        for my $img (@{$self->images}) {
            say "$i. " . $img->render;
            $i++;
        }
        say "\n============================\n";
    }

    sub view_image ($self, $idx) {
        my $img = $self->images->[$idx - 1];
        if ($img) {
            say "\n🔍 詳細表示:";
            say $img->render_full;
        }
    }

    sub export_audit_log ($self) {
        say "\n📋 ギャラリー監査ログ";
        say "=" x 70;
        for my $entry (@{$self->audit_log}) {
            say sprintf("%s | %-12s | %-10s | %s",
                $entry->{timestamp},
                $entry->{user},
                $entry->{action},
                $entry->{target},
            );
        }
        say "=" x 70;
        say "Total entries: " . scalar(@{$self->audit_log});
    }
}

# === メイン処理 ===
package main {
    use v5.36;

    say "📸 ギャラリーを初期化中...\n";

    my $gallery = GhostGallery->new;

    # AuditProxy + Virtual Proxy のチェーン
    $gallery->add_image(
        AuditProxy->new(
            inner_proxy => ImageProxy->new(name => '叫ぶ亡霊'),
        )
    );

    $gallery->add_image(
        AuditProxy->new(
            inner_proxy => ImageProxy->new(name => '禁断の肖像画'),
        )
    );

    $gallery->add_image(
        AuditProxy->new(
            inner_proxy => ImageProxy->new(name => '呪われた王冠'),
        )
    );

    say "✅ 初期化完了!\n";

    # ユーザーAがギャラリーを閲覧
    $gallery->set_user({ name => '田中太郎' });
    $gallery->show_gallery;
    $gallery->view_image(1);
    $gallery->view_image(2);

    say "\n" . "-" x 50 . "\n";

    # ユーザーBがギャラリーを閲覧
    $gallery->set_user({ name => '山田花子' });
    $gallery->show_gallery;
    $gallery->view_image(1);
    $gallery->view_image(3);

    # 監査ログをエクスポート
    $gallery->export_audit_log;
}

実行結果は以下のようになります。

 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
📸 ギャラリーを初期化中...

✅ 初期化完了!

=== 👻 ゴーストギャラリー ===
ログイン: 田中太郎

[AUDIT] 2026-01-17 23:10:44 | 田中太郎      | PREVIEW    | 叫ぶ亡霊
1. 👻 叫ぶ亡霊 [プレビュー]
[AUDIT] 2026-01-17 23:10:44 | 田中太郎      | PREVIEW    | 禁断の肖像画
2. 👻 禁断の肖像画 [プレビュー]
[AUDIT] 2026-01-17 23:10:44 | 田中太郎      | PREVIEW    | 呪われた王冠
3. 👻 呪われた王冠 [プレビュー]

============================


🔍 詳細表示:
[AUDIT] 2026-01-17 23:10:44 | 田中太郎      | VIEW_FULL  | 叫ぶ亡霊
🖼️ 叫ぶ亡霊 [8K]

🔍 詳細表示:
[AUDIT] 2026-01-17 23:10:44 | 田中太郎      | VIEW_FULL  | 禁断の肖像画
🖼️ 禁断の肖像画 [8K]

--------------------------------------------------

=== 👻 ゴーストギャラリー ===
ログイン: 山田花子

[AUDIT] 2026-01-17 23:10:44 | 山田花子      | PREVIEW    | 叫ぶ亡霊
1. 👻 叫ぶ亡霊 [プレビュー]
[AUDIT] 2026-01-17 23:10:44 | 山田花子      | PREVIEW    | 禁断の肖像画
2. 👻 禁断の肖像画 [プレビュー]
[AUDIT] 2026-01-17 23:10:44 | 山田花子      | PREVIEW    | 呪われた王冠
3. 👻 呪われた王冠 [プレビュー]

============================


🔍 詳細表示:
[AUDIT] 2026-01-17 23:10:45 | 山田花子      | VIEW_FULL  | 叫ぶ亡霊
🖼️ 叫ぶ亡霊 [8K]

🔍 詳細表示:
[AUDIT] 2026-01-17 23:10:45 | 山田花子      | VIEW_FULL  | 呪われた王冠
🖼️ 呪われた王冠 [8K]

📋 ギャラリー監査ログ
======================================================================
2026-01-17 23:10:44 | 田中太郎      | PREVIEW    | 叫ぶ亡霊
2026-01-17 23:10:44 | 田中太郎      | PREVIEW    | 禁断の肖像画
2026-01-17 23:10:44 | 田中太郎      | PREVIEW    | 呪われた王冠
2026-01-17 23:10:44 | 田中太郎      | VIEW_FULL  | 叫ぶ亡霊
2026-01-17 23:10:44 | 田中太郎      | VIEW_FULL  | 禁断の肖像画
2026-01-17 23:10:44 | 山田花子      | PREVIEW    | 叫ぶ亡霊
2026-01-17 23:10:44 | 山田花子      | PREVIEW    | 禁断の肖像画
2026-01-17 23:10:44 | 山田花子      | PREVIEW    | 呪われた王冠
2026-01-17 23:10:45 | 山田花子      | VIEW_FULL  | 叫ぶ亡霊
2026-01-17 23:10:45 | 山田花子      | VIEW_FULL  | 呪われた王冠
======================================================================
Total entries: 10

何が変わったか

  1. ログ出力がAuditProxyに集約されている
  2. ログフォーマットが統一されている
  3. 複数画像のログを一箇所で管理できる
  4. GhostImageImageProxyにはログのコードがない
  5. ログストレージを差し替えれば、出力先を簡単に変更できる

Logging Proxyのポイント

ログエントリの構造化

1
2
3
4
5
6
7
my $entry = {
    timestamp => $timestamp,
    user      => $self->current_user->{name},
    action    => $action,
    target    => $self->name,
    details   => $details,
};

構造化されたログにすることで、後から検索・分析しやすくなります。

ログストレージの共有

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

複数のAuditProxyで同じストレージを共有することで、ログを一元管理できます。

アクション名の明確化

1
2
$self->_log('PREVIEW');
$self->_log('VIEW_FULL');

どの操作が行われたかを明確にするアクション名を定義します。

完成コード

最終的な完成コードを掲載します。

  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
#!/usr/bin/env perl
# 言語: perl
# バージョン: 5.36以上
# 依存: Moo(cpanmでインストール)
# ファイル名: ghost_gallery_audit.pl

use v5.36;

# === 高解像度アート画像クラス(RealSubject) ===
package GhostImage {
    use Moo;
    use Time::HiRes qw(sleep);

    has name => ( is => 'ro', required => 1 );
    has resolution => ( is => 'ro', default => sub { '8K' } );

    sub BUILD ($self, $args) { sleep(0.2) }
    sub render ($self) { "🖼️ " . $self->name . " [" . $self->resolution . "]" }
}

# === Virtual Proxy ===
package ImageProxy {
    use Moo;

    has name => ( is => 'ro', required => 1 );
    has resolution => ( is => 'ro', default => sub { '8K' } );
    has _real_image => ( is => 'lazy', init_arg => undef, builder => '_build_real_image' );

    sub _build_real_image ($self) {
        GhostImage->new(name => $self->name, resolution => $self->resolution);
    }

    sub render ($self) { "👻 " . $self->name . " [プレビュー]" }
    sub render_full ($self) { $self->_real_image->render }
}

# === Audit Proxy (Logging Proxy) ===
package AuditProxy {
    use Moo;

    has inner_proxy => ( is => 'ro', required => 1 );
    has current_user => ( is => 'rw', default => sub { { name => 'anonymous' } } );
    has log_storage => ( is => 'ro', default => sub { [] } );

    sub name ($self) { $self->inner_proxy->name }

    sub _log ($self, $action) {
        my ($s, $m, $h, $d, $mo, $y) = localtime;
        my $ts = sprintf("%04d-%02d-%02d %02d:%02d:%02d", $y+1900, $mo+1, $d, $h, $m, $s);
        push @{$self->log_storage}, {
            timestamp => $ts, user => $self->current_user->{name},
            action => $action, target => $self->name,
        };
        say "[AUDIT] $ts | " . $self->current_user->{name} . " | $action | " . $self->name;
    }

    sub render ($self) { $self->_log('PREVIEW'); $self->inner_proxy->render }
    sub render_full ($self) { $self->_log('VIEW_FULL'); $self->inner_proxy->render_full }
}

# === ギャラリー ===
package GhostGallery {
    use Moo;

    has images => ( is => 'ro', default => sub { [] } );
    has current_user => ( is => 'rw', default => sub { { name => 'ゲスト' } } );
    has audit_log => ( is => 'ro', default => sub { [] } );

    sub add_image ($self, $img) {
        $img = AuditProxy->new(inner_proxy => $img->inner_proxy, log_storage => $self->audit_log)
            if $img->isa('AuditProxy');
        push @{$self->images}, $img;
    }

    sub set_user ($self, $user) {
        $self->current_user($user);
        $_->current_user($user) for grep { $_->isa('AuditProxy') } @{$self->images};
    }

    sub show_gallery ($self) {
        say "\n=== 👻 ゴーストギャラリー ===\nログイン: " . $self->current_user->{name} . "\n";
        my $i = 1; say "$i. " . $_->render and $i++ for @{$self->images};
        say "\n============================\n";
    }

    sub view_image ($self, $idx) {
        my $img = $self->images->[$idx - 1];
        say "\n🔍 " . ($img ? $img->render_full : "not found") if $img;
    }

    sub export_audit_log ($self) {
        say "\n📋 監査ログ\n" . "=" x 60;
        say "$_->{timestamp} | $_->{user} | $_->{action} | $_->{target}" for @{$self->audit_log};
        say "=" x 60 . "\nTotal: " . scalar(@{$self->audit_log});
    }
}

# === メイン ===
package main {
    my $gallery = GhostGallery->new;
    $gallery->add_image(AuditProxy->new(inner_proxy => ImageProxy->new(name => '叫ぶ亡霊')));
    $gallery->add_image(AuditProxy->new(inner_proxy => ImageProxy->new(name => '禁断の肖像画')));

    $gallery->set_user({ name => '田中太郎' });
    $gallery->show_gallery;
    $gallery->view_image(1);

    $gallery->set_user({ name => '山田花子' });
    $gallery->view_image(2);

    $gallery->export_audit_log;
}

まとめ

今回は、Logging Proxy(ロギングプロキシ)を学びました。

  • ログ出力を代理オブジェクトに集約
  • ログフォーマットの統一
  • ログストレージの一元管理

次回は「遠隔アーカイブへ引っ越し」と題して、Remote Proxy(リモートプロキシ)を学びます。画像を外部のアーカイブサーバーに移動し、ネットワーク越しのアクセスを透過的に扱います。

シリーズ全体の目次は以下をご覧ください。

参考リンク

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