信頼性パターンが語るコードの意図 - リトライとタイムアウトで安定性を設計する技術
現代のソフトウェアシステムは、ネットワーク越しに様々な外部サービスと連携することが不可欠となっています。データベース、外部API、メッセージキューなど、システムの構成要素は複雑化し、それらとの連携部分では一時的な障害や遅延が発生する可能性があります。このような状況下で、アプリケーションの安定性を維持するためには、リトライやタイムアウトといった「信頼性パターン」を適切に実装することが重要です。
しかし、これらの信頼性パターンがコードに組み込まれる際、単に特定の値を設定するだけでは、その背後にある設計者の「意図」が失われがちです。なぜこの回数リトライするのか? なぜこのタイムアウト値なのか? どのような状況でリトライするべきで、どのような状況では諦めるべきなのか? これらの疑問がコードから読み取れない場合、コードの理解は難しくなり、意図しない挙動や潜在的な問題を招く原因となります。
本記事では、プログラマーがリトライやタイムアウトといった信頼性パターンを実装する際に、コードを通じてその設計意図を効果的に伝えるための技術と考察をご紹介します。コードの可読性を高め、レビューでの認識合わせを容易にし、システム障害発生時の迅速な対応を可能にするために、どのようにコードに「意味」を持たせるかを掘り下げていきます。
リトライ処理で意図を伝える
外部サービスへのリクエストが一時的なエラーで失敗した場合、時間を置いて再度同じリクエストを試みることは、システムの可用性を高める上で有効な手段です。これをリトライ(再試行)処理と呼びます。
リトライ処理を実装する上で重要な設定値には、リトライの最大回数、リトライ間の待機時間、そしてリトライ対象となるエラーの種類などがあります。これらの設定値がコード中にマジックナンバーとして埋め込まれていると、その意図を読み取ることは困難です。
Before: 意図が不明瞭なリトライ処理
以下は、リトライ処理が実装されているものの、その意図がコードから読み取りにくい例です。
public String callExternalService() {
int retryCount = 0;
while (retryCount < 3) { // なぜ3回?
try {
String result = makeRequest(); // 外部サービス呼び出し
return result;
} catch (ExternalServiceTemporaryException e) {
// 一時的なエラーの場合のみリトライ対象とする意図だが、エラーの種類がコメントでしか説明されていない
System.err.println("一時的なエラーが発生しました。リトライします: " + e.getMessage());
retryCount++;
if (retryCount < 3) {
try {
Thread.sleep(1000 * retryCount); // なぜ1秒ずつ増やす?
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw new RuntimeException("リトライ待機中に中断されました", ie);
}
}
} catch (Exception e) { // それ以外のエラーはリトライしない意図
throw new RuntimeException("外部サービス呼び出しで予期せぬエラーが発生しました", e);
}
}
throw new RuntimeException("外部サービス呼び出しが指定回数リトライしても成功しませんでした"); // 失敗した意図
}
private String makeRequest() throws ExternalServiceTemporaryException, Exception {
// 実際の外部サービス呼び出しロジック
// ...
return "Success";
}
このコード例では、リトライ回数を示す 3
や、待機時間の計算に使用される 1000
(ミリ秒)がマジックナンバーとして出現しています。また、リトライ対象とするエラーの種類もコメントで補足されていますが、コード構造自体からその意図が明確に伝わるわけではありません。なぜリトライ回数が3回なのか、なぜ待機時間が指数関数的に増加するのか(指数バックオフの意図)、これらの設計判断の背景にある意図がコードだけでは分かりにくい状態です。
After: 意図を明確にしたリトライ処理
リトライの意図をコードで明確に伝えるためには、定数や設定オブジェクトを活用し、意味のある命名を行うことが有効です。また、リトライポリシーを表現するクラスやメソッドに処理を切り出すことで、構造自体が意図を語るように改善できます。
// リトライに関する設定値を定義するクラス (または設定ファイルから読み込む)
public class RetryPolicy {
private static final int MAX_RETRIES = 3; // 一時的な障害からの回復を期待する最大リトライ回数
private static final long BASE_WAIT_TIME_MILLIS = 1000; // 指数バックオフの基本待機時間 (1秒)
private static final Class<? extends Exception>[] RETRYABLE_EXCEPTIONS =
new Class[]{ExternalServiceTemporaryException.class}; // リトライ対象とする一時的な例外クラス
public int getMaxRetries() { return MAX_RETRIES; }
public long getBaseWaitTimeMillis() { return BASE_WAIT_TIME_MILLIS; }
public boolean isRetryableException(Exception e) {
for (Class<? extends Exception> retryableException : RETRYABLE_EXCEPTIONS) {
if (retryableException.isInstance(e)) {
return true;
}
}
return false;
}
// リトライ間の待機時間を計算するメソッド (指数バックオフの意図を表現)
public long calculateWaitTimeMillis(int currentRetryCount) {
if (currentRetryCount <= 0) return 0;
// Math.pow(2, currentRetryCount - 1) * BASE_WAIT_TIME_MILLIS の意図: 1, 2, 4, ... 倍と待機時間を増やす
return (long) Math.pow(2, currentRetryCount - 1) * BASE_WAIT_TIME_MILLIS;
}
}
// 外部サービス呼び出しを行うサービスクラス
public class ExternalServiceClient {
private final RetryPolicy retryPolicy; // リトライポリシーを依存注入
public ExternalServiceClient(RetryPolicy retryPolicy) {
this.retryPolicy = retryPolicy;
}
public String callExternalService() {
for (int retryCount = 0; retryCount <= retryPolicy.getMaxRetries(); retryCount++) {
try {
return makeRequest(); // 外部サービス呼び出し
} catch (Exception e) {
if (retryPolicy.isRetryableException(e)) {
System.err.println("一時的なエラーが発生しました。リトライ #" + (retryCount + 1) + ": " + e.getMessage());
if (retryCount < retryPolicy.getMaxRetries()) {
long waitTime = retryPolicy.calculateWaitTimeMillis(retryCount + 1);
try {
Thread.sleep(waitTime); // calculateWaitTimeMillisの意図に従って待機
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw new RuntimeException("リトライ待機中に中断されました", ie);
}
} else {
// 最大リトライ回数を超えた場合の意図を明確にする例外
throw new MaxRetriesExceededException("外部サービス呼び出しが指定回数リトライしても成功しませんでした", e);
}
} else {
// リトライ対象外のエラーの場合の意図を明確にする
throw new PermanentExternalServiceException("外部サービス呼び出しで予期せぬエラーが発生しました", e);
}
}
}
// ここには到達しないはずだが、到達した場合のフォールバックやログなどの意図をここに記述
throw new RuntimeException("予期せぬリトライロジックの終了");
}
private String makeRequest() throws ExternalServiceTemporaryException, PermanentExternalServiceException {
// 実際の外部サービス呼び出しロジック
// 一時的なエラーの場合は ExternalServiceTemporaryException をスロー
// 永続的なエラーの場合は PermanentExternalServiceException をスロー
// ...
return "Success";
}
}
// リトライ対象とする一時的な例外クラス (独自のクラスで意図を明確にすることも有効)
class ExternalServiceTemporaryException extends Exception {
public ExternalServiceTemporaryException(String message, Throwable cause) { super(message, cause); }
}
class PermanentExternalServiceException extends RuntimeException {
public PermanentExternalServiceException(String message, Throwable cause) { super(message, cause); }
}
class MaxRetriesExceededException extends RuntimeException {
public MaxRetriesExceededException(String message, Throwable cause) { super(message, cause); }
}
改善後のコードでは、リトライ回数や基本待機時間などが RetryPolicy
クラス内の定数として定義され、それぞれの名前(MAX_RETRIES
, BASE_WAIT_TIME_MILLIS
)がその数値を設定した「意図」を伝えています。リトライ間の待機時間計算ロジックは calculateWaitTimeMillis
メソッドに切り出され、「指数バックオフ」という意図をメソッド名や内部ロジックで表現しています。また、リトライ対象とするエラーは isRetryableException
メソッドでチェックされ、リトライ可能な例外クラスをリストで持つことで、どのエラーに対して回復を試みるのかという意図が明確になっています。さらに、リトライ回数超過やリトライ対象外のエラーに対する処理も、専用の例外クラスなどを用いることで、それぞれの失敗が何を意味するのかという意図がより伝わりやすくなります。
タイムアウトで意図を伝える
外部サービスへのリクエストが応答を返さないまま長時間待機してしまうと、アプリケーションのスレッドやコネクションなどのリソースを枯渇させ、システム全体に影響を及ぼす可能性があります。これを防ぐために、一定時間応答がない場合にリクエストを中断させる処理をタイムアウトと呼びます。
タイムアウト設定値は、その処理が完了するまでに許容される最大時間を表します。この値も、コード中のマジックナンバーとして埋め込まれていると、その設定値の根拠や意図が不明確になります。
Before: 意図が不明瞭なタイムアウト設定
以下は、タイムアウト設定がコード中に直接記述され、意図が分かりにくい例です。
public String callExternalServiceWithTimeout() {
try {
// 外部サービス呼び出しライブラリのタイムアウト設定
// 設定値がマジックナンバー
ServiceClient client = new ServiceClient();
client.setConnectTimeout(5000); // なぜ5秒? 接続確立のタイムアウト?
client.setReadTimeout(15000); // なぜ15秒? データ読み込みのタイムアウト?
String result = client.requestData();
return result;
} catch (TimeoutException e) { // タイムアウトした場合の意図
throw new RuntimeException("外部サービスからの応答がタイムアウトしました", e);
} catch (Exception e) {
throw new RuntimeException("外部サービス呼び出しでエラーが発生しました", e);
}
}
class ServiceClient {
private int connectTimeout;
private int readTimeout;
public void setConnectTimeout(int connectTimeout) { this.connectTimeout = connectTimeout; }
public void setReadTimeout(int readTimeout) { this.readTimeout = readTimeout; }
public String requestData() throws TimeoutException, Exception {
// 実際の外部サービス呼び出しとタイムアウト制御ロジック
// ...
return "Data";
}
}
このコードでは、5000
や15000
といった数値が直接コードに書かれており、これらの値が設定された根拠(例: 外部サービスのSLA、一般的なネットワーク遅延、許容できるユーザー待機時間など)や、それぞれが接続タイムアウトと読み込みタイムアウトを意味しているという意図が、コメントや文脈から推測するしかありません。処理の種類によって適切なタイムアウト値は異なりますが、このコードだけではその違いを表現する余地もありません。
After: 意図を明確にしたタイムアウト設定
タイムアウトの意図を明確に伝えるためには、設定値を定数や設定ファイルから読み込むようにし、その設定名に意味を持たせることが重要です。また、処理の種類ごとに異なるタイムアウト設定があることを、構造として表現することも有効です。
// 処理の種類ごとに異なるタイムアウト設定を保持するクラス (または設定ファイルから読み込む)
public class ServiceTimeoutConfig {
private static final int DEFAULT_CONNECT_TIMEOUT_MILLIS = 5000; // デフォルトの接続タイムアウト (汎用的な設定)
private static final int GET_DATA_READ_TIMEOUT_MILLIS = 15000; // データ取得処理の読み込みタイムアウト (特定の処理に紐づく設定)
private static final int POST_DATA_READ_TIMEOUT_MILLIS = 30000; // データ登録処理の読み込みタイムアウト (処理時間の長い可能性を考慮)
public int getDefaultConnectTimeoutMillis() { return DEFAULT_CONNECT_TIMEOUT_MILLIS; }
public int getGetDataReadTimeoutMillis() { return GET_DATA_READ_TIMEOUT_MILLIS; }
public int getPostDataReadTimeoutMillis() { return POST_DATA_READ_TIMEOUT_MILLIS; }
// 処理名や種類に応じて適切なタイムアウト値を返すメソッド
public int getReadTimeoutMillis(String processName) {
switch (processName) {
case "getData": return GET_DATA_READ_TIMEOUT_MILLIS;
case "postData": return POST_DATA_READ_TIMEOUT_MILLIS;
default: return DEFAULT_CONNECT_TIMEOUT_MILLIS * 3; // 例: デフォルトの読み込みタイムアウト
}
}
}
// 外部サービス呼び出しを行うサービスクラス
public class ExternalServiceClient {
private final ServiceTimeoutConfig timeoutConfig; // タイムアウト設定を依存注入
public ExternalServiceClient(ServiceTimeoutConfig timeoutConfig) {
this.timeoutConfig = timeoutConfig;
}
public String callExternalServiceGetData() {
try {
ServiceClient client = new ServiceClient();
// 接続タイムアウトの意図を名称で伝える
client.setConnectTimeout(timeoutConfig.getDefaultConnectTimeoutMillis());
// データ取得処理の読み込みタイムアウトの意図を名称で伝える
client.setReadTimeout(timeoutConfig.getGetDataReadTimeoutMillis());
String result = client.requestData("getData"); // 処理名を渡すことで、設定の意図と紐づける
return result;
} catch (TimeoutException e) {
// タイムアウトした場合の意図を明確にする例外
throw new ExternalServiceTimeoutException("外部サービスからの応答がデータ取得処理でタイムアウトしました", e);
} catch (Exception e) {
throw new RuntimeException("外部サービス呼び出しでエラーが発生しました", e);
}
}
public String callExternalServicePostData() {
try {
ServiceClient client = new ServiceClient();
client.setConnectTimeout(timeoutConfig.getDefaultConnectTimeoutMillis());
// データ登録処理の読み込みタイムアウトの意図を名称で伝える
client.setReadTimeout(timeoutConfig.getPostDataReadTimeoutMillis());
String result = client.requestData("postData"); // 処理名を渡すことで、設定の意図と紐づける
return result;
} catch (TimeoutException e) {
// タイムアウトした場合の意図を明確にする例外
throw new ExternalServiceTimeoutException("外部サービスからの応答がデータ登録処理でタイムアウトしました", e);
} catch (Exception e) {
throw new RuntimeException("外部サービス呼び出しでエラーが発生しました", e);
}
}
}
class ServiceClient {
private int connectTimeout;
private int readTimeout;
public void setConnectTimeout(int connectTimeout) { this.connectTimeout = connectTimeout; }
public void setReadTimeout(int readTimeout) { this.readTimeout = readTimeout; }
public String requestData(String processName) throws TimeoutException, Exception {
// 実際の外部サービス呼び出しとタイムアウト制御ロジック
// connectTimeoutとreadTimeoutを使用
// ...
return "Data for " + processName;
}
}
// タイムアウトが発生した場合の例外クラス (独自のクラスで意図を明確にすることも有効)
class ExternalServiceTimeoutException extends RuntimeException {
public ExternalServiceTimeoutException(String message, Throwable cause) { super(message, cause); }
}
改善後のコードでは、ServiceTimeoutConfig
クラスに処理の種類(getData
, postData
など)に応じたタイムアウト値が定数として定義され、それぞれの定数名がその設定値の意図を伝えています。外部サービス呼び出しを行うメソッド(callExternalServiceGetData
, callExternalServicePostData
)は、それぞれの処理に必要なタイムアウト設定を ServiceTimeoutConfig
から取得して使用しています。これにより、コードの利用者はどの処理に対してどのタイムアウト設定が適用されているのか、その意図を容易に理解できます。また、タイムアウト発生時の例外処理も専用の例外クラスを用いることで、障害発生時に何が起きたのか(単なるエラーではなくタイムアウトであること)という意図が明確に伝わります。
設定値の外部化と意図伝達
リトライ回数やタイムアウト値といった信頼性パターンの設定は、環境や運用ポリシーによって変更される可能性があります。これらの設定値をコード中に直接記述するのではなく、設定ファイルや環境変数などから読み込むように外部化することは、システムの柔軟性を高めるだけでなく、意図伝達の観点からも重要です。
設定ファイル(例: application.yml
や application.properties
)に意味のある名前で設定値を記述することで、コードを読む開発者はそのコードが使用している設定値の「名前」を見れば、それが何のための設定値なのか、その意図を理解できます。
# application.yml の例
external-service:
retry:
max-attempts: 5 # 外部サービス呼び出しの最大リトライ試行回数
backoff-millis: 2000 # 指数バックオフの基本待機時間 (2秒)
# リトライ対象エラーリストなど、より詳細な設定も可能
timeout:
connect-millis: 3000 # 外部サービスへの接続タイムアウト (3秒)
read-millis: 10000 # 外部サービスからの応答読み込みタイムアウト (10秒)
# 特定の処理ごとのタイムアウト設定なども可能
# get-data-read-millis: 15000
このように設定値を外部化し、意味のある名前を付けることで、コード自体は設定値の取得方法に集中でき、具体的な数値の裏にある「この設定は何のためにあるのか」という意図は、設定ファイルを読むことで明確に伝わります。これは、運用担当者や他の開発者がシステムの挙動を理解し、必要に応じて設定を変更する際に非常に役立ちます。
まとめ
リトライやタイムアウトといった信頼性パターンは、現代の分散システムにおいてアプリケーションの安定性を確保するための重要な技術です。これらのパターンを実装する際には、単に機能を満たすだけでなく、その背後にある設計者の「意図」をコードで明確に伝える努力が不可欠です。
本記事でご紹介したように、マジックナンバーを排除して定数や設定オブジェクトに置き換える、意味のある命名規則を用いる、リトライポリシーやタイムアウト設定を構造として表現する、そして設定値を外部化するといったアプローチは、コードの可読性を飛躍的に向上させます。これにより、他の開発者はコードを容易に理解できるようになり、コードレビューでの認識のずれを減らし、システムに問題が発生した際には迅速に原因を特定し、適切な対応をとることが可能になります。
信頼性パターンは、システムがどのように失敗し、どのように回復を試みるのかという、システムの非常に重要な側面をコードで表現するものです。この「失敗と回復の意図」を明確に伝えることは、チーム全体の開発効率とシステムの運用安定性に大きく貢献するでしょう。今後、信頼性パターンを実装する際には、ぜひ「このコードはどのような意図でこのように設定されているのか?」という観点を意識してみてください。