Featured image of post 第3回-審査ロジックをチェッカーに分離する

第3回-審査ロジックをチェッカーに分離する

各審査ロジックを独立したMooクラスに分割し、Chain of Responsibilityパターンでチェーン構築。拡張性の高い決済審査システムを完成させます。

@nqounetです。

「架空ECサイトで学ぶ決済審査システム」シリーズの第3回(最終回)です。

シリーズ全体の目次はこちらをご覧ください。

前回は、審査条件が増えてコードが複雑化する問題を体験しました。

今回は、Mooを使って各審査ロジックを独立した「チェッカー」クラスに分割し、拡張性の高い決済審査システムを完成させます。

注意: このシリーズで扱う決済システムは学習用の架空システムです。実際の決済システムを開発する際は、PCI DSS などのセキュリティ基準を遵守してください。

発想の転換

前回のコードでは、1つの関数の中に全ての審査ロジックが詰め込まれていました。

1
2
3
4
5
6
7
8
sub check_payment ($request) {
    # 金額チェック
    # 有効期限チェック
    # ブラックリストチェック
    # 残高チェック
    # 不正利用検知
    # ... 全部ここに書く
}

これを、こう考え直してみましょう。

「審査条件ごとに独立したチェッカーを作り、それらを数珠つなぎにして、リクエストを順番に通していく」

基底クラスを作る

まず、全てのチェッカーの親となる基底クラスを作ります。

 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
# PaymentChecker.pm
# Perl v5.36+, Moo

package PaymentChecker;
use v5.36;
use Moo;

# 次のチェッカーへの参照
has 'next_handler' => (
    is        => 'rw',
    predicate => 'has_next_handler',
);

# 次のチェッカーを設定して、チェーンを構築
sub set_next ($self, $handler) {
    $self->next_handler($handler);
    return $handler;  # チェーン構築を続けられるように
}

# 審査を実行(サブクラスでオーバーライド)
sub check ($self, $request) {
    # デフォルトは次のハンドラに委譲
    return $self->pass_to_next($request);
}

# 次のハンドラに処理を委譲
sub pass_to_next ($self, $request) {
    if ($self->has_next_handler) {
        return $self->next_handler->check($request);
    }
    # チェーンの最後に到達 = 全チェック通過
    return { ok => 1 };
}

1;

コードのポイント

next_handler 属性

次のチェッカーへの参照を保持します。predicate => 'has_next_handler' を指定することで、has_next_handler メソッドが自動生成されます。

set_next メソッド

チェーンを構築するためのメソッドです。戻り値として引数のハンドラを返すことで、メソッドチェーンが可能になります。

pass_to_next メソッド

現在のチェッカーが「OK」と判断した場合、次のチェッカーに処理を委譲します。チェーンの最後に到達したら、全チェック通過として { ok => 1 } を返します。

具象チェッカーを作る

基底クラスを継承して、各審査条件に対応するチェッカーを作ります。

金額上限チェッカー

 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
# LimitChecker.pm
# Perl v5.36+, Moo

package LimitChecker;
use v5.36;
use Moo;
extends 'PaymentChecker';

has 'limit' => (
    is      => 'ro',
    default => 100_000,  # デフォルト10万円
);

sub check ($self, $request) {
    my $amount = $request->{amount} // 0;

    if ($amount >= $self->limit) {
        return {
            ok     => 0,
            reason => sprintf('金額が上限(%d円)を超えています', $self->limit),
        };
    }

    # OKなら次のチェッカーへ
    return $self->pass_to_next($request);
}

1;

有効期限チェッカー

 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
# ExpiryChecker.pm
# Perl v5.36+, Moo

package ExpiryChecker;
use v5.36;
use Moo;
extends 'PaymentChecker';

sub check ($self, $request) {
    my $expiry_year  = $request->{expiry_year}  // 0;
    my $expiry_month = $request->{expiry_month} // 0;

    my ($current_year, $current_month) = (localtime)[5,4];
    $current_year  += 1900;
    $current_month += 1;

    my $is_expired = $expiry_year < $current_year ||
        ($expiry_year == $current_year && $expiry_month < $current_month);

    if ($is_expired) {
        return {
            ok     => 0,
            reason => 'カードの有効期限が切れています',
        };
    }

    return $self->pass_to_next($request);
}

1;

ブラックリストチェッカー

 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
# BlacklistChecker.pm
# Perl v5.36+, Moo

package BlacklistChecker;
use v5.36;
use Moo;
extends 'PaymentChecker';

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

sub check ($self, $request) {
    my $card_number = $request->{card_number} // '';

    for my $blacklisted (@{ $self->blacklist }) {
        if ($card_number eq $blacklisted) {
            return {
                ok     => 0,
                reason => 'このカードは使用できません',
            };
        }
    }

    return $self->pass_to_next($request);
}

1;

チェーンを組み立てる

これらのチェッカーを数珠つなぎにして、決済審査を行います。

 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
# payment_chain.pl
# Perl v5.36+, Moo

use v5.36;
use utf8;
use warnings;
binmode STDOUT, ':utf8';

# 上記のクラスを use する
# (実際は lib ディレクトリに配置して use lib 'lib' とする)

# チェッカーを作成
my $limit_checker = LimitChecker->new(limit => 100_000);
my $expiry_checker = ExpiryChecker->new;
my $blacklist_checker = BlacklistChecker->new(
    blacklist => ['4111111111111111', '5500000000000004'],
);

# チェーンを構築
$limit_checker
    ->set_next($expiry_checker)
    ->set_next($blacklist_checker);

# 審査開始点
my $first_checker = $limit_checker;

# 決済審査を実行
my $result = $first_checker->check({
    amount       => 50_000,
    expiry_year  => 2028,
    expiry_month => 12,
    card_number  => '4242424242424242',
});

if ($result->{ok}) {
    say "承認: 決済処理に進みます";
}
else {
    say "拒否: $result->{reason}";
}

何が良くなったか

1. 各チェッカーが独立している

金額チェックは LimitChecker、有効期限チェックは ExpiryChecker というように、責任が明確に分離されています。

2. 単体テストが書きやすい

1
2
3
4
# LimitChecker だけをテスト
my $checker = LimitChecker->new(limit => 100_000);
my $result = $checker->check({ amount => 50_000 });
# 次のチェッカーがないので、これだけで金額チェックの結果がわかる

3. 新しいチェッカーを追加しやすい

残高チェッカーを追加したくなったら、新しいクラスを作るだけです。既存のコードは変更不要です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# BalanceChecker.pm
package BalanceChecker;
use v5.36;
use Moo;
extends 'PaymentChecker';

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

sub check ($self, $request) {
    my $card_number = $request->{card_number} // '';
    my $amount      = $request->{amount}      // 0;
    my $balance     = $self->balance_db->{$card_number} // 0;

    if ($balance < $amount) {
        return { ok => 0, reason => '利用可能枠が不足しています' };
    }
    return $self->pass_to_next($request);
}
1;

4. 審査順序を変更しやすい

チェーンの構築順序を変えるだけです。

1
2
3
4
5
6
# ブラックリストを最初にチェックしたい場合
$blacklist_checker
    ->set_next($limit_checker)
    ->set_next($expiry_checker);

my $first_checker = $blacklist_checker;  # 開始点を変更

このパターンの名前

実は、このパターンには名前があります。

Chain of Responsibility(責任の連鎖)パターン

GoF(Gang of Four)のデザインパターンの1つで、複数のオブジェクトを鎖のようにつなげ、リクエストを順番に処理していくパターンです。

  • 各ハンドラは自分が処理できるかを判断する
  • 処理できない(または次も見てほしい)場合は、次のハンドラに委譲する
  • どのハンドラが処理するかは、実行時に決まる

フォーム検証、ログ処理、認証・認可など、多くの場面で活用されています。

シリーズを振り返って

このシリーズでは、架空ECサイト「ペルマート」の決済審査システムを題材に、以下のことを学びました。

第1回: シンプルなif文で審査ロジックを実装

  • 早期リターンでコードを読みやすく
  • 決済リクエストはハッシュリファレンスで表現

第2回: 審査条件が増えてコードが複雑化

  • if文のネストが深くなる問題
  • 1つの関数に全ロジックが集中する問題
  • テストが書きにくい問題

第3回: Chain of Responsibilityパターンで解決

  • 各審査ロジックを独立したクラスに分割
  • チェーン構造で連結
  • 拡張性と保守性が向上

問題を体験してから解決策を学ぶというアプローチで、デザインパターンの必要性を実感できたのではないでしょうか。

関連シリーズ

同じChain of Responsibilityパターンを別のドメインで学べるシリーズもあります。

完成コード

このシリーズの完成コードを1つのスクリプトにまとめました。

  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
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
#!/usr/bin/env perl
# payment-check-03.pl
# ペルマート決済審査(Chain of Responsibility版)
# Perl v5.36+, Moo

use v5.36;
use utf8;
use warnings;
use Moo;
binmode STDOUT, ':utf8';

# ===========================================
# 基底クラス: PaymentChecker
# ===========================================
package PaymentChecker {
    use Moo;

    has 'next_handler' => (
        is        => 'rw',
        predicate => 'has_next_handler',
    );

    sub set_next ($self, $handler) {
        $self->next_handler($handler);
        return $handler;
    }

    sub check ($self, $request) {
        return $self->pass_to_next($request);
    }

    sub pass_to_next ($self, $request) {
        if ($self->has_next_handler) {
            return $self->next_handler->check($request);
        }
        return { ok => 1 };
    }
}

# ===========================================
# 金額上限チェッカー
# ===========================================
package LimitChecker {
    use Moo;
    extends 'PaymentChecker';

    has 'limit' => (
        is      => 'ro',
        default => 100_000,
    );

    sub check ($self, $request) {
        my $amount = $request->{amount} // 0;

        if ($amount >= $self->limit) {
            return {
                ok     => 0,
                reason => sprintf('金額が上限(%d円)を超えています', $self->limit),
            };
        }

        return $self->pass_to_next($request);
    }
}

# ===========================================
# 有効期限チェッカー
# ===========================================
package ExpiryChecker {
    use Moo;
    extends 'PaymentChecker';

    sub check ($self, $request) {
        my $expiry_year  = $request->{expiry_year}  // 0;
        my $expiry_month = $request->{expiry_month} // 0;

        my ($current_year, $current_month) = (localtime)[5,4];
        $current_year  += 1900;
        $current_month += 1;

        my $is_expired = $expiry_year < $current_year ||
            ($expiry_year == $current_year && $expiry_month < $current_month);

        if ($is_expired) {
            return {
                ok     => 0,
                reason => 'カードの有効期限が切れています',
            };
        }

        return $self->pass_to_next($request);
    }
}

# ===========================================
# ブラックリストチェッカー
# ===========================================
package BlacklistChecker {
    use Moo;
    extends 'PaymentChecker';

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

    sub check ($self, $request) {
        my $card_number = $request->{card_number} // '';

        for my $blacklisted (@{ $self->blacklist }) {
            if ($card_number eq $blacklisted) {
                return {
                    ok     => 0,
                    reason => 'このカードは使用できません',
                };
            }
        }

        return $self->pass_to_next($request);
    }
}

# ===========================================
# 残高チェッカー
# ===========================================
package BalanceChecker {
    use Moo;
    extends 'PaymentChecker';

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

    sub check ($self, $request) {
        my $card_number = $request->{card_number} // '';
        my $amount      = $request->{amount}      // 0;
        my $balance     = $self->balance_db->{$card_number} // 0;

        if ($balance < $amount) {
            return {
                ok     => 0,
                reason => '利用可能枠が不足しています',
            };
        }

        return $self->pass_to_next($request);
    }
}

# ===========================================
# 不正利用検知チェッカー
# ===========================================
package FraudChecker {
    use Moo;
    extends 'PaymentChecker';

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

    has 'threshold' => (
        is      => 'ro',
        default => 3,
    );

    sub check ($self, $request) {
        my $card_number  = $request->{card_number} // '';
        my $recent_count = $self->transaction_log->{$card_number} // 0;

        if ($recent_count >= $self->threshold) {
            return {
                ok     => 0,
                reason => '短時間での連続決済を検知しました',
            };
        }

        return $self->pass_to_next($request);
    }
}

# ===========================================
# メイン処理
# ===========================================
package main;

# ダミーデータ(学習用)
my %BALANCE_DB = (
    '4242424242424242' => 100_000,
    '5105105105105100' => 500_000,
);

my %TRANSACTION_LOG = (
    '4111111111111111' => 5,  # 不正利用の疑い
);

# チェッカーを作成
my $limit_checker = LimitChecker->new(limit => 100_000);
my $expiry_checker = ExpiryChecker->new;
my $blacklist_checker = BlacklistChecker->new(
    blacklist => ['4111111111111111', '5500000000000004'],
);
my $balance_checker = BalanceChecker->new(
    balance_db => \%BALANCE_DB,
);
my $fraud_checker = FraudChecker->new(
    transaction_log => \%TRANSACTION_LOG,
    threshold       => 3,
);

# チェーンを構築
$limit_checker
    ->set_next($expiry_checker)
    ->set_next($blacklist_checker)
    ->set_next($balance_checker)
    ->set_next($fraud_checker);

# 審査開始点
my $first_checker = $limit_checker;

# === 実行例 ===
my @test_cases = (
    {
        name         => '正常な決済',
        amount       => 50_000,
        expiry_year  => 2028,
        expiry_month => 12,
        card_number  => '5105105105105100',
    },
    {
        name         => '金額オーバー',
        amount       => 200_000,
        expiry_year  => 2028,
        expiry_month => 12,
        card_number  => '5105105105105100',
    },
    {
        name         => '期限切れ',
        amount       => 50_000,
        expiry_year  => 2025,
        expiry_month => 6,
        card_number  => '5105105105105100',
    },
    {
        name         => 'ブラックリスト',
        amount       => 50_000,
        expiry_year  => 2028,
        expiry_month => 12,
        card_number  => '4111111111111111',
    },
    {
        name         => '残高不足',
        amount       => 80_000,
        expiry_year  => 2028,
        expiry_month => 12,
        card_number  => '4242424242424242',  # 10万円の枠に対して8万円
    },
);

for my $test (@test_cases) {
    say "=== $test->{name} ===";
    my $result = $first_checker->check($test);

    if ($result->{ok}) {
        say "承認: 決済処理に進みます";
    }
    else {
        say "拒否: $result->{reason}";
    }
    say "";
}
comments powered by Disqus
Hugo で構築されています。
テーマ StackJimmy によって設計されています。