При разработке мобильных приложений мы так или иначе сталкиваемся с необходимостью парсинга серверных данных во внутренние модели приложения. В подавляющем большинстве случаев эти данные приходят в формате 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 и строку в дату, а сейчас все это доступно “из коробки”.
Shiny2
fix