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

Похожие статьи:
Старт курса в Киеве: — 18 декабряСтарт курса в Одессе — 18 декабряНабор в группу в Одессе и Киеве уже начался!Набирается группа...
Привет, меня зовут Андрей, я Engineering Manager в компании Uptech. В этой статье хочу рассказать об одном из архитектурных подходов для...
Всем привет! Меня зовут Андрей, и я пишу Android-приложения в компании Genesis Media для наших медиапроектов в Африке. В этой статье...
Американська корпорація Apple видалила популярну соцмережу ТікТок зі свого App Store для користувачів із росії. Крім того,...
From the time when the UAE got independence in 1971, this city has been driven single-mindedly on the way to fast-tracked growth and expansion. After its formation, economy has full-fledged more than 230 times....
Яндекс.Метрика