Проектування 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 від джерела Вами<T>, не передаючи цей виклик своїм передплатникам, а замість цього повторно підписуючись на джерело Вами<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 used attempts
 /// - 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 used attempts
 /// - 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 used attempts
 /// - 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 .

Опубліковано: 12/04/19 @ 10:00
Розділ Різне

Рекомендуємо:

Сутичка двох екодзун: ITIL vs PMBoK
DOU Hobby: Стрільба – любов до зброї і ураження цілі
DOU Labs: як в Provectus створили ProPlanner – SMART-планувальник робочих завдань
Три історії про IT-шників, що займаються громадською діяльністю
Ruby/Rails дайджест #28: важливі оновлення для кількох версій Ruby on Rails, реліз Ruby 2.5.5 і 2.6.2