第十一章 Combine中的错误处理

1,316 阅读17分钟

正如在第 1 章“Hello,Combine!”中提到的,Combine 发布者声明了两个通用约束:Output,它定义了发布者发出的值的类型,以及 Failure,它定义了这个发布者可以以何种失败结束。

Never

失败类型为 Never 的发布者表示发布者永远不会失败。 虽然这乍一看似乎有点奇怪,但它为这些发布商提供了一些非常强大的保证。具有从不失败类型的发布者可让您专注于使用发布者的值,同时绝对确保发布者永远不会失败。只有完成后才能成功完成。

img2147.jpg

setFailureType

将绝对可靠的发布者转变为容易出错的发布者的第一种方法是使用 setFailureType。这是另一个仅适用于失败类型为 Never 的发布者的运算符

首先定义示例范围之外的 MyError 错误类型。稍后您将重用此错误类型。然后,您通过创建一个与您之前使用的类似的 Just 来开始示例 现在,您可以使用 setFailureType 将发布者的失败类型更改为 MyError。在 Just 之后立即添加以下行

enum MyError: Error {
  case ohNo
}
 
Just("Hello")
	.setFailureType(to: MyError.self)
        .eraseToAnyPublisher()

现在用 sink 来订阅发布者。在上面代码后添加以下代码

.sink(
  receiveCompletion: { completion in
    switch completion {
    // 2
    case .failure(.ohNo):
      print("Finished with Oh No!")
    case .finished:
      print("Finished successfully!")
    }
  },
  receiveValue: { value in
    print("Got value: \(value)")
  }
)
.store(in: &subscriptions)”

你可能已经注意到关于上述代码的两个有趣的事实:

1、他使用了 sink(receiveCompletion:receiveValue:),而 sink(receiveValue:) 不再可用,因为此发布者可能会以失败事件完成。结合迫使您处理此类publisher的完成事件

2、失败类型严格输入为 MyError,这让您可以针对 .failure(.ohNo) 情况,而无需进行不必要的转换来处理该特定错误。

当然,setFailureType 的效果只是一个类型系统定义。由于原始发布者是 Just,因此实际上不会抛出任何错误。 在本章后面,您将了解更多关于如何从您自己的发布者那里实际产生错误的信息。但首先,还有一些运营商专门针对永不失败的出版商。

assign(to:on:)

您在第 2 章“发布者和订阅者”中学到的分配运算符仅适用于不会失败的发布者,与 setFailureType 相同。如果你仔细想想,这完全是有道理的。将错误发送到提供的键路径会导致未处理的错误或未定义的行为

example(of: "assign(to:on:)") {
  // 1
  class Person {
    let id = UUID()
    var name = "Unknown"
  }

  // 2
  let person = Person()
  print("1", person.name)

  Just("Shai")
    .handleEvents( // 3
      receiveCompletion: { _ in print("2", person.name) }
    )
    .assign(to: \.name, on: person) // 4
    .store(in: &subscriptions)
}

1、定义一个具有 id 和 name 属性的 Person 类。

2、创建一个 Person 实例并立即打印其名称。

3、一旦发布者发送完成事件,使用您之前了解的 handleEvents 再次打印此人的姓名。

4、最后,使用assign 将人名设置为发布者发出的任何内容

运行结果:

1 Unknown
2 Shai

正如预期的那样,一旦 Just 发出它的值,assign 就会更新这个人的名字,这是有效的,因为 Just 不会失败。相比之下,如果Publihser有一个非Never的类型,你认为会发生什么?

Just("Shai") 下方添加以下行:

.setFailureType(to: Error.self)

在这段代码中,你将失败类型设置为标准的 Swift 错误。这意味着它现在不是 Publisher<String, Never>,而是 Publisher<String, Error>。 尝试运行您的Playground。会发现有报错信息:

referencing instance method 'assign(to:on:)' on 'Publisher' requires the types 'Error' and 'Never' be equivalent

assign(to:)

assign(to:on:) 有一个棘手的部分——它会强烈捕获提供给 on 参数的对象。 让我们探讨为什么这是有问题的。 在上一个示例之后立即添加以下代码

example(of: "assign(to:)") {
  class MyViewModel: ObservableObject {
    // 1
    @Published var currentDate = Date()

    init() {
      Timer.publish(every: 1, on: .main, in: .common) // 2
        .autoconnect() 
        .prefix(3) // 3
        .assign(to: \.currentDate, on: self) // 4
        .store(in: &subscriptions)”
	 }
  }

  // 5
  let vm = MyViewModel()
  vm.$currentDate
    .sink(receiveValue: { print($0) })
    .store(in: &subscriptions)
}

这段代码有点长,让我们分解一下:

1、在ViewModel中定义一个 @Published 属性。它的初始值是当前日期。

2、创建一个定时器的Publisher,它每秒发出当前日期。

3、使用prefix操作符设定只接受 3 个发布数据。

4、应用assign(to:on:) 运算符将每个日期更新分配到@Published 属性。

5、实例化ViewModel,用sink开始订阅,并打印出每个值。

运行结果:

2021-08-21 12:43:32 +0000
2021-08-21 12:43:33 +0000
2021-08-21 12:43:34 +0000
2021-08-21 12:43:35 +0000

正如预期的那样,上面的代码打印了分配给已发布属性的初始日期,然后连续更新了 3 次(受前缀运算符限制)。 表面上看一切正常,到底哪里出了问题?

👊对assign(to:on:) 的调用创建了一个强引用的订阅。 本质上——self 挂在订阅上,而订阅挂在 self 上,创建了一个保留周期,导致内存泄漏👊

img2186.jpg

幸运的是,Apple引入了该运算符的另一个重载 assign(to:)。 该运算符专门处理通过提供对其预计发布者的 inout 引用,将发布的值重新分配给 @Published 属性。

将原本的assign行改为

.assign(to: &$currentDate)

使用assign(to:) 操作符并将其传递给预计发布者的inout 引用打破了保留周期,让您轻松处理上述问题。 此外,它会在内部自动处理订阅的内存管理,这让您可以省略 store(in: &subscriptions) 行。

assertNoFailure

当您想在开发过程中保护自己并确认发布者无法完成失败事件时,assertNoFailure 运算符非常有用。 它不会阻止上游发出失败事件。但是,如果它检测到错误,它会因致命错误而崩溃。在开发环境中可以使用它来发现错误。

在playground中实现如下代码:

  // 1
  Just("Hello")
    .setFailureType(to: MyError.self)
    .assertNoFailure() // 2
    .sink(receiveValue: { print("Got value: \($0) ")}) // 3
    .store(in: &subscriptions)”
  1. 使用 Just 创建一个Publisher并将其失败类型设置为 MyError。
  2. 如果发布者以失败事件完成,则使用 assertNoFailure 以致命错误崩溃。这会将发布者的失败类型恢复为Never
  3. 使用 sink 打印出接收到的值。请注意,由于 assertNoFailure 将故障类型设置回 Never,因此 可以使用sink(receiveValue:)

我们在assertNoFailure()前输入.sink,可以看到

截屏2021-10-08 下午9.29.03.png

我们只能使用sink(receiveCompletion:receiveValue:)的sink方法,因为我们将Publisher的错误类型设置为了非Never类型,所以只能使用带有receiveCompletion的sink方法。 当我们在加入了.assertNoFailure()操作符后,Publihser的错误类型又变回了Never,所以可以使用 sink(receiveValue:)的方法了。

现在我们在.setFailureType后增加一行代码

.tryMap { _ in throw MyError.ohNo }

我们用tryMap来强制抛出一个错误,看Combine如何处理

这时运行playground会报错

 error: Execution was interrupted, reason: EXC_BREAKPOINT (code=1, subcode=0x1a833abc0).

The process has been left at the point where it was interrupted, use "thread return -x" to return to the state before expression evaluation.

playground崩溃是因为Publisher发布了错误,这就是assertFailureType所做的。在某种程度上,您可以将 assertFailure() 视为代码的保护机制。虽然不是您应该在生产环境中使用的东西,但对在开发过程中尽早发现错误非常有用。 在继续下一部分之前,注释掉对 tryMap 的调用。

处理错误

Combine 提供了一些技术和工具来处理发布错误信息的Publisher。这包括内置发布者和您自己的发布者。 但首先,您如何实际产生故障事件?如上一节所述,有几种方法可以做到这一点。您刚刚使用了 tryMap,那么为什么不了解更多有关这些 try 运算符如何工作的信息呢?

try* 操作符

当涉及到错误时,Combine 中所有带有 try 前缀的运算符的行为方式都相同。您将在本章中只试验 tryMap 运算符

在playground中增加如下代码:

// 1
enum NameError: Error {
	case tooShort(String)
	case unknown
}

// 2
let names = ["Marin", "Shai", "Florent"].publisher

names
// 3
.map { value in
	return value.count
}
.sink(
	receiveCompletion: { print("Completed with \($0)") },
	receiveValue: { print("Got value: \($0)") }
)
  1. 定义一个 NameError 错误枚举。
  2. 创建一个发布三个不同字符串的发布者。
  3. 将每个字符串映射为它的长度。

运行playground,得到如下信息:

Got value: 5
Got value: 4
Got value: 7
Completed with finished

现在我们为代码增加一些功能:当字符串长度小于5的时候,抛出一个错误 我们将代码中map部分替换为

    .tryMap { value -> Int in
      // 1
      let length = value.count
      
      // 2
      guard length >= 5 else {
        throw NameError.tooShort(value)
      }
      
      // 3
      return value.count
    }

注意这里我们不能用map,因为map不能抛出错误,所以只能使用tryMap

运行playground:

Got value: 5 Completed with failure(__lldb_expr_261.NameError.tooShort("Shai"))

可以看到“Shai”字符串长度小于5,所以抛出了错误,而且Combine的流程也停止了

映射错误 (Mapping Error)

map 和 tryMap 之间的差异不止是后者可以抛出错误。虽然 map 继承了现有的失败类型并且只操作发布者的值,但 tryMap 不会——它实际上将错误类型擦除为一个普通的 Swift 错误。与带有 try 前缀的对应物相比,所有运算符都是如此。

在playground中实现如下代码:

  // 1
  enum NameError: Error {
    case tooShort(String)
    case unknown
  }

  // 2
  Just("Hello")
    .setFailureType(to: NameError.self) // 3
    .map { $0 + " World!" } // 4 
    .sink(
      receiveCompletion: { completion in
        // 5
        switch completion {
        case .finished:
          print("Done!")
        case .failure(.tooShort(let name)):
          print("\(name) is too short!")
        case .failure(.unknown):
          print("An unknown name error occurred")
        }
      },
      receiveValue: { print("Got value \($0)") }
    )
    .store(in: &subscriptions)
  1. 定义一个 NameError 用于此示例。
  2. 创建一个只发出字符串 Hello 的 Just。
  3. 使用 setFailureType 将失败类型设置为 NameError。
  4. 使用 map 将另一个字符串附加到发布的字符串。
  5. 最后,使用 sink 的 receiveCompletion 为 NameError 的每个失败案例打印出适当的消息。

运行playground:

Hello is too short!

当我们在switch completion这行,按住Option点击completion,会发现

截屏2021-10-08 下午10.07.30.png

请注意,Completion 的失败类型是 NameError,这正是您想要的。 setFailureType 运算符允许您专门针对 NameError 失败,例如 failure(.tooShort(let name))。 接下来,将map更改为 tryMap。您会立即注意到playground不再编译。再次观察competion的类型 你会发现他的类型现在是Subscribers.Completion<Error>

这是我们需要重点注意的 tryMap 删除了严格类型的错误,并用通用的 Swift.Error 类型替换了它。即使您实际上并未从 tryMap 中抛出错误,也会发生这种情况——您只是使用了它!这是为什么? 仔细想想,原因很简单:Swift 尚不支持类型化抛出,尽管自 2015 年以来 Swift Evolution 中一直围绕这个话题进行讨论。这意味着当您使用带有 try 前缀的运算符时,您的错误类型将总是被删除到最常见的祖先:Swift.Error。 所以你能对它做点啥?对于发布者来说,严格类型的故障的全部意义在于让您处理——在本例中——专门处理 NameError,而不是任何其他类型的错误

一种幼稚的方法是手动将通用错误转换为特定的错误类型,但这并不理想。它打破了严格类型错误的整个目的。幸运的是,Combine 为这个问题提供了一个很好的解决方案,称为 mapError。 在调用 tryMap 之后,立即添加以下行

.mapError { $0 as? NameError ?? .unknown }

mapError 接收从上游发布者抛出的任何错误,并让您将其映射到您想要的任何错误。在这种情况下,您可以利用它将错误转换回 NameError 或回退到 NameError.unknown 错误。在这种情况下,你必须提供一个回退错误,因为理论上强制转换可能会失败——即使它不会在这里——你必须从这个运算符返回一个 NameError。

这会将失败恢复到其原始类型,并将您的发布者恢复为 Publisher<String, NameError>。 构建并运行playground。它应该最终编译并按预期工作:

Got value Hello World!
Done!

最后将tryMap改进为

.tryMap { throw NameError.tooShort($0) }

这个调用会立即从 tryMap 中抛出一个错误。再次检查控制台输出,并确保您得到正确键入的 NameError

Hello is too short!

设计我们自己的可抛出错误API

在构建您自己的基于组合的代码和 API 时,您经常会使用来自其他来源的 API,这些 API 会返回因各种类型而失败的发布者。创建您自己的 API 时,您通常还希望围绕该 API 提供您自己的错误。

在playground中实现下面代码:

class DadJokes {
    // 1
    struct Joke: Codable {
      let id: String
      let joke: String
    }
	// 2
    func getJoke(id: String) -> AnyPublisher<Joke, Error> {
      let url = URL(string: "https://icanhazdadjoke.com/j/\(id)")!
      var request = URLRequest(url: url)
      request.allHTTPHeaderFields = ["Accept": "application/json"]
      
      // 3
      return URLSession.shared
        .dataTaskPublisher(for: request)
        .map(\.data)
        .decode(type: Joke.self, decoder: JSONDecoder())
        .eraseToAnyPublisher()
    }
 }

上述代码中:

  1. 定义一个Joke的数据结构,用于JSON解码。
  2. 提供一个 getJoke(id:) 方法,该方法当前返回一个发布者,该发布者的Output是一个Joke数据,并且可能会因标准的 Swift.Error 而失败。
  3. 使用 URLSession.dataTaskPublisher(for:) 调用 icanhazdadjoke API 并使用 JSONDecoder 和 decode 运算符将结果数据解码为 Joke。

最后,你会想要实际使用你的新 API。在 DadJokes 类的正下方添加以下内容

// 4
let api = DadJokes()
let jokeID = "9prWnjyImyd"
let badJokeID = "123456"

// 5
api
  .getJoke(id: jokeID)
  .sink(receiveCompletion: { print($0) },
        receiveValue: { print("Got joke: \($0)") })
  .store(in: &subscriptions)”
  1. 创建一个 DadJokes 的实例,并用有效和无效的Joke ID 定义两个常量。
  2. 使用有效的Joke ID 调用 DadJokes.getJoke(id:) 并打印任何完成事件或解码的Joke本身

此时运行playground会得到以下信息:

Got joke: Joke(id: "9prWnjyImyd", joke: "Why do bears have hairy coats? Fur protection.")
finished

运行结果没有问题,但这个时候,您需要问自己:“这个特定Publisher会导致哪些类型的错误?”

本Demo中可能出现的错误包括:

由于各种原因,例如连接不良或无效请求,调用 dataTaskPublisher 可能会失败并显示 URLError Joke ID 不存在 如果 API 响应更改或其结构不正确,则解码 JSON 响应可能会失败。 任何其他未知错误!错误很多而且是随机的,因此不可能考虑所有边缘情况。出于这个原因,您总是希望有一个处理段来涵盖所有错误,统一处理

现在我们处理这些可能的错误,加入如下代码:

enum Error: Swift.Error, CustomStringConvertible {
  // 1
  case network
  case jokeDoesntExist(id: String)
  case parsing
  case unknown
  
  // 2
  var description: String {
    switch self {
    case .network:
      return "Request to API Server failed"
    case .parsing:
      return "Failed parsing response from server"
    case .jokeDoesntExist(let id):
      return "Joke with ID \(id) doesn't exist"
    case .unknown:
      return "An unknown error occurred"
    }
  }
}
  1. 通过声明一个枚举来列出所有可能出现的错误
  2. 让枚举符合 CustomStringConvertible 协议,它可以让您为每个错误情况提供友好的描述

添加上述错误类型后,您的 Playground 将不再编译。这是因为 getJoke(id:) 返回一个 AnyPublisher<Joke, Error>。之前,Error 指的是 Swift.Error,但现在它指的是 DadJokes.Error

因为我们声明了自己的Error枚举类型

—— 在这种情况下,这实际上是你想要的。 那么,您如何将各种可能的和不同类型的错误全部映射到您的 DadJoke.Error 中?答案就是 mapError

让我们在decode和eraseToAnyPublisher中间加入以下代码:

.mapError { error -> DadJokes.Error in
  switch error {
  case is URLError:
    return .network
  case is DecodingError:
    return .parsing
  default:
    return .unknown
  }
}

这个简单的 mapError 使用 switch 语句将发布者可能抛出的任何类型的错误替换为 DadJokes.Error。您可能会问自己:“我为什么要包装这些错误?”对此的答案是双重的

  1. 您的发布者现在可以保证只会出现 DadJokes.Error 失败,这在使用 API 并处理可能的错误时非常有用。你确切地知道你会从类型系统中得到什么
  2. 您不会泄露 API 的实现细节。想一想,如果您使用 URLSession 执行网络请求并使用 JSONDecoder 对响应进行解码,您的 API 的使用者是否关心?显然不是!消费者只关心你的 API 本身定义为错误的内容——而不关心它的内部依赖项

还有一个你没有处理过的错误:一个不存在的Joke ID。尝试替换以下行

.getJoke(id: jokeID)

.getJoke(id: badJokeID)

然后运行playground,会发现一个错误

failure(Failed parsing response from server)

有趣的是,当您发送一个不存在的 ID 时,icanhazdadjoke 的 API 不会因为 HTTP 代码 404(未找到)而失败——正如大多数 API 所期望的那样。相反,它会发回不同但有效的 JSON 响应

{ message = "Joke with id "123456" not found"; status = 404; }

处理这种情况有一个小技巧,我们用如下代码替换原来的map部分

.tryMap { data, _ -> Data in
  // 6
  guard let obj = try? JSONSerialization.jsonObject(with: data),
        let dict = obj as? [String: Any],
        dict["status"] as? Int == 404 else {
    return data
  }
  
  // 7
  throw DadJokes.Error.jokeDoesntExist(id: id)
}
  1. 你使用 JSONSerialization 来尝试检查一个状态字段是否存在并且值为 404,即这个Joke是否不存在。如果不是这种情况,您只需返回数据,以便将其推送到下游到解码操作符。
  2. 如果确实找到了 404 状态代码,则会抛出 .jokeDoesntExist(id:) 错误

再运行playground,会发现

failure(An unknown error occurred)

失败实际上被视为未知错误,而不是 DadJokes.Error,因为您没有在 mapError 中处理该类型。 这时我们需要将mapError段中的

return .unknown

替换为

return error as? DadJokes.Error ?? .unknown

如果其他错误类型都不匹配,则尝试将其转换为 DadJokes.Error,如果不成功,再返回 .unknown

最后再运行playground,得到结果

failure(Joke with ID 123456 doesn't exist)

这也是我们想要的结果。

在结束此示例之前,您可以在 getJoke(id:) 中进行最后一项优化。 您可能已经注意到,Joke ID 由字母和数字组成。在我们的“错误 ID”的情况下,您只发送了数字。无需执行网络请求,您可以预先验证您的 ID 是否失败,从而避免浪费网络资源(不进行Http请求)。

在getJoke(id:)的开始部分加入如下代码:

guard id.rangeOfCharacter(from: .letters) != nil else {
  return Fail<Joke, Error>(
    error: .jokeDoesntExist(id: id)
  )
  .eraseToAnyPublisher()
}

在这段代码中,你首先要确保 id 至少包含一个字母。如果不是这种情况,您会立即返回失败

Fail 是一种特殊的发布者,它让您立即且强制地失败并提供错误。 它非常适合您希望根据某些条件提前失败的情况。最后,“使用 eraseToAnyPublisher 获得预期的 AnyPublisher<Joke, DadJokes.Error> 类型。 就是这样!使用无效 ID 再次运行您的示例,您将收到相同的错误消息。但是,它会立即发布并且不会执行网络请求。

在继续之前,请恢复对 getJoke(id:) 的调用以使用 jokeID 而不是 badJokeId。 此时,您可以通过手动“破坏”您的代码来验证您的错误逻辑。执行以下每项操作后,撤消您的更改,以便您可以尝试下一个操作: 创建上面的 URL 时,在其中添加一个随机字母以断开 URL。运行 Playground,你会看到:

failure(Request to API Server failed)。

注释掉以 request.allHttpHeaderFields 开头的行并运行 Playground。由于服务器响应将不再是 JSON,而是纯文本,您将看到输出:

failure(Failed parsing response from server)。

像之前一样,向 getJoke(id:) 发送一个随机 ID。运行 Playground,你会得到:

failure(带有 ID {your ID} 的笑话不存在)。

捕获和重试(catch & retry)

您学到了大量有关对 Combine 代码进行错误处理的知识,但我们将最好的内容留到最后,还有两个最后的主题:捕获错误和重试失败的发布者。

Publisher 是一种统一表示工作的方式,其优点在于您拥有许多运算符,这让您可以用很少的代码行完成大量的工作。

原书中这里有一个获取网络图片的例子,看起来会很耽误时间,所以我略过了这段,只把结论告诉大家

  1. 在网络请求时,可以使用.retry(重试次数)进行重试。

  2. 使用catch操作符来捕获所有可能出现的错误,然后在catch执行代码段中,需要返回一个Publisher 如用Just返回一个默认数据,书中的例子是在获取高质量图片失败的时候,在catch中用同样的请求获取低质量的图片,也是返回了一个Publisher

本章的Key Points

  • 失败类型为 Never 的发布者保证不会发出失败事件。
  • 许多操作符只与可靠的Publisher合作。例如:sink(receiveValue:)、setFailureType、assertNoFailure 和assign(to:on:)。
  • 带有 try 前缀的运算符允许您从它们内部抛出错误,而非 try 运算符则不会。
  • 由于 Swift 不支持类型化抛出,因此调用带有 try 前缀的运算符,会将发布者错误转换为普通 Swift 错误。
  • 使用 mapError 映射发布者的失败类型,并将发布者中的所有失败类型统一为单一类型。
  • 当基于其他发布者具有自己的错误类型创建自己的 API 时,将所有可能的错误包装到您自己的 Error 类型中以统一它们并隐藏您的 API 的实现细节。
  • 您可以使用 retry 运算符重新订阅失败的发布者额外的次数。
  • 当您想为发布者提供默认后备值时,replaceError(with:) 很有用。
  • 最后,您可以使用 catch 用不同的后备发布者替换失败的发布者