[Разработка под iOS, Swift] Работа с сложными JSON-объектами в Swift (Codable)

Автор Сообщение
news_bot ®

Стаж: 6 лет 9 месяцев
Сообщений: 27286

Создавать темы news_bot ® написал(а)
20-Мар-2021 22:31

Написать эту статью меня сподвиг почти случившийся нервный срыв, причиной которого стало мое желание научиться общаться с сторонними API, конкретно меня интересовал процесс декодирования JSON-докуметов! Нервного срыва я, к счастью, избежал, поэтому теперь настало время сделать вклад в сообщество и попробовать опубликовать свою первую статью на Хабре. Почему вообще возникли проблемы с такой простой задачей?Чтобы понять, откуда проблемы, нужно сначала рассказать об инструментарии, которым я пользовался. Для декодирования JSON-объектов я использовал относительно новый синтезированный (synthesized) протокол библиотеки Foundation - Сodable. Codable - это встроенный протокол, позволяющий заниматься кодированием в объект текстового формата и декодированием из текстового формата. Codable - это протокол-сумма двух других протоколов: Decodable и Encodable. Cтоит сделать оговорку, что протокол называется синтезированным тогда, когда часть его методов и свойств имеют дефолтную реализацию. Зачем такие протоколы нужны? Чтобы облегчить работу с их подписанием, как минимум уменьшив количество boilerplate-кода. Еще такие протоколы позволяют работать с композицией, а не наследованием!Вот теперь поговорим о проблемах:
  • Во-первых, как и все новое, этот протокол плохо описан в документации Apple. Что я имею в виду под "плохо описан"? Разбираются самые простые случаи работы с JSON объектами; очень кратко описаны методы и свойства протокола.
  • Во-вторых, проблема языкового барьера. На понимание того, как составить поисковой запрос ушло несколько часов. Нервы в это время кончались с молниеносной скоростью.
  • В-третьих, то что гуглится простыми методами не рассказывает о сложных случаях
Теперь давайте поговорим о конкретном кейсе. Кейс такой: используя API сервиса Flickr, произвести поиск N-фотографий по ключевому слову (ключевое слово: читай поисковой запрос) и вывести их на экран. Сначала все стандартно: получаем ключ к API, ищем нужный REST-метод в документации к API, смотрим описание аргументов запроса к ресурсу, cоставляем и отправляем GET-запрос. И тут видим это в качестве полученного от Flickr JSON объекта:
{
   "photos":{
      "page":1,
      "pages":"11824",
      "perpage":2,
      "total":"23648",
      "photo":[
         {
            "id":"50972466107",
            "owner":"191126281@N@7" ,
            "secret":"Q6f861f8b0",
            "server":"65535",
            "farm":66,
            "title":"Prompt & Reliable Electrolux Oven Repairs in Gold Coast",
            "ispublic":1,
            "isfriend":0,
            "isfamily":0
         },
         {
            "id":"50970556873",
            "owner":"49965961@NG0",
            "secret":"21f7a6424b",
            "server":"65535",
            "farm" 66,
            "title":"IMG_20210222_145514",
            "ispublic":1,
            "isfriend":0,
            "isfamily":0
         }
      ]
   },
   "stat":"ok"
}
Ага, все вроде бы хорошо. Но где же фотографии? А фотографий здесь нет, потому что это логично. Почему логично? Фото - тяжелый объект, передающийся по сети в виде последовательности байт (чем лучше качество фото, тем больше байт). Так вот из-за того, что фото громоздкие, а интерфейс приложения в идеале должен быть отзывчивым, поэтому информация о таких объектах передается в виде их местонахождения (местонахождения той самой последовательности байт) на другом ресурсе (сервере) сервиса Flickr для того, чтобы потом можно было распараллелить процесс загрузки фотографий. Что имеем? Имеем ситуацию, где необходимо составить два последовательных GET-запроса к разным ресурсам, причем второй запрос будет использовать информацию первого! Соответственно алгоритм действий: запрос-декодирование-запрос-декодирование. И вот тут. и начались проблемы. От JSON-объекта полученного после первого запроса мне были нужен только массив с информацией о фото и то не всей, а только той, которая репрезентует информацию о его положении на сервере. Эту информацию предоставляют поля объекта "photo": "id", "secret", "server"То есть мы хотим, чтобы итог запроса был декодирован и помещен в похожую на эту структуру данных:
struct Photo {
    let id: String
    let secret: String
    let server: String
}
let results: [Photos] = // ...
Вся остальная "мишура" на не нужна. Так вот материала, описывающего best practices обработки такого JSON-объекта очень мало. Давайте разбираться. Сначала предположим, что никакой лишней информации нам не пришло. Таким образом я хочу ввести в курс дела тех, кто не работал с Codable. Представим, что наш объект выглядит вот так.
{
   "id":"50972466107",
   "owner":"191126281@N07",
   "secret":"06f861f8b0"
}
Здесь все предельно просто. Нужно создать структуру данных, имена свойств которой будут совпадать с ключами JSON-объекта (здесь это "id", "secret", "server"); типы свойств нашей структуры также обязаны удовлетворять типам, которым равны значения ключей (надеюсь, не запутал). Далее нужно просто подписаться на протокол Decodable, который сделает все за нас, потому что он умеет работать с вложенными типами (то есть если типы свойств являются его подписчиками, то и сам объект тоже будет по умолчанию на нее подписан). Что значит подписан? Это значит, что все методы смогут определиться со своей реализацией "по умолчанию". Далее процесс парсинга целиком. (Я декодирую из строки, которую предварительно перевожу в объект типа Data, потому что метод decode(...) объекта JSONDecoder работает с Data).Полезные советы:
  • Используйте сервисы, чтобы тренироваться на простых примерах, например если вы не хотите работать не с чьим API - используйте сервис jsonplaceholder.typicode.com, он даст вам доступ к простейшим JSON-объектами, получаемым с помощью GET-запросов.
  • Также полезным на мой взгляд является сервис jsonformatter.curiousconcept.com . Он предоставляет доступ к функционал выравниванивания "кривых" REST объектов, которые обычно показывает нам консоль Playground Xcode.
  • Последний мощный tool - app.quicktype.io - он описывает структуру данных на Swift по конкретному JSON-объекту.
Вернемся к мучениям. Парсинг:
struct Photo: Decodable {
    let id: String
    let secret: String
    let server: String
}
let json = """
{
   "id":"50972466107",
   "owner":"191126281@N07",
   "secret":"06f861f8b0"
}
"""
let data = json.data(using: .utf8)
let results: Photo = try! JSONDecoder().decode(Photo.self, from: data)
Обратите внимание на то, что любой ключ JSON-объекта, использующий следующую нотацию "key" : "sometexthere" для Decodable видится как видится как String, поэтому такой код создаст ошибку в run-time. Decodable не умеет явно coerce-ить (приводить типы).
struct Photo: Decodable {
    let id: Int
    let secret: String
    let server: Int
}
let json = """
{
   "id":"50972466107",
   "owner":"191126281@N07",
   "secret":"06f861f8b0"
}
"""
let data = json.data(using: .utf8)
let results: Photo = try! JSONDecoder().decode(Photo.self, from: data)
Усложним задачу. А что если нам пришел такой объект?
{
       "id":"50972466107",
       "owner":"191126281@N07",
       "secret":"06f861f8b0",
       "server":"65535",
       "farm":66,
       "title":"Prompt & Reliable Electrolux Oven Repairs in Gold Coast",
       "ispublic":1,
       "isfriend":0,
       "isfamily":0
    }
Здесь все элементарно, потому что Decodable умный протокол, который умеет парсить только те свойства, которые описывает наша структура и не "ругаться " на то, что некоторые свойства отсутствуют. Это логично, потому что хоть API и должно являться устойчивым, по мнению последователей "Чистого архитектора" Роберта Мартина, но никто не гарантирует нам то, что его разработчики баз данных не захотят, например, внести новых свойств. Если бы это не работало - наши приложения бы постоянно "крашились".Разберем дополнительный функционал доступный из коробки. Следующий вид JSON-объекта:
[
    {
       "id":"50972466107",
       "owner":"191126281@N07",
       "secret":"06f861f8b0",
       "server":"65535",
       "farm":66,
       "title":"Prompt & Reliable Electrolux Oven Repairs in Gold Coast",
       "ispublic":1,
       "isfriend":0,
       "isfamily":0
    },
    {
       "id":"50970556873",
       "owner":"49965961@N00",
       "secret":"21f7a6524b",
       "server":"65535",
       "farm":66,
       "title":"IMG_20210222_145514",
       "ispublic":1,
       "isfriend":0,
       "isfamily":0
    }
]
Тут придется добавить всего несколько логичных строк (или даже символов) кода. Помним, что массив объектов конкретного типа - это тоже тип!
struct Photo: Decodable {
    let id: String
    let secret: String
    let server: String
}
let json = """
[
    {
       "id":"50972466107",
       "owner":"191126281@N07",
       "secret":"06f861f8b0",
       "server":"65535",
       "farm":66,
       "title":"Prompt & Reliable Electrolux Oven Repairs in Gold Coast",
       "ispublic":1,
       "isfriend":0,
       "isfamily":0
    },
    {
       "id":"50970556873",
       "owner":"49965961@N00",
       "secret":"21f7a6524b",
       "server":"65535",
       "farm":66,
       "title":"IMG_20210222_145514",
       "ispublic":1,
       "isfriend":0,
       "isfamily":0
    }
]
"""
let data = json.data(using: .utf8)
let results: [Photo] = try! JSONDecoder().decode([Photo].self, from: data)
Добавление квадратных скобок к имени типа все сделало за нас, а все потому что [Photo] - это конкретный тип в нотации Swift. На что можно напороться с массивами: массив из одного объекта - тоже массив! Теперь разберем последний, обсуждаемый везде функционал, который пригодится в дальнейшем. Сначала залезем немного "под капот" Decodable и еще раз поймем, что такое JSON.
  • JSON-объект - это текст, удовлетворяющий заданной над ним формальной грамматике. Формальная грамматика - набор правил, задающих будущий вид объектов. Формальная грамматика однозначно может определить, является ли текст JSON-объектом или нет! Придуман он для того, чтобы обеспечить кросс-платформенность, потому что такой элементарный тип как Character есть в любом языке, а значит можно его использовать для идентификации содержимого JSON-объекта и дальнейшего приведения к клиентским структурам данных.
  • JSON - ОО текстовый формат. Что же это значит? То, что он общается с программистами на уровне объектов в привычном понимании ООП, только объекты эти утрированные: методы с помощью JSON мы передать не можем (можем, но зачем). Что все это значит? Это значит то, что любая открывающая фигурная скобка открывает новый контейнер (область видимости)
  • Decodable может перемещаться по этим контейнерам и забирать оттуда только необходимую нам информацию.
Сначала поймем, каким инструментарием необходимо пользоваться. Протокол Decodable определяет generic enum CodingKeys, который сообщает парсеру (декодеру) то, как именно необходимо соотносить ключ JSON объекта с названием свойства нашей структуры данных, то есть это перечисление является ключевым для декодера объектом, с помощью него он понимает, в какое именно свойство клиентской структуры присваивать следующее значение ключа! Для чего может быть полезна перегрузка этого перечисления в топорных условиях, я думаю всем ясно. Например, для того, чтобы соблюсти стиль кодирования при парсинге: JSON-объект использует snake case для имен ключей, а Swift поощряет camel case. Как это работает?
struct Photo: Decodable {
    let idInJSON: String
    let secretInJSON: String
    let serverInJSON: String
    enum CodingKeys: String, CodingKey {
        case idInJSON = "id_in_JSON"
        case secretInJSON = "secret_in_JSON"
        case serverInJSON = "server_in_JSON"
    }
}
rawValue перечисления CodingKeys говорят, как выглядят имена наших свойств в JSON-документе!Отсюда мы и начнем наше путешествие внутрь контейнера! Еще раз посмотрим на JSON который нужно было изначально декодировать!
{
   "photos":{
      "page":1,
      "pages":"11824",
      "perpage":2,
      "total":"23648",
      "photo":[
         {
            "id":"50972466107",
            "owner":"191126281@N@7" ,
            "secret":"Q6f861f8b0",
            "server":"65535",
            "farm":66,
            "title":"Prompt & Reliable Electrolux Oven Repairs in Gold Coast",
            "ispublic":1,
            "isfriend":0,
            "isfamily":0
         },
         {
            "id":"50970556873",
            "owner":"49965961@NG0",
            "secret":"21f7a6424b",
            "server":"65535",
            "farm" 66,
            "title":"IMG_20210222_145514",
            "ispublic":1,
            "isfriend":0,
            "isfamily":0
         }
      ]
   },
   "stat":"ok"
}
Опишем контейнеры:
  • Первый контейнер определяет объект, состоящий из двух свойств: "photos", "stat"
  • Контейнер "photos" в свою очередь определяет объект состоящий из пяти свойств: "page", "pages", "perpages", "total", "photo"
  • "photo" - просто массив контейнеров, некоторый свойства из которых нам нужны.
Как можно решать задачу в лоб?
  • Объявить кучу вложенных типов и радоваться жизни. Итог пишем dummy алгоритмы ручного приведение между уже клиентскими объектами. Это нехорошо!
  • Пользоваться функционалом Decodable протокола, а точнее его перегружать дефолтную реализацию инициализатора и переопределять CodingKeys! Это хорошо! Оговорка: к сожалению Swift (по понятным причинам!) не дает определять в extension stored properties, а computed properties не видны Сodable/Encodable/Decodable, поэтому красиво работать с чистыми JSON массивами не получится.
Решая вторым способом, мы прокладываем для декодера маршрут к тем данным, которые нам нужны: говорим ему зайди в контейнера photos и забери массив из свойства photo c выбранными нами свойствамиСразу приведу код этого решения и уже потом объясню, как он работает!
// (1) Определили объект Photo только с необходимыми к извлечению свойствами.
struct Photo: Decodable {
    let id: String
    let secret: String
    let server: String
}
// (2) Определяем JSONContainer, то есть описываем куда нужно идти парсеру и что забирать.
struct JSONContainer: Decodable {
    // (3) photos совпадает c именем ключа "photos" в JSON, но теперь мы написали, что хранить этот ключ будет не весь контейнер, а только его часть - массив, который является значением ключа photo!
    let photos: [Photo]
}
extension JSONContainer {
    // (4) Описываем CodingKeys для парсера.
    enum CodingKeys: String, CodingKey {
        case photos
        // (5) Здесь определяем только те имена ключей, которые будут нужны нам внутри контейнера photos.
        // (6) Здесь необязательно соблюдать какие-то правила именования, но название PhotosKeys - дает представление о том, что мы рассматриваем ключи внутри значения ключа photos
        enum PhotosKeys: String, CodingKey {
            // (7) Описываем конкретно интересующий нас ключ "photo"
            case photoKey = "photo"
        }
    }
    // (8) Дальше переопределяем инициализатор
    init(from decoder: Decoder) throws {
        // (9) Заходим внутрь JSON, который определяется контейнером из двух ключей, но нам из них нужно только одно - photos
        let container = try decoder.container(keyedBy: CodingKeys.self)
        // (10) Заходим в контейнер (nested - вложенный) ключа photos и говорим какие именно ключи смы будем там рассматривать
        let photosContainer = try container.nestedContainer(keyedBy: CodingKeys.PhotosKeys.self, forKey: .photos)
        // (11) Декодируем уже стандартным методом
        // (12) Дословно здесь написано следующее положи в свойство photos объект-массив, который определен своим типом и лежит .photoKey (.photoKey.rawValue == "photo")
        photos = try photosContainer.decode([Photo].self, forKey: .photoKey)
    }
}
Вот и все, теперь когда экземпляр объекта JSONDecoder. Будет вызывать decode() - под капотом он будет использовать наш инициализатор для работы с декодированиемПодводя итоги, хотелось бы сказать, что работа с сетью в iOS-разработке наполнена разнообразными "сюрпризами", поэтому если вы можете что-то добавить в комментариях - обязательно делайте это!Всем спасибо!P.S. По прошествии некоторого времени был сделан вывод, что мапить к итоговой структуре в коде, пользуясь только встроенным поведением Codable, нормально. Вывод был сделан после просмотра сессии WWDC, разбирающей работу с данными пришедшими из сетиСсылка на сессию
===========
Источник:
habr.com
===========

Похожие новости: Теги для поиска: #_razrabotka_pod_ios (Разработка под iOS), #_swift, #_networking, #_codable, #_json, #_parsing, #_razrabotka_pod_ios (
Разработка под iOS
)
, #_swift
Профиль  ЛС 
Показать сообщения:     

Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы

Текущее время: 22-Ноя 20:45
Часовой пояс: UTC + 5