跳到主要内容

弹性中继应用

·阅读时间 22 分钟
客座文章

这篇文章是 Coinbase 的员工工程师 Ernie Turner 撰写的客座文章。Coinbase 已在其应用中全面采用 Relay,并且是 Relay 团队的强大盟友。去年,他们帮助共同开发了 Relay VSCode 扩展。Ernie 同意与我们分享这篇内部工程博客文章。

如何在服务中断期间为客户提供最佳体验

在理想情况下,Coinbase 的所有服务都不会出现故障,并且我们 GraphQL 架构中的所有字段都会始终正确解析。由于这并不实际,因此 Coinbase 应用程序应能够抵御停机并最大程度地减少对客户的影响:一项服务停机不应阻止用户使用或与整个应用程序交互。但是,当我们的应用程序无法按预期工作时,向用户传达问题也很重要。显示传达停机并带有重试按钮的错误消息比让用户迷惑于缺失内容或无法交互的 UI 更好。

这篇文章将介绍处理 Relay 应用程序中丢失数据的常见模式和最佳实践。

屏幕架构和错误边界

在讨论处理 GraphQL 查询中的服务停机和故障之前,让我们先讨论更广泛的屏幕架构,以及 React 错误边界如何在正确使用的情况下帮助创造更好的用户体验。

就像生活中大多数事情一样,错误边界应该适度使用。让我们看看 Coinbase 零售应用程序中的一个常见屏幕。

上面屏幕中的任何部分都可能无法获取渲染所需的数据,但我们如何处理这些故障会决定用户使用我们应用程序的体验。例如,仅对任何故障使用一个屏幕级错误边界会导致应用程序在发生任何错误时无法使用,无论该错误的重要性如何。相反,用各自的错误边界包装每个组件也会创造同样糟糕的体验。最后,完全省略出错的组件与另外两个选项一样糟糕。没有一种一劳永逸的方法,所以让我们逐一分解这些方法,并解释为什么它们会创造糟糕的用户体验。

全屏错误

上面的 UI 是 Coinbase 的全屏错误回退,如果一项服务正在经历中断,而我们无法获取渲染此屏幕上组件所需的数据,则会显示该错误。在某些情况下,这实际上会创造良好的用户体验。我们可能没有向用户提供有关发生情况的详细信息,但在大多数情况下,提供技术原因是不可能的,也不会改善用户的体验。但是,我们正在告诉他们某些东西无法正常工作,并为他们提供一个清晰的重试按钮,让他们尝试重新启动应用程序。

如果我们向用户显示此内容的原因是我们无法加载某些非关键内容,例如资产价格历史图表或他们的观察列表状态,我们不应该关闭整个屏幕。隐藏比特币的当前价格并阻止用户进行交易,仅仅因为我们无法告诉他们比特币是否在他们的观察列表中,是一种负面的用户体验。

此 UI 的另一个缺点是它从用户那里隐藏了所有应用程序导航。即使我们有充分的理由向用户显示全屏错误,但这并不意味着我们应该在此过程中隐藏应用程序的其余部分。用户仍然应该能够导航到不同的屏幕。实际上,我们应该只向用户显示“全屏错误”,而不是“全应用程序错误”。

到处都是错误消息

上面显示的 UI 在很多方面更糟糕。这是先前体验的另一端,显示用户全屏错误会更好。价格历史图表上的错误消息是有意义的,因为用户会期望该 UI 出现在此屏幕上,但是如果用户甚至看不到比特币的价格或找不到交易按钮,我们真的应该向他们显示第一个屏幕截图中的 UI(但带有导航)——因为该屏幕的核心目标和目的已经丢失了。

此图像还演示了错误边界如何变得过于普遍。整个价格历史图表,包括时间范围选择器,应该只有一个错误消息,而不是每个时间范围都有一个。

空回退

上面的 UI 与前面的示例一样糟糕,在这种情况下,我们的错误边界回退到空内容。对于某些 UI 元素,这样做是有意义的。观察列表旁边的共享按钮缺失对于此 UI 来说并不关键,因此省略它是有意义的。但是,隐藏比特币的当前价格、价格历史图表和交易按钮会导致 UI 无法使用,甚至会产生误导。即使每天不使用应用程序的用户也会知道有些不对劲。我们也没有给用户任何重试任何故障的选项——用户只看到空内容,没有办法恢复。

用户应该看到什么?

以下两个屏幕截图显示了一个更好的用户体验示例。第一个屏幕截图是用户应该看到的,如果我们无法获取比特币的当前价格,或者如果我们无法确定用户是否被允许交易。第二个屏幕截图将是用户更好的体验,如果我们无法获取比特币价格的当前变化或价格历史。

所有这些都表明需要对屏幕上的 UI 部分进行分类:什么对用户体验至关重要,用户期望看到什么 UI,以及什么支持性内容对于体验来说是可选的。

关键 UI 元素与预期 UI 元素与可选 UI 元素

应用程序屏幕中的所有 UI 元素并不相同。屏幕的某些部分对于用户与 UI 的核心信息或交互至关重要,而其他部分可能只是更多信息性和对用户有帮助。对于 Coinbase 的应用程序设计,我们将 UI 元素分为三类:**关键**、**预期**和**可选**。

关键 UI 元素

定义用户与 UI 交互的核心信息或交互的屏幕部分。如果没有这些元素,屏幕就毫无意义,如果它们缺失,用户会感到困惑和/或愤怒,因为不清楚为什么应用程序无法按预期工作。

假设我们无法加载显示这些关键 UI 元素所需的数据。在这种情况下,我们应该向用户显示一条全屏错误消息,解释问题(如果可能),并提供一个重试按钮,让他们可以轻松地尝试重新请求丢失的数据。

让用户与缺少关键 UI 元素的应用程序交互会导致混淆、愤怒,甚至可能导致资金损失,如果用户能够完成交易而不知道发生的事情的全部细节。

关键 UI 元素的示例

  • Coinbase 应用程序主屏幕上用户的当前投资组合余额
  • 订单预览屏幕上的资产价格、支付方式和总购买价格
  • Earn 屏幕上用户的终身收益和每种资产的收益

预期 UI 元素

预期 UI 元素是指屏幕中可能不服务于屏幕的核心目的,但大多数用户会期望存在的元素。如果屏幕上缺少预期 UI 元素,用户可能会认为出问题了,但这不会阻止他们执行屏幕上的核心操作。

如果我们无法加载显示这些预期 UI 元素所需的数据,我们应该向用户显示一个组件本地错误消息,告诉他们缺少预期 UI。这些错误消息还应附带一个重试按钮,让用户可以重新请求丢失的数据。本地化错误更有可能不被用户看到或交互,这在一定程度上是可以接受的,因为它们对于屏幕的核心目的来说不是必需的。

让用户与缺少预期 UI 元素的应用程序交互应该是可以接受的,但这可能会导致对发生情况的混淆。完全省略这些 UI 元素而没有附带的错误消息会创造更糟糕的体验。

预期 UI 元素的示例

  • 购买资产屏幕(用户在其中输入购买金额)上的资产当前价格
  • 资产详细信息屏幕上的价格历史图表
  • Coinbase 卡屏幕上的最近交易列表

可选 UI 元素

可选 UI 元素是指屏幕中纯粹支持屏幕主要目的的元素。一些用户可能会注意到这些元素缺失,但其他人可能完全没有意识到它们应该存在。在任何一种情况下,用户都不会被阻止完成他们在屏幕上的主要目标。

如果我们无法加载显示这些可选 UI 元素所需的数据,我们应该直接将它们从 UI 中完全省略。但是,这样做会带来以下风险

A. 用户可能不知道任何东西缺失 B. 用户将无法重新请求此 UI 的数据,除非他们进行全屏刷新。

开发人员应考虑这些缺点,并确保它们不会造成负面的用户体验。相反,这些错误应该被记录下来,以便产品工程师在用户体验不理想时收到通知。

可选 UI 元素示例

  • 在资产详情页面提供卡片
  • 交易页面上的资产类别部分(Coinbase 新闻,热门交易等)
  • 首页上的新闻提要

让我们回到上面的图片,并将 UI 的各个部分归类到这些类别中。

元素分类限制

在上面的示例中,我们有一个屏幕,它有两个关键组件,两个预期组件和一个可选组件。应用程序中的大多数屏幕应该只包含少量关键的 UI 组件。对于某些屏幕,整个 UI 可能会由单个关键组件组成。

预期元素也是如此。如果我们有一个屏幕由五个独立的预期 UI 元素组成,我们最终会得到上面的截图,其中“重试”按钮散布在整个应用程序中。如果可能,将单个屏幕上的预期元素和重试按钮的数量限制为一到两个。

下拉刷新

对于所有上述情况,移动应用程序上的用户应该能够下拉刷新以重试屏幕上的任何失败请求。对于 Relay 应用程序,这通常意味着重试整个屏幕级别的查询。如果屏幕由于缺少数据而存在任何错误消息或隐藏的组件,使用下拉刷新应该始终尝试修复所有这些错误条件。

与您的产品经理和设计师合作

所有这些分类都是主观的——上面所有示例都只是一种观点,设计师或 PM 可能对屏幕应该如何降级有不同的看法。在设计应用程序 UI 时,跨职能对齐非常重要。团队应该咨询工程师、设计师和产品经理,以确保整个应用程序的屏幕无缝且符合品牌。

Relay 如何帮助

将您的屏幕分类后,下一步是在您的应用程序中添加适当的 ErrorBoundaries,并根据其分类配置组件的 GraphQL 片段。这就是 Relay 可以提供帮助的地方。根据我们使用 Relay 应用程序的经验,我们创建了一些关于如何处理 GraphQL 查询中缺少数据的最佳实践。

背景

Coinbase 的目标是使用可空模式,因为 Relay 团队建议。主要驱动因素是它将如何处理服务中断和缺少查询数据的决定权交给了客户端工程师。如果没有可空模式,处理缺少数据的决定将在服务器上做出(通过将空值冒泡到最近的可空父级),客户端代码无法改变此决定。

此决定得到了 Relay @required 指令 的支持,该指令允许客户端工程师使用指令对其查询和片段进行注释,这些指令告诉 Relay 如何在运行时处理缺少的数据。这减少了工程师原本需要编写的样板代码。表面上看,这个指令非常简单:它只有三个选项,这些选项都非常直接。但是,在尝试将此指令用于各种用例时,很明显选择哪个选项并不总是显而易见的,使用该指令本身的决定也并不总是显而易见的。

@required 的局部性

@required 指令的一大特点是,它只影响您使用它的片段。它永远不会改变查询同一字段的其他片段的行为。这使您能够添加或删除指令,而无需考虑组件作用域之外的任何内容。这一点很重要,因为不同的组件可能被归类为不同的类别,即使它们从同一查询获取数据。能够在同一查询的片段中使用不同的 @required 参数标记字段对于帮助构建理想的用户体验非常重要。

使用 action: LOG vs action: NONE

LOGNONE 操作都具有相同的运行时行为,但 LOG 会向您选择的日志机制发送一条消息,记录返回为 null 的字段的完整路径。对于大多数需要使用 @required 指令的用例,应该使用 LOG 而不是 NONE。只有在某个字段预计对某些用户为 null 时,才应优先使用 NONE

虽然使用 action: LOG 创建的日志条目本身可能无法操作,但它可以作为未来错误的线索,是一个有用的信号。能够查看错误的历史记录并看到特定字段意外地为 null,可以帮助跟踪用户在工作流中可能遇到的未来错误。

何时使用 @required(action:LOG/NONE)

LOG/NONE 操作仅应用于对显示组件中的可选 UI 必要的字段。在设计应用程序时,这会显示出两种不同的用例

  1. 您的组件是可选 UI,如果字段或一组字段为 null,则根本不应该呈现
  2. 您的组件的一部分是可选 UI,并且依赖于一个对象类型字段,如果缺少该对象的一个或多个子字段,该对象将毫无意义

让我们来看一个包含这两种用例的片段

fragment MyFragment on Asset {
id
name @required(action: LOG)
slug @required(action: LOG)
color
supply {
total @required(action: LOG)
circulating @required(action: LOG)
}
}

对于此片段,我们说如果我们没有获取 name 或 slug 字段,则整个片段无效。如果这些字段从服务器返回为 null,我们根本无法呈现此组件。此片段还展示了如何使用 @required(action: LOG/NONE) 指令使整个对象类型字段无效。此片段表示,如果我们没有 supply.totalsupply.circulating 字段中的任何一个,则整个 supply 对象本身无效,应该为 null。然后,此可空性将用于隐藏此组件 UI 的可选部分。

现在让我们看看我们的组件将如何处理来自此查询的结果

const asset = useFragment(
graphql`
fragment MyFragment on Asset {
id
name @required(action: LOG)
slug @required(action: LOG)
color
supply {
total @required(action: LOG)
circulating @required(action: LOG)
}
}
`,
assetRef,
);

// If we couldn't get the required asset name or slug fields, hide this entire UI
if (asset === null) {
return null;
}
// Otherwise hide certain portions of the UI if data is missing
return (
<>
<Title color={asset.color}>{asset.name}</Title>
<Subtitle>{asset.slug}</Subtitle>
{asset.supply && (
<SupplyStats total={asset.supply.total} circulating={asset.supply.circulating} />
)}
</>
);

@required 指令在这里非常有效,因为它消除了我们原本需要编写的复杂空值检查。无需检查 asset.nameasset.slug 字段是否都为 null,我们只需检查整个片段是否为空,并阻止渲染即可。检查是否应该呈现 SupplyStats 组件时也是如此。我们只需要检查父字段是否为 null,就可以知道两个子字段是否不为 null。

何时使用 @required(action:THROW)

使用 @required(action: THROW) 更直接。此操作应用于渲染预期或关键 UI 组件所需的字段。如果这些字段从服务器返回为 null,则您的组件应向最近的 ErrorBoundary 抛出错误,用户应看到错误消息。

ErrorBoundary 在树中向上移动多远取决于您希望在出现错误时删除多少 UI。例如,如果我们向用户显示错误而不是资产价格历史图,那么保留时间序列按钮仍然可见就没有意义,整个 UI 应该消失。但是,我们也不希望在发生这种情况时删除整个屏幕。

确保您的 ErrorBoundary 为用户提供一种机制来重试失败的查询,以查看他们是否可以在后续尝试中获取数据。我们应该始终将错误消息与可操作的元素配对,以让用户恢复。我们不应该依赖用户能够(或知道)使用下拉刷新来重新加载屏幕。

关于在数组中的字段上使用 @required(action: THROW) 的说明

您几乎不应该在选择数组字段和该数组的字段的组件中使用 THROW 操作。以下是一个不应该做的示例

function Component({ assetPriceRef }) {
const { quotes } = useFragment(
graphql`
fragment ComponentFragment on AssetPriceData {
quotes {
# Returns an array of items
timestamp
price @required(action: THROW)
}
}
`,
assetPriceRef,
);
}

此组件同时选择 quotes 数组以及该数组中每个项目上的 timestampprice 字段。如果我们希望在没有获取任何报价时向用户显示错误,则在 quotes 字段上放置 THROW 是可以接受的。但是,如果该数组中的单个 price 字段为 null,则在 price 字段上放置 THROW 将导致向用户显示错误。这可能不是我们想要的行为。如果我们正确地获取了过去一天的 24 个报价中的 23 个报价,我们应该仍然显示我们拥有的结果,并且只省略空值。

相反,我们应该使用 action: LOG/NONE,这样我们只会使数组中的单个项目无效,而不是所有项目。然后,如果需要,我们可以选择性地过滤掉数组中的 null 值。

function Component({ assetPriceRef }) {
const { quotes } = useFragment(
graphql`
fragment ComponentFragment on AssetPriceData {
quotes {
# Returns an array of items
timestamp
price @required(action: LOG)
}
}
`,
assetPriceRef,
);
const validQuotes = quotes.filter(removeNull);
}

何时不应在字段上使用 @required

这个问题的无用答案是“当字段不是必需时,不要使用 @required”。这个答案低估了什么是必需的,什么不是必需的,尤其是在您的片段有十几个字段或更多字段时,答案通常更加细致。但是,我们可以遵循一些最佳实践来决定是否将字段标记为必需。再次强调,与您的 PM 和设计师合作以帮助您做出这些决定非常重要。

省略 @required 指令和使用它时也有细微的差别 LOG/NONE 操作。主要区别在于,当该字段呈现的 UI 是可选 UI 时,您应该省略 @required 指令。

应用程序中的某些组件可以渲染不同 UI 分类组合。例如,单个组件可能负责同时显示资产的当前价格以及在一段时间内有多少百分比的用户购买或出售了该资产。这意味着该组件混合了关键 UI(资产价格)和可选 UI(购买/出售统计信息)。

如果使用某个字段来渲染可选内容,而该内容可以从 UI 中完全省略,而不会给用户造成困惑(记住,这就是可选 UI 的定义),那么您不应在该字段上使用 @required 指令。相反,您应该在代码中添加检查,以在字段为 null 时省略 UI。

function SomeComponent({ queryRef }) {
const { asset } = useFragment(
graphql`
asset {
latestQuote @required(action: THROW) # Required data
buyPercent # Optional data
}`,
queryRef,
);

return (
<div>
<div>Price: {asset.latestQuote}</div>
{asset.buyPercent !== null && (
<>
<div>Buy Percent: {asset.buyPercent}</div>
<div>Sell Percent: {1 - asset.buyPercent}</div>
</>
)}
</div>
);
}

在这个例子中,在 buyPercent 字段上使用 @required(action: LOG/NONE) 是不正确的,因为这会导致整个片段无效,而这并不是我们想要的行为。

另一个不太常见的省略 @required 指令的情况是,当你能够提供一个安全的回退值时。为字段提供回退/默认值,如果处理不当,可能非常危险。虽然在一些情况下,回退到默认值可能是安全的,但这通常非常罕见,应该避免。但是,如果你可以提供一个安全的回退值,你应该避免在该字段上添加 @required,而是使用回退值。

以下是一些关于何时提供回退值的指南

  • 不应使用数字字段(数字或表示数字的字符串)的回退值。
    • 使用 0 来代替缺失的值,总是会给用户带来更多的困惑。Coinbase 是一家金融公司,如果我们无法向用户显示准确的值,我们就不应该显示它们。向用户显示他们的账户余额为 0.00 美元,显然比向他们显示错误消息要糟糕得多。这是一个明显的用例,但即使是在资产价格变动百分比、Coinbase 卡的年化收益率 (APY%) 或者用户可以通过 Coinbase Earn 获得的金额等地方,如果我们没有实际的值,也永远不应该显示 0。
  • 谨慎使用布尔字段的回退值。
    • 布尔字段的回退值的首选通常是将该字段设置为 false。根据布尔字段的含义,回退到 false 可能会比向用户显示错误消息产生更糟糕的用户体验。对于诸如 isEligibleForOffer 之类的字段,回退到 false 可能是可以接受的,因为这很可能是显示可选内容。但对于诸如 hasCoinbaseOneSubscription 之类的字段,回退到 false 是不可接受的,因为对于 CoinbaseOne 订阅用户来说,该内容是预期的,用户会对应用程序中缺少该 UI 感到困惑。
  • 谨慎使用数组字段的空数组回退。
    • 如果你向用户显示他们的 Coinbase 卡交易列表,回退到空数组是一个坏主意,但如果你向用户显示最近添加的资产列表,回退到空数组可能没问题,因为该组件已经需要处理数组为空的情况。
  • 字符串字段通常只需处理 null。
    • 在某些情况下,你可能希望对返回为 null 的字符串字段回退到空字符串,但这通常会创建与直接将该字段保留为 null 相同的代码路径。大多数模式中的字符串字段都不希望为空,因此回退到空字符串会导致负面的用户体验,用户会看到空字符串而不是实际内容。
function SomeComponent({ queryRef }) {
const asset = useFragment(
graphql`
fragment MyFragment on Asset {
canTrade @required(action: THROW) # Required data
hasOfferToStake # Optional data
}
`,
assetRef,
);

const showStakeOffer = asset.hasOfferToStake ?? false;

return (
<div>
{asset.canTrade && <Button>Trade</Button>}
{showStakeOffer && <Button>Stake your currency</Button>}
</div>
);
}

总结

如果你从本文中获得了任何信息,我希望是,在处理停机和服务中断时,需要认真考虑如何处理。处理故障状态是构建世界级应用程序的重要组成部分。在确定新功能范围时,确保你的设计和 PM 团队与你的团队保持一致。如果他们没有就数据缺失时如何向用户展示信息提供建议,请进行回退,并作为团队达成一致意见。

Relay 是一个强大的工具,可以帮助你处理应用程序故障。它能够以细粒度的方式帮助你决定如何处理故障,这可能比你习惯的要多一些工作。然而,这种额外的努力最终会得到回报,并有助于提升你的应用程序的用户体验。