Робота c JSON в Swift


Покажіть мені розробника, якому ще жодного разу не доводилося працювати з 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.  
  3. За допомогою механізму associated values ​​можливими значеннями цього перерахування встановлюються примітивні Swift-типи, соотвествующие JSON-типами (github )
  4.  
  5. Основний конструктор типу перевіряє тип переданого об'єкта і повертає екземпляр з потрібним значенням перерахування (github )
  6.  
  7. При цьому для контейнерів (масивів і словників) елементи обробляються рекурсивно (github )
  8.  
  9. Для навігації по іерерхіі JSON реалізовані subscripts, які повертають соотвествующие елементи для масивів і словників (github )
  10.  
  11. Для зворотного перетворення з JSON-перерахування до соотвествующий примітивний Swift-тип використовуються computed properties , які повертають асоційоване значення тільки в разі збігу типу (github )
  12.  
Перетворивши вихідну об'єктну модель в таке подання ми отримуємо зручний інтерфейс, який можна використовувати для навігації по ієрархії і отримання значення очікуваного типу:
 
 
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 .

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.