Самая замечательная вещь, которую мы получили в Swift 4 — это протокол Codable, который представляет собой typealias протоколов Encodable и Decodable, и классы JSONDecode и JSONEncode. Благодаря этому вы теперь можете преобразовывать множество JSON объектов, а также Property Lists в эквивалентные структуры Struct и классы Class без единой строки дополнительного кода. Наконец-то Swift 4 и новый Foundation ответили на вопрос, как нужно парсить JSON в Swift.
Между прочим, если вы наберете для поиска строку «swift json library», то вы получите в Github порядка 75 замечательных репозиториев. Но новое «родное» Codable решение Apple в Swift 4 превосходит их все. Далее в статье я сосредоточусь на JSON, потому что это наиболее значимая задача для разработчиков, и представлю примеры на Playground.
Код можно посмотреть на Github.
Основы.
Я начну с примера, представленного в знаменитой статье Сhris Eidhof «Parsing JSON in Swft» (перевод здесь) ( 2014 г.), в которой впервые была сделана попытка создания в Swift JSON парсера на основе понятий функционального программирования, но это было сложное решение. Позже Apple представила свой способ парсинга JSON с помощью класса JSONSerialization, который также отличался громоздкостью.
Сейчас мы увидим, как в Swift 4 эта проблема решается 3 строками кода.
Итак, нам необходимо превратить приведенный ниже JSON объект, состоящий из строк и чисел, в структуру данных Swift, которая может состоять из Int, String, Double, различных классов Class, структур Struct, массивов Array, словарей Dictionary, географических координат CLLocationCoordinate2D и т.д.:
Структура данных нашего Swift объекта Stat будет в Swft 4 достаточно простой:
Это вложенные структуры, имена свойств которых в точности совпадают с ключами JSON объекта. Кроме того, все эти структуры подтверждают протокол Codable. Это все, что нам понадобиться. Создаем экземпляр JSONDecoder и получаем объект stat, соответствующий JSON объекту inputJSON:
Делаем dump объекта stat на консоли:
Структуры необязательно должны быть вложенными:
Результат будет тот же.
Пользовательская настройка имен ключей.
Однако чаще всего имена свойств в Swift объектах не совпадают с ключами JSON объектов, потому что в Swift мы используем «верблюжий» стиль camelStyle для имен свойств, а для именования ключей JSON объекта, как правило, используется «змеиный» стиль snake_style.
Например, в нашем случае мы бы хотели назвать свойство needsPassword в структуре Blog, а не needspassword, как в JSON объекте.
Для этого мы должны вмешаться в реализацию протокола Codable. По умолчанию компилятор генерирует перечисление enum CodingKeys для объекта, подтверждающего протокол Codable, ключи для JSON объекта совпадающие с именами свойств. для пользовательской настройки мы должны выполнить свою собственную реализацию перечисления enum CodingKeys:
Код парсинга останется тот же самый, а результат dump объекта stat на консоли будет немного другим:
Мы можем превратить полученный Swift объект stat в JSON объект (по существу выполнить обратную операцию) с помощью экземпляра encode класса JSONEncode:
В результате мы получим наш исходный JSON объект. Для форматирования полученного результата json мы использовали свойство .outFormatting экземпляра encode класса JSONEncode и установили его в .prettyPrinted:
Несоответствие структуры Swift объекта и JSON объекта
Если в JSON объекте присутствуют неинтересные нам ключи и значения, то они никак не повлияют на нашу структуру Swift объекта и на результат парсинга — мы можем вообще их не учитывать. Давайте добавим в наш JSON объект несколько дополнительных объектов:
Наш результат никак не поменялся, следовательно, нам нет необходимости учитывать все поля в JSON объекте. В нашем случае у нас нет свойства pages в структуре Stat, нет свойств email и createAt в структуре Blog. Руководствуясь этим мы можем исключить и свойство stat в структуре Stat, если целью парсинга является извлечение массива блогов [Blog]:
Код парсинга остался тем же самый и результат — объект stat — не изменился.
Если мы хотим добавить свойства email и createAt в структуру Blog, то это можно совершенно безболезненно сделать практически без изменения кода парсинга, но нужно учесть, что эти свойства встречаются не у всех блогов Blog, следовательно, они должны быть Optional:
Если код парсинга оставить неизменным, то мы получим ошибку:
Эта ошибка сообщает, что по ключу create_at ожидалось Double значение, а вместо этого получили String/Data.
Настройка даты
В JSON нет типа данных, представляющего дату, и обычно используется представление даты в виде строки или числа, а о формате реального представления даты договаривается сервер и клиент. Что касается даты, то чаще всего используется представление даты в виде строки и ISO 8601 формат. По умолчанию классы JSONDecoder и JSONEncode используют опцию .deferToDate, что означает представление даты в виде Double.
В нашем JSON объекте дата представлена в виде строки «2017-08-22T12:19:00Z» ,и нам нужно настроить свойство dateDecodingStrategy нашего decoder на опцию .iso8601:
В результате ошибка исчезнет и мы получим dump объекта stat на консоли:
Мы видим, что у первого блога появились Optional свойства email и createAt и дата представлена в правильном формате, а у второго блога свойства email и createAt равны nil.
Кстати, encode прекрасно работает и дает следующий результат обратного преобразования:
Для encode мы не настраивали формат даты, и формат представления даты остался установленным по умолчанию и равным .deferToDate, что соответствует числовому представлению даты.
Давайте поменяем свойство dateEncodingStrategy нашего encoder на опцию .iso8601:
В результате получим нужный нам результат:
Возможны и другие опции для представления даты:
.formatted(DateFormatter)
– вы можете использовать свой собственный экземпляр класса DateFormatter.
.custom( (Date, Encoder) throws -> Void )
– для случая действительного очень пользовательского вы можете использовать замыкание.
.millisecondsSince1970
and .secondsSince1970
— не рекомендутся использовать, так как данные о зоне действия времени отсутствуют и могут ввести пользователя в заблуждение.
Массивы Array и словари Dictionary
Допустим, что в нашем JSON объекте почта блога email представлена массивом:
В этом случае нам нужно лишь сделать свойство email не Optional строкой, а Optional массивом строк:
и мы получим правильный результат для decode:
и для encode:
Если в JSON объекте почта email маркируется своим местоположением с помощью словаря :
то придется использовать не Optional массив, а Optional массив словарей:
и в этом случае мы получим правильные результаты парсинга для decode:
и encode:
До сих пор мы переименовывали свойства, меняли способ представления дат, пропускали ненужные нам свойства в JSON объектах, но иерархия Swift объектов полностью отражала иерархию JSON объектов, и парсинг давался нам легко — 3-5 строк кода.
А что если нам не нужна топовая часть JSON, и мы хотим построить только нужный нам массив блогов blog в структуре Blogs.
В этом случае нам придется реализовать свой собственный инициализатор init (from decoder: Decoder).
Реализация инициализатора init (from decoder: Decoder)
Прежде, чем мы приступим к реализации инициализатора, давайте рассмотрим основных игроков, и начнем с Decoder.
Это контейнеры:
- Keyed Container – обеспечивает значениями по ключам. Это по существу словарь.
- Unkeyed Container – обеспечивает упорядоченными значениями без ключей. Это означает массив.
- Single Value Container – выдает «сырые» значения без каких-либо контейнерных элементов.
Посмотрим на типовую часть нашего JSON объекта с точки зрения этих контейнеров:
Экземпляры классов JSONDecoder и JSONEncoder имеют соответствующие API для получения контейнеров. Например, топовый контейнер Keyed Container мы получаем у decoder с помощью метода:
let container = try decoder.container(keyedBy: CodingKeys.self)
Аргументом этого метода является перечисление с ключами keyedBy этого контейнера. Так как мы решили убрать из Модели два топовых контейнера, то нам необходимо самим создать перечисление enum TopCodingKeys:
Для структуры Blogs создаем расширение extension, в котором размещаем метод init (from decoder: Decoder):
Есть, по крайней мере, три способа получения массива блогов [Blog], но в любом случае мы должны получить самый топовый контейнер container и указать для него ключи TopCodingKeys.self.
let container = try decoder.container(keyedBy: TopCodingKeys.self)
Далее мы рассмотрим 3 способа получения массива блогов [Blog], скользя все ниже и ниже по иерархии объекта JSON. И тем самым продемонстрируем гибкость API класса JSONDecoder.
Первый способ состоит в извлечении словаря [String:[Blog]] для ключа .blogs из основного контейнера container и требует всего одной строки кода:
let blogs = try container.decode([String:[Blog]].self, forKey: .blogs)
Второй способ связан с получением вложенного Keyed Container по ключу .blogs и извлечении из него массива [Blog] по ключу .blog. Этот способ требует уже две строки кода :
let meta = try container.nestedContainer(keyedBy: TopCodingKeys.self,
forKey: .blogs)
let blogs = try meta.decode([Blog].self, forKey: .blog)
Третий способ спускается еще ниже по иерархии JSON объекта и работает непосредственно с элементами Blog массива блогов [Blog]. Этот способ очень полезен, если структура и типы данных Модели Blog и JSON элемента, представляющего блог Blog существенно отличаются. В нашем случае этого нет и мы приводим этот способ для демонстрации гибкости API класса JSONDecode, но в реальной практике у вас в Модели могут быть геокоординаты, которые в JSON всегда представляются массивом строк [String], а должны быть массивом Doble. В нашем случае третий способ выглядит уже сложнее первых 2-х:
Также как и во втором способе мы получаем вложенного Keyed Container по ключу .blogs, но далее извлекаем из него по ключу .blog вложенный UNkeyed Container blogsContainer, соответствующий массиву [Blog] и начинаем декодировать его элементы blog, которые накапливаем в массиве blogs. Полученный массив blogs используем для инициализации нашей структуры Blogs.
В результате применения любого из 3-х способов мы получаем с помощью decode:
один и тот же массив блогов blogs:
Мы можем использовать полученную информацию для encode:
и получим в качестве JSON объекта массив блогов blog:
Но мы можем восстановить исходный JSON объект, если создадим пользовательский конструктор func encode(to encoder: Encoder) throws в структуре Blogs:
Этот метод абсолютно симметричен нашему пользовательскому инициализатору init (from decoder: Decoder). Мы создаем контейнер container с ключами TopCodingKeys и начинаем добавлять в него нужные нам элементы. Здесь также, как и в инициализаторе, можно предложить 3 способа добавления элементов в зависимости от уровня иерархии добавляемых JSON объектов.
Сначала мы получаем контейнер container у encode:
var container = encoder.container(keyedBy: TopCodingKeys.self)
Контейнер container должен быть var, потому что к нему мы будем добавлять новые элементы. Мы добавляем «потерянные» элементы .stat и .pages, а также целиком словарь [«blog»: self.blog] для ключа .blogs :
try container.encode(«ok», forKey: .stat)
try container.encode(1, forKey: .pages)
try container.encode([«blog»: self.blog], forKey: .blogs)
Это первый способ, самый короткий.
Давайте рассмотрим 3-ий способ, когда каждый блог в массиве блогов blog добавляется отдельно:
Для придания определенного смысла этому способу, мы немного усложним задачу и будем добавлять во все блоги, в которых отсутствует email, вполне определенный email [[ «scholl»: «school@gmail.com»]]. Безусловно, эта задача носит искусственный характер, но здесь она приведена для того, чтобы показать, что JSON объект может существенно отличаться от Swift Модели.
В результате получаем следующий JSON объект:
Заключение.
В этой статье представлены некоторые примеры использования API нового протокола Codable в Swift 4. Код можно посмотреть на Github.
Ссылки: