Featured image of post 第2回-呪いの絵は誰でも見ていい? - Mooで作るゴーストギャラリー・ビューワ

第2回-呪いの絵は誰でも見ていい? - Mooで作るゴーストギャラリー・ビューワ

鍵付きの呪いの絵が追加されたギャラリー。直アクセスでは危険なため、Protection Proxyで権限チェックを挟み、許可されたユーザーだけが閲覧できるようにします。

@nqounetです。

「Mooで作るゴーストギャラリー・ビューワ」シリーズの第2回です。前回はVirtual Proxyを使って画像の遅延初期化を実装しました。

今回は「呪いの絵は誰でも見ていいのか?」という問いに答えます。一部の呪いの絵は、見た人に不幸をもたらすかもしれません。そこで、アクセス制御を導入します。

今回のゴール

ギャラリーに「鍵付き」の呪いの絵を追加します。鍵付きの絵は、適切な権限を持つユーザーだけが閲覧できるようにします。Protection Proxy(アクセス制御プロキシ)を使って、権限チェックを実装します。

前回までの状態

前回作成したコードでは、Virtual Proxyを使って遅延初期化を実装しました。今回は、このコードに「権限」の概念を追加していきます。

新しい要件

ギャラリーのオーナーから新しい要求が来ました。

  • 一部の絵には「鍵」がかかっている
  • 鍵付きの絵は「管理者」または「VIP会員」しか見られない
  • 一般ユーザーが鍵付きの絵を見ようとしたらブロックする

最初は、既存のコードに直接権限チェックを追加しようと思いました。

直接追加する方法(破綻パターン)

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

use v5.36;

package GhostImage {
    use Moo;

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

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

    sub render ($self, $user) {
        # 画像クラスに権限チェックを追加(良くない設計)
        if ($self->is_locked) {
            my $role = $user->{role} // 'guest';
            unless ($role eq 'admin' || $role eq 'vip') {
                return "🔒 [アクセス拒否] " . $self->name;
            }
        }
        return "🖼️ " . $self->name;
    }
}

package main {
    my $image = GhostImage->new(
        name      => '禁断の肖像画',
        is_locked => 1,
    );

    my $guest_user = { name => 'ゲスト', role => 'guest' };
    my $admin_user = { name => '管理者', role => 'admin' };

    say $image->render($guest_user);
    say $image->render($admin_user);
}

出力は以下です。

1
2
🔒 [アクセス拒否] 禁断の肖像画
🖼️ 禁断の肖像画

一見動いていますが、この設計には大きな問題があります。動けばいいってものじゃない。

破綻のポイント

  • 画像クラス(GhostImage)にアクセス制御の責務が混入している(まずい)
  • renderメソッドの引数として$userを渡す必要がある(インターフェースが変わると既存コード全滅)
  • 権限のロジックが複雑になると、画像クラスが肥大化する(太ったクラスは悪)
  • 今後、ロギングやキャッシュなど別の機能を追加するたびに、同じクラスが変更される(閉じてない!)

これは単一責任の原則(SRP)に違反しています。画像クラスは「画像を表現する」ことだけに集中すべきです。余計な仕事を押し付けられた画像クラスは、いつか限界を迎えます。

Protection Proxyで解決する

Protection Proxyを導入して、アクセス制御の責務を分離します。

Protection Proxyの構造

	classDiagram
    class GuardProxy {
        +inner_proxy
        +required_roles[]
        +current_user
        +render()
        +render_full()
        -_check_access()
    }

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

    class GhostImage {
        +name
        +resolution
        +render()
    }

    GuardProxy --> ImageProxy : wraps
    ImageProxy --> GhostImage : lazy creates

    note for GuardProxy "アクセス権限を\nチェックして通過/拒否"

アクセス制御のシーケンス

	sequenceDiagram
    participant User as ユーザー
    participant Guard as GuardProxy
    participant Proxy as ImageProxy
    participant Real as GhostImage

    User->>Guard: render_full()
    Guard->>Guard: _check_access()

    alt 権限なし (guest)
        Guard-->>User: ⛔ アクセス拒否
    else 権限あり (admin/vip)
        Guard->>Proxy: render_full()
        Proxy->>Real: render()
        Real-->>Proxy: 画像データ
        Proxy-->>Guard: 画像データ
        Guard-->>User: 🖼️ 詳細表示
    end
  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
#!/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) {
        say "  [LOADING] " . $self->name . "...";
        sleep(0.3);
        say "  [LOADED] " . $self->name;
    }

    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) {
        return GhostImage->new(
            name       => $self->name,
            resolution => $self->resolution,
        );
    }

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

    sub render_full ($self) {
        return $self->_real_image->render;
    }
}

# === Protection Proxy(アクセス制御): Guard Proxy ===
package GuardProxy {
    use Moo;

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

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

    has current_user => (
        is      => 'rw',
        default => sub { { name => 'ゲスト', role => 'guest' } },
    );

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

    sub _check_access ($self) {
        my $user_role = $self->current_user->{role} // 'guest';
        my @allowed = @{$self->required_roles};
        return grep { $_ eq $user_role } @allowed;
    }

    sub render ($self) {
        if ($self->_check_access) {
            return "🔓 " . $self->inner_proxy->render;
        }
        return "🔒 [鍵付き] " . $self->inner_proxy->name;
    }

    sub render_full ($self) {
        unless ($self->_check_access) {
            return "⛔ [アクセス拒否] " . $self->inner_proxy->name . " - 権限がありません";
        }
        return $self->inner_proxy->render_full;
    }
}

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

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

    has current_user => (
        is      => 'rw',
        default => sub { { name => 'ゲスト', role => 'guest' } },
    );

    sub add_image ($self, $image) {
        push @{$self->images}, $image;
    }

    sub set_user ($self, $user) {
        $self->current_user($user);
        # 全てのGuardProxyにユーザーを設定
        for my $image (@{$self->images}) {
            if ($image->isa('GuardProxy')) {
                $image->current_user($user);
            }
        }
    }

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

    sub view_image ($self, $index) {
        my $image = $self->images->[$index - 1];
        if ($image) {
            say "\n🔍 詳細表示中...";
            say $image->render_full;
        }
        else {
            say "画像が見つかりません。";
        }
    }
}

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

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

    my $gallery = GhostGallery->new;

    # 通常の画像(Virtual Proxyのみ)
    $gallery->add_image(
        ImageProxy->new(name => '叫ぶ亡霊')
    );

    # 鍵付きの画像(GuardProxy + Virtual Proxy)
    $gallery->add_image(
        GuardProxy->new(
            inner_proxy => ImageProxy->new(name => '禁断の肖像画'),
        )
    );

    # 通常の画像
    $gallery->add_image(
        ImageProxy->new(name => '消えた家族写真')
    );

    # VIP限定の画像
    $gallery->add_image(
        GuardProxy->new(
            inner_proxy    => ImageProxy->new(name => '呪われた王冠'),
            required_roles => ['vip'],
        )
    );

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

    # ゲストユーザーとして表示
    $gallery->show_gallery;
    $gallery->view_image(2);

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

    # VIPユーザーとしてログイン
    $gallery->set_user({ name => 'VIP太郎', role => 'vip' });
    $gallery->show_gallery;
    $gallery->view_image(2);
    $gallery->view_image(4);
}

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

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

✅ 初期化完了!

=== 👻 ゴーストギャラリー ===
ログイン中: ゲスト (guest)

1. 👻 叫ぶ亡霊 [プレビュー]
2. 🔒 [鍵付き] 禁断の肖像画
3. 👻 消えた家族写真 [プレビュー]
4. 🔒 [鍵付き] 呪われた王冠

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

🔍 詳細表示中...
⛔ [アクセス拒否] 禁断の肖像画 - 権限がありません

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

=== 👻 ゴーストギャラリー ===
ログイン中: VIP太郎 (vip)

1. 👻 叫ぶ亡霊 [プレビュー]
2. 🔓 👻 禁断の肖像画 [プレビュー]
3. 👻 消えた家族写真 [プレビュー]
4. 🔓 👻 呪われた王冠 [プレビュー]

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

🔍 詳細表示中...
  [LOADING] 禁断の肖像画...
  [LOADED] 禁断の肖像画
🖼️ 禁断の肖像画 [8K]

🔍 詳細表示中...
  [LOADING] 呪われた王冠...
  [LOADED] 呪われた王冠
🖼️ 呪われた王冠 [8K]

何が変わったか

  1. GhostImageクラスにはアクセス制御のコードがない
  2. GuardProxyがアクセス制御の責務を担う
  3. GuardProxyは内部にImageProxyを持ち、チェーンのように動作する
  4. 権限要件はrequired_rolesで柔軟に設定可能

Proxyのチェーン構造

今回のコードでは、2つのProxyがチェーンになっています。

1
2
3
GuardProxy (アクセス制御)
    └── ImageProxy (遅延初期化)
            └── GhostImage (実体)

この構造のメリットは以下です。

  • 各Proxyが単一の責務を持つ
  • 必要に応じてProxyを追加・削除できる
  • 既存のコードを変更せずに機能を拡張できる

Protection Proxyのポイント

権限チェックは呼び出し前に行う

1
2
3
4
5
6
sub render_full ($self) {
    unless ($self->_check_access) {
        return "⛔ [アクセス拒否] ...";
    }
    return $self->inner_proxy->render_full;
}

権限がなければ、内部のProxyにアクセスする前に処理を中断します。

権限要件を柔軟に設定する

1
2
3
4
has required_roles => (
    is      => 'ro',
    default => sub { ['admin', 'vip'] },
);

画像ごとに異なる権限要件を設定できます。VIP限定、管理者限定など、用途に応じて変更できます。

完成コード

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

  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
#!/usr/bin/env perl
# 言語: perl
# バージョン: 5.36以上
# 依存: Moo(cpanmでインストール)
# ファイル名: ghost_gallery_guard.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) {
        say "  [LOADING] " . $self->name . "...";
        sleep(0.3);
        say "  [LOADED] " . $self->name;
    }

    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) {
        return GhostImage->new(
            name       => $self->name,
            resolution => $self->resolution,
        );
    }

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

# === Protection Proxy (Guard Proxy) ===
package GuardProxy {
    use Moo;

    has inner_proxy => ( is => 'ro', required => 1 );
    has required_roles => ( is => 'ro', default => sub { ['admin', 'vip'] } );
    has current_user => ( is => 'rw', default => sub { { role => 'guest' } } );

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

    sub _check_access ($self) {
        my $role = $self->current_user->{role} // 'guest';
        return grep { $_ eq $role } @{$self->required_roles};
    }

    sub render ($self) {
        $self->_check_access
            ? "🔓 " . $self->inner_proxy->render
            : "🔒 [鍵付き] " . $self->inner_proxy->name;
    }

    sub render_full ($self) {
        return "⛔ [アクセス拒否] " . $self->inner_proxy->name
            unless $self->_check_access;
        return $self->inner_proxy->render_full;
    }
}

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

    has images => ( is => 'ro', default => sub { [] } );
    has current_user => ( is => 'rw', default => sub { { role => 'guest' } } );

    sub add_image ($self, $img) { push @{$self->images}, $img }

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

    sub show_gallery ($self) {
        say "\n=== 👻 ゴーストギャラリー ===";
        say "ログイン: " . ($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🔍 詳細表示...\n" . ($img ? $img->render_full : "画像が見つかりません");
    }
}

# === メイン ===
package main {
    my $gallery = GhostGallery->new;

    $gallery->add_image(ImageProxy->new(name => '叫ぶ亡霊'));
    $gallery->add_image(GuardProxy->new(inner_proxy => ImageProxy->new(name => '禁断の肖像画')));
    $gallery->add_image(ImageProxy->new(name => '消えた家族写真'));
    $gallery->add_image(GuardProxy->new(inner_proxy => ImageProxy->new(name => '呪われた王冠'), required_roles => ['vip']));

    $gallery->show_gallery;
    $gallery->view_image(2);

    say "\n" . "=" x 40 . "\n";

    $gallery->set_user({ name => 'VIP太郎', role => 'vip' });
    $gallery->show_gallery;
    $gallery->view_image(2);
}

まとめ

今回は、Protection Proxy(アクセス制御プロキシ)を学びました。

  • 権限チェックを代理オブジェクトに分離
  • 元のクラスはシンプルなまま維持
  • 複数のProxyをチェーンして組み合わせ可能

次回は「何度も見るなら貯めたい」と題して、Caching Proxy(キャッシュプロキシ)を学びます。高解像度の再描画が遅い問題を、キャッシュで高速化します。

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

参考リンク

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