使用 Swift 進行 JSON 解析

SwiftGG翻譯組發表於2016-09-06

作者:Soroush Khanlou,原文連結,原文日期:2016-04-08
譯者:Lanford3_3;校對:pmst;定稿:CMB

使用 Swift 解析 JSON 是件很痛苦的事。你必須考慮多個方面:可選類性、型別轉換、基本型別(primitive types)、構造型別(constructed types)(其構造器返回結果也是可選型別)、字串型別的鍵(key)以及其他一大堆問題。

對於強型別(well-typed)的 Swift 來說,其實更適合使用一種強型別的有線格式(wire format)。在我的下一個專案中,我將會選擇使用 Google 的 protocol buffers這篇文章說明了它的好處)。我希望在得到更多經驗後,寫篇文章說說它和 Swift 配合起來有多麼好用。但目前這篇文章主要是關於如何解析 JSON 資料 —— 一種被最廣泛使用的有線格式。

對於 JSON 的解析,已經有了許多優秀的解決方案。第一個方案,使用如 Argo 這樣的庫,採用函式式操作符來柯里化一個初始化構造器:


extension User: Decodable {
  static func decode(j: JSON) -> Decoded<User> {
    return curry(User.init)
      <^> j <| "id"
      <*> j <| "name"
      <*> j <|? "email" // Use ? for parsing optional values
      <*> j <| "role" // Custom types that also conform to Decodable just work
      <*> j <| ["company", "name"] // Parse nested objects
  }
}

Argo 是一個非常好的解決方案。它簡潔,靈活,表達力強,但柯里化以及奇怪的操作符都是些不太好理解的東西。(Thoughtbot 的人已經寫了一篇不錯的文章來對這些加以解釋)

另外一個常見的解決方案是,手動使用 guard let 進行處理以得到非可選值。這個方案需要手動做的事兒會多一些,對於每個屬性的處理都需要兩行程式碼:一行用來在 guard 語句中生成非可選的區域性變數,另一行設定屬性。若要得到上例中同樣的結果,程式碼可能長這樣:


class User {
  init?(dictionary: [String: AnyObject]?) {
    guard
      let dictionary = dictionary,
      let id = dictionary["id"] as? String,
      let name = dictionary["name"] as? String,
      let roleDict = dictionary["role"] as? [String: AnyObject],
      let role = Role(dictionary: roleDict)
      let company = dictionary["company"] as? [String: AnyObject],
      let companyName = company["name"] as? String,
        else {
          return nil
    }
    
    self.id = id
    self.name = name
    self.role = role
    self.email = dictionary["email"] as? String
    self.companyName = companyName
  }
}

這份程式碼的好處在於它是純 Swift 的,不過看起來比較亂,可讀性不佳,變數間的依賴鏈並不明顯。舉個例子,由於 roleDict 被用在 role 的定義中,所以它必須在 role 被定義前定義,但由於程式碼如此繁雜,很難清晰地找出這種依賴關係。

(我甚至都不想提在 Swift 1 中解析 JSON 時,大量 if let 巢狀而成的鞭屍金字塔(pyramid-of-doom),那可真是糟透了,很高興現在我們有了多行的 if letguard let 結構。)


Swift 的錯誤處理釋出的時候,我覺得這東西糟透了。似乎不管從哪一個方面都不及 Result

  • 你無法直接訪問到錯誤:Swift 的錯誤處理機制在 Result 型別之上,新增了一些必須使用的語法(是的,事實如此),這讓人們無法直接訪問到錯誤。

  • 你不能像使用 Result 一樣進行鏈式處理。Result 是個 monad,可以用 flatMap 連結起來進行有效的處理。

  • Swift 錯誤模型無法非同步使用(除非你進行一些 hack,比如說提供一個內部函式來丟擲結果), 但 Result 可以。

儘管 Swift 的錯誤處理模型有著這些看起來相當明顯的缺點,但有篇文章講述了一個使用 Swift 錯誤模型的例子,在該例子中 Swift 的錯誤模型明顯比 Objective-C 的版本更加簡潔,也比 Result 可讀性更強。這是怎麼回事呢?

這裡的祕密在於,當你的程式碼中有許多 try 呼叫的時候,利用帶有 do/catch 結構的 Swift 錯誤模型進行處理,效果會非常好。在 Swift 中對程式碼進行錯誤處理時需要寫一些模板程式碼。在宣告函式時,你需要加入 throws, 或使用 do/catch 結構顯式地處理所有錯誤。對於單個 try 語句來說,做這些事讓人覺得很麻煩。然而,就多個 try 語句而言,這些前期工作就變得物有所值了。


我曾試圖尋找一種方法,能夠在 JSON 缺失某個鍵時列印出某種警告。如果在訪問缺失的鍵時,能夠得到一個報錯,那麼這個問題就解決了。由於在鍵缺失的時候,原生的 Dictionary 型別並不會丟擲錯誤,所以需要有個物件對字典進行封裝。我想實現的程式碼大概長這樣:


struct MyModel {
    let aString: String
    let anInt: Int
    
    init?(dictionary: [String: AnyObject]?) {
        let parser = Parser(dictionary: dictionary)
        do {
            self.aString = try parser.fetch("a_string")
            self.anInt = try parser.fetch("an_int")
        } catch let error {
            print(error)
            return nil 
        }
    }
}

理想的說來,由於型別推斷的存在,在解析過程中我甚至不需要明確地寫出型別。現在讓我們絲分縷解,看看怎麼實現這份程式碼。首先從 ParserError 開始:


struct ParserError: ErrorType {
    let message: String
}

接下來,我們開始搞定 Parser。它可以是一個 struct 或是一個 class。(由於它不會被用在別的地方,所以他的引用語義並不重要。)


struct Parser {
    let dictionary: [String: AnyObject]?
    
    init(dictionary: [String: AnyObject]?) {
        self.dictionary = dictionary
    }
}

我們的 parser 將會獲取一個字典並持有它。

fetch 函式開始顯得有點複雜了。我們來一行一行地進行解釋。類中的每個方法都可以型別引數化,以充分利用型別推斷帶來的便利。此外,這個函式會丟擲錯誤,以使我們能夠獲得處理失敗的資料:


func fetch<T>(key: String) throws -> T {

下一步是獲取鍵對應的物件,並保證它不是空的,否則丟擲一個錯誤。


let fetchedOptional = dictionary?[key]
guard let fetched = fetchedOptional else {
    throw ParserError(message: "The key "(key)" was not found.")
}

最後一步是,給獲得的值加上型別資訊。


guard let typed = fetched as? T else {
    throw ParserError(message: "The key "(key)" was not the correct type. It had value "(fetched)."")
}

最終,返回帶型別的非空值。


    return typed
}

(我將會在文末附上包含所有程式碼的 gist 和 playground)

這份程式碼是可用的!型別引數化及型別推斷為我們處理了一切。上面寫的 “理想” 程式碼完美地工作了:


self.aString = try parser.fetch("a_string")

我還想新增一些東西。首先,新增一種方法來解析出那些確實可選的值(譯者注:也就是我們允許這些值為空)。由於在這種情況下我們並不需要丟擲錯誤,所以我們可以實現一個簡單許多的方法。但很不幸,這個方法無法和上面的方法同名,否則編譯器就無法知道應該使用哪個方法了,所以,我們把它命名為 fetchOptional。這個方法相當的簡單。


func fetchOptional<T>(key: String) -> T? {
    return dictionary?[key] as? T
}

(如果鍵存在,但是並非你所期望的型別,則可以丟擲一個錯誤。為了簡略起見,我就不寫了)

另外一件事就是,在字典中取出一個物件後,有時需要對它進行一些額外的轉換。我們可能得到一個列舉的 rawValue,需要構建出對應的列舉,或者是一個巢狀的字典,需要處理它包含的物件。我們可以在 fetch 函式中接收一個閉包作為引數,作進一步地型別轉換,並在轉換失敗的情況下丟擲錯誤。泛型中 U 引數型別能夠幫助我們明確 transformation 閉包轉換得到的結果值型別和 fetch 方法得到的值型別一致。


func fetch<T, U>(key: String, transformation: (T) -> (U?)) throws -> U {
    let fetched: T = try fetch(key)
    guard let transformed = transformation(fetched) else {
        throw ParserError(message: "The value "(fetched)" at key "(key)" could not be transformed.")
    }
    return transformed
}

最後,我們希望 fetchOptional 也能接受一個轉換閉包作為引數。


func fetchOptional<T, U>(key: String, transformation: (T) -> (U?)) -> U? {
    return (dictionary?[key] as? T).flatMap(transformation)
}

看啊!flatMap 的力量!注意,轉換閉包 transformationflatMap 接收的閉包有著一樣的形式:T -> U?

現在我們可以解析帶有巢狀項或者列舉的物件了。


class OuterType {
    let inner: InnerType
    
    init?(dictionary: [String: AnyObject]?) {
        let parser = Parser(dictionary: dictionary)
        do {
            self.inner = try parser.fetch("inner") { InnerType(dictionary: $0) }
        } catch let error {
            print(error)
            return nil 
        }
    }
}

再一次注意到,Swift 的型別推斷魔法般地為我們處理了一切,而我們根本不需要寫下任何 as? 邏輯!

用類似的方法,我們也可以處理陣列。對於基本資料型別的陣列,fetch 方法已經能很好地工作了:


let stringArray: [String]

//...
do {
    self.stringArray = try parser.fetch("string_array")
//...

對於我們想要構建的特定型別(Domain Types)的陣列, Swift 的型別推斷似乎無法那麼深入地推斷型別,所以我們必須加入另外的型別註解:


self.enums = try parser.fetch("enums") { (array: [String]) in array.flatMap(SomeEnum(rawValue: $0)) }

由於這行顯得有些粗糙,讓我們在 Parser 中建立一個新的方法來專門處理陣列:


func fetchArray<T, U>(key: String, transformation: T -> U?) throws -> [U] {
    let fetched: [T] = try fetch(key)
    return fetched.flatMap(transformation)
}

這裡使用 flatMap 來幫助我們移除空值,減少了程式碼量:


self.enums = try parser.fetchArray("enums") { SomeEnum(rawValue: $0) }

末尾的這個閉包應該被作用於 每個 元素,而不是整個陣列(你也可以修改 fetchArray 方法,以在任意值無法被構建時丟擲錯誤。)

我很喜歡泛型模式。它很簡單,可讀性強,而且也沒有複雜的依賴(這只是個 50 行的 Parser 型別)。它使用了 Swift 風格的結構, 還會給你非常特定的錯誤提示,告訴你 為何 解析失敗了,當你在從伺服器返回的 JSON 沼澤中摸爬滾打時,這顯得非常有用。最後,用這種方法解析的另外一個好處是,它在結構體和類上都能很好地工作,這使得從引用型別切換到值型別,或者反之,都變得很簡單。

這裡是包含所有程式碼的一個 gist,而這裡是一個作為補充的 Playground.

本文由 SwiftGG 翻譯組翻譯,已經獲得作者翻譯授權,最新文章請訪問 http://swift.gg

相關文章