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

Эффективный JSON с функциональными концепциями и generics в Swift

Время на прочтение 14 мин
Количество просмотров 12K
Автор оригинала: Tony DiPasquale
Это перевод статьи Tony DiPasquale «Efficient JSON in Swift with Functional Concepts».

Предисловие переводчика


Передо мной была поставлена задача: закачать данные в формате JSON с Flickr.com о 100 топ местах, в которых сделаны фотографии на данный момент, в массив моделей:

//------ Массив моделей Places
struct Places {   
    var places : [Place]
}

//-----Модель Place
struct Place {

    let placeURL: NSURL
    let timeZone: String
    let photoCount : Int
    let content : String
    
}

Кроме чисто прагматической задачи, мне хотелось посмотреть как в Swift работает «вывод типа из контекста» (type Inference), какие возможности Swift в функциональном программировании, и я выбрала для парсинга JSON алгоритмы из статьи Tony DiPasquale «Efficient JSON in Swift with Functional Concepts and Generics», в которой он «протягивает» generic тип Result для обработки ошибок по всей цепочке преобразований: от запроса к серверу до размещения данных в массив Моделей для последующего представления в UITableViewController.

Чтобы посмотреть, как Swift работает "в связке" с Objective-C, для считывания данных с Flickr.com использовался Flickr API, представленный в курсе Стэнфордского Университета "Stanford CS 193P iOS 7", написанный на Objective-C.
В результате помимо небольшого расширения Моделей:

extension Place: JSONDecodable {
    static func create(placeURL: String)(timeZone: String)(photoCount: String)(content: String) -> Place {
        return Place(placeURL: toURL(placeURL), timeZone: timeZone, photoCount: photoCount.toInt() ?? 0, content: content)
    }
    static func decode(json: JSON) -> Place? {
        return _JSONParse(json) >>> { d in
            Place.create
                <^> d <| "place_url"
                <*> d <| "timezone"
                <*> d <| "photo_count"
                <*> d <| "_content"
        }
    }
}

extension Places: JSONDecodable { 
    static func create(places: [Place]) -> Places {
        return Places(places: places)
    }
    static func decode(json: JSON) -> Places? {
        return _JSONParse(json) >>> { d in
            Places.create
                <^> d <| "places" <| "place"
            
        }
    }
}

... мне самостоятельно пришлось написать только три строчки кода:

class ViewController: UIViewController {
        override func viewDidLoad() {
        super.viewDidLoad()
        
        //--------------- URL для places из Flickr.com ------------------------------------------      
        let urlPlaces  = NSURLRequest( URL: FlickrFetcher.URLforTopPlaces())       
        performRequest(urlPlaces ) { (places: Result<Places>) in
            println("\(stringResult(places))")                      
        }
    }
}


И добавить "мост" между Swift и Objective-C - файл EfficientJSONBrief-Bridging-Header.h , в котором указываем FlickrFetcher.h файл API Flickr :



Код можно посмотреть на Github.

Перевод


Несколько месяцев назад Apple представила новый язык программирования, Swift, чем сильно воодушевила разработчиков относительно будущего написания приложений для iOS и OS X. Люди немедленно, начиная с версии Xcode 6 Beta1, начали пробовать Swift и понадобилось не так много времени, чтобы обнаружить, что парсинг JSON - редкое приложение обходится без него - не так прост как в Objective-C. Swift является статически типизованным языком, а это означает, что мы не можем больше забрасывать объекты в типизованные переменные и заставлять компилятор доверять нам, что они таковыми и являются. Теперь, в Swift, компилятор выполняет проверку, давая нам уверенность, что мы случайно не вызовем runtime ошибки. Это позволяет нам опираться на компилятор при создании безошибочного кода, но это также означает, что мы должны делать дополнительную работу, чтобы его удовлетворить. В этом посту я обсуждаю API для парсинга JSON, который использует концепции функционального программирования и дженерики ( Generics ) для создания читаемого и эффективного кода.

Запрашиваем Модель User


Первое, что нам необходимо - это преобразование данных, которые мы получаем по сетевому запросу, в JSON. В прошлом мы использовали NSJSONSerialization.JSONObjectWithData(NSData, Int, &NSError), что давало нам тип данных Optional JSON и возможную ошибку ( error ), если возникали проблемы с парсингом. Тип данных для JSON объектов в Objective-C - это NSDictionary, который может содержать любые объекты в своих значениях. В Swift у нас новый тип словаря, который требует, чтобы мы определили типы данных, которые им поддерживаются. Теперь объекты JSON превратились в Dictionary<String, AnyObject>. AnyObject используется из-за того, что JSON значение может быть String, Double, Bool, Array, Dictionary или null. Когда мы пытаемся использовать JSON для получения созданной нами модели, приходится тестировать каждый ключ, который мы получаем из JSON словаря, на предмет подходящего типа данных элементов модели. В качестве примера возьмем модель пользователя User:

struct User {
  let id: Int
  let name: String
  let email: String
}


Давайте посмотрим, как может выглядеть запрос и ответ сервера для текущего пользователя:

func getUser(request: NSURLRequest, callback: (User) -> ()) {
  let task = NSURLSession.sharedSession().dataTaskWithRequest(request)
         { data, urlResponse, error in
                  var jsonErrorOptional: NSError?
                  let jsonOptional: AnyObject! =
                         NSJSONSerialization.JSONObjectWithData(data,
                                  options: NSJSONReadingOptions(0),
                                    error: &jsonErrorOptional)

    if let json = jsonOptional as? Dictionary<String, AnyObject> {
      if let id = json["id"] as AnyObject? as? Int {
        if let name = json["name"] as AnyObject? as? String {
          if let email = json["email"] as AnyObject? as? String {
            let user = User(id: id, name: name, email: email)
            callback(user)
          }
        }
      }
    }
  }
  task.resume()
}


После многочисленных вложенных if-let предложений, мы наконец-то получили наш User объект. Можно себе представить, что чем больше у модели будет свойств, тем она будет выглядеть все ужаснее и ужаснее . Кроме того, мы не отслеживаем ошибки, которые возможны на любом шаге: в случае ошибки мы не получим ничего. Наконец, мы должны будем писать этот код для каждой модели, которая требуется для нашего API, что приведет к значительному дублированию кода.
Начнем рефакторинг нашего кода, но прежде для упрощения JSON типов определим некоторые алиасы типов typealias.

typealias JSON = AnyObject
typealias JSONDictionary = Dictionary<String, JSON>
typealias JSONArray = Array<JSON>


Рефакторинг: Добавляем управления ошибками


Во-первых, мы будем проводить рефакторинг нашей функции с целью управления ошибками.

И тут нам понадобится первая концепция функционального программирования, тип Either<A, B>. Это позволит нам вернуть пользователю объект как в случае, если все проходит успешно, так и в случае возникновения ошибки. В Swift можно так реализовать тип Either<A, B> :

enum Either<A, B> {
  case Left(A)
  case Right(B)
}


Мы можем использовать Either<NSError, User> в качестве типа, который передается нашему callback, следовательно, вызывающая функция сможет управлять как успешно "разобранной" моделью User, так и ошибкой (error).

func getUser(request: NSURLRequest, callback: (Either<NSError, User>) -> ()) {
  let task = NSURLSession.sharedSession().dataTaskWithRequest(request) { data, urlResponse, error in
    // если  возвращается ошибка, то посылаем ее в callback
    if let err = error {
      callback(.Left(err))
      return
    }

    var jsonErrorOptional: NSError?
    let jsonOptional: JSON! = NSJSONSerialization.JSONObjectWithData(data,
                                                options: NSJSONReadingOptions(0),
                                                  error: &jsonErrorOptional)

    // если возникает ошибка парсинга JSON, посылаем ее в callback
    if let err = jsonErrorOptional {
      callback(.Left(err))
      return
    }

    if let json = jsonOptional as? JSONDictionary {
      if let id = json["id"] as AnyObject? as? Int {
        if let name = json["name"] as AnyObject? as? String {
          if let email = json["email"] as AnyObject? as? String {
            let user = User(id: id, name: name, email: email)
            callback(.Right(user))
            return
          }
        }
      }
    }

    // если не смогли "разобрать" какие-то свойства, то посылаем ошибку в callback
    callback(.Left(NSError()))
  }
  task.resume()
}


Теперь функция, вызывающая наш getUser может использовать switch предложение для Either , и что-то делать с текущим пользователем User или показывать ошибку.

getUser(request) { either in
  switch either {
  case let .Left(error):
    // показываем сообщение об ошибке

  case let .Right(user):
    //делаем что-то с user
  }
}


Мы немного упростили это, предполагая, что Left всегда будет NSError. Вместо этого давайте использовать подобный, но другой тип
Result , который будет содержать либо значение, которое мы ищем, либо ошибку. Его реализация выглядит так:

enum Result<A> { case Error(NSError) case Value(A) }


В текущей версии Swift (1.1), тип Result вызовет ошибку компиляции. Swift должен знать, какой тип будет помещен внутрь всех значений перечисления. Мы можем создать постоянный класс (constant class) для размещения нашего generic значения A.

(Примечание переводчика. В настоящий момент в Swift перечисления enum не могут быть generic на самом топовом уровне, но, как было сказано в статье, могут быть представлены как generic, если их обернуть в "постоянный" class box):


final class Box<A> {
  let value: A

  init(_ value: A) {
    self.value = value
  }
}

enum Result<A> {
  case Error(NSError)
  case Value(Box<A>)
}


Заменяя Either на Result, мы получим следующее:

func getUser(request: NSURLRequest, callback: (Result<User>) -> ()) {
  let task = NSURLSession.sharedSession().dataTaskWithRequest(request) { data, urlResponse, error in
    //  если  возвращается ошибка, то посылаем ее в callback
    if let err = error {
      callback(.Error(err))
      return
    }

    var jsonErrorOptional: NSError?
    let jsonOptional: JSON! = NSJSONSerialization.JSONObjectWithData(data, options: NSJSONReadingOptions(0), error: &jsonErrorOptional)

    // если возникает ошибка парсинга JSON, посылаем ее в callback
    if let err = jsonErrorOptional {
      callback(.Error(err))
      return
    }
    if let json = jsonOptional as? JSONDictionary {
      if let id = json["id"] as AnyObject? as? Int {
        if let name = json["name"] as AnyObject? as? String {
          if let email = json["email"] as AnyObject? as? String {
            let user = User(id: id, name: name, email: email)
            callback(.Value(Box(user)))
            return
          }
        }
      }
    }

    // если не смогли "разобрать" какие-то свойства, то посылаем ошибку в callback
    callback(.Error(NSError()))
  }
  task.resume()
}

getUser(request) { result in
  switch result {
  case let .Error(error):
    // показываем сообщение об ошибке

  case let .Value(boxedUser):
    let user = boxedUser.value
    // делаем что-то с user
  }
}


Небольшое изменение. Но давайте продолжим рефакторинг.

Рефакторинг: Уничтожение дерева проверки типов


На следующем этапе мы избавимся от уродливых JSON парсингов путем создания отдельных JSON парсеров для каждого типа. В нашем объекте есть только String, Int и Dictionary, так что необходимы 3 функции для парсинга этих типов.

func JSONString(object: JSON?) -> String? {
  return object as? String
}

func JSONInt(object: JSON?) -> Int? {
  return object as? Int
}

func JSONObject(object: JSON?) -> JSONDictionary? {
  return object as? JSONDictionary
}


Теперь JSON парсинг выглядит так:

if let json = JSONObject(jsonOptional) {
  if let id = JSONInt(json["id"]) {
    if let name = JSONString(json["name"]) {
      if let email = JSONString(json["email"]) {
        let user = User(id: id, name: name, email: email)
      }
    }
  }
}


Использование этих функций все еще не отменяет кучу if-let синтаксиса. Такие концепции функционального программирования как Монады, Функторы, Аппликативные Функторы и Каррирование помогут нам "сжать" наш парсинг.

Во-первых, давайте посмотрим на монаду Maybe, которая подобна Optional в Swift. У монад есть оператор bind ("связывание"), который, при использовании с Optionals, разрешает нам "связывать" Optional c функцией, которая берет не-Optional и возвращает Optional. Если первый Optional, который на входе, - это .None, то возвращается .None, в противном случае оператор bind "разворачивает" первый Optional и применяет к нему функцию.

infix operator >>> { associativity left precedence 150 }

func >>><A, B>(a: A?, f: A -> B?) -> B? {
  if let x = a {
    return f(x)
  } else {
    return .None
  }
}


В других функциональных языках оператор >>= используется для bind ("связывание"); но в Swift этот оператор уже занят и используется для побитового сдвига, так что вместо него будем использовать оператор >>> .

Применяя его к JSON парсингу, получим:

if let json = jsonOptional >>> JSONObject {
  if let id = json["id"] >>> JSONInt {
    if let name = json["name"] >>> JSONString {
      if let email = json["email"] >>> JSONString {
        let user = User(id: id, name: name, email: email)
      }
    }
  }
}


Теперь мы можем убрать Optional параметры из наших парсеров:

func JSONString(object: JSON) -> String? {
  return object as? String
}

func JSONInt(object: JSON) -> Int? {
  return object as? Int
}

func JSONObject(object: JSON) -> JSONDictionary? {
  return object as? JSONDictionary
}


У функторов есть оператор fmap для применения функций к значениям, обернутым в некоторый контекст. У аппликативных функторов также есть оператор apply для применения обернутых функций к значениям, обернутым в некоторый контекст. В нашем случае контекст, в который "заворачиваются" наши значения - это Optional. Это означает, что мы можем комбинировать многочисленные Optional значения с функцией, которая берет множество не-Optional значений. Если все значения присутствуют и представлены .Some, то мы получаем результат, обернутый в Optional. Если какое-то из этих значений представлено .None, мы получаем .None. Мы можем определить эти операторы в Swift следующим образом:

infix operator <^> { associativity left } // Functor's fmap (usually <$>)
infix operator <*> { associativity left } // Applicative's apply

func <^><A, B>(f: A -> B, a: A?) -> B? {
  if let x = a {
    return f(x)
  } else {
    return .None
  }
}

func <*><A, B>(f: (A -> B)?, a: A?) -> B? {
  if let x = a {
    if let fx = f {
      return fx(x)
    }
  }
  return .None
}


Но прежде, чем мы соберем все это вместе, нам необходимо каррировать вручную инициализатор (init) нашей модели User, так как Swift не поддерживает автокаррирование (auto-currying). Каррирование означает, что если мы на вход каррирования подаем функцию с меньшим числом параметров, чем у нее есть, то каррирование возвращает функцию с оставшимися параметрами. И наша User модель будет выглядеть так:

struct User {
  let id: Int
  let name: String
  let email: String

  static func create(id: Int)(name: String)(email: String) -> User {
    return User(id: id, name: name, email: email)
  }
}


Собирая все вместе, наш JSON парсинг будет выглядеть так:

if let json = jsonOptional >>> JSONObject {
  let user = User.create <^>
              json["id"]    >>> JSONInt    <*>
              json["name"]  >>> JSONString <*>
              json["email"] >>> JSONString
}


Если какой-то из наших парсеров возвращает .None,то user будет .None. Это выглядит намного лучше, но мы еще не закончили.
Теперь наша функция getUser изменится:

func getUser(request: NSURLRequest, callback: (Result<User>) -> ()) {
  let task = NSURLSession.sharedSession().dataTaskWithRequest(request) { data, urlResponse, error in
    // если  возвращается ошибка, то посылаем ее в callback
    if let err = error {
      callback(.Error(err))
      return
    }

    var jsonErrorOptional: NSError?
    let jsonOptional: JSON! = NSJSONSerialization.JSONObjectWithData(data, options: NSJSONReadingOptions(0), error: &jsonErrorOptional)

    //  если возникает ошибка парсинга JSON, посылаем ее в callback
    if let err = jsonErrorOptional {
      callback(.Error(err))
      return
    }
    if let json = jsonOptional >>> JSONObject {
      let user = User.create <^>
                  json["id"]    >>> JSONInt    <*>
                  json["name"]  >>> JSONString <*>
                  json["email"] >>> JSONString
      if let u = user {
        callback(.Value(Box(u)))
        return
      }
    }

    // если не смогли "разобрать" какие-то свойства, то посылаем ошибку в callback
    callback(.Error(NSError()))
  }
  task.resume()
}


Рефакторинг: Убираем многочисленные returns с помощью "bind" (связывания)


Заметьте, что в предыдущей функции мы четыре раза вызываем callback . Если мы забудем хотя бы одно предложение return , то мы ошибочно представим результат как NSError. Мы можем уничтожить этот потенциальный bug и сделать более понятной эту функцию в дальнейшем, если разобьем эту функцию на 3 различные части: парсинг ответа сервера, парсинг данных в JSON и парсинг JSON в объект User.
Каждый из этих шагов берет один вход и возвращает результат его преобразования для следующего шага или ошибку. Это звучит как идеальный случай для использования оператора bind для нашего типа Result.

Функции parseResponse понадобится Result с data и статусным кодом ответа сервера. API iOS дает нам только NSURLResponse и держит data отдельно, поэтому мы сделаем маленькую вспомогательную структуру :

struct Response {
  let data: NSData
  let statusCode: Int = 500

  init(data: NSData, urlResponse: NSURLResponse) {
    self.data = data
    if let httpResponse = urlResponse as? NSHTTPURLResponse {
      statusCode = httpResponse.statusCode
    }
  }
}


Теперь мы можем передать нашей функции parseResponse структуру Response и проверить ответ сервера на ошибки, прежде чем заниматься с data.

func parseResponse(response: Response) -> Result<NSData> {
  let successRange = 200..<300
  if !contains(successRange, response.statusCode) {
    return .Error(NSError()) // настройте сообщение об ошибке как вам нравится
  }
  return .Value(Box(response.data))
}


Следующие функции понадобятся нам для преобразования типа Optional в тип Result, но прежде создадим одну очень простую абстракцию.

func resultFromOptional<A>(optional: A?, error: NSError) -> Result<A> {
  if let a = optional {
    return .Value(Box(a))
  } else {
    return .Error(error)
  }
}


Следующая функция - преобразование наших data в JSON:

func decodeJSON(data: NSData) -> Result<JSON> {
  let jsonOptional: JSON! = NSJSONSerialization.JSONObjectWithData(data, options: NSJSONReadingOptions(0), error: &jsonErrorOptional)
  return resultFromOptional(jsonOptional, NSError()) // используйте ошибку из NSJSONSerialization или задайте свое сообщение об ошибке
}


Теперь добавляем декодирование JSON непосредственно в саму модель:

struct User {
  let id: Int
  let name: String
  let email: String

  static func create(id: Int)(name: String)(email: String) -> User {
    return User(id: id, name: name, email: email)
  }

  static func decode(json: JSON) -> Result<User> {
    let user = JSONObject(json) >>> { dict in
      User.create <^>
          dict["id"]    >>> JSONInt    <*>
          dict["name"]  >>> JSONString <*>
          dict["email"] >>> JSONString
    }
    return resultFromOptional(user, NSError()) // задайте сообщение об ошибке
  }
}


Перед тем как скомбинировать все вместе, давайте распространим действие оператора >>> на тип Result :

func >>><A, B>(a: Result<A>, f: A -> Result<B>) -> Result<B> {
  switch a {
  case let .Value(x):     return f(x.value)
  case let .Error(error): return .Error(error)
  }
}


и добавим пользовательский инициализатор к Result :

enum Result<A> {
  case Error(NSError)
  case Value(Box<A>)

  init(_ error: NSError?, _ value: A) {
    if let err = error {
      self = .Error(err)
    } else {
      self = .Value(Box(value))
    }
  }
}


Теперь комбинируем все эти функции с оператором >>>.

func getUser(request: NSURLRequest, callback: (Result<User>) -> ()) {
  let task = NSURLSession.sharedSession().dataTaskWithRequest(request) { data, urlResponse, error in
    let responseResult = Result(error, Response(data: data, urlResponse: urlResponse))
    let result = responseResult >>> parseResponse
                                >>> decodeJSON
                                >>> User.decode
    callback(result)
  }
  task.resume()
}


Здорово, я восхищен полученным результатом. Вы можете подумать: “Это действительно круто. Не могу дождаться чтобы это попробовать!”, но нам нужно еще кое-что доделать!

Рефакторинг: Избавляемся от "типа" с помощью generics


Это здорово, но придется писать это для каждой модели, которую мы хотим получать из JSON. Давайте использовать generics, чтобы сделать это абсолютно абстрактным.
Введем протокол JSONDecodable и скажем нашей функции, что возвращаемый тип должен подтверждать этот протокол:

protocol JSONDecodable {
class func decode(json: JSON) -> Self?
}


Следующим шагом напишем функцию, которая будет декодировать любую модель, подтверждающую протокол JSONDecodable, в Result :

func decodeObject<A: JSONDecodable>(json: JSON) -> Result<A> {
  return resultFromOptional(A.decode(json), NSError()) // custom error
}


Теперь заставим User подтвердить этот протокол:

struct User: JSONDecodable {
  let id: Int
  let name: String
  let email: String

  static func create(id: Int)(name: String)(email: String) -> User {
    return User(id: id, name: name, email: email)
  }

  static func decode(json: JSON) -> User? {
    return JSONObject(json) >>> { d in
      User.create <^>
        json["id"]    >>> JSONInt    <*>
        json["name"]  >>> JSONString <*>
        json["email"] >>> JSONString
  }
}


Мы изменили функцию декодера decode для User так, чтобы она возвращала Optional User вместо Result. Это позволяет нам иметь абстрактную функцию, которая вызывает resultFromOptional после decode вместо того, чтобы вызывать ее для каждой модели в функции decode.
Наконец, мы уберем парсинг и декодирование из функции performRequest для лучшей читаемости. Теперь у нас есть две финальные функции: performRequest и parseResult :

func performRequest<A: JSONDecodable>(request: NSURLRequest, callback: (Result<A>) -> ()) {
  let task = NSURLSession.sharedSession().dataTaskWithRequest(request) { data, urlResponse, error in
    callback(parseResult(data, urlResponse, error))
  }
  task.resume()
}

func parseResult<A: JSONDecodable>(data: NSData!, urlResponse: NSURLResponse!, error: NSError!) -> Result<A> {
  let responseResult = Result(error, Response(data: data, urlResponse: urlResponse))
  return responseResult >>> parseResponse
                        >>> decodeJSON
                        >>> decodeObject
}


Дальнейшее изучение

Пример кода представлен в GitHub.

Если вы интересуетесь функциональным программированием или какими-то его аспектами, представленными в этом посте, посмотрите Haskell и особенно этот пост из книги Learn You a Haskell. Также посмотрите пост Pat Brisbin о парсинге options с использованием аппликативного функтора.

Послесловие переводчика


В ссылке GitHub дан лишь окончательный вариант кода. Если вы хотите следовать за логикой статьи шаг за шагом, а также тестировать алгоритмы, представленные в статье, на данных, имитирующих ошибки, то код можно найти здесь.

Есть еще две статьи Tony DiPasquale в продолжение этой, еще более интересные:

"Real World JSON Parsing with Swift" - перевод "Реальный мир парсинга JSON в Swift"
"Parsing Embedded JSON and Arrays in Swift" - перевод "Парсинг вложенных JSON и массивов (Arrays) в Swift."

Если кого-то интересуют эксперименты с этими алгоритмами (в том числе и на реальных данных и с имитацией ошибок) :

"Swift код к статьe Tony DiPasquale “Эффективный JSON с функциональными коцепциями и дженериками в Swift”"

"Swift код к статье Tony DiPasquale “Реальный мир парсинга JSON в Swift”"

"Swift код к статье “Парсинг вложенных JSON и массивов (Arrays) в Swift.”"
Теги:
Хабы:
+11
Комментарии 4
Комментарии Комментарии 4

Публикации

Истории

Работа

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

Московский туристический хакатон
Дата 23 марта – 7 апреля
Место
Москва Онлайн
Геймтон «DatsEdenSpace» от DatsTeam
Дата 5 – 6 апреля
Время 17:00 – 20:00
Место
Онлайн