Формат 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» будет выглядеть следующим образом:

image

Сравнение 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)


  1. babylon
    19.06.2018 02:10

    Создадим для него модель данных:
    struct Mac: Codable {
    var name: String
    var screenSize: Int
    var cpuCount: Int
    }

    Только 1 вопрос — почему нельзя использовать JSON в качестве модели + parser который эту модель обрабатывает? Как минимум будет все короче и легче рефакториться при добаление новых ключей.


    1. Passt Автор
      19.06.2018 10:25

      Зачем использовать дополнительный parser, если Codable автоматически сам распарсит все поля? Если надо добавить еще поля в модель, то это делается одной строкой, добавляете поле и все, не вижу никаких проблем. Codable для того и существует, чтобы не писать лишний код


  1. babylon
    19.06.2018 11:03

    Вы не ответили на вопрос. Сodable распарсит по модели. Но сама модель это код, а не JSON. Я про это.


    1. Passt Автор
      19.06.2018 12:15

      Сложно понять, что вы хотите добиться, если вам нужен JSON, то вы его получите после сетевого запроса и можете делать с ним что хотите, можете оставить так и хранить, например, если же вам нужны данные из JSON, то Decodable распарсит.


      1. babylon
        19.06.2018 20:09

        Я хочу добиться адаптивного поведения не залазя в код, а настраивая только JSON


  1. GDXRepo
    19.06.2018 11:29

    Вопрос в количестве телодвижений. Лично у меня не получилось, например, красиво и с минимальным вмешательством в код создать нормальный сетевой движок для сервера, с которым я работаю. AlamofireObjectMapper сильно помог, и сильно сократил количество кода. Codable — это определенно шаг вперед, но это не панацея, к сожалению, и сторонние либы все равно мощнее, иногда ощутимо. Не нужно изобретать велосипед, изобретенный до вас, Codable поможет в легких случаях, но полноценное сложное сетевое ядро на нем особо не напишешь (или придется потратить больше времени и строк кода, чем хотелось бы). На мой взгляд, конечно.


    1. Passt Автор
      19.06.2018 12:30

      Codable безусловно не панацея, и можно использовать сторонние библиотеки, к сожалению AlamofireObjectMapper не использовал, пробовал только SwiftyJSON, но в итоге больше понравился Decodable и у меня весь проект сделан на нем, более 50 APIшек со структурами данных разной сложности, без проблем. Но, зная Alamofire, думаю у них тоже достойный продукт.


      1. GDXRepo
        19.06.2018 12:43

        ObjectMapper, насколько я знаю, писал другой специалист, не разработчики Alamofire, но могу ошибаться. Если сравнивать SwiftyJSON с Codable, то я с вами соглашусь, они очень близки по функционалу. Попробуйте AlamofireObjectMapper, думаю, в будущем он поможет вам сократить количество времени и кода на сетевое ядро. Мне понравилось, во всяком случае. SwiftyJSON/Codable не подошли конкретному к моему проекту, специфический сервер. А так — лишь бы нравилось, если конкретное решение вам подошло — это замечательно.


  1. babylon
    19.06.2018 13:14

    FlatBuffers никак нельзя назвать велосипедом.


  1. 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)


    1. Passt Автор
      19.06.2018 15:48

      Да, конечно, там ratingsContainer, поправил, спасибо! )


      1. cainford
        19.06.2018 15:54

        Кстати, не поленился и посмотрел в плейграунде. Там еще несколько ошибок. Рекомендую всё же потестить код и внести исправления.


  1. Passt Автор
    19.06.2018 16:10

    Теперь точно все ок )


    1. cainford
      19.06.2018 16:16

      Это String, а не Double и Int:
      «average»: «4.1034»,
      «votes_count»: «29»

      Ключи не совпадают:
      «average_rating» и «average»


      1. Passt Автор
        19.06.2018 18:50

        Поправил JSON