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