これはゆめみ Advent Calendar 2019 3日目の記事です。
リトライ実装編
リクエストの再送信、つまりリトライはよくある用件ですが、通信のリトライの一番面倒なところは通信は基本非同期で動くので、初心者にはかなり厄介な部分になるかと思います。しかし実はこの非同期のリトライ要件は、再帰呼び出しとクロージャの組み合わせを使えばそれほど難しい要件ではありません。
今リファクタリングにあたってるとあるプロジェクトでは通信部分を APIKit で実装しているので、今日はこの APIKit を利用した場合のリトライ実装を解説します。
まず、リトライの実現するための再帰呼び出しですが、私の経験則では 外に使ってもらうための本呼び出し
と、本呼び出しないで実際使っている再帰用呼び出し
を分けて作った方が仕様変更に柔軟なのと、ソースコードが読みやすいです。つまり:
// 再帰呼び出し用のメソッド
private func recursiveDoSomething() {
recursiveDoSomething()
}
// 外側に使ってもらうためのメソッド
func doSomething() {
recurciveDoSomething()
}
doSomething()
という感じです。確かに今のこの実装では doSomething
も recursiveDoSomething
も中身は一緒ですが、しかし実際この二つのメソッドは二つの異なる要件を満たすためにあります。前者は単純に「リクエストを投げる」ためにあるのですが、後者は「万が一通信が失敗した時に、もう一回同じリクエストを投げる」ためにあるのです。用件が違うので、当然それぞれ細かい動作が微妙に違うことも予想できます。DRY 原則でも書いてありますように、重複してはいけないのはソースコードではなく知識=コンテキストです。むしろコンテキストが違えば、たまたま同じコードになってるとしてもそれは分けるべきです。
ちょっと脱線しすぎちゃいましたので話戻します。そのリトライをする際に当然ながら回数の制限をしなくてはいけません、じゃないと通信不良が続くといつまで経っても処理が終わらないので無限ループになります。というわけでここでは一旦デフォルト最大 5 回までリトライする、という要件を設定しておきます。
さて仕様を整理します:
- 特定なリクエストを投げて、レスポンスもらったら終了ハンドラーを実行します
- 通信不良によるタイムアウト等のレスポンスをもらった場合、もう一回1.を繰り返します
- 最大5回まで2.を実行しますが、それでもダメなら終了ハンドラーにエラーレスポンスを入れて実行します。
ここまでわかったらコード化するのは簡単ですね:
// 再帰呼び出し用のメソッド
// `Request` 及び `Request.Response` は APIKit によって定義されたリクエストとレスポンスのprotocol
private func recursiveSend(_ request: Request, availableRetryTimes: Int, completionHandler: @escaping (Result<Request.Response, Error>) -> Void) {
// `session` は APIKit によって定義されたリクエストを投げるためのセッション
session.send(request) { result in
switch result {
case .success(let response):
completion(.success(response)
case .failure(let error)
// 今回はサンプルコードの単純化のために全てのエラーレスポンスを再帰呼び出ししていますが、実際はエラーレスポンスの種類によって切り替えています;
// 例えばそもそもリクエスト自体に問題があった場合は何度リトライしても同じ結果なのでリトライしない
if availableRetryTimes > 0 {
recursiveSend(request, availableRetryTimes: availableRetryTimes - 1, completionHandler: completionHandler)
} else {
completion(.failure(error))
}
}
}
}
func send(_ request: Request, maxRetryTimes: Int = 5, completion: @escaping (Result<Request.Response, Error>) -> Void) {
recursiveSend(request, availableRetryTimes: maxRetryTimes, completionHandler: completion)
}
// 下記の `request` は実際の API にアクセスするための APIKit のリクエストインスタンス
send(request) { result in
// doSomething(with: result)
}
お気づきでしょうか、リトライ回数の制御に、私は敢えてそれぞれ違う引数名にしています。外側の呼び出し用のものは「最大リトライ回数」の名前にしていますが、再帰メソッドでは「リトライ可能回数」にしているのです。それぞれ違うコンテキストになりますので。そしてこの二つのメソッドを分けることによって、最大リトライ回数の引数のデフォルト値を設定しながら、再帰呼び出しの関数には該当引数を必ず入れなくてはならない安全性も担保できています。
サンプル記事としての簡潔のために、エラー処理を大幅に簡略化していますが、実際はコメントに書いてあるとおりエラーによって切り分けが必要です。この記事ではあくまでリトライの実現に関する記事なのでとりあえず全てのエラーをリトライさせています。そしてリトライ処理の肝がこれだけです:
if availableRetryTimes > 0 {
recursiveSend(request, availableRetryTimes: availableRetryTimes - 1, completionHandler: completionHandler)
} else {
completion(.failure(error))
}
ご覧の通り、もしエラーがもらったら、まずリトライ可能の回数を確認します。もしもうリトライ可能回数がまだある(0 以上)なら、リトライ可能回数を 1 つ減らして、それ以外の引数は全てそのまま使ってこの再帰呼び出しメソッドを呼び出します;もしもうリトライ可能回数がもうない(0 かそれ以下)になったら、終了ハンドラーに今のエラーを渡して実行させます。再帰呼び出されるときは必ずリトライ可能回数が減るので、度重なる先呼び出しでいつかは 0 になって終了ハンドラーが呼び出されます。
ね、簡単でしょ?
テスト実装編
さて、リトライの実装ができたら、今度はこのリトライのロジックをテストしなくてはならないですね。リトライのテストですから、当然テスト対象として:
- 1 回目で成功レスポンスもらったらそのまま終了ハンドラーを回す
- 1 回目で成功レスポンスもらえず、ただしリトライ可能な失敗レスポンスもらったら所定の回数まで回して、途中で成功レスポンスもらえたらそのまま終了ハンドラーを回す
- 所定の回数まで回して全部失敗レスポンスなら終了して終了ハンドラーに失敗レスポンスを入れて回す
- 1回目で成功レスポンスでもリトライ可能な失敗レスポンスでもなく、リトライ不可な失敗レスポンスもらったらそのまま終了ハンドラーに失敗レスポンス入れて回す
といった感じですかね。
しかしテストするときに必ずしもネットワークが悪いとは限らないし、ましては CI 環境ならなおさらのことです。ここで役に立つのは特定な通信に独自のレスポンスを返させるスタブライブラリーです。この記事では弊社でもよく利用している OHHTTPStubs を使って解説します。
OHHTTPStubs を使ってスタブを作るのはとても簡単です。通信が開始する前に、このように設定してあげればいいです:
stub(condition: pathEndsWith("/path/of/api")) { _ in
let responseObject: [String: String] = ["key": "value"] // 成功レスポンス
return OHHTTPStubsResponse(jsonObject: responseObject, statusCode: 200, headers: nil)
}
上記のように設定すれば、/path/of/api
で終わるパスにアクセスするときに、自分で設定した responseObject
が返されます。
しかし、これでは何回 /path/of/api
アクセスしても同じ成功レスポンスが返されますが、我々が欲しいのは特定の回数にアクセスした時だけ成功し、それ以外では失敗するレスポンスが欲しいです。どうしましょう?
この問題は実は簡単です。OHHTTPStubs の具体的になんのレスポンスを返すかの処理は、静的なレスポンスオブジェクトではなく、動的にレスポンスオブジェクトを生成するクロージャなので、アクセスする回数のカウンターをつけてあげればいいです。そのカウンター変数は XCTestCase
クラスにプロパティーとしてつけてあげるのもいいですが、ここで紹介したいのはプロパティーを作らずに通常の変数としてキャプチャーさせる方法です。この方法は本呼び出しの stub(condition:response)
メソッドをラップしたメソッドを作り、このラップメソッドの中に変数を組み込んで response
クロージャにキャプチャーさせればいいです。つまりコードにするとこんな感じです:
private func setStub(condition: @escaping OHHTTPStubsTestBlock, successOnTimeOfRequestReceived: Int, response: @escaping OHHTTPStubsResponseBlock) {
var timesOfRequestReceived = 0
stub(condition: condition) { (request) -> OHHTTPStubsResponse in
timesOfRequestReceived += 1
if timesOfRequestReceived == successOnTimeOfRequestReceived {
return response(request)
} else {
return OHHTTPStubsResponse(error: NSError(domain: NSURLErrorDomain, code: URLError.notConnectedToInternet.rawValue))
}
}
}
func useStub(successOnTimeOfRequestReceived: Int) {
setStub(condition: pathEndsWith("/path/of/api"), successOnTimeOfRequestReceived: successOnTimeOfRequestReceived) { _ in
let responseObject: [String: String] = ["key": "value"] // 成功レスポンス
return OHHTTPStubsResponse(jsonObject: responseObject, statusCode: 200, headers: nil)
}
}
このように setStub
を呼び出せば、中の処理として、(request) -> OHHTTPStubsResponse
クロージャは該当パスにアクセスされるたびに実行されるので、実行したらラッパーメソッドの timesOfRequestReceived
を取得し +=1
して現在のアクセス回数を割り出します。初期値が 0
で必ず値をプラス 1 してから評価するので、1 回目のアクセスは timesOfRequestReceived
を評価するときの値が 1
になります。そしてこの timesOfRequestReceived
が successOnTimeOfRequestReceived
と同じ値になる時のみ、成功レスポンスを返し、それ以外の時は URLError.notConnectedToInternet
のエラーが返されるようにスタブが設定されます。
ちなみにこのようにラッパーメソッド内にクロージャで使う変数を作るメリットは、このラッパーメソッドにとってスレッドセーフになるのと、大元のオブジェクトに余計な状態を持さず、プロパティーを汚さないで済むことです。
ここまでわかったら、後のテストケースの作成は簡単ですね、こんな感じにすればいいです:
// `APIKit.SessionTaskError` は `Equatable` に適合していないので、ひとまず対応させておく
extension APIKit.SessionTaskError: Equatable {
public static func == (lhs: AppError.External, rhs: AppError.External) -> Bool {
return lhs.localizedDescription == rhs.localizedDescription
}
}
// ここの `Response` も実際の `Request.Response` に置き換えられますが、これも `XCTAssertEqual` を使うために `Equatable` に適合する必要があります
// `expectedResponse` が実際の成功レスポンスインスタンス
typealias TestCase = (successOnTime: Int, expectedResult: Result<Response, APIKit.SessionTaskError>)
let testCases: [TestCase] = [
(0, .failure(.connectionError(NSError(domain: NSURLErrorDomain, code: URLError.notConnectedToInternet.rawValue)))),
(1, .success(expectedResponse)),
(2, .success(expectedResponse)),
(3, .success(expectedResponse)),
(4, .success(expectedResponse)),
(5, .success(expectedResponse)),
(6, .success(expectedResponse)),
(7, .failure(.connectionError(NSError(domain: NSURLErrorDomain, code: URLError.notConnectedToInternet.rawValue)))),
]
for testCase in testCases {
let testExpectation = expectation(description: "\(testCase)")
useStub(successOnTimeOfRequestReceived: testCase.successOnTime)
send(request) { (result) in // この `request` は実際の API パス `/path/of/api` にアクセスするための APIKit のリクエストインスタンス
defer {
OHHTTPStubs.removeAllStubs()
testExpectation.fulfill()
}
XCTAssertEqual(result, testCase.expectedResult)
}
wait(for: [testExpectation], timeout: 5)
}
このように、成功レスポンスを返すときのアクセス回数と、想定されているレスポンスのタップルのテストケース配列を作っておけば、あとはこの配列を回して、実際に本当に想定通りにリトライしているのかが確認できます。
ちなみにここで注意しないといけないのは、上のリトライ実装編で定義したリトライ回数のデフォルト値が 5
ですが、これはリトライ回数ですので、最初のアクセスの試みも含めれば合計最大 6
回のアクセスが可能となる仕様になります。この人によってわかりにくいかもしれない仕様も、きちんとテストすることで保証されることがわかります。
最後に、リトライ実装編ではこの解説コードをシンプルにするために、リトライすべき失敗レスポンスとすべきでない失敗レスポンスを区別させていないため、ここのテストも最後のテスト対象である
4. 1回目で成功レスポンスでもリトライ可能な失敗レスポンスでもなく、リトライ不可な失敗レスポンスもらったらそのまま終了ハンドラーに失敗レスポンス入れて回す
をテストしていません。しかしこの記事では一番肝なアプローチを解説しましたので、この仕様の対応はもう難しくないはずです。興味ある方は是非この失敗レスポンスの切り分けとテストの実装を試してみてください。
明日の記事
ゆめみアドベントカレンダー 4 日目は @t_kanno さん!👏
[Laravel6.0] monologでカスタムログを出力する。