Pull to refresh

Работа c JSON в Swift

Reading time8 min
Views29K

Покажите мне разработчика, которому еще ни разу не приходилось работать с JSON. За счет своей простоты, гибкости и наглядности этот формат представления данных набрал большую популярность и сейчас используется повсеместно. Вот и я во время экспериментов со Swift быстро столкнулся с необходимостью разбора JSON-данных.

Собственно с задачей прямого и обратного преобразования JSON из текстового представления в объектную модель отлично справляется стандартный Foundation API – NSJSONSerialization. В Apple проделали серьезную работу для обеспечения прямого и обратного взаимодействия Swift и Objective-C кода (Using Swift with Cocoa and Objective-C), поэтому использование привычных Cocoa API не только возможно на практике, но и удобно и не выглядит неестественно:

let jsonString = "{\"name\":\"John\",\"age\":32,\"phoneNumbers\":[{\"type\":\"home\",\"number\":\"212 555-1234\"}]}"
let jsonData = jsonString.dataUsingEncoding(NSUTF8StringEncoding, allowLossyConversion: true)
let jsonObject: AnyObject! = NSJSONSerialization.JSONObjectWithData(jsonData, options: NSJSONReadingOptions(0), error: nil)

Но работать с полученным результатом в статически типизированном Swift неудобно – требуется цепочка проверок и приведений типов для получения любого значения. Далее я рассмотрю варианты решения этой проблемы, а заодно мы познакомимся с некоторыми особенностями Swift.

Формально полученное через NSJSONSerialization представлением JSON состоит из экземпляров Foundation-типов – NSNull, NSNumber, NSString, NSArray, NSDictionary. Но runtime bridging обеспечивает полную совместимость и взаимозаменяемость этих типов и соотвествующих Swift-примитивов – числовых типов (Int, Double и пр.), String, Array, Dictionary. Поэтому в Swift-коде мы можем работать с полученным объектом “нативно”. Допустим нам необходимо проверить значение первого номера телефона. На Objective-C это могло бы выглядеть так:

NSString *number = jsonObject[@“phoneNumbers”][0][@“number”];
NSAssert(["212 555-1234" isEqualToString:number], @”numbers should match”);

Благодаря использованию динамической типизации навигация по иерархии JSON-объекта не вызывает проблем в Objective-C. Swift, напротив, использует строгую статическую типизацию, поэтому на каждом шаге “вглубь” иерархии необходимо использовать явное приведение типов:

let person = jsonObject! as Dictionary<String,AnyObject>
let phoneNumbers = person["phoneNumbers"] as Array<AnyObject>
let phoneNumber = phoneNumbers[0] as Dictionary<String,AnyObject>
let number = phoneNumber["number"] as String
assert(number == "212 555-1234")

К сожалению текущая версия компилятора (Xcode 6 beta 2) генерирует ошибку для этого кода – есть проблема с разбором выражений явного приведения типов с операндами, использующими subscripts. Это можно обойти через промежуточные переменные:

let person = jsonObject! as Dictionary<String,AnyObject>
let phoneNumbers : AnyObject? = person["phoneNumbers"]
let phoneNumbersArray = phoneNumbers as Array<AnyObject>
let phoneNumber : AnyObject? = phoneNumbersArray[0]
let phoneNumberDict = phoneNumber as Dictionary<String,AnyObject>
let number : AnyObject? = phoneNumberDict["number"]
let numberString = number as String
assert(numberString == "212 555-1234")

Этот вариант работает правильно, но выглядит конечно ужасно. Объединить получение значения в одном выражении можно с помощью optional downcasting и optional chaining:

let maybeNumber = (((jsonObject as? NSDictionary)?["phoneNumbers"] as? NSArray)?[0] as? NSDictionary)?["number"] as? NSString
assert(maybeNumber == "212 555-1234")

Уже лучше, но такую запись конечно трудно назвать удобной – для чтения и особенно для редактирования.

Понятно, что это лиш частный случай общей проблемы – сложности работы с динамическими структурами данных в языках со строгой типизацией. Существуют разные подходы к решению этой проблемы, некоторые изменяют сам способ обработки данных. Но для конкретного случая с разбором JSON-объектов мне хотелось найти простое решение, отвечающее следующим требованиям:
  • компактность и читаемость кода;
  • свести к минимуму необходимость использования явного приведения типов;
  • возможность и удобство “раскрытия” нескольких уровней иерархии в одном выражении по-цепочке;
  • исключить возможность возникновения runtime-ошибок из-за несоответствия структуры JSON ожиданиям – в таких случаях значение всего выражения должно быть nil;
  • минимизировать накладные расходы ресурсов CPU и памяти.

Первым делом я попытался найти и проанализировать готовые решения.

Enum-представление

Сразу несколько авторов предлагают схожий подход:

Рассмотрим вкратце их устройство на примере json-swift:
  1. Для представления JSON-объектов вводится новый тип-перечисление (enum) (github)
  2. С помощью механизма associated values возможными значениями этого перечисления устанавливаются примитивные Swift-типы, соотвествующие JSON-типам (github)
  3. Основной конструктор типа проверяет тип переданного объекта и возвращает экземпляр с нужным значением перечисления (github)
  4. При этом для контейнеров (массивов и словарей) элементы обрабатываются рекурсивно (github)
  5. Для навигации по иерерхии JSON реализованы subscripts, которые возвращают соотвествующие элементы для массивов и словарей (github)
  6. Для обратного преобразования из JSON-перечисления в соотвествующий примитивный Swift-тип используются computed properties, которые возвращают ассоциированное значение только в случае совпадения типа (github)

Преобразовав исходную объектную модель в такое представление мы получаем удобный интерфейс, который можно использовать для навигации по иерархии и получения значения ожидаемого типа:

let number = JSON(jsonObject)?[“phoneNumbers”]?[0]?[“number”]?.string
assert(number == "212 555-1234")

Здесь механизм optional chaining обеспечивает гарантию отсутствия runtime-ошибок. При разборе объекта с несоответствующей структурой значением всего выражения будет nil (кроме случая обращения по индексу за границами массива).

Получается, что такое решение отвечает всем выдвинутым требованиям, кроме одного. При его использовании происходит обязательный рекурсивный обход всей иерархии JSON-объекта и создание нового объектного представления данных. Конечно в каких-то случаях такие накладные расходы не играют принципиальной роли. Но все же вцелом такое решение никак нельзя назвать оптимальным с точки зрения использования ресурсов CPU и памяти.

Кардинальным способом решения проблемы было бы использование такого объектного представления JSON прямо на этапе преобразования из текстового представления. Но такой подход уже выходит за рамки рассматриваемой задачи – удобной работы с нативным объектным представлением JSON.

Ленивая обработка

Другой подход к решению проблемы полного преобразования – использование “ленивой” логики для проверки и приведения типов при обходе JSON. Вместо того, чтобы сразу пересоздавать всю иерерахию JSON со значениями нужных типов, можно делать это на каждом шаге “вглубь” – только для одного запрашиваемого элемента. Реализацию именно такого подхода предлагает небезызвестный Mike Ash: gist.github.com/mikeash/f443a8492d78e1d4dd10

К сожалению, при таком подходе не получится представить отдельное JSON-значение в таком же удобном виде (enum + associated values). Но такое решение очевидно более оптимальное. На первый взгляд и тут есть небольшие накладные расходы в виде создания дополнителього объекта-обертки на каждом шаге вглубь иерархии. Но эти объекты определены как структуры (struct Value), поэтому их инициализация и использование могут быть хорошо оптимизированы компилятором в Swift.

Решение

Мне все же хотелось найти решение, не использующее новые типы, а расширяющее поведение стандартных типов необходимым образом. Давайте разберем подробнее выражение со стандартным синтаксисом

(((jsonObject as? NSDictionary)?["phoneNumbers"] as? NSArray)?[0] as? NSDictionary)?["number"] as? NSString

Фактически проблемы здесь вызывают только переходы к элементам словарей и массивов. Это объясняется тем, что обращение по subscript ([1] или [“number”]) накладывает требование на тип значения, к которому оно применяется – в нашем случае мы приводим к NSDictionary или к NSArray. Или с другой стороны – полученные значения из NSArray и NSDictionary имеют тип AnyObject, что требует приведения типа для использования далее в цепочке вызовов.

Получается, что необходимость приведения исчезнет, если мы будем оперировать универсальным типом, который изначально поддерживает оба варианта subscript и возвращает объекты такого же типа. В Swift такому определению формально соответствует протокол:

protocol JSONValue {
    subscript(key: String) -> JSONValue? { get }
    subscript(index: Int) -> JSONValue? { get }
}

Таким образом протокол определят JSON-значение, к которому всегда можно обратиться по subscript (с Int или String параметром). В результате можно получить либо элемент коллекции (если объект является коллекцией, ее тип соответствует типу subscript и элемент с таким subscript есть в коллекции), либо nil.

Чтобы работать со стандартными типами таким образом, нужно обеспечить их соответствие JSONValue. Swift позволяет добавлять реализацию протоколов через extensions. В результате все решение выглядит так:

protocol JSONValue {
    subscript(#key: String) -> JSONValue? { get }
    subscript(#index: Int) -> JSONValue? { get }
}

extension NSNull : JSONValue {
    subscript(#key: String) -> JSONValue? { return nil }
    subscript(#index: Int) -> JSONValue? { return nil }
}

extension NSNumber : JSONValue {
    subscript(#key: String) -> JSONValue? { return nil }
    subscript(#index: Int) -> JSONValue? { return nil }
}

extension NSString : JSONValue {
    subscript(#key: String) -> JSONValue? { return nil }
    subscript(#index: Int) -> JSONValue? { return nil }
}

extension NSArray : JSONValue {
    subscript(#key: String) -> JSONValue? { return nil }
    subscript(#index: Int) -> JSONValue? { return index < count && index >= 0 ? JSON(self[index]) : nil }
}

extension NSDictionary : JSONValue {
    subscript(#key: String) -> JSONValue? { return JSON(self[key]) }
    subscript(#index: Int) -> JSONValue? { return nil }
}

func JSON(object: AnyObject?) -> JSONValue? {
    if let some : AnyObject = object {
        switch some {
        case let null as NSNull: return null
        case let number as NSNumber: return number
        case let string as NSString: return string
        case let array as NSArray: return array
        case let dict as NSDictionary: return dict
        default: return nil
        }
    } else {
        return nil
    }
}

Несколько замечаний:
  • чтобы избежать конфликта со стандартными subscripts в NSDictionary и NSArray, используются subscripts с именованными параметрами – #key, #index;
  • для приведения произвольных значений к типу JSONValue используется вспомогательная функция, потому что стандартные операторы проверки и приведения типов работают только для протоколов, помеченных атрибутом @objc (скорее всего это объясняется тем, что соответствие не-@objc протоколам может быть добавлено таким Swift-типам, данные о которых недоступны в runtime);
  • хотя код и оперирует Foundation-типами, runtime bridging обеспечивает правильную работу и со Swift-примитивами.

В результате для работы с JSON мы можем использовать выражение:

let maybeNumber = JSON(jsonObject)?[key:"phoneNumbers"]?[index:0]?[key:"number"] as? NSString
assert(maybeNumber == "212 555-1234")

Хотя это и не самый компактный вариант из рассмотренных, такое решение полностью отвечает всем перечисленным требованиям.

Альтернативный вариант

На основе той же идеи возможен вариант с использованием протокола с @objc атрибутом. Это позволяет использовать явное приведение типов вместо вспомогательной функции, но запрещает использование subscripts – вместо них придется использовать обычные методы. Зато эти методы можно объявить как @optional:

@objc
protocol JSON {
    @optional func array(index: Int) -> JSON?
    @optional func object(key: String) -> JSON?
}

extension NSArray : JSON {
    func array(index: Int) -> JSON? { return index < count && index >= 0 ? self[index] as? JSON : nil }
}

extension NSDictionary: JSON {
    func object(key: String) -> JSON? { return self[key] as? JSON }
}

extension NSNull : JSON {}

extension NSNumber : JSON {}

extension NSString : JSON {}

Пример использования:

let maybeNumber = (jsonObject as? JSON)?.object?(“phoneNumbers”)?.array?(0)?.object?(“number”) as? NSString
assert(maybeNumber == "212 555-1234")

Не так компактно, как вариант с subscripts. Кого-то может смутить количество знаков вопроса, но с другой стороны каждое его использование вполне объяснимо и несет смысловую нагрузку.

На мой взгляд найденные решения отвечают заданным требованиям и выглядят предпочтительнее других рассмотренных вариантов. А использованную идею – выделение универсального протокола с методами, возвращающим optional-значения – можно использовать для удобной работы не только с JSON, но и с другими динамическими структурами данных.

Код и примеры использования доступны на github.
Tags:
Hubs:
+18
Comments2

Articles