Формат JSON приобрел большую популярность, именно он обычно используется для передачи данных и выполнения запросов в клиент-серверных приложениях. Для парсинга JSON требуются инструменты кодирования/декодирования данного формата, и компания Apple не так давно провела их обновление. В данной статье мы рассмотрим методы парсинга JSON с применением протокола Decodable, сравним новый протокол Codable с предшественником NSCoding, оценим преимущества и недостатки, разберем все на конкретных примерах, а также рассмотрим некоторые особенности, встречающиеся при реализации протоколов.
Что такое Codable?
На WWDC2017, вместе с новой версией языка Swift 4, компания Apple представила новые инструменты кодирования/декодирования данных, которые реализуются следующими тремя протоколами:
— Codable
— Encodable
— Decodable
В большинстве случаев данные протоколы используются для работы с JSON, но кроме этого они также используются для сохранения данных на диск, передачи по сети и т.д. Encodable применяется для преобразования структур данных Swift в объекы JSON, Decodable же наоборот, помогает преобразовать объекты JSON в модели данных Swift. Протокол Codable объединяет предыдущие два и является их typealias:
typealias Codable = Encodable & Decodable
Чтобы соответствовать данным протоколам, типы данных должны реализовать следующие методы:
Encodable
encode(to:) — кодирует модель данных в заданный тип кодировщика
Decodable
init(from:) — инициализирует модель данных из предоставленного декодера
Codable
encode(to:)
init(from:)
Простой пример использования
Теперь рассмотрим простой пример использования Codable, так как он реализует и Encodable и Decodable, то на данном примере можно сразу увидеть весь функционал протоколов. Допустим у нас есть простейшая структура данных JSON:
{
"title": "Nike shoes",
"price": 10.5,
"quantity": 1
}
Модель данных для работы с данным JSON будет выглядеть следующим образом:
struct Product: Codable {
var title:String
var price:Double
var quantity:Int
enum CodingKeys: String, CodingKey {
case title
case price
case quantity
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(title, forKey: .title)
try container.encode(price, forKey: .price)
try container.encode(quantity, forKey: .quantity)
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
title = try container.decode(String.self, forKey: .title)
price = try container.decode(Double.self, forKey: .price)
quantity = try container.decode(Int.self, forKey: .quantity)
}
}
Реализованы оба необходимых метода, также описано перечисление, для определения списка полей кодирования/декодирования. На самом деле запись можно сильно упростить, потому что Codable поддерживает автогенерацию методов encode(to:) и init(from:), а также необходимого перечисления. То есть в данном случае можно записать структуру следующим образом:
struct Product: Codable {
var title:String
var price:Double
var quantity:Int
}
Предельно просто и минималистично. Единственно, не стоит забывать что такой лаконичной записью не получится воспользоваться в случае если:
— структура модели ваших данных отличается от той, которую вы хотите кодировать/декодировать
— вам может потребоваться кодировать/декодировать дополнительные свойства, кроме свойств вашей модели данных
— некоторые свойства вашей модели данных могут не поддерживать протокол Codable. В этом случае вам необходимо будет преобразовать их из/в протокол Codable
— в случае если имена переменных в модели данных и имена полей в контейнере у вас не совпадают
Так как мы уже рассмотрели простейшее определение модели данных, стоит привести и небольшой пример ее практического использования:
Вот так, в одну строчку, можно распарсить ответ сервера в формате JSON:
let product: Product = try! JSONDecoder().decode(Product.self, for: data)
А следующий код наоборот, создаст JSON-обьект из модели данных:
let productObject = Product(title: "Cheese", price: 10.5, quantity: 1)
let encodedData = try? JSONEncoder().encode(productObject)
Все очень удобно и быстро. Правильно описав модели данных и сделав их Codable, вы буквально одной строкой можете кодировать/декодировать данные. Но мы рассмотрели самую простую модель данных, содержащую небольшое количество полей простого типа. Рассмотрим возможные проблемы:
Не все поля в модели данных являются Codable
Чтобы модель ваших данных могла реализовать протокол Codable все поля модели должны поддерживать данный протокол. По умолчанию, протокол Codable поддерживают следующие типы данных: String, Int, Double, Data, URL. Также Codable поддерживают Array, Dictionary, Optional, но только в случае, если они содержат типы Codable. Если же некоторые свойства модели данных не соответствуют Codable, то их необходимо привести к нему.
struct Pet: Codable {
var name: String
var age: Int
var type: PetType
enum CodingKeys: String, CodingKey {
case name
case age
case type
}
init(from decoder: Decoder) throws {
.
.
.
}
func encode(to encoder: Encoder) throws {
.
.
.
}
}
Если в своей модели данных Codable мы используем кастомный тип, например такой как PetType, и хотим его кодировать/декодировать, то он обязательно тоже должен тоже реализовывать свои init и encode.
Модель данных не соответствует полям JSON
Если в вашей модели данных определено например 3 поля, а в JSON-объекте вам приходит 5 полей, 2 из которых являются дополнительными к тем 3, то в парсинге ничего не изменится, вы просто достанете свои 3 поля из тех 5. Если же произойдет обратная ситуация и в JSON-объекте будет отсутствовать хотя бы одно поле модели данных, то произойдет ошибка времени выполнения.
Если в JSON-объекте некоторые поля могут являться необязательными и периодически отсутствовать, то в данном случае необходимо сделать их опциональными:
class Product: Codable {
var id: Int
var productTypeId: Int?
var art: String
var title: String
var description: String
var price: Double
var currencyId: Int?
var brandId: Int
var brand: Brand?
}
Использование более сложных структур JSON
Часто ответ сервера представляет из себя массив сущностей, то есть вы запрашиваете, например, список магазинов и получаете ответ в виде:
{
"items": [
{
"id": 1,
"title": "Тест видео",
"link": "https://www.youtube.com/watch?v=Myp6rSeCMUw",
"created_at": 1497868174,
"previewImage": "http://img.youtube.com/vi/Myp6rSeCMUw/mqdefault.jpg"
},
{
"id": 2,
"title": "Тест видео 2",
"link": "https://www.youtube.com/watch?v=wsCEuNJmvd8",
"created_at": 1525952040,
"previewImage": "http://img.youtube.com/vi/wsCEuNJmvd8/mqdefault.jpg"
}
]
}
В этом случае вы можете записать и декодировать это просто как массив сущностей Shop.
struct ShopListResponse: Decodable {
enum CodingKeys: String, CodingKey {
case items
}
let items: [Shop]
}
В данном примере сработает автоматическая функция init, но если вы захотите написать декодирование самостоятельно, надо будет указать декодируемый тип как массив:
self.items = try container.decode([Shop].self, forKey: .items)
Структура Shop соответственно тоже должна реализовывать протокол Decodable
struct Shop: Decodable {
var id: Int?
var title: String?
var address: String?
var shortAddress: String?
var createdAt: Date?
enum CodingKeys: String, CodingKey {
case id
case title
case address
case shortAddress = "short_address"
case createdAt = "created_at"
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.id = try? container.decode(Int.self, forKey: .id)
self.title = try? container.decode(String.self, forKey: .title)
self.address = try? container.decode(String.self, forKey: .address)
self.shortAddress = try? container.decode(String.self, forKey: .shortAddress)
self.createdAt = try? container.decode(Date.self, forKey: .createdAt)
}
}
Парсинг данного массива элементов будет выглядеть следующим образом:
let parsedResult: ShopListResponse = try? JSONDecoder().decode(ShopListResponse.self, from: data)
Таким образом можно без особого труда работать с массивами моделей данных и использовать их внутри других моделей.
Формат даты
В данном примере есть еще один нюанс, здесь мы первый раз столкнулись с использованием типа Date. При использовании данного типа возможны проблемы с кодировкой даты и обычно данный вопрос согласовывается с бэкендом. По умолчанию используется формат .deferToDate:
struct MyDate : Encodable {
let date: Date
}
let myDate = MyDate(date: Date())
try! encoder.encode(foo)
myDate будет выглядеть следующим образом:
{
"date" : 519751611.12542897
}
Если же нам требуется использовать например формат .iso8601, то мы можем легко изменить формат с помощью свойства dateEncodingStrategy:
encoder.dateEncodingStrategy = .iso8601
Теперь дата будет выглядеть так:
{
"date" : "2017-06-21T15:29:32Z"
}
Также вы можете использовать кастомный формат даты или вообще написать свой декодировщик даты, используя следующие опции форматирования:
.formatted(DateFormatter) — свой формат декодировщика даты
.custom( (Date, Encoder) throws -> Void ) — создание полностью своего формата декодирования даты
Парсинг вложенных объектов
Мы уже рассмотрели как можно использовать модели данных внутри других моделей, но иногда требуется произвести парсинг полей JSON, входящих в другие поля, без использования отдельной модели данных. Проблема будет более понятна, если рассмотреть ее на примере. У нас есть JSON следующего вида:
{
"id": 349,
"art": "M0470500",
"title": "Крем-уход Vichy 50 мл",
"ratings": {
"average_rating": 4.1034,
"votes_count": 29
}
}
Нам надо произвести парсинг полей «average» и «votes_count», это можно решить двумя способами, либо создать модель данных Ratings с двумя полями и сохранять данные в него, либо можно использовать nestedContainer. Первый случай мы уже обсуждали, а использование второго будет выглядеть следующим образом:
class Product: Decodable {
var id: Int
var art: String?
var title: String?
var votesCount: Int
var averageRating: Double
enum CodingKeys: String, CodingKey {
case id
case art
case title
case ratings
}
enum RatingsCodingKeys: String, CodingKey {
case votesCount = "votes_count"
case averageRating = "average_rating"
}
required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.id = try container.decode(Int.self, forKey: .id)
self.art = try? container.decode(String.self, forKey: .art)
self.title = try? container.decode(String.self, forKey: .title)
// Nested ratings
let ratingsContainer = try container.nestedContainer(keyedBy: RatingsCodingKeys.self, forKey: .ratings)
self.votesCount = try ratingsContainer.decode(Int.self, forKey: .votesCount)
self.averageRating = try ratingsContainer.decode(Double.self, forKey: .averageRating)
}
}
То есть эта проблема решается созданием еще одного дополнительного контейнера с помощью nestedContainter и его дальнейшим парсингом. Этот вариант удобен в случае если количество вложенных полей не такое большое, в противном же случае лучше использовать дополнительную модель данных.
Несоответствие названий полей JSON и свойств модели данных
Если обратить внимание на то как определены перечисления в наших моделях данных, то можно увидеть что элементам перечислений иногда присваивается строка, изменяющая дефолтное значение, например:
enum RatingsCodingKeys: String, CodingKey {
case votesCount = "votes_count"
case averageRating = "average_rating"
}
Это делается для того, чтобы поставить в правильное соответствие названия переменных модели и полей JSON. Обычно это требуется для полей, название которых состоит из нескольких слов, и в JSON они разделяются нижним подчеркиванием. В принципе, такое доопределение перечисления является наиболее популярным и выглядит несложно, но даже в этом случае Apple придумало более элегантное решение. Эту проблему можно решить одной строкой, используя keyDecodingStrategy. Данная возможность появилась в Swift 4.1
Допустим у вас JSON вида:
let jsonString = """
[
{
"name": "MacBook Pro",
"screen_size": 15,
"cpu_count": 4
},
{
"name": "iMac Pro",
"screen_size": 27,
"cpu_count": 18
}
]
"""
let jsonData = Data(jsonString.utf8)
Создадим для него модель данных:
struct Mac: Codable {
var name: String
var screenSize: Int
var cpuCount: Int
}
Переменные в модели записаны в соответствии с соглашением, начинаются со строчной буквы и далее каждое слово начинается c заглавной(так называемый camelCase). Но в JSON поля записаны с нижним подчеркиванием(так называемый snake_case). Теперь, чтобы парсинг прошел успешно нам необходимо либо определить в модели данных перечисление, в котором мы установим соответствие названий полей JSON с названиями переменных, либо мы получим ошибку времени выполнения. Но теперь есть возможность просто определить keyDecodingStrategy
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
do {
let macs = try decoder.decode([Mac].self, from: jsonData)
} catch {
print(error.localizedDescription)
}
Для функции encode вы соответственно можете использовать обратное преобразование:
encoder.keyEncodingStrategy = .convertToSnakeCase
Также есть возможность кастомизировать keyDecodingStrategy с помощью следующего замыкания:
let jsonDecoder = JSONDecoder()
jsonDecoder.keyDecodingStrategy = .custom { keys -> CodingKey in
let key = keys.last!.stringValue.split(separator: "-").joined()
return PersonKey(stringValue: String(key))!
}
Данная запись, например, позволяет использователь разделитель "-" для JSON. Пример используемого JSON:
{
"first-Name": "Taylor",
"last-Name": "Swift",
"age": 28
}
Таким образом зачастую можно избежать дополнительного определения перечисления.
Обработка ошибок
При парсинге JSON и при конвертации данных из одного формата в другой неизбежны ошибки, поэтому давайте рассмотрим варианты обработки разных видов ошибок. При декодировании возможны следующие виды ошибок:
- DecodingError.dataCorrupted(DecodingError.Context) — данные повреждены. Обычно означает что данные, которые вы пытаетесь декодировать не соответствуют ожидаемому формату, например вместо ожидаемого JSON вы получили совсем другой формат.
- DecodingError.keyNotFound(CodingKey, DecodingError.Context) — требуемое поле не было найдено. Означает что поле, которое вы ожидали получить, отсутствует
- DecodingError.typeMismatch(Any.Type, DecodingError.Context) — несоответствие типов. Когда тип данных в модели не совпадает с типом полученного поля
- DecodingError.valueNotFound(Any.Type, DecodingError.Сontext) — отсутствует значение для определенного поля. Поле, которое вы определили в модели данных, не смогло проинициализироваться, вероятно, в полученных данных это поле равно nil. Эта ошибка происходит только с неопциональными полями, если поле не обязательно должно иметь значение, не забудьте сделать его опционалом.
При кодировании же данных возможна ошибка:
EncodingError.invalidValue(Any.Type, DecodingError.Context) — не удалось преобразовать модель данных в определенный формат
Пример обработки ошибок при парсинге JSON:
do {
let decoder = JSONDecoder()
_ = try decoder.decode(businessReviewResponse.self, from: data)
} catch DecodingError.dataCorrupted(let context) {
print(DecodingError.dataCorrupted(context))
} catch DecodingError.keyNotFound(let key, let context) {
print(DecodingError.keyNotFound(key,context))
} catch DecodingError.typeMismatch(let type, let context) {
print(DecodingError.typeMismatch(type,context))
} catch DecodingError.valueNotFound(let value, let context) {
print(DecodingError.valueNotFound(value,context))
} catch let error{
print(error)
}
Обработку ошибок конечно лучше вынести в отдельную функцию, но тут, для наглядности, анализ ошибок идет вместе с парсингом. Например, вывод ошибки при отсутствии значения для поля «product» будет выглядеть следующим образом:
Сравнение Codable и NSCoding
Безусловно, протокол Codable — это большой шаг вперед в кодировании/декодировании данных, но до него существовал протокол NSCoding. Попробуем сравнить их и посмотреть какие преимущества появились у Codable:
- При использовании протокола NSCoding, объект обязательно должен быть подклассом NSObject, что автоматически подразумевает то, что наша модель данных должна быть классом. В Codable же нет необходимости наследования, соответственно модель данных может быть и class, и struct и enum.
- Если вам требуются раздельные функции кодирования и декодирования, как, например, в случае с парсингом JSON-данных, полученных через API, вы можете использовать только один протокол Decodable. То есть нет необходимости реализовывать порой ненужные методы init или encode.
- Codable может автоматически генерировать требуемые методы init и encode, а также дополнительное перечисление CodingKeys. Это, конечно же, работает только в случае если у вас простые поля в структуре данных, иначе, потребуется дополнительная кастомизация. В большинстве случаев, особенно для базовых структур данных, можно использовать автоматическую генерацию, особенно если вы переопределите keyDecodingStrategy, это удобно и сокращает часть лишнего кода.
Протоколы Codable, Decodable и Encodable позволили сделать еще один шаг к удобству преобразования данных, появились новые, более гибкие инструменты парсинга, сократилось количество кода, была автоматизирована часть процессов преобразования. Протоколы нативно реализованы в Swift 4 и дают возможность сократить применение сторонних библиотек, таких как SwiftyJSON, при этом сохранив удобство использования. Протоколы также дают возможность правильно организовать структуру кода, выделив модели данных и методы для работы с ними, в отдельные модули.
Комментарии (15)
babylon
19.06.2018 11:03Вы не ответили на вопрос. Сodable распарсит по модели. Но сама модель это код, а не JSON. Я про это.
Passt Автор
19.06.2018 12:15Сложно понять, что вы хотите добиться, если вам нужен JSON, то вы его получите после сетевого запроса и можете делать с ним что хотите, можете оставить так и хранить, например, если же вам нужны данные из JSON, то Decodable распарсит.
babylon
19.06.2018 20:09Я хочу добиться адаптивного поведения не залазя в код, а настраивая только JSON
GDXRepo
19.06.2018 11:29Вопрос в количестве телодвижений. Лично у меня не получилось, например, красиво и с минимальным вмешательством в код создать нормальный сетевой движок для сервера, с которым я работаю. AlamofireObjectMapper сильно помог, и сильно сократил количество кода. Codable — это определенно шаг вперед, но это не панацея, к сожалению, и сторонние либы все равно мощнее, иногда ощутимо. Не нужно изобретать велосипед, изобретенный до вас, Codable поможет в легких случаях, но полноценное сложное сетевое ядро на нем особо не напишешь (или придется потратить больше времени и строк кода, чем хотелось бы). На мой взгляд, конечно.
Passt Автор
19.06.2018 12:30Codable безусловно не панацея, и можно использовать сторонние библиотеки, к сожалению AlamofireObjectMapper не использовал, пробовал только SwiftyJSON, но в итоге больше понравился Decodable и у меня весь проект сделан на нем, более 50 APIшек со структурами данных разной сложности, без проблем. Но, зная Alamofire, думаю у них тоже достойный продукт.
GDXRepo
19.06.2018 12:43ObjectMapper, насколько я знаю, писал другой специалист, не разработчики Alamofire, но могу ошибаться. Если сравнивать SwiftyJSON с Codable, то я с вами соглашусь, они очень близки по функционалу. Попробуйте AlamofireObjectMapper, думаю, в будущем он поможет вам сократить количество времени и кода на сетевое ядро. Мне понравилось, во всяком случае. SwiftyJSON/Codable не подошли конкретному к моему проекту, специфический сервер. А так — лишь бы нравилось, если конкретное решение вам подошло — это замечательно.
cainford
19.06.2018 15:46Спасибо за статью!
Пора тоже переходить со SwiftyJSON на Codable.
В примере с nested-контейнером, я так понимаю, ошибка. Верно будет так:
self.votesCount = try? ratingsContainer.decode(Int.self, forKey: .votesCount)
self.averageRating = try? ratingsContainer.decode(Double.self, forKey: .averageRating)
babylon
Только 1 вопрос — почему нельзя использовать JSON в качестве модели + parser который эту модель обрабатывает? Как минимум будет все короче и легче рефакториться при добаление новых ключей.
Passt Автор
Зачем использовать дополнительный parser, если Codable автоматически сам распарсит все поля? Если надо добавить еще поля в модель, то это делается одной строкой, добавляете поле и все, не вижу никаких проблем. Codable для того и существует, чтобы не писать лишний код