Парсим JSON в Swift 4 на примерах.

Самая замечательная вещь, которую мы получили в 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.

Ссылки:

Ultimate Guide to JSON Parsing With Swift 4.

JSON Parsing in Swift 4.

Swift 4 JSON Parsing.