Как автоматизировать безопасный декодинг массивов в Swift с @propertyWrapper

image-loader.svg

Привет! На связи Влад, iOS-разработчик из Ozon. Сегодня я поделюсь с вами, возможно, не самым очевидным способом использования propertyWrappers. Обёртки позволяют добавлять дополнительную логику свойствам. В одну из них мы спрятали описание безопасного декодинга массивов, и теперь нам достаточно пометить свойство как @SafeDecode — и всё начинает работает автоматически. О том, как они работают и как их завести у себя, читайте дальше.

Что такое безопасный декодинг

Для тех, кто сталкивается с безопасным декодингом впервые, поясню: безопасный декодинг массива — это декодинг, при котором в декодируемом массиве может содержаться элемент, не соответствующий ожидаемому формату; при этом в результате мы получим все элементы массива, которые смогли распарсить.

Например, у нас есть структура:

struct Article {
  let title: String // обязательное поле
  let subtitle: String? // не обязательное поле
}

И мы пытаемся распарсить такой массив данных:

[
    {
        "title": "Title1",
        "subtitle": "Subtitle1"
    },
    {
        // В этом элементе нет: "title": "Title1",
        "subtitle": "Subtitle1"
    }
]
do {
	let articles = try JSONDecoder().decode([Article].self, from: jsonData) 
} catch {
 	print(error)
  // Мы получим ошибку: "No value associated with key title (\"title\")."
  // Потому что во втором элементе нет title, из-за этого 
  // весь массив не распарсится
}

Чтобы всё-таки получить все остальные элементы, мы используем propertyWrapper. Он содержит внутри логику, которая фильтрует ошибки и возвращает полученные значения.

Для тех, кто ещё не работал с propertyWrapper

Если вы уже знаете, как работает обёртка свойств, смело переходите к следующему разделу. Или можете освежить знания.

PropertyWrapper — это обёртка, позволяющая добавлять дополнительную логику самому свойству. То есть, например, мы можем сделать так, чтобы все слова в строке начинались с заглавной буквы или чтобы числа в переменной были всегда меньше 12. И всё это — всего одной строкой.

Давайте попробуем.

Для начала сделаем основу propertyWrapper:

@propertyWrapper
struct Example {
  public var wrappedValue: Any 
  
  public init(wrappedValue: Any) {
      self.wrappedValue = wrappedValue 
  }
}

Она состоит из маркировки @propertyWrapper и обязательного свойства wrappedValue.

Эту обёртку уже можно использовать:

struct Numbers {
	@Example let value: Any
}

Но она пока что ничего не делает.

Посмотрим, как выглядит propertyWrapper, который будет устанавливать в свойство только положительные числа с помощью abs():

@propertyWrapper
struct Abs {
    private var number: Int = 0
    var wrappedValue: Int {
        get { number }
        set { number = abs(newValue) }
    }
}

Вся логика работы у нас спрятана в одной строке: number = abs(newValue). Чтобы сделать из этой обёртки что-то новое, достаточно поменять только эту строку.

Также у нас нет init(wrappedValue: Any), как в основе, потому что мы сразу задали значение для number. Если этого не сделать, придётся дописать init().

Пример использования:

struct Number {
    @Abs var nonNegativeNumber: Int
}

var number = Number()
number.nonNegativeNumber = -1
print(number.nonNegativeNumber) // 1
number.nonNegativeNumber = -77
print(number.nonNegativeNumber) // 77

Теперь любое число, установленное в nonNegativeNumber, будет положительным благодаря обёртке @Abs.

Давайте посмотрим, как ещё можно сделать эту же обёртку. Мы можем вместо приватного number сделать всё в wrappedValue, для этого нам понадобится наблюдатель свойства didSet {}:

@propertyWrapper
struct Abs {
    var wrappedValue: Int {
        didSet { wrappedValue = abs(wrappedValue) }
    }

    init(wrappedValue: Int) {
        self.wrappedValue = abs(wrappedValue)
    }
}

Результат будет тот же:

struct Number {
    @Abs var nonNegativeNumber: Int
}

var number = Number()
number.nonNegativeNumber = -15
print(number.nonNegativeNumber) // 15
number.nonNegativeNumber = -40
print(number.nonNegativeNumber) // 40

А теперь рассмотрим пример, в котором propertyWrapper будет удалять цифры из конца строки:

@propertyWrapper
struct WithoutDecimalDigits {
    var wrappedValue: String {
        didSet { wrappedValue = wrappedValue.trimmingCharacters(in: .decimalDigits) }
    }

    init(wrappedValue: String) {
        self.wrappedValue = wrappedValue.trimmingCharacters(in: .decimalDigits)
    }
}

Вся логика работы содержится в didSet{}. При таком подходе нам обязательно нужно установить значение wrappedValue через init(). Это связано с тем, что наблюдатели свойств начинают работать только после установки значения в объект. Проще говоря, блок didSet{} заработает только после установки значения wrappedValue в init().

Реализация:

struct Example {
    @WithoutDecimalDigits var value: String
}


let exampleString = Example(value: "Hello 123")
print(exampleString.value) // "Hello "

Теперь наша обёртка удаляет все цифры из строки.

Зная эти основы, можно делать удобные propertyWrappers для своего проекта. Но использовать их нужно с осторожностью. Если скрыть внутри сложную логику, то, в будущем, можно случайно добавить неочевидное поведение.

Как безопасно декодировать массив с propertyWrapper

Обёртки очень легко использовать:

struct Example: Decodable {
	@SafeArray let articlesArray: [Article]
}

Мы помечаем декодируемый массив как @SafeArray — и в нём будут все элементы, которые можно получить.

Чтобы propertyWrapper заработал, нужно сделать две вещи:

  1. Подготовить новый тип Throwable, который может содержать либо значение, либо ошибку.

  2. Написать расширение для SingleValueDecodingContainer.

Делаем тип, он будет очень простым:

enum Throwable: Decodable {
    case success(T)
    case failure(Error)

    init(from decoder: Decoder) throws {
        do {
            let decoded = try T(from: decoder)
            self = .success(decoded)
        } catch let error {
            self = .failure(error)
        }
    }
}

А теперь сделаем расширение.

Шаг 1. Подготавливаем расширение:

extension SingleValueDecodingContainer {
    func safelyDecodeArray() throws -> [T] where T: Decodable {
      
    }
}

Шаг 2. Добавляем декодинг массива:

extension SingleValueDecodingContainer {
    func safelyDecodeArray() throws -> [T] where T: Decodable {
      let decodedArray = (try? decode([Throwable].self)) ?? []
    }
}

Шаг 3. Фильтруем и возвращаем декодируемый массив:

extension SingleValueDecodingContainer {
    func safelyDecodeArray() throws -> [T] where T: Decodable {
        let decodedArray = (try? decode([Throwable].self)) ?? []

        let filtredArray = decodedArray.compactMap { result -> T? in
            switch result {
            case let .success(value):
                return value
            case .failure(_):
                return nil
            }
        }

        return filtredArray
    }
}

В результате декодинга safelyDecodeArray вернёт либо все полученные элементы, либо пустой массив.

Следующие два шага — для тех, кто хочет добавить обработку ошибок и проверку на пустой массив; если вы хотите сразу перейти к реализации propertyWrapper, их можно пропустить.

Шаг 4. Добавляем проверку и возвращаем ошибку, если после фильтрации получился пустой массив:

extension SingleValueDecodingContainer {
    func safelyDecodeArray() throws -> [T] where T: Decodable {

	      ...

        if filtredArray.isEmpty {
            throw DecodingError.dataCorruptedError(in: self, debugDescription: "Empty array of elements is not allowed")
        }

        return filtredArray
    }
}

Шаг 5. Добавляем возможность выводить все полученные ошибки через callback:

extension SingleValueDecodingContainer {
    // 1. Добавим callback для вывода описания ошибок onItemError: (([String: Any]) -> Void)? 
    func safelyDecodeArray(onItemError: (([String: Any]) -> Void)?) throws -> [T] where T: Decodable {

        let decodedArray = (try? decode([Throwable].self)) ?? []

        // 2. Чтобы иметь доступ к индексу элемента, добавим enumerated() и index
        let filtredArray = decodedArray.enumerated().compactMap { index, result -> T? in
            switch result {
            case let .success(value):
                return value
            // 3. Добавим errorInfo и его передачу через callback
            case let .failure(error):
                var errorInfo = [String: Any]()
                errorInfo["error"] = error
                errorInfo["index"] = index
                onItemError?(errorInfo)
                return nil
            }
        }

        if filtredArray.isEmpty {
            throw DecodingError.dataCorruptedError(in: self, debugDescription: "Empty array of elements is not allowed")
        }

        return filtredArray
    }
}

Теперь у нас есть возможность использовать вывод описания ошибок декодинга в нашем propertyWrapper.

Финальный шаг. Реализуем propertyWrapper:

@propertyWrapper
public struct SafeArray: Decodable {
    public let wrappedValue: [T]

    public init(wrappedValue: [T]) {
        self.wrappedValue = wrappedValue
    }
  
    public init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        wrappedValue = try container.safelyDecodeArray()
    }
}

Это всё, что нужно сделать, чтобы использовать обёртку для безопасного декодинга массивов. Теперь можно помечать массивы как SafeArray — и всё заработает автоматически.

Код из статьи целиком вы найдёте в последнем разделе.

Дополнительные propertyWrappers

В примере мы парсили массив в константу. Если нам нужно менять массив после парсинга, достаточно заменить в обёртке let на var, потому что обёрнутое свойство должно быть таким же, как wrappedValue:

@propertyWrapper
public struct SafeMutableArray: Decodable {
    public var wrappedValue: [T]
  	...
}

Тогда свойство тоже можно будет сделать переменной:

struct Example: Decodable {
    @SafeMutableArray var articlesArray: [Article]
}

Если нам нужно получить опциональный массив, необходимо добавить опциональность и для wrappedValue:

@propertyWrapper
public struct SafeOptionalArray: Decodable {
    public let wrappedValue: [T]?

    public init(wrappedValue: [T]?) {
        self.wrappedValue = wrappedValue
    }

    public init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        wrappedValue = try? container.safelyDecodeArray()
    }
}

Чтобы после декодинга опциональный массив можно было изменить, достаточно снова заменить let wrappedValue на var wrappedValue.

Вместо вывода

Это был, на мой взгляд, не самый очевидный способ декодирования данных в Swift, однако это ещё не все возможности property Wrapper.

Так как обёртки используются в структурах и классах, то их можно попробовать использовать в любом месте приложения, добавляя любую нужную логику, которую можно уместить.

Но всегда помните о том, что большая сила влечёт за собой и большую ответственность: если оставить внутри обёртки сложную логику, то она может аукнуться неочевидным поведением обёрнутого свойства. Применяйте инструмент там, где это действительно необходимо и к месту.

Код из статьи

@propertyWrapper
public struct SafeArray: Decodable {
    public let wrappedValue: [T]
    public init(wrappedValue: [T]) {
        self.wrappedValue = wrappedValue
    }
    
    public init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        wrappedValue = try container.safelyDecodeArray(onItemError: nil)
    }
}

extension SingleValueDecodingContainer {
    func safelyDecodeArray(onItemError: (([String: Any]) -> Void)?)
    throws -> [T] where T: Decodable {
        let decodedArray = (try? decode([Throwable].self)) ?? []
        let filtredArray = decodedArray.enumerated().compactMap { index, result -> T? in
            switch result {
            case let .success(value):
                return value
            case let .failure(error):
                var errorInfo = [String: Any]()
                errorInfo["error"] = error
                errorInfo["index"] = index
                onItemError?(errorInfo)
                return nil
            }
        }
        
        if filtredArray.isEmpty {
            throw DecodingError.dataCorruptedError(in: self, debugDescription: "Empty array of elements is not allowed")
        }
        
        return filtredArray
    }
}

enum Throwable: Decodable {
    case success(T)
    case failure(Error)
    
    init(from decoder: Decoder) throws {
        do {
            let decoded = try T(from: decoder)
            self = .success(decoded)
        } catch let error {
            self = .failure(error)
        }
    }
}

© Habrahabr.ru