Проектирование 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.