设计我们自己的可抛出错误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()
}
}
上述代码中:
- 定义一个Joke的数据结构,用于JSON解码。
- 提供一个 getJoke(id:) 方法,该方法当前返回一个发布者,该发布者的Output是一个Joke数据,并且可能会因标准的 Swift.Error 而失败。
- 使用 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)”
- 创建一个 DadJokes 的实例,并用有效和无效的Joke ID 定义两个常量。
- 使用有效的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"
}
}
}
- 通过声明一个枚举来列出所有可能出现的错误
- 让枚举符合 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。您可能会问自己:“我为什么要包装这些错误?”对此的答案是双重的
- 您的发布者现在可以保证只会出现 DadJokes.Error 失败,这在使用 API 并处理可能的错误时非常有用。你确切地知道你会从类型系统中得到什么
- 您不会泄露 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)
}
- 你使用 JSONSerialization 来尝试检查一个状态字段是否存在并且值为 404,即这个Joke是否不存在。如果不是这种情况,您只需返回数据,以便将其推送到下游到解码操作符。
- 如果确实找到了 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 是一种统一表示工作的方式,其优点在于您拥有许多运算符,这让您可以用很少的代码行完成大量的工作。
原书中这里有一个获取网络图片的例子,看起来会很耽误时间,所以我略过了这段,只把结论告诉大家
-
在网络请求时,可以使用
.retry(重试次数)进行重试。 -
使用
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 用不同的后备发布者替换失败的发布者