天天看點

如何處理 Swift 中的異步錯誤

作者:Olivier Halligon,原文連結,原文日期:2016-02-06

譯者:ray16897188;校對:小鍋;定稿:numbbbbb

在之前的一篇文章中,我介紹了如何在Swift中使用

throw

做錯誤處理。但是如果你處理的是異步流程,

throw

就無法勝任,該怎麼辦?

throw

和異步有啥問題?

回顧下,我們可以像下面這樣,在一個可能失敗的函數中使用

throw

關鍵字:

// 定義錯誤類型和一個可抛出的函數
enum ComputationError: ErrorType { case DivisionByZero }
func inverse(x: Float) throws -> Float {
  guard x != 0 else { throw ComputationError.DivisionByZero }
  return 1.0/x
}
// 調用它
do {
  let y = try inverse(5.0)
} catch {
  print("Woops: \(error)")
}
           

但如果函數是異步的,需要等待一段時間才會傳回結果,比如帶着 completion block 的函數,這個時候怎麼辦?

func fetchUser(completion: User? /* throws */ -> Void) /* throws */ {
  let url = …
  NSURLSession.sharedSession().dataTaskWithURL(url) { (data, response, error) -> Void in
//    if let error = error { throw error } // 我們不能這樣做, fetchUser 不能“異步地抛出”
    let user = data.map { User(fromData: $0) }
    completion(user)
  }.resume()
}
// 調用
fetchUser() { (user: User?) in
  /* do something */
}
           

這種情況下如果請求失敗的話,你怎麼

throw

  • fetchUser

    函數

    throw

    是不合理的,因為這個函數(被調用後)會立即傳回,而網絡錯誤隻會在這之後發生。是以當錯誤發生時再

    throw

    一個錯誤就太晚了,

    fetchUser

    函數調用已經傳回。
  • 你可能想把

    completion

    标成

    throws

    ?但是調用

    completion(user)

    的代碼在

    fetchUser

    裡,不是在調用

    fetchUser

    的代碼裡。是以接受并處理錯誤的代碼必須是

    fetchUser

    本身,而非

    fetchUser

    的調用點。是以這個方案也不行。?

攻克這道難題

可以曲線救國:讓

completion

不直接傳回

User?

,而是傳回一個

Void throws -> User

的 throwing 函數,這個 throwing 函數會傳回一個

User

(我們把這個函數命名為

UserBuilder

)。這樣我們就又能

throw

了。

之後當 completion 傳回這個

userBuilder

函數時,我們用

try userBuilder()

去通路裡面的

User

... 或者讓它

throw

出錯誤。

enum UserError: ErrorType { case NoData, ParsingError }
struct User {
  init(fromData: NSData) throws { /* … */ }
  /* … */
}

typealias UserBuilder = Void throws -> User
func fetchUser(completion: UserBuilder -> Void) {
  let url = …
  NSURLSession.sharedSession().dataTaskWithURL(url) { (data, response, error) -> Void in
    completion({ UserBuilder in
      if let error = error { throw error }
      guard let data = data else { throw UserError.NoData }
      return try User(fromData: data)
    })
  }.resume()
}

fetchUser { (userBuilder: UserBuilder) in
  do {
    let user = try userBuilder()
  } catch {
    print("Async error while fetching User: \(error)")
  }
}
           

這樣 completion 就不會直接傳回一個

User

,而是傳回一個

User

... 或抛出錯誤。之後你就又可以做錯誤處理了。

但說實話,用

Void throws -> User

來代替

User?

并不是最優雅、可讀性最強的解決方案。還有其他辦法嗎?

介紹 Result

回到 Swift 1.0 的時代,那時還沒有

throw

,人們得用一種函數式的方法來處理錯誤。由于 Swift 從函數式程式設計的世界中借鑒過來很多特性,是以當時人們在 Swift 中用

Result

模式來做錯誤處理還是很合理的。

Result

長這樣1:

enum Result<T> {
  case Success(T)
  case Failure(ErrorType)
}
           

Result

這個類型其實很簡單:它要麼指代一次成功 —— 附着一個關聯值(associated value)代表着成功的結果 —— 要麼指代一次失敗 —— 有一個關聯的錯誤。它是對可能會失敗的操作的完美抽象。

那麼我們怎麼用它?建立一個

Result.Success

或者一個

Result.Failure

,然後把作為結果的

Result

2 傳入

completion

,最後調用

completion

func fetchUser(completion: Result<User> -> Void) {
  let url = …
  NSURLSession.sharedSession().dataTaskWithURL(url) { (data, response, error) -> Void in
    if let error = error {
      return completion( Result.Failure(error) )
    }
    guard let data = data else {
      return completion( Result.Failure(UserError.NoData) )
    }
    do {
      let user = try User(fromData: data)
      completion( Result.Success(user) )
    } catch {
      completion( Result.Failure(error) )
    }
  }.resume()
}
           

還記得 monads 麼?

Result

的好處就是它可以變成一個 Monad。記得Monads麼?這意味着我們可以給

Result

添加高階的

map

flatMap

方法,後兩者會接受一個

f: T->U

或者

f: T->Result<U>

類型的閉包,然後傳回一個

Result<U>

如果一開始的

Result

是一個

.Success(let t)

,那就對這個

t

使用這個閉包,得到

f(t)

的結果。如果是一個

.Failure

,那就把這個錯誤繼續傳下去:

extension Result {
  func map<U>(f: T->U) -> Result<U> {
    switch self {
    case .Success(let t): return .Success(f(t))
    case .Failure(let err): return .Failure(err)
    }
  }
  func flatMap<U>(f: T->Result<U>) -> Result<U> {
    switch self {
    case .Success(let t): return f(t)
    case .Failure(let err): return .Failure(err)
    }
  }
}
           

如果想要了解更多資訊,我建議你去重讀我寫的關于 Monads 的文章,但現在長話短說,我們來修改代碼:

func readFile(file: String) -> Result<NSData> { … }
func toJSON(data: NSData) -> Result<NSDictionary> { … }
func extractUserDict(dict: NSDictionary) -> Result<NSDictionary> { … }
func buildUser(userDict: NSDictionary) -> Result<User> { … }

let userResult = readFile("me.json")
  .flatMap(toJSON)
  .flatMap(extractUserDict)
  .flatMap(buildUser)
           

上面代碼中最酷的地方:如果其中一個方法(比如

toJSON

)失敗了,傳回了一個

.Failure

,那随後這個 failure 會一直被傳遞到最後,而且不會被傳入到

extractUserDict

buildUser

方法裡面去。

這就可以讓錯誤“走一條捷徑”:和

do...catch

一樣,你可以在鍊條的結尾一并處理所有錯誤,而不是在每個中間階段做處理,很酷,不是麼?

Result

throw

,再從

throw

Result

問題是,

Result

不包含在 Swift 标準庫中,而無論怎樣,還是有很多函數使用

throw

來報告同步錯誤(譯注:synchronous errors,與異步錯誤 asynchronous errors 相對)。比如,在實際應用場景中從一個

NSDictionary

建立一個

User

,我們可能得用

init(dict: NSDictionary) throws

構造器,而不是

NSDictionary -> Result<User>

函數。

那怎麼去融合這兩個世界呢?簡單,我們來擴充一下

Result

3!

extension Result {
  // 如果是 .Success 就直接傳回值,如果是 .Failure 抛出錯誤
  func resolve() throws -> T {
    switch self {
    case Result.Success(let value): return value
    case Result.Failure(let error): throw error
    }
  }

  // 如果表達式傳回值則建構一個 .Success,否則就建構一個 .Failure
  init(@noescape _ throwingExpr: Void throws -> T) {
    do {
      let value = try throwingExpr()
      self = Result.Success(value)
    } catch {
      self = Result.Failure(error)
    }
  }
}
           

現在我們就可以很輕松地将 throwing 構造器轉換成一個閉包,該閉包傳回一個

Result

func buildUser(userDict: NSDictionary) -> Result<User> {
  // 這裡我們調用了 `init` 并使用一個可抛出的尾閉包來建構 `Result`
  return Result { try User(dictionary: userDict) }
}
           

之後如果我們将

NSURLSession

封裝到一個函數中,這個函數就會異步的傳回一個

Result

,我們可以按個人喜好來調整這兩個世界的平衡,例如:

func fetch(url: NSURL, completion: Result<NSData> -> Void) {
  NSURLSession.sharedSession().dataTaskWithURL(url) { (data, response, error) -> Void in
    completion(Result {
      if let error = error { throw error }
      guard let data = data else { throw UserError.NoData }
      return data
    })
  }.resume()
}
           

上面的代碼也調用了 completion block,往裡面傳了一個由 throwing closure4 建立的

Result

對象。

随後我們就可以用

flatMap

把這些都串起來,再根據實際需求決定是否進入

do...catch

的世界:

fetch(someURL) { (resultData: Result<NSData>) in
  let resultUser = resultData
    .flatMap(toJSON)
    .flatMap(extractUserDict)
    .flatMap(buildUser)

  // 如果我們想在剩下代碼中回到 do/try/catch 的世界
  do {
    let user = try resultUser.resolve()
    updateUI(user: user)
  } catch {
    print("Error: \(error)")
  }
}
           

我承諾,這就是未來

(校對注:作者這裡的标題使用了雙關語,承諾的英文為 "Promise", 未來的單詞為 "Future"。)(定稿注:這篇文章提到的這種模式術語就是 "Promise",是以說是雙關。)

Result

很炫酷,但是既然它們的主要用途是異步函數(因為同步函數我們已經有了

throw

),那何不讓它也實作對異步的管理呢?

實際上已經有一個這樣的類型TM,它就是

Promise

(有時候也叫

Future

,這兩個術語很像)。

Promise

類型結合了

Result

類型(能成功或者失敗)和異步性。一個

Promise<T>

既可以在一段時間後(展現了異步方面的特性)被成功賦成

T

類型的值(譯注:這裡的指派英文是 fulfill,原意是履行,而 Promise 本身也有承諾的意思。

Promise<T>

被成功指派,等同于承諾被履行),又可能在錯誤發生時被拒絕(reject)。

一個

Promise

也是一個 monad。但和通常以

map

flatMap

的名字來調用它的 monadic 函數不同,按規定這兩個函數都通過

then

來調用:

class Promise<T> {
  // 與 map 對應的 monad,在 Promise 通常被稱為 then
  func then(f: T->U) -> Promise<U>
  // 與 flatMap 對應的 monad,在 Promise 中也被稱為 then 
  func then(f: T->Promise<U>) -> Promise<U>
}
           

錯誤也通過

.error

.recover

解包。在代碼中,它的使用方式和你使用一個

Result

基本相同,畢竟它倆都是 monad:

fetch(someURL) // returns a `Promise<NSData>`
  .then(toJSON) // assuming toJSON is now a `NSData -> Promise<NSDictionary>`
  .then(extractUserDict) // assuming extractUserDict is now a `NSDictionary -> Promise<NSDictionary>`
  .then(buildUser) // assuming buildUser is now a `NSDictionary -> Promise<User>`
  .then {
    updateUI(user: user)
  }
  .error { err in
    print("Error: \(err)")
  }
           

感受到了嗎,這看起來多麼流暢多麼優雅!這就是把一些微處理步驟精密連接配接起來的流(stream),而且它還替你做了異步處理和錯誤處理這樣的髒活兒累活兒。如果在處理流程中有錯誤發生,比如在

extractUserDict

中出錯,那就直接跳到

error

回調中。就像用

do...catch

或者

Result

一樣。

fetch

中使用

Promise

—— 取代 completion block 或者

Result

—— 看起來應該是這樣的:

func fetch(url: NSURL) -> Promise<NSData> {
  // PromiseKit 有一個便利的 `init`,會傳回一個 (T?, NSError?) 閉包到 `Promise` 中
  return Promise { resolve in
    NSURLSession.sharedSession().dataTaskWithURL(url) { (data, _, error) -> Void in
      resolve(data, error)
    })
  }.resume()
}
           

fetch

方法會立即傳回,是以就沒必要用

completionBlock

了。但它會傳回一個

Promise

對象,這個對象隻去執行

then

裡面的閉包 - 在(異步)資料延時到達、

Promise

這個對象被成功指派(譯注:promise is fulfilled,也是承諾被履行的意思)之後。

Observe 和 Reactive

Promise

很酷,但還有另外一個概念,可以在實作微處理步驟流的同時支援異步操作,并且支援處理這個流中任何時間任何地點發生的錯誤。

這個概念叫做 Reactive Programming(響應式程式設計)。你們之中可能有人知道

ReactiveCocoa

(簡寫 RAC),或者

RxSwift

。即便它和

Promises

有部分相同的理念(異步、錯誤傳遞,...),它還是超越了

Futures

Promises

這個級别:

Rx

允許某時刻有多個值被發送(不僅僅有一個傳回值),而且還擁有其他繁多豐富的特性。

這就是另外一個全新話題了,之後我會對它一探究竟。

  1. 這是對

    Result

    可能的實作方式中的一種。其他的實作也許就會有一個更明确的錯誤類型。↩
  2. 在這裡我調用

    return completion(…)

    時用了一個小花招,并沒有調用

    completion(...)

    然後再

    return

    來退出函數的作用域。這個花招能成功,是因為

    completion

    傳回一個

    Void

    fetchUser

    也傳回一個

    Void

    (什麼都不傳回),而且

    return Void

    和單個

    return

    一樣。這完全是個人偏好,但我還是覺得能用一行寫完更好。↩
  3. 這段代碼中,

    @noescape

    關鍵字的意思是

    throwingExpr

    能被保證在

    init

    函數的作用域裡是被直接拿來使用 - 相反則是把它存在某個屬性中以後再用。用了這個關鍵字你的編譯器不用強迫你在傳進一個閉包時在調用點使用

    self.

    或者

    [weak self]

    了,還能避免引用循環的産生。↩
  4. 在這裡暫停一下,看看這段代碼多像在開篇的時候我們寫的

    UserBuilder

    的那段,感覺我們開篇時就走在了正确的路上。? ↩
本文由 SwiftGG 翻譯組翻譯,已經獲得作者翻譯授權,最新文章請通路 http://swift.gg。