Featured image of post 第7回-Perl例外処理とNull Object - エラーを優雅に扱う

第7回-Perl例外処理とNull Object - エラーを優雅に扱う

PerlのTry::Tinyによる例外処理とNull Objectパターンを組み合わせ、全API失敗時でもシステムを停止させない堅牢なエラーハンドリングを実装します。

前回の振り返り

前回は、キャッシュ機能を追加してAPIへのリクエスト回数を削減しました。パフォーマンスは大幅に向上しましたが、まだ考慮すべきことがあります。

今回は、APIが失敗した場合のエラーハンドリングについて学びます。

今回の目標

第7回となる今回は、すべてのAPIが失敗した場合の対処法を実装します。Try::Tinyで例外をキャッチし、Null Object的なデフォルト値を返す設計を学びます。

障害は必ず発生する

外部APIを使うシステムでは、障害は「起こるかもしれないもの」ではなく「必ず起こるもの」です。

想定される障害

障害原因
ネットワークエラーインターネット接続の問題
タイムアウトAPIサーバーの応答遅延
APIサーバーダウンメンテナンス、障害
レート制限API呼び出し回数の上限超過
認証エラーAPIキーの期限切れ、無効化

現状のコードの問題

現在のFacadeは、すべてのAPIが失敗するとundefを返します。

1
2
3
4
5
6
7
sub get_weather ($self, $city) {
    for my $adapter ($self->adapters->@*) {
        my $weather = $adapter->get_weather($city);
        return $weather if $weather;
    }
    return;  # すべて失敗 → undef
}

呼び出し側は毎回undefチェックが必要です。

1
2
3
4
5
6
7
my $weather = $facade->get_weather('Tokyo');
if ($weather) {
    say "気温: $weather->{temperature}℃";
}
else {
    say "天気情報を取得できませんでした";
}

これでは、呼び出し側にエラーハンドリングの責任が分散してしまいます。

Try::Tinyによる例外処理

Perlで例外処理を行う標準的な方法はTry::Tinyモジュールです。

基本的な使い方

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
use Try::Tiny;

try {
    # 例外が発生する可能性のあるコード
    my $result = risky_operation();
}
catch {
    # 例外をキャッチ
    warn "エラーが発生しました: $_";
}
finally {
    # 成功・失敗に関わらず実行
    cleanup();
};

Adapterでの例外処理

実際のAPI呼び出しでは、HTTP::Tinyが例外を投げる可能性があります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
package WeatherAdapter::OpenWeatherMap {
    use v5.36;
    use Moo;
    use Try::Tiny;

    has name => (is => 'ro', default => 'OpenWeatherMap');

    with 'WeatherAdapter::Role';

    sub get_weather ($self, $city) {
        try {
            my $data = $self->_fetch_from_api($city);
            return $self->_parse_response($data);
        }
        catch {
            warn "[$self->{name}] エラー: $_";
            return;  # 失敗時はundef
        };
    }
}

フォールバック機能と組み合わせることで、1つのAPIが失敗しても次のAPIを試行できます。

Null Objectパターン

すべてのAPIが失敗した場合、undefの代わりに「デフォルト値」を返すアプローチがあります。これはNull Objectパターンと呼ばれます。

Null Objectとは

Null Objectは、「何もしない」または「デフォルト動作をする」オブジェクトです。

  • 通常のオブジェクトと同じインターフェースを持つ
  • undefチェックが不要になる
  • 呼び出し側のコードがシンプルになる

デフォルト天気データ

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
sub default_weather ($city) {
    return {
        city        => $city,
        temperature => undef,
        humidity    => undef,
        condition   => '取得できませんでした',
        source      => 'default',
        is_default  => 1,
    };
}

このデフォルトデータを返すことで、呼び出し側は常に有効なデータ構造を受け取れます。

実装: エラーハンドリング付きFacade

すべてを統合した実装を見てみましょう。

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

# 天気サービスの共通インターフェース(Role)
package WeatherAdapter::Role {
    use v5.36;
    use Moo::Role;

    requires 'get_weather';
    requires 'name';
}

# OpenWeatherMap用Adapter(エラーをシミュレート)
package WeatherAdapter::OpenWeatherMap {
    use v5.36;
    use Moo;
    use Try::Tiny;

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

    with 'WeatherAdapter::Role';

    sub _get_raw_data ($self, $city) {
        # 特定の都市でエラーをシミュレート
        if (grep { $_ eq $city } $self->fail_cities->@*) {
            die "API connection timeout for $city";
        }

        my %mock_data = (
            'Tokyo'   => { name => 'Tokyo',   main => { temp => 25.5, humidity => 60 }, weather => [{ description => '晴れ' }] },
            'Osaka'   => { name => 'Osaka',   main => { temp => 27.2, humidity => 65 }, weather => [{ description => '曇り' }] },
            'Nagoya'  => { name => 'Nagoya',  main => { temp => 26.8, humidity => 62 }, weather => [{ description => '晴れ' }] },
        );
        return $mock_data{$city};
    }

    sub get_weather ($self, $city) {
        try {
            my $data = $self->_get_raw_data($city);
            return unless $data;
            return {
                city        => $data->{name},
                temperature => $data->{main}{temp},
                humidity    => $data->{main}{humidity},
                condition   => $data->{weather}[0]{description},
                source      => $self->name,
            };
        }
        catch {
            warn "[$self->{name}] $city: $_";
            return;
        };
    }
}

# WeatherStack用Adapter(エラーをシミュレート)
package WeatherAdapter::WeatherStack {
    use v5.36;
    use Moo;
    use Try::Tiny;

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

    with 'WeatherAdapter::Role';

    sub _get_raw_data ($self, $city) {
        # 特定の都市でエラーをシミュレート
        if (grep { $_ eq $city } $self->fail_cities->@*) {
            die "API rate limit exceeded for $city";
        }

        my %mock_data = (
            'Tokyo'   => { location => { name => 'Tokyo' },   current => { temperature => 26, humidity => 58, weather_descriptions => ['Sunny'] } },
            'Sapporo' => { location => { name => 'Sapporo' }, current => { temperature => 19, humidity => 68, weather_descriptions => ['Rain'] } },
            'Sendai'  => { location => { name => 'Sendai' },  current => { temperature => 22, humidity => 65, weather_descriptions => ['Cloudy'] } },
        );
        return $mock_data{$city};
    }

    sub get_weather ($self, $city) {
        try {
            my $data = $self->_get_raw_data($city);
            return unless $data;
            return {
                city        => $data->{location}{name},
                temperature => $data->{current}{temperature},
                humidity    => $data->{current}{humidity},
                condition   => $data->{current}{weather_descriptions}[0],
                source      => $self->name,
            };
        }
        catch {
            warn "[$self->{name}] $city: $_";
            return;
        };
    }
}

# エラーハンドリング付きFacade
package WeatherFacade {
    use v5.36;
    use Moo;

    has adapters        => (is => 'ro', required => 1);
    has use_default     => (is => 'ro', default => 1);   # デフォルト値を使うか
    has default_message => (is => 'ro', default => '情報を取得できませんでした');

    # デフォルト天気データを生成
    sub _default_weather ($self, $city) {
        return {
            city        => $city,
            temperature => undef,
            humidity    => undef,
            condition   => $self->default_message,
            source      => 'default',
            is_default  => 1,
        };
    }

    # 天気情報を取得
    sub get_weather ($self, $city) {
        for my $adapter ($self->adapters->@*) {
            my $weather = $adapter->get_weather($city);
            if ($weather) {
                return { $weather->%*, is_default => 0 };
            }
        }

        # すべて失敗した場合
        if ($self->use_default) {
            return $self->_default_weather($city);
        }
        return;
    }

    # 複数都市の天気を一括取得
    sub get_weather_bulk ($self, @cities) {
        my @results;
        for my $city (@cities) {
            push @results, $self->get_weather($city);
        }
        return @results;
    }
}

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

    say "=== 天気予報アグリゲーター(エラーハンドリング版) ===";
    say "";

    # 両方のAPIで「Fukuoka」がエラーになるように設定
    my $facade = WeatherFacade->new(
        adapters => [
            WeatherAdapter::OpenWeatherMap->new(fail_cities => ['Fukuoka']),
            WeatherAdapter::WeatherStack->new(fail_cities => ['Fukuoka']),
        ],
        use_default => 1,
    );

    say "--- 天気情報取得 ---";
    say "";

    my @cities = qw(Tokyo Osaka Sapporo Fukuoka);

    for my $city (@cities) {
        my $weather = $facade->get_weather($city);

        # 常に有効なデータ構造が返る
        my $temp = $weather->{temperature} // '---';
        my $status = $weather->{is_default} ? '[デフォルト]' : "[via $weather->{source}]";

        say "$weather->{city}: $weather->{condition}(気温: $temp℃)$status";
    }

    say "";
    say "--- デフォルト値を使わない場合 ---";

    my $facade_strict = WeatherFacade->new(
        adapters => [
            WeatherAdapter::OpenWeatherMap->new(fail_cities => ['Fukuoka']),
            WeatherAdapter::WeatherStack->new(fail_cities => ['Fukuoka']),
        ],
        use_default => 0,  # デフォルト値を使わない
    );

    my $result = $facade_strict->get_weather('Fukuoka');
    if ($result) {
        say "Fukuoka: $result->{condition}";
    }
    else {
        say "Fukuoka: データ取得に失敗しました(undefが返されました)";
    }
}

実行結果:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
=== 天気予報アグリゲーター(エラーハンドリング版) ===

--- 天気情報取得 ---

[OpenWeatherMap] Fukuoka: API connection timeout for Fukuoka at ...
[WeatherStack] Fukuoka: API rate limit exceeded for Fukuoka at ...
Tokyo: 晴れ(気温: 25.5℃)[via OpenWeatherMap]
Osaka: 曇り(気温: 27.2℃)[via OpenWeatherMap]
Sapporo: Rain(気温: 19℃)[via WeatherStack]
Fukuoka: 情報を取得できませんでした(気温: ---℃)[デフォルト]

--- デフォルト値を使わない場合 ---

[OpenWeatherMap] Fukuoka: API connection timeout for Fukuoka at ...
[WeatherStack] Fukuoka: API rate limit exceeded for Fukuoka at ...
Fukuoka: データ取得に失敗しました(undefが返されました)

Null Objectのメリット

Before(undefを返す場合)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
my $weather = $facade->get_weather($city);
if ($weather) {
    say "$city: $weather->{condition}";
    if (defined $weather->{temperature}) {
        say "気温: $weather->{temperature}℃";
    }
}
else {
    say "$city: 取得できませんでした";
}

After(Null Objectを返す場合)

1
2
3
4
my $weather = $facade->get_weather($city);
# 常に有効なハッシュが返るので、そのまま使える
my $temp = $weather->{temperature} // '---';
say "$city: $weather->{condition}(気温: $temp℃)";
  • undefチェックが不要
  • コードがシンプルになる
  • 表示ロジックに集中できる

エラー情報の記録

プロダクション環境では、エラーをロギングして後で分析できるようにします。

 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 WeatherFacade {
    use v5.36;
    use Moo;

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

    sub get_weather ($self, $city) {
        my @errors;

        for my $adapter ($self->adapters->@*) {
            my $weather = $adapter->get_weather($city);
            if ($weather) {
                return { $weather->%*, is_default => 0 };
            }
            # エラーを記録(Adapterからエラー情報を取得する設計も可)
            push @errors, {
                adapter => $adapter->name,
                city    => $city,
                time    => time(),
            };
        }

        # エラーログに追加
        push $self->_errors->@*, @errors;

        return $self->_default_weather($city);
    }

    # エラーログを取得
    sub get_errors ($self) {
        return $self->_errors->@*;
    }
}

グレースフルデグラデーション

すべてのAPIが失敗しても、システム全体を停止させず、可能な限りサービスを継続する設計を「グレースフルデグラデーション(優雅な機能縮退)」と呼びます。

実装例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# キャッシュ + デフォルト値の組み合わせ
sub get_weather ($self, $city) {
    # 1. キャッシュを確認
    if (my $cached = $self->_get_from_cache($city)) {
        return $cached;
    }

    # 2. APIから取得を試行
    for my $adapter ($self->adapters->@*) {
        my $weather = $adapter->get_weather($city);
        if ($weather) {
            $self->_set_to_cache($city, $weather);
            return $weather;
        }
    }

    # 3. 期限切れキャッシュを使用(古いデータでも返す)
    if (my $stale = $self->_get_stale_cache($city)) {
        return { $stale->%*, is_stale => 1 };
    }

    # 4. 最終手段: デフォルト値
    return $self->_default_weather($city);
}

優先順位:

  1. 新鮮なキャッシュ
  2. 新規API取得
  3. 古いキャッシュ(stale)
  4. デフォルト値

まとめ

今回は、エラーハンドリングとデフォルト値の設計を学びました。

  • Try::Tinyで例外をキャッチし、フォールバックを継続
  • Null Objectパターンでデフォルト値を返す
  • 呼び出し側のコードがシンプルになる
  • グレースフルデグラデーションでサービス継続

障害に強いシステムになりました。

次回予告

次回は最終回です。これまで作成してきたすべての機能を統合し、完成版のコードを確認します。そして、このシリーズで使った2つのパターンの「正体」を明かします。お楽しみに!

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