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

LinkedIn

7 комментариев

Подписаться на комментарииОтписаться от комментариев Комментарии могут оставлять только пользователи с подтвержденными аккаунтами.

много кода на swift и нет guard’ов — пост не читал но осуждаю

Велосипед.
Уже изобретен инструментарий соответствующий.
Уже есть retry, retryWhen + materialize из RxSwift — делают все указаное.

Спасибо за комментарий. Я в статье указал что подобные вещи уже реализованы в RxSwift. Приведенный подход можно применить когда не используются FRP библиотеки и подходы.

....только вопрос «нависчо?».....
нависчо НЕ использовать RxSwift в проекте.
а также нависчо использовать код-велосипед когда рядом есть готовое (и главное-оттестированое) решение.

Конечно, рассуждая про эти «нависчо» можно уйти в дебри.
Четкого ответа тут не будет.

да очень простой ответ — ну не обязаны люди использовать Rx. есть Swift, а все остальное — опционально

Спасибо, интересно

Подписаться на комментарии