Как стать автором
Обновить

Работа со сложными JSON-объектами в Swift (Codable)

Время на прочтение10 мин
Количество просмотров15K

Написать эту статью меня сподвиг почти случившийся нервный срыв, причиной которого стало мое желание научиться общаться с сторонними 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", "owner", "server"

То есть мы хотим, чтобы итог запроса был декодирован и помещен в похожую на эту структуру данных:

struct Photo {
    let id: String
    let owner: 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 . Он выполняет текстовое выравнивание JSON объектов, которые обычно показывает нам консоль Playground Xcode, когда мы печатаем туда ответ сервера на запрос.

  • Последний мощный tool - app.quicktype.io - он описывает структуру данных на Swift по конкретному JSON-объекту.

Вернемся к мучениям. Парсинг:

struct Photo: Decodable {
    let id: String
    let secret: String
    let owner: 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 owner: 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 owner: 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 ownerInJSON: String
    
    enum CodingKeys: String, CodingKey {
        case idInJSON = "id_in_JSON"
        case secretInJSON = "secret_in_JSON"
        case ownerInJSON = "owner_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 owner: 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, разбирающей работу с данными пришедшими из сети

Ссылка на сессию

P.S.S. Спасибо комментаторам. Дополним предыдущее послесловие. Допустим мы не хотим изменять поведение Decodable, а задача перед нами стоит та же самая. Тогда создадим две сущности и сервис между ними: транспортный объект, бизнес объект и маппер. Транспортный объект будет описывать либо весь JSON-объект, либо необходимую его часть. Инстанцирование транспортного объекта будет отдано Decodable. Переход от структуры транспортного объекта к структуре бизнес объекта будет реализован с помощью нашего маппера. Бизнес объект же будет представлять модель. В нашем примере бизнес объект - массив объектов типа Photo. Вкратце, такой подход позволяет работать с API более гибко и убрать часть логики, отвечающей за получение начальных данных в транспортный объект.

Теги:
Хабы:
Всего голосов 4: ↑2 и ↓2+4
Комментарии6

Публикации

Истории

Работа

iOS разработчик
15 вакансий
Swift разработчик
15 вакансий

Ближайшие события

15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань