Нюансы обработки ошибок в completion-коллбеке
В практике программирования Swift часто можно встретить следующий формат completion-коллбеков, которые могут возвращать либо результат без ошибки, либо ошибку:
func doSomething(_ completion: (data: Data?, error: Error?) -> Void())
Проблема таких completion-коллбеков в опционалах которые должны разрешить проблемы возвращения nil
на месте параметра data
в случае ошибки и nil
в параметре error
. Но кроме этих двух состояния возникают ещё два непредусмотренных логикой - когда оба параметра nil
, или когда оба параметра установлены. В этой заметке я хотел бы порассуждать о том как правильно обрабатывать такие вызовы, т.к. это проблема встречается часто при работе со старыми методами из CocoaTouch
, при разработке кода с обратной совместимостью API, а так же при работе с библиотеками третьих сторон.
Как должна была бы выглядеть идеальная обработка? Идеально следовало бы использовать guard else
инструкцию что бы разделить разветвление на код для обработки успешной ситуации от неуспешной. Принято успешную ситуации обрабатывать в теле функции (или замыкания) - т.е. в главной ветви, а неуспешный внутри блока guard
или if
- т.е. в побочной ветви.
С учётом этого все, код мог бы выглядеть так:
doSomething { (data, error) in
guard error == nil else {
// Неуспешная ситуация
}
// Успешная ситуация
}
Допустим мы пишем некоторую обёртку для RxSwift
для операции типа Single
.
Если закрыть глаза на ошибки компиляции, то в идеальном мире это должно было бы привести к следующему коду:
func doSomethingAsync() -> Single<Data?> {
.create { single in
doSomething { (data, error) in
guard error == nil else {
single(.failure(error))
return
}
single(.success(data))
}
return Disposables.create {}
}
}
Какие проблемы в этом коде можно обнаружить?
- Строчка
single(.failure(error))
требует что бы ошибкаerror
имела неопциональный тип, но наш блокguard
не снимает опциональность. - Обработка ситуации когда
data == nil
перекладывается на подписчика. - Не обрабатывается ситуация когда есть и ошибка
error != nil
, и данныеdata != nil
(возможно полезные для дебага)
В действительности все вышеприведённые проблемы справедливы не только для асинхронного кода, но и для синхронного императивного. Просто в реактивном коде такие проблемы проявляют себя наиболее чётко.
Рассмотрим возможные решения по порядку.
Обработка опциональной ошибки
Вариант 1 - force unrawp
.
Т.к. guard
нам гарантирует что error
в блоке else-return
уже не может быть nil
, то можно использовать force unwrap
:
guard error == nil else {
single(.failure(error!))
return
}
single(.success(data))
У этого подхода есть ряд проблем:
- всё же абсолютных гарантий нет, что после невыполнения условия
error == nil
в блокеelse
переменнаяerror
действительно будет продолжать иметь значение, поэтому это источника потенциального крэша force unrawp
это плохая практика в целом, поэтому наличие разрешённыхforce unrawp
может замыливать глаз при код-ревью и усложнять автоматизацию линтинга
Вариант 2 - guard-else-if
.
Вместо force unrawp
можно добавить ещё один блок if-else
. Это решает проблемы с возможным рассинхронном, т.к. if let
создаст копию переменной error
:
guard error == nil else {
if let error = error {
single(.failure(error))
}
return
}
single(.success(data))
У этого подхода есть ряд проблем:
- громоздкий синтаксис
Вариант 3 - optional map
Следующий вариант строится на наличии специального вариант map
для опционального значения что позволяет получить распакованное значение внутри блока map
:
guard error == nil else {
error.map { single(.failure($0) }
return
}
single(.success(data))
Проблемы этого подхода:
- непонятный синтаксис
Вариант 4 - do catch
Можно заменить конструкцию guard
на аналогичный do-catch
блок:
do {
try error.map { throw $0 }
single(.success(data))
} catch {
single(.failure(error))
}
Проблемы этого подхода:
- непонятный синтаксис
- по-факту вариация
if-else
Вариант 5 - хелпер
Можно завести хелпер который бы обслуживал ошибку:
func handle(_ error: Error?, with completion: (Error) -> Void) -> Bool {
error.map { completion($0); false } ?? true
}
Тогда guard
-блок примет следующий вид:
guard handle(error, with: {
single(.failure($0))
}) else {
return
}
single(.success(data))
Из плюсов такого подхода - можно добавить в хендер дополнительный код для логирования и дебага, что может быть полезно.
Минусы
- запутанно
- сам хендлер может быть источником проблем.
Ситуация когда пустые данные это ошибка
Желательно проектировать интерфейсы так что бы не перекладывать на потребителя кода дополнительную работу по проверке полученного результата. Таким образом желательно что бы функция doSomethingAsync
возвращала не Single<Data?>
, а Single<Data>
.
Но в таком случае у нас появляется ещё один тип ошибок (поверх того что может быть возвращён функцией doSomething
):
enum ServiceError: Error {
case noData
}
class Service {
func doSomethingAsync() -> Single<Data> {
.create { single in
doSomething { (data, error) in
guard error == nil else {
if let error = error {
single(.failure(error))
}
return
}
guard let data = data else {
single(.failure(ServiceError.noData))
return
}
single(.success(data))
}
return Disposables.create {}
}
}
}
Ситуация когда с ошибкой пришли непустые данные
Иногда бывает что вместе с ошибкой приходит ещё какая-то информация. Такое бывает, например, когда функция не только получает данные, но и проверяет их корректность. Так что с ошибкой могут быть возвращены данные для дебага и логирования.
enum ServiceError: Error {
case noData
case errorWithData(Error, Data)
}
class Service {
func doSomethingAsync() -> Single<Data> {
.create { single in
doSomething { (data, error) in
guard error == nil else {
if let error = error {
if let data = data {
single(.failure(ServiceError.errorWithData(error, data)))
} else {
single(.failure(error))
}
}
return
}
guard let data = data else {
single(.failure(ServiceError.noData))
return
}
single(.success(data))
}
return Disposables.create {}
}
}
}
Как видно из примера - код становится всё запутанней, один из вариантов решения проблемы это перейти к switch-case
или do-catch
.
Вариант switch-case
enum ServiceError: Error {
case noData
case errorWithData(Error, Data)
}
class Service {
func doSomethingAsync() -> Single<Data> {
.create { single in
doSomething { (data, error) in
switch (data, error) {
case let .some(data), nil:
single(.success(data))
case nil, nil:
single(.failure(ServiceError.noData))
case nil, let .some(error):
single(.failure(error))
case let .some(data), let .some(error):
single(.failure(ServiceError.errorWithData(error, data)))
}
}
return Disposables.create {}
}
}
}
Вариант do-catch
Подход с do-catch
строится по тому же принципу - нужно добавить проверки для каждого случая и в случае ошибки выбросить её для перехвата в catch
блоке:
enum ServiceError: Error {
case noData
case errorWithData(Error, Data)
}
class Service {
func doSomethingAsync() -> Single<Data> {
.create { single in
doSomething { (data, error) in
do {
if let error = error, let data = data {
throw ServiceError.errorWithData(error, data)
}
if let error = error {
throw error
}
guard let data = data else {
throw ServiceError.noData
}
single(.success(data)
} catch {
single(.failure(error)
}
}
return Disposables.create {}
}
}
}
Преимущество такого подхода в гибкости, потому что валидацию ошибочных случаев можно вынести в специальный метод:
enum ValidationError<T>: Error {
case noData
case errorWithData(Error, T)
}
func validate<T>(_ value: T?, error: Error?) -> T throws {
if let error = error, let data = data {
throw ValidationError<T>.errorWithData(error, data)
}
if let error = error {
throw error
}
guard let data = data else {
throw ValidationError<T>.noData
}
}
class Service {
func doSomethingAsync() -> Single<Data> {
.create { single in
doSomething { (data, error) in
do {
single(.success(try validate(data, error))
} catch {
single(.failure(error)
}
}
return Disposables.create {}
}
}
}
Преимущество такого решения в том что функцию validate
можно использовать с произвольными типами.
Заключение
Как можно видеть - универсального решения проблемы обработки ошибок в completion-коллбеке нет.
В простых случаях можно обойтись guard
блоком с обработкой ошибки через map
или аналогичную if-let
конструкцию.
В более сложных следует писать функции-обработчики, либо валидаторы.
Наиболее мощным решением с позиции ООП будет писать функции-валидаторы которые способы выбрасывать ошибки, в этом случае всё сводится к тривиальном блоку do-catch
:
do {
single(.success(try validate(data, error))
} catch {
single(.failure(error)
}
При этом сами валидаторы могут вести логирование и собирать дополнительную информацию.