Проектирование retry обертки для функций на Swift
Всем привет! Меня зовут Алексей Савченко, я iOS инженер в компании Genesis. Недавно я столкнулся с ситуацией, когда некоторая функция в проекте могла сгенерировать ошибку при определенном стечении обстоятельств, и был смысл в повторном вызове этой функции.
Язык Swift и iOS SDK из коробки не содержат такой функционал, поэтому я хочу поделиться с вами своим решением, которое я реализовал в поисках ответа для такой задачи.
Реализация
В повседневной работе существует множество ситуаций, когда используемые нами функции могут давать сбой, например, генерировать ошибки, возвращать пустые Optional
-объекты и т. д. Если пустой Optional
-объект — это плохой способ сигнализации о том, что работа функции завершена некорректно, то генерация ошибки (ключевое слово throw) — это то, что Swift поддерживает поддерживает из коробки и является предпочтительным способом по умолчанию.
// Так не нужно делать func readFileContents(at fileURL: URL) -> SomeObject? { if fileExitsts(at: fileURL) { if let data = try? Data(contentsOf: fileURL) { return SomeObject(with: data) } else { return nil } } else { return nil } } // Уже лучше enum DomainSpecificError: Error { case fileDoesNotExist(url: URL) } func readFileContents(at fileURL: URL) throws -> SomeObject { if fileExitsts(at: fileURL) { let data = try Data(contentsOf: fileURL) return SomeObject(with: data) } else { throw DomainSpecificError.fileDoesNotExist(url: fileURL) } }
Генерирующие ошибки функции были подходящим способом передачи ошибок, но есть способ разработать более удобный синтаксис для генерирующих ошибки функций. Предстоящий выпуск Swift 5 будет включать в себя тип Result<T>
, который позволяет моделировать возвращаемое значение некоторой функции как успех (кейс .success
) или неудачу (кейс .failure
).
Общее определение Result<T>
выглядит следующим образом:
enum Result<T> { case success(T) case failure(Error) // Other functions and properties // ... }
Как вы, наверное, заметили, Result<T>
очень похож на Optional из stdlib Swift. Но вместо того, чтобы возвращать пустой Optional (в случае. none или nil), мы можем завернуть значение Error
в кейс .failure
и обработать его соответствующим образом.
// Example of `Result` usage func readFileContents(at fileURL: URL) -> Result<SomeObject> { if fileExitsts(at: fileURL) { do { let data = try Data(contentsOf: fileURL) return Result.success(SomeObject(with: data)) } catch { return Result.failure(error) } } else { return Result.failure(DomainSpecificError.fileDoesNotExist(url: fileURL)) } }
Также возможно применение функций map
и flatMap
к Result<T>
для достижения цепочных преобразований (например, Optional chaining). Пример применения map и flatMap показан ниже. Я опустил ненужные детали внутри функций, чтобы подчеркнуть общую схему использования типа Result.
let inputFileURL: URL = Constant.inputFileURL let outputFileURL: URL = Constant.outputFileURL struct SomeModel { } func readFile(at fileURL: URL) -> Result<Data> { ... } func parseFileData(_ data: Data) -> Result<SomeModel> { ... } func applyChanges(to model: SomeModel) -> SomeModel { ... } func convertToData(_ model: SomeModel) -> Result<Data> { ... } func write(_ data: Data, to targetURL: URL) -> Result<Void> { ... } // Example of chaining let resultOfReadChangeWrite = readFile(at: inputFileURL) .flatMap(parseFileData) .map(applyChanges(to:)) .flatMap(convertToData) .flatMap { data in write(data, to: outputFileURL) } switch resultOfReadChangeWrite { case .success: // whole chain of operations passed successfully case .failure(let error): // some error occured in process }
Если вам интересно узнать больше о map
и flatMap
, обращайтесь к официальной документации Swift.
Но что, если нужно повторить попытку в случае «failure»?
Могут быть ситуации, когда в случае ошибки необходимо «повторить» вызов функции. Наиболее распространенным является случай вызова API, например, вызов API для sign-in пользователя и ситуация, когда сетевой вызов прерывается или что-то подобное.
В этом случае вы можете подумать об использовании if-else
или switch
и некоторой локальной переменной, чтобы отслеживать количество повторов вызова функции. Это может подойти, если такое поведение необходимо для некоторого частного случая. Но, если необходимо масштабировать такое поведение в нескольких местах, все может пойти наперекосяк. В мире RxSwift есть оператор retry, который отвечает на уведомление onError
от источника Observable<T>
, не передавая этот вызов своим подписчикам, а вместо этого повторно подписываясь на источник Observable<T>
и предоставляя ему еще одну возможность завершить свою последовательность без ошибок.
Моя идея заключалась в том, чтобы полностью инкапсулировать упомянутое поведение универсальным и модульным способом для синхронной и асинхронной функций. Пожалуйста, смотрите листинг ниже для реализации:
struct FalliableSyncOperation<Input, Output> { typealias ResultHandler = (Result<Output>) -> Void typealias SyncOperation = (Input) -> Result<Output> private var attempts = 0 private let maxAttempts: Int private let wrapped: SyncOperation /// - Parameters: /// - maxAttempts: Maximum number of attempts to take /// - operation: Function to wrap init(_ maxAttempts: Int = 2, operation: @escaping SyncOperation) { self.maxAttempts = maxAttempts self.wrapped = operation } /// Execute wrapped function /// /// - Parameters: /// - input: Input value /// - completion: Closure that will handle final outcome of execution func execute(with input: Input, completion: ResultHandler) { let result = wrapped(input) if result.isFail && attempts < maxAttempts { spawnOperation(with: attempts + 1).execute(with: input, completion: completion) } else { completion(result) } } /// - Parameter attempts: New value of attempts used /// - Returns: Operation with updated `attempts` value private func spawnOperation(with attempts: Int) -> FailableSyncOperation<Input, Output> { var op = FailableSyncOperation(maxAttempts, operation: wrapped) op.attempts = attempts return op } }
Приведенный выше код описывает оболочку универсальной синхронной (sync) функции, которая принимает некоторое входное значение и возвращает Result<Output>
.
Для упрощения приведенный ниже пример просто вычисляет случайное значение и сравнивает его, а не выполняется какая-то реальная работа с данными. Надеюсь, это не повлияет на понимание применения и возможностей предложенного подхода.
// Creates a wrapper of a function that takes nothing (Void) and returns an Int in case of success let operation = FailableSyncOperation<Void, Int> { _ in if arc4random_uniform(10) < 5 { print("Sync operation fail") return Result.fail(DomainError.someError) } else { print("Sync operation success") return Result.success(42) } } operation.execute(with: ()) { (result) in print("Result of failable sync operaion - \(result)") }
Приведенный выше код описывает применение обертки над синхронной функцией и ее выполнение. Обертка функции может быть выполнена до 3 раз, и приведет либо к .success
, либо к .failure
в худшем случае.
Тот же подход может быть реализован для асинхронных функций:
struct FalliableAsyncOperation<Input, Output> { typealias ResultHandler = (Result<Output>) -> Void typealias AsyncOperation = (Input, (Result<Output>) -> Void) -> Void private var attempts = 0 private let maxAttempts: Int private let wrapped: AsyncOperation /// - Parameters: /// - maxAttempts: Maximum number of attempts to take /// - operation: Function to wrap init(_ maxAttempts: Int = 2, operation: @escaping AsyncOperation) { self.maxAttempts = maxAttempts self.wrapped = operation } /// Execute wrapped function /// /// - Parameters: /// - input: Input value /// - completion: Closure that will handle final outcome of execution func execute(with input: Input, completion: ResultHandler) { wrapped(input) { result in if result.isFailure && attempts < maxAttempts { spawnOperation(with: attempts + 1).execute(with: input, completion: completion) } else { completion(result) } } } /// - Parameter attempts: New value of attempts used /// - Returns: Operation with updated `attempts` value private func spawnOperation(with attempts: Int) -> FailableAsyncOperation<Input, Output> { var op = FailableAsyncOperation(maxAttempts, operation: wrapped) op.attempts = attempts return op } }
Ниже приведен пример использования. Оно почти идентично примеру синхронной функции, но имеет сигнатуру и ведет себя как асинхронная функция:
func someAsyncFunction(_ completion: ((Result<Int>) -> Void)) { if arc4random_uniform(10) < 5 { print("Async operation fail") completion(.fail(SomeError())) } else { print("Async operation success") completion(.success(42)) } } // Wrap function to an abstraction let async = FailableAsyncOperation<Void, Int> { (input, handler) in someAsyncFunction(handler) } async.execute(with: ()) { (result) in print("Result of failable async operaion - \(result)") }
Сделаем нашу обертку немного умнее
Как я упоминал ранее, возможность повторного вызова особенно полезна для функций, которые выполняют API запросы. Наша реализация уже выполняет эту задачу, но в ней отсутствует одно полезное поведение — задержка между последовательными повторными вызовами. Чтобы добавить эту функциональность, нужно предоставить нужную нам DispatchQueue
, на которой будет выполняться работа и требуемый TimeInterval
для наших ошибочных оберток операций. Реализация асинхронной версии приведена ниже:
struct FallibleAsyncOperation<Input, Output> { typealias ResultHandler = (Result<Output>) -> Void typealias AsyncOperation = (Input, (Result<Output>) -> Void) -> Void private var attempts = 0 private let maxAttempts: Int private let wrapped: AsyncOperation private let queue: DispatchQueue private let retryDelay: TimeInterval /// Fallible synchronous operation wrapper /// /// - Parameters: /// - maxAttempts: Maximum number of attempts to take /// - queue: Target queue that on which wrapped function will be executed. (Defaults to `.main`) /// - retryDelay: Desired delay between consecutive retries. (Defaults to: 0) /// - operation: Function to wrap init(maxAttempts: Int = 2, queue: DispatchQueue = .main, retryDelay: TimeInterval = 0, operation: @escaping AsyncOperation) { self.maxAttempts = maxAttempts self.wrapped = operation self.queue = queue self.retryDelay = retryDelay } /// Execute wrapped function /// /// - Parameters: /// - input: Input value /// - completion: Closure that will handle final outcome of execution func execute(with input: Input, completion: @escaping ResultHandler) { queue.asyncAfter(deadline: .now()) { self.wrapped(input) { result in if result.isFailure && self.attempts < self.maxAttempts { self.queue.asyncAfter(deadline: .now() + self.retryDelay, execute: { self.spawnOperation(with: self.attempts + 1).execute(with: input, completion: completion) }) } else { completion(result) } } } } /// - Parameter attempts: New value of attempts used /// - Returns: Operation with updated `attempts` value private func spawnOperation(with attempts: Int) -> FallibleAsyncOperation<Input, Output> { var op = FallibleAsyncOperation(maxAttempts: maxAttempts, queue: queue, retryDelay: retryDelay, operation: wrapped) op.attempts = attempts return op } }
Конкретное применение требует незначительных изменений для внедрения нового поведения:
func someAsyncFunction(_ completion: ((Result<Int>) -> Void)) { if arc4random_uniform(10) < 5 { print("Async operation fail") completion(.failure(SomeError())) } else { print("Async operation success") completion(.success(42)) } } let specificQueue = DispatchQueue(label: "SomeSpecificQueue") let async = FallibleAsyncOperation<Void, Int>(maxAttempts: 2, queue: specificQueue, retryDelay: 3) { input, handler in someAsyncFunction(handler) } async.execute(with: ()) { (result) in print("Result of failable async operaion - \(result)") }
В результате на выходе мы получили обобщенный оберточный тип инкапсулирующий retry поведение для функций определенной сигнатуры, который можно использовать в любой части приложения и поможет уйти от костылизации участков приложения, где retry поведение оправданно.
Исходный код доступен на GitHub. Полная реализация находится в файле FallibleOperation.swift.
Буду рад любому фидбэку. Как всегда, вы можете связаться со мной через LinkedIn или Facebook.