Pull to refresh

Повышаем лояльность парсера серверных данных в iOS

Reading time7 min
Views1.6K

При разработке мобильных приложений мы так или иначе сталкиваемся с необходимостью парсинга серверных данных во внутренние модели приложения. В подавляющем большинстве случаев эти данные приходят в формате JSON. Начиная со Swift 4 основным инструментом для парсинга JSON является использование протокола Decodable и объекта JSONDecoder.


Данный подход значительно упростил процесс парсинга данных и сократил количество boilerplate кода. В большинстве случаев достаточно просто создать модели со свойствами, названными также как и поля в JSON объекте и всю остальную работу JSONDecoder сделает за вас. Минимум кода, максимум пользы. Однако этот подход имеет один недостаток, а именно, крайне низкую лояльность парсера. Поясню. При любом несоответствии внутренней модели данных (Decodable объектов) тому, что пришло в JSON, JSONDecoder бросает ошибку и мы теряем весь объект целиком. Возможно, в некоторых ситуациях такая модель поведения предпочтительна, особенно, если речь идет, например, о финансовых операциях. Но во многих случаях было бы полезно сделать процесс парсинга более лояльным. В этой статье я бы хотел поделиться своим опытом и рассказать об основных способах повышения этой самой лояльности.


Фильтрация невалидных объектов


Ну и первым пунктом идет, разумеется, фильтрация невалидных объектов. Во многих ситуациях мы не хотим терять объект целиком, если один из вложенных объектов не валиден. Это относится как к одиночным объектам, так и к массивам объектов. Приведу пример. Допустим, мы делаем приложение для продажи товаров и на одном из экранов мы получаем список товаров примерно в таком виде.


{
    "products": [
       {...},
       {...},
       ....
    ]
}

И мы не хотим терять весь список товаров, если один из них не прошел валидацию. Тут будет разумным отфильтровать этот объект и вернуть пользователю остальной список. Разумеется, будет не лишним залогировать данную проблему и разобраться с ней, но отображать пустой лист все-таки не лучшее решение. К сожалению, JSONDecoder не реализует фильтрацию объектов массива “из коробки” и требует написание дополнительного кода.


Далее, каждый из продуктов представлен следующим объектом:


{
    "id": 1,
    "title": "Awesome product",
    "price": 12.2,
    "image": {
        "id": 1,
        "url": "http://image.png",
        "thumbnail_url": "http://thumbnail.png"
    }
}

В нем, как видно, есть вложенный объект image. И вот тут встает вопрос, хотим ли мы фильтровать продукты, которые содержат невалидные объекты image или хотим их оставить, сделав свойство image = nil. К сожалению, как и в случае с массивом данных, JSONDecoder не позволяет отфильтровать отдельное свойство.


По факту, JSONDecoder использует 2 основных метода: decode и decodeIfPresent. Второй метод используется при парсинге optional свойства и отличается от первого лишь тем, что возвращает nil, если ключ отсутствует, либо содержит null. Однако в случае невалидного объекта оба метода выбрасывают ошибку и мы теряем весь объект целиком.


Что же, проблема обозначена, можно приступать к решению. Разумеется, можно переопределить конструктор init(decoder) и использовать try? для преобразования ошибки в nil. Но данный подход нельзя назвать оптимальным, поскольку переопределение конструктора во всех моделях приведет к увеличению однотипного boilerplate кода. Куда более рациональным подходом будет использовать обертки над свойствами.


struct FailableDecodable<Value: Decodable>: Decodable {

    var wrappedValue: Value?

    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        wrappedValue = try? container.decode(Value.self)
    }
}

struct FailableDecodableArray<Value: Decodable>: Decodable {

    var wrappedValue: [Value]

    init(from decoder: Decoder) throws {
        var container = try decoder.unkeyedContainer()
        var elements: [Value] = []
        while !container.isAtEnd {
            if let element = try? container.decode(Value.self) {
                elements.append(element)
            }
        }
        wrappedValue = elements
    }
}

В результате наша модель данных будет выглядеть следующим образом.


struct ProductList: Decodable {
    var products:FailableDecodableArray<Product>
}

struct Product: Decodable {
    let id: Int
    let title: String
    let price: Double
    let image: FailableDecodable<Image>?
}

struct Image: Decodable {
    let id: Int
    let url: String
    let thumbnailUrl: String
}

Теперь все работает так как мы хотим и нет необходимости в переопределении конструктора. Однако, данный вариант удлиняет синтаксис доступа к переменным.


let products = productsList.products.wrappedValue
let image = products.first?.image.wrappedValue

В случае с FailableDecodableArray проблема решается достаточно просто. Поскольку он является оберткой над массивом, то не составит труда реализовать для него протоколы RandomAccessCollection и MutableCollection и использовать объект напрямую, не обращаясь к wrappedValue. А вот с одиночным объектом FailableDecodable дела обстоят чуть сложнее. Можно, разумеется, создать computed property для каждого такого объекта, но опять же, вряд ли это можно назвать оптимальным решением. Итак, основных вариантов упрощения синтаксиса два.


@propertyWrapper


Можно воспользоваться новой фишкой Swift 5.1 — @propertyWrapper. Для этого нам достаточно просто добавить соответствующую аннотацию к нашим оберткам


@propertyWrapper
struct FailableDecodable<Value: Decodable>: Decodable {
    ...
}

@propertyWrapper
struct FailableDecodableArray<Value: Decodable>: Decodable {
    ...
}

В результате наши модели будут выглядеть следующим образом


struct ProductList: Decodable {
    @FailableDecodableArray
    var products:[Product]
}

struct Product: Decodable {
    let id: Int
    let title: String
    let price: Double
    @FailableDecodable
    let image:Image?
}

Плюсом данного подхода является то, что мы получаем доступ к wrappedValue напрямую и синтаксис остается простым и понятным. Но, как вы понимаете, если бы у этого варианта не было недостатка, то я бы не говорил, что вариантов два :)


Итак, жирным недостатком этого подхода является то, что аннотированные свойства не могут быть optional. То есть конструкция


@FailableDecodable
let image:Image?

Будет преобразована компилятором в следующий вид


let image: FailableDecodable<Image>

Тот факт, что свойство имеет optional тип Image? говорит лишь о том, что wrappedValue имеет optional тип, но никак не сам объект обертки.
А синтаксис наподобие следующего Swift не поддерживает


@FailableDecodable?
let image:Image?

Как вы понимаете, данный факт приводит к тому, что в случае отсутствия соответствующего ключа в JSON мы получаем ошибку вместо желаемого nil в объекте. Поэтому использовать @propertyWrapperможно лишь тогда, когда вы на 100% уверены в наличии данного ключа в JSON.


@dynamicMemberLookup


Вторым вариантом является использование аннотации dynamicMemberLookup.


@dynamicMemberLookup
struct FailableDecodable<Value: Decodable>: Decodable {
    var wrappedValue: Value?

    subscript<Prop>(dynamicMember kp: KeyPath<Value, Prop>) -> Prop {
        wrappedValue[keyPath: kp]
    }

    subscript<Prop>(dynamicMember kp: WritableKeyPath<Value, Prop>) -> Prop {
            get {
                wrappedValue[keyPath: kp]
            }

            set {
                wrappedValue[keyPath: kp] = newValue
            }
    }
}

Для этого мы создаем 2 subscript, один для readonly свойств, второй для read/write свойств. В этом случае определение моделей останется без изменений.


struct ProductList: Decodable {
    var products:FailableDecodableArray<Product>
}

struct Product: Decodable {
    let id: Int
    let title: String
    let price: Double
    let image: FailableDecodable<Image>?
}

В отличие от @propertyWrapper мы получаем доступ не к самому свойству wrappedValue, а лишь к его свойствам.


let imageURL = products.first?.image.url

Основным преимуществом данного подхода является то, что мы имеем возможность создать optional свойства. Ну, а основным недостатком является то, что мы имеем прямой доступ только к свойствам wrappedValue, но не к самому объекту, что приводит к невозможности вызова методов этого объекта через данный синтаксис.


products.first?.image.load() // Compilation error
products.first?.image.wrappedValue.load() // Success

Но если ваши модели лишь хранят данные и не имеют методов (либо их количество незначительно), то данный недостаток не должен сильно испортить вам жизнь.


Приведение типов


Следующий аспект в котором возможно улучшение лояльности парсера — это приведение типов. Возникает эта проблема из-за того, что в отличие от многих серверных языков (которые и формируют JSON) Swift является строго типизированным языком, для которого “1” и 1 — это разные типы и без помощи разработчика один в другой не может быть преобразован. На данный момент JSONDecoder поддерживает один-единственный вариант приведения типов, а именно, преобразование строки в дату. Но не менее часто может возникнуть необходимость преобразовать строку в число. Например, описанный ранее объект Product может прийти в следующем виде.


{
    "id": 1,
    "title": "Awesome product",
    "price": "12.2",
    "image": {
        "id": 1,
        "url": "http://image.png",
        "thumbnail_url": "http://thumbnail.png"
    }
}

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


struct Convertible<Value: Decodable & LosslessStringConvertible>: Decodable {
    var wrappedValue: Value
    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        guard let stringValue = try? container.decode(String.self),
            let value = Value(stringValue) else {
                wrappedValue = try container.decode(Value.self)
                return
        }
        wrappedValue = value
    }
}

struct Product: Decodable {
    let id: Int
    let title: String
    let price: Convertible<Double>
    let image: FailableDecodable<Image>?
}

Теперь вне зависимости от того пришла ли строка или число, вы получаете валидное значение на парсинге. Как и в случае с FailableDecdable вы можете применить аннотации @propertyWrapper и @dynamicMemberLookup для упрощения синтаксиса доступа к свойствам.


Вариант с обратным преобразованием (из числа в строку) встречается гораздо реже и лично я не сталкивался с ним ни разу. Могу предположить, что он может быть полезен в случае, если API возвращает какие-либо значения в виде чисел, но вы не планируете совершать с этими числами никаких математических операций, а лишь отображаете их где-то на экране. В этом случае, возможно, вы захотите изначально хранить эти данные в виде строк, чтобы, во-первых, избежать лишнего кода с интерполяцией строк, ну и, во-вторых, не переживать о том, что в этом поле придет строка и все сломается. В любом случае, если необходимость такого преобразования есть, то это тоже решается с помощью оберток.


struct StringConvertible: Decodable {
    var wrappedValue: String
    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        guard let number = try? container.decode(Double.self) else {
            wrappedValue = try container.decode(String.self)
            return
        }
        wrappedValue = "\(number)"
    }
}

Подводя итог всему вышесказанному, хочется отметить, что появление протокола Decodable в совокупности с классом JSONDecoder значительно упростили нам жизнь в том, что связано с парсингом серверных данных. Однако, стоит отметить, что JSONDecoder на данный момент обладает крайне низкой лояльностью и для ее улучшения (в случае необходимости) нужно немного поработать и написать несколько оберток. Думаю, в дальнейшем все эти возможности будут реализованы и в самом объекте JSONDecoder, ведь еще относительно недавно он не умел даже преобразовывать ключи из snakecase в camelcase и строку в дату, а сейчас все это доступно “из коробки”.

Tags:
Hubs:
+2
Comments4

Articles