使用泛型與函數式思想高效解析 JSON

更多優秀譯文請關注我們的微信公眾號:learnSwift

原文連接:Efficient JSON in Swift with Functional Concepts and Generics

就在幾個月前,蘋果推出了一門全新的編程語言,其名為Swift, 這讓我們對未來 iOS 和 OS X 開發充滿了期待與興奮。人們紛紛開始使用 Xcode Beta1 版本來進行 Swift 開發,但是很快就發現解析 JSON 這一常見的操作在 Swift 中并不如在 Objectitve-C 中那樣快捷和方便。Swift 是一門靜態類型的語言,這意味我們不能簡單地將對象賦值給一個特定類型的變量,并且讓編譯器相信這些對象就是我們所聲明的那種類型。在 Swift 當中,編譯器會進行檢查,以確保我們不會意外地觸發運行時錯誤。這使得我們可以依賴編譯器來寫出一些無 bug 的代碼,同時我們必須做許多額外的工作來使編譯器不報錯。在這篇文章當中,我將使用函數式思想和泛型來探討如何編寫易讀高效的 JSON 解析代碼。

請求用戶(User)模型

我們要做的事就是將網絡請求獲得的數據解析成 JSON。之前我們一直使用的是 NSJSONSerialization.JSONObjectWithData(NSData, Int, &NSError)方法,這個方法返回一個可選的 JSON 數據類型,如果解析過程出錯會得到 NSError 類型的數據。在 Objective-C 當中,JSON 的數據類型是一個可以包含任何其它數據類型的 NSDictionary類型。 而在 Swift 當中, 新的字典類型要求我們必須顯式指定它所包含的數據的類型。JSON 數據被指定為Dictionary<String, AnyObject>類型。這里使用 AnyObject的原因是 JSON 的值有可能為 StringDoubleBoolArrayDictionary 或者 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 { 
      // 在 beta5 中,存在一個 bug,所以我們首先要強行轉換成 AnyObject?
        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 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> 作為傳入回調的參數,這樣調用者便可以直接處理解析過的User對象或者錯誤。

func getUser(request: NSURLRequest, callback: 
                (Either<NSError, User>) -> ()) {
  let task = NSURLSession.sharedSession().dataTaskWithRequest(request) 
  { data, urlResponse, error in
    // 如果響應返回錯誤,我們將把錯誤發送給回調
    if let err = error {
      callback(.Left(err))
      return
    }
    
    var jsonErrorOptional: NSError?
    let jsonOptional: JSON! = 
    NSJSONSerialization.JSONObjectWithData(data, 
    options: NSJSONReadingOptions(0), error: &jsonErrorOptional)
    
    // 如果我們不能解析 JSON,就將發送回去一個錯誤
    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(.Left(NSError()))
  }
  task.resume()
}

現在調用getUser的地方可以直接使用Either,然后對接收到的用戶對象進行處理,或者直接顯示錯誤。

getUser(request) { either in
  switch either {
  case let .Left(error):
    //顯示錯誤信息

  case let .Right(user):
    //對user進行操作
  }
}

我們假設Left一直是NSError,這可以進一步簡化代碼。我們可以使用一個不同的類型 Result<A> 來保存我們需要的類型數據和錯誤信息。它的實現方式如下:

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

在當前的 Swift 版本(Beta 5)中,上面的 Result類型會造成編譯錯誤(譯者注:事實上,在 Swift 1.2 中還是有錯誤)。 Swift 需要知道存儲在enum當中數據的確切類型。可以通過創建一個靜態類作為包裝類型來解決這個問題:

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
    // 如果響應返回錯誤,我們將把錯誤發送給回調
    if let err = error {
      callback(.Error(err))
      return
    }

    var jsonErrorOptional: NSError?
    let jsonOptional: JSON! =
     NSJSONSerialization.JSONObjectWithData(data, 
     options: NSJSONReadingOptions(0), error: &jsonErrorOptional)
    
    // 如果我們不能解析 JSON,就返回一個錯誤
    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(.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, IntDictionary 三種類型,所以我們需要三個函數來對這三種類型進行解析。

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 語句。函數式編程中的 MonadsApplicative Functors,以及 Currying 概念可以幫助我們來壓縮這段代碼。首先看看與 Swift 中的可選類型十分相似的 Monad。Monad 中有一個綁定(bind)運行符,這個運行符可以給一個可選類型綁定一個函數,這個函數接受一個非可選類型參數,并返回一個可選類型的返回值。如果第一個可選類型是 .None這個運行符會返回 .None ,否則它會對這個可選類型進行解包,并使用綁定的函數調用解包后的數據。

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)
      }
    }
  }
}

接著就可以去掉解析函數里的可選參數:

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
}

Functors 有一個fmap運算符,可以在某些上下文中通過函數應用到解包后的值上面。Applicative Functors 也有apply運算符,可以在某些上下文中通過解包后的函數應用到解包后的值上面。這里的上下文是一個包含了值的可選值。這就意味著我們可以使用一個能夠帶有多個非可選值的函數來連接多個可選值。如果所有的值都存在,.Some會得到可選值解包的結果。如果其中任何值是.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
}

先別著急使用這些代碼,由于 Swift 不支持自動柯里化(auto-currying), 我們需要手動柯里化(curry)結構體User中的init方法。柯里化的意思是當我們給定一個函數的參數比它原來的參數更少時,這個函數將返回一個包含剩余參數的函數。我們的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
    // 如果響應返回錯誤,返回錯誤
    if let err = error {
      callback(.Error(err))
      return
    }

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

    // 如果我們不能解析 JSON,返回錯誤
    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(.Error(NSError()))
  }
  task.resume()
}

重構:通過綁定消除多個返回

觀察到在上面的函數中,我們的調用了callback函數 4 次。漏掉任何一次都會制造 bug。我們可以把這個函數分解成 3 個互不相關的部分,從而消除潛在的 bug 并重構這個函數。這三個部分是:解析響應,解析數據為 JSON 和解析 JSON 為User對象。這些步驟中的每一步都帶有一個輸入和返回下一個步驟的輸入或者錯誤。綁定我們的Result類型看起來是一個不錯的方案。
parseResponse函數需要Result數據和響應的狀態碼。iOS API 只提供了NSURLResponse并保證數據獨立。所以我們創建一個小結構體來輔助一下:

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
    }
  }
}

現在我們可以把Response結構體傳入parseResponse函數,然后在處理數據之前處理錯誤。

func parseResponse(response: Response) -> Result<NSData> {
  let successRange = 200..<300
  if !contains(successRange, response.statusCode) {
    return .Error(NSError()) // 自定義你想要的錯誤信息
  }
  return .Value(Box(response.data))
}

下一個函數需要我們將一個可選值轉換成Result類型,我們先來抽象一下。

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

接下來的函數需要解析數據為 JSON:

func decodeJSON(data: NSData) -> Result<JSON> {
  let jsonOptional: JSON! = 
  NSJSONSerialization.JSONObjectWithData(data, 
  options: NSJSONReadingOptions(0), error: &jsonErrorOptional)
  return resultFromOptional(jsonOptional, NSError()) 
  // 使用默認的錯誤或者自定義錯誤信息
}

然后,我們在User類型中添加 JSON 到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)
  }

  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()
}

Wow,即使再次書寫這些代碼,我都對這些結果感到興奮。你可能會想,"這已經非常酷炫了,我們已經迫不及待的想用它了!",但是這還不算完!

重構:使用泛型抽象類型

已經非常棒了,但是我們仍然想編寫這個解析器適用于任何類型。我可以使用泛型(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()) // 自定義錯誤
}

現在我們可以讓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 <^>
        d["id"]    >>> JSONInt    <*>
        d["name"]  >>> JSONString <*>
        d["email"] >>> JSONString
  }
}

我們改變了User的解析函數,用可選的User替換掉Result<User>。這樣我們就擁有了一個抽象的函數,可以在解碼后調用resultFromOptional,替代之前模型中必須使用的decode函數。

最后,我們抽象performRequest函數中的解析和解碼過程,讓它們變得更加易讀。下面是最終的performRequestparseResult函數:

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寫的博客:Applicative Options Parsing in Haskell

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,321評論 6 543
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,559評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,442評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,835評論 1 317
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,581評論 6 412
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,922評論 1 328
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,931評論 3 447
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,096評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,639評論 1 336
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,374評論 3 358
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,591評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,104評論 5 364
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,789評論 3 349
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,196評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,524評論 1 295
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,322評論 3 400
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,554評論 2 379

推薦閱讀更多精彩內容

  • 發現 關注 消息 iOS 第三方庫、插件、知名博客總結 作者大灰狼的小綿羊哥哥關注 2017.06.26 09:4...
    肇東周閱讀 12,175評論 4 61
  • Spring Cloud為開發人員提供了快速構建分布式系統中一些常見模式的工具(例如配置管理,服務發現,斷路器,智...
    卡卡羅2017閱讀 134,826評論 18 139
  • 生活就像一塊巧克力,你永遠不知道它到底有多黑。 生活如一片死水,她出生了,上學了,被欺負了,又在眾多否定的聲音中考...
    優票票閱讀 131評論 0 0
  • 驀然回首三十載, 一事無成心自憐。 雄心猶在已無志, 兀自空想花已殘。 人生匆忙至而立,猛然回首事無成; 呆坐空想...
    閑居散人閱讀 860評論 0 5
  • 風起龍飛閱讀 227評論 0 13