How to handle errors and expose them to the user
作为开发者,我们往往是一群相当乐观的人。至少这是您在查看我们编写的代码时得到的印象——我们主要关注的是正确的路径,并且倾向于在错误处理上花费较少的时间和精力。
即使在本系列中,我们也一直忽略了错误处理。事实上,我们大多忽略了这一点:在之前的文章中,我们用默认值替换了任何错误,这对于我们的应用原型来说是可以的,但对于任何进入生产阶段的应用来说,这可能不是一个可靠的策略。
这一次,让我们仔细看看如何适当地处理错误!
Error handling strategies
在深入研究如何处理错误之前,让我们先讨论几种错误处理策略,以及它们是否适合我们的场景。
Ignoring the error
乍听起来,这可能是一个糟糕的想法,但在处理特定情况下的某些类型的错误时,这实际上是一个可行的选择。下面是一些例子:
- 用户的设备暂时离线,或者有其他原因导致应用程序无法访问服务器。
- 服务器暂时关闭,但很快就会恢复。
在许多情况下,用户可以继续离线工作,一旦设备恢复在线,应用程序可以与服务器同步。当然,这需要某种离线同步解决方案(如Cloud Firestore)。
提供一些用户反馈以确保用户理解他们的数据尚未同步是一种很好的做法。许多应用程序会显示一个图标(例如一个向上指向箭头的云)来表明同步过程仍在进行中,或者一个警告标志来提醒用户,一旦他们重新上线,他们需要手动触发同步。
Retrying (with exponential back-off)
在其他情况下,不能忽略错误。想象一下一个热门活动的预订系统:服务器可能会被大量的请求压垮。在这种情况下,我们希望确保系统不会因为用户每隔几秒钟点击“刷新”而崩溃。相反,我们希望分散重试之间的时间。使用指数后退(exponential backoff)策略既符合用户的利益,也符合系统运营商的利益:运营商可以确保他们的服务器不会因为用户不断刷新而不堪重负,用户最终应该通过应用程序自动重试来完成预订。
Showing an error message
有些错误需要用户的操作——例如,如果保存文档失败。在这种情况下,适当的做法是显示一个模型对话框来引起用户的注意,并询问他们如何继续。对于不太严重的错误,显示toast
(显示一小段时间然后消失的覆盖)可能就足够了。
Replacing the entire view with an error view
在某些情况下,用错误UI替换整个UI可能是合适的。一个著名的例子是Chrome,如果设备离线,它会显示Chrome Dino,让用户知道他们的设备离线,并帮助他们度过时间,直到他们的连接恢复与一个有趣的跳跑游戏。
Showing an inline error message
在用户提供的数据无效的情况下,这是一个很好的选择。不是所有的输入错误都能被本地表单验证检测到。例如,在线商店可能有一条业务规则,规定超过一定数量的货物必须使用特定的运输提供商进行运输。在客户端应用程序中实现所有这些业务规则并不总是可行的(一个可配置的规则引擎在这里肯定会有所帮助),所以我们需要准备好处理这些类型的语义错误。
理想情况下,我们应该在相应的输入字段旁边显示这些类型的错误,以帮助用户提供正确的输入。
Typical error conditions and how to handle them
为了让您更好地理解如何在实际场景中应用它,让我们在本系列前面创建的注册表单中添加一些错误处理。特别是,我们将处理以下错误条件:
- 设备/网络离线
- 语义验证错误
- 响应解析错误/无效URL
- 服务器内部错误
Implementing a fallible network API
在上一篇文章中,我们实现了一个与身份验证服务器接口的AuthenticationService
。这有助于我们将所有内容整齐地组织起来,并按关注点分开:
- 视图(
SignUpScreen
)显示状态并接受用户的输入 - 视图模型(
SignUpScreenViewModel
)保存视图显示的状态。反过来,它使用其他api对用户的操作做出反应。在这个特殊的应用中,视图模型使用AuthenticationService
与认证服务器交互 - 服务(
AuthenticationService
)与身份验证服务器交互。它的主要职责是将服务器的响应转换为客户端可以使用的格式。例如,它将JSON转换为Swift结构体,并且(与本文最相关)它处理任何网络层错误并将它们转换为客户端可以更好地处理的ui级错误。
下图概述了各个类型是如何协同工作的:
如果你看一下我们在上一篇文章中写的代码,你会注意到checkusernameavailablepublisher
有一个失败类型Never
——这意味着它声明永远不会有错误。
1 | func checkUserNameAvailablePublisher(userName: String) -> AnyPublisher<Bool, Never> { ... } |
这是一个相当大胆的声明,特别是考虑到网络错误非常普遍!我们之所以能够保证这一点,是因为我们将任何错误都替换为返回值false
:
1 | func checkUserNameAvailablePublisher(userName: String) -> AnyPublisher<Bool, Never> { |
要将这个相当宽松的实现转换为向调用者返回有意义的错误消息的东西,我们首先需要更改publisher的failure type,并停止通过返回false
来掩盖任何错误:
1 | enum APIError: LocalizedError { |
我们还引入了一个自定义错误类型APIError
。这将允许我们将API内部可能发生的任何错误(无论是网络错误还是数据映射错误)转换为语义丰富的错误,以便在视图模型中更容易地处理。
Calling the API and handling errors
既然API有了failure type,我们还需要更新调用方。一旦publisher发出failure,管道将终止——除非您捕获错误。使用flatMap
时处理错误的典型方法是将其与catch
操作符结合使用:
1 | somePublisher |
将此策略应用到视图模型中的代码中,得到以下代码:
1 | private lazy var isUsernameAvailablePublisher: AnyPublisher<Bool, Never> = { |
就这样,我们回到了起点!如果API发出错误(例如,用户名太短),我们捕获error
(1)并将其替换为false
(2) -这正是我们之前的行为。除了,我们写了更多的代码……
这种方法似乎毫无用处,所以让我们后退一步,看看我们的解决方案的需求:
- 我们希望使用管道发出的值来驱动提交按钮的状态,并在选择的用户名不可用时显示一条警告消息。
- 如果管道发出失败,我们希望禁用提交按钮,并在用户名输入字段下面的错误标签中显示错误消息。
- 我们如何准确地处理errors将取决于failure的type,这将在本文后面讨论。
This means:
- 我们需要确保我们能够接受
failures
和successes
- 我们需要确保管道在接收到故障时不会终止
为了实现这一切,我们将把checkUserNameAvailablePublisher
的结果映射到result
类型。Result
是一个枚举,可以捕获success和failure状态。将checkUserNameAvailablePublisherto
的结果映射到Result
也意味着管道在发出失败时将不再终止。
让我们首先为Result
类型定义一个类型别名,使我们的工作简单一些:
typealias Available = Result<Bool, Error>
要将publisher
的结果转换为result
类型,我们可以使用John Sundell在他的文章the power of extensions in Swift中实现的以下操作符:
1 | extension Publisher { |
这允许我们像这样在视图模型中更新isUsernameAvailablePublisher
:
1 | private lazy var isUsernameAvailablePublisher: AnyPublisher<Available, Never> = { |
有了这些基本的管道,让我们看一下如何处理我前面概述的不同错误场景。
Handling Device/Network Offline Errors
在移动设备上,断断续续的连接是很常见的:尤其是当你在移动的时候,你可能在一个覆盖不好或没有覆盖的地区。
是否应该显示错误消息取决于具体情况:
对于我们的用例,我们可以假设用户至少具有间歇性连接。当用户正在填写表单时,告诉用户我们无法访问服务器会分散他们的注意力。相反,我们应该忽略表单验证的任何连接错误(而是运行本地表单验证逻辑)。
一旦用户输入了所有详细信息并提交了表单,如果设备仍处于离线状态,我们应该显示一条错误消息。
捕获这种类型的错误需要我们在两个不同的地方进行更改。首先,在checkUserNameAvailablePublisher
中,我们使用mapError
捕获任何上游错误,并将它们转换为APIError
1 | enum APIError: LocalizedError { |
然后,在我们的视图模型中,我们映射结果以检测它是否是failure(1,2)。如果是,我们提取error并检查它是否是网络传输error。如果是这种情况,我们返回一个空字符串(3)来抑制错误消息:
1 | class SignUpScreenViewModel: ObservableObject { |
如果isUsernameAvailablePublisher
返回成功,我们提取Bool
值,告诉我们所需的用户名是否可用,并将其映射到适当的消息。
最后,我们将管道的结果赋值给usernameMessage
(4)和isValid
(5)已发布的(published)属性,它们驱动视图上的UI。
请记住,对于这种UI,忽略网络错误是一个可行的选择——对于您的用例来说,这可能是一个完全不同的故事,所以在应用此技术时请使用您自己的判断。
到目前为止,我们还没有向用户暴露任何错误,所以让我们来看看我们真正想让用户意识到的一类错误。
Handling Validation Errors
大多数验证错误应该在客户端本地处理,但有时我们无法避免在服务器上运行一些额外的验证步骤。理想情况下,服务器应该返回4xx
范围内的HTTP
状态码,并可选地返回提供更多详细信息的有效负载(payload)。
在我们的示例应用程序中,服务器要求用户名的最小长度为4个字符,并且我们有一个禁止的用户名列表(例如“admin”或“superuser”)。
对于这些情况,我们希望显示一条警告消息并禁用提交按钮。
我们的后端实现是基于Vapor
的,并将响应HTTP
状态400
和任何验证错误的错误负载。如果你对它的实现感到好奇,可以查看routes.swift
中的代码。
处理这个错误场景需要我们在两个地方进行更改:服务实现和视图模型。让我们首先看一下服务实现。
因为我们应该在尝试从响应中提取有效负载之前处理任何错误,所以处理服务器错误的代码需要在检查URLErrors
之后和映射数据之前运行:
1 | struct APIErrorMessage: Decodable { |
让我们仔细看看这段代码的作用:
- 如果响应不是
httpurlresponse
,我们返回APIError.invalidResponse
- 我们使用Swift的模式匹配来检测请求是否成功执行,也就是说,使用200到299范围内的HTTP状态码
- 否则,服务器发生错误。由于我们使用了
Vapor
,服务器将在JSON
有效负载中返回有关错误的详细信息,因此我们现在可以将此信息映射到APIErrorMessage
结构体,并在下面的代码中使用它来创建更有意义的错误消息 - 如果服务器返回一个
HTTP
状态400
,我们就知道这是一个验证错误(详细信息请参见服务器实现),并返回一个APIError.validationError
,包括我们从服务器接收到的详细错误消息
在视图模型中,我们现在可以使用这些信息告诉用户他们选择的用户名不符合要求:
1 | init() { |
没错,只有三行代码。我们已经完成了所有艰苦的工作,所以是时候收获好处了🎉
Handling Response Parsing Errors
在许多情况下,服务器发送的数据与客户端期望的不匹配:
- 响应包含额外的数据,或者重命名了一些字段
- 客户端通过强制门户(captive portal)进行连接(例如在酒店中)
在这些情况下,客户端接收数据,但格式错误。为了帮助用户解决这种情况,我们需要分析用户的反应,然后提供适当的指导,例如:
- 下载最新版本的应用程序
- 通过系统浏览器登录到强制门户 (captive portal)
当前实现使用decode
操作符解码响应有效负载,并在无法映射有效负载的情况下抛出错误。这工作得很好,任何解码错误都将被捕获并显示在UI上。然而,像“数据无法读取,因为它丢失了”这样的错误消息并不是真正的用户友好。相反,让我们尝试显示对用户更有意义的消息,并建议升级到最新版本的应用程序(假设服务器正在返回新应用程序能够利用的额外数据)。
为了能够提供关于解码错误的更细粒度的信息,我们需要与decode
操作符分开,并返回到手动映射数据(不用担心,多亏了JSONDecoder
和Swift的Codable
协议,这非常简单):
1 | // ... |
通过APIError
遵循LocalizedError
协议,并实现errorDescription
属性,我们可以提供更加用户友好的错误消息(我也包括了其他错误条件的自定义消息):
1 | enum APIError: LocalizedError { |
现在,为了让用户清楚地知道他们应该更新应用,我们还会显示一个警告。下面是警报的代码:
1 | struct SignUpScreen: View { |
您将注意到,该警报的表示状态是由视图模型上的已发布属性showUpdateDialog
驱动的。让我们相应地更新视图模型(1),并添加将isUsernameAvailablePublisher
的结果映射到这个新属性的Combine
管道:
1 | class SignUpScreenViewModel: ObservableObject { |
正如您所看到的,没有什么太花哨的—我们本质上只是从isUsernameAvailablePublisher
获取任何事件,并将它们转换为Bool
值,只有当我们接收到.decodingerror(2)
时才变为真值。
我们现在使用isUsernameAvailablePublisher
来驱动三个不同的Combine
管道,我想要显式地调用它——因为isUsernameAvailablePublisher
最终将导致一个网络请求被触发——确保每次击键最多只发送一个网络请求是很重要的。本系列的前一篇文章深入解释了如何做到这一点,但值得指出的是,使用.share()(3)
起着关键作用。
Handling Internal Server Errors
在一些罕见的情况下,我们的应用程序的后端可能会有一些问题——可能是系统的一部分离线维护,某些进程死亡,或者服务器不堪重负。通常,服务器将返回一个5xx范围内的HTTP状态码来表示这一点。
模拟错误条件示例服务器包含模拟本文中讨论的一些错误条件的代码。您可以通过发送特定的
username
值来触发错误条件:
任何少于4个字符的用户名都会导致
tooshort
的验证错误,通过HTTP400
状态码和包含详细错误消息的JSON
有效负载发出信号。空的用户名将导致一个
emptyName
错误消息,指示用户名不能为空。有些用户名是被禁止的:“admin”或“superuser”将导致
illegalName
验证错误。其他用户名,如“peterfriese”、“johnnyappleseed”、“page”和“johndoe”已经被占用,因此服务器将告诉客户端这些用户名不再可用。
发送
illegalresponse
作为用户名将返回一个字段太少的JSON
响应,导致客户端上的解码错误。发送
servererror
将模拟数据库问题(databasecorrupated
),并将被标记为没有重试提示(with no retry hint)的HTTP 500(因为我们假设这不是临时情况,重试将是徒劳的)。发送“maintenance”作为用户名将返回一个
maintenance
(维护)错误,以及一个retry-after
报头,该报头表明客户端可以在一段时间后重试此调用(这里的想法是服务器正在进行预定的维护,并且将在重新启动后进行备份)。
让我们添加处理服务器端错误所需的代码。正如我们在之前的错误场景中所做的那样,我们需要添加一些代码来将HTTP
状态码映射到APIError
enum:
1 | if (200..<300) ~= urlResponse.statusCode { |
要在UI中显示用户友好的错误信息,我们所需要做的就是向视图模型中添加几行代码:
1 | isUsernameAvailablePublisher |
到目前为止,一切顺利。
对于某些服务器端错误场景,可能值得在短时间后重试请求。例如,如果服务器进行了维护,它可能会在几秒钟后再次备份。
Combine包含一个重试操作符,可用于自动重试任何失败的操作。将它添加到我们的代码中是一个简单的一行代码:
1 | return URLSession.shared.dataTaskPublisher(for: url) |
然而,正如你在运行应用程序时所注意到的,这将导致任何失败的请求被重试三次。这不是我们想要的——例如,我们希望任何验证错误都出现在视图模型中。相反,它们也将被重试操作符捕获。
更重要的是,重试之间没有停顿。如果我们的目标是减轻已经不堪重负的服务器的压力,那么发送四个请求(原始请求加上三次重试)会使情况变得更糟。
那么我们怎样才能确保
- 我们只重试某些类型的失败?
- 重试失败的请求之前有暂停吗?
我们的实现需要能够捕获任何上游(upstream)错误,并将它们沿管道传播到下一个操作符。然而,当我们捕获一个serverError
时,我们希望暂停一会儿,然后重新启动整个管道,以便重试URL请求。
让我们首先确保能够(1)捕获所有错误,(2)过滤掉serverError
,以及(3)沿着管道传播所有其他错误。tryCatch
操作符“通过用另一个发布者替换它或抛出新的错误来处理来自上游发布者的错误”。这正是我们需要的:
1 | return URLSession.shared.dataTaskPublisher(for: url) |
当我们捕获到serverError
时,我们希望等待一小段时间,然后重新启动管道。
为此,我们可以触发一个新事件(使用Just publisher
),将其延迟几秒钟,然后使用flatMap
启动一个新的dataTaskPublisher
。我们没有将管道的整个代码粘贴到if语句中,而是将dataTaskPublisher
赋值给一个局部变量:
1 | let dataTaskPublisher = URLSession.shared.dataTaskPublisher(for: url) |
关于这段代码的几点注意事项:
Just
发布者(publisher)期望它能发布一些价值。因为使用哪个值并不重要,所以我们可以发送任何想要的值。我决定发送一个空元组,这通常用于当你的意思是“nothing”的情况。- 我们重试发送请求10次,这意味着它将总共发送11次(原始调用加上10次重试)。
这个数字如此之高的唯一原因是为了更容易地看到,一旦服务器返回成功的结果,管道就会结束。当您以username
发送maintenance
时,演示服务器可以模拟从计划维护中恢复:它将抛出InternalServerError.maintenance
对每个第一个和第二个请求进行维护(映射到HTTP 500)。每第三次请求,它将返回一个成功(即HTTP 200
)。最好的方法是从Xcode
中运行服务器(运行打开服务器项目并按下run
按钮)。然后,为包含throw InternalServerError.maintenance
的行创建一个Sound breakpoint
:
每次服务器接收到username=maintenace
的请求时,您都会听到一个声音。现在,运行示例应用程序并以username
输入maintenance
。您将听到服务器响应两次错误,然后才返回成功。
Closure
在本系列的最近一集中,我们使用了一种相当宽松的方法来处理错误,这一次,我们采取了更加严肃的态度。
在本集中,我们使用了几种策略来处理错误并将其公开给UI。错误处理是开发人员质量软件的一个重要方面,并且有很多相关的材料。然而,如何向用户暴露错误这方面的讨论并不多,我希望本文能帮助您更好地理解如何实现这一点。
与原始代码相比,代码变得有点复杂,这是我们将在下一集中讨论如何实现您自己的Combine操作符时要解决的问题。为了演示这是如何工作的,我们将实现一个操作符,使处理增量回退就像在组合管道中添加一行一样简单!
感谢阅读🔥