跳转到内容

一个微前端可以失败

微前端会引入新的 Runtime Boundary。尤其在动态 Integration 中,Remote 可以在 Shell 仍然正常的情况下无法访问、无法激活,或者在运行过程中失败。

Martin Fowler 与 James Lewis 把 “Design for failure” 列为微服务架构的重要特征之一:应用必须预期单个 Service 会失败,并尽可能以受控方式处理这种失败。1 同样的要求也适用于微前端:

一个 Remote 的故障应该影响它负责的产品区域,而不是整个应用。

这不是反对动态微前端的论据,而是分布式交付的直接后果。更多自治意味着更多可能的 Failure State。架构因此不能只为 Happy Path 设计。

一套只有在所有部件都可用时才能工作的分布式架构,本质上只是一个分布式 Happy Path。

真正重要的不是 Remote 会不会失败。

而是失败能传播多远。

在动态微前端里,Shell 可以正常,而某个 Remote Artifact 加载失败。Manifest 可能不存在,Server 可能无响应,Artifact 可能发布不完整。即使加载成功,Activation 和已经运行起来的 Remote 也仍然可能出错。

这些状态并不自动说明架构差。

它们首先说明系统的某些部分可以独立运行。

但只有当平台也能处理其中一个部分失败时,这种独立性才真正有价值。

微前端只有在它的失败范围同样被限制时,才算真正独立。

Remote 可以失败,但整个产品不应该跟着失败

Section titled “Remote 可以失败,但整个产品不应该跟着失败”

标题是故意写得很尖锐。

它并不是说 Remote 不重要,更不是说业务关键能力可以随意不可靠,只因为它们被放进了独立 Remote。

当 Invoice Remote 失败时,Invoice Processing 当然可能不可用。Resilience 并不要求 Shell 临时实现第二份 Invoice Function。

合理目标更小,但架构要求反而更高:

不合理的要求:
└── 即使 Capability 失败,它仍然完整工作
合理的要求:
├── Error 留在受影响区域
├── 其他产品区域继续可用
├── 当前状态可以被用户理解
└── 可以 Retry 或安全返回

也就是说,故障被预期并限制。Navigation 和其他产品区域继续工作;受影响区域展示可理解的 Error State;用户可以 Retry、返回,Operations 也能够有针对性地诊断。

Resilience 不意味着“故障中的 Use Case 仍然继续成功”。

它意味着“不要顺便把整个产品一起弄死”。

Remote 并不是只会在一个阶段失败。Integration Architecture 至少应该区分三类 Failure。

第一类错误发生在 Activation 之前。Shell 尝试加载 Manifest、JavaScript Artifact 或动态 Entry Point,却在合理时间内得不到可用结果。

可能原因包括:

  • Remote Manifest 不可用;
  • JavaScript Artifact 无法访问;
  • CDN 或 Server 故障;
  • Network Error;
  • Timeout;
  • Artifact 发布不完整或本身损坏。

Shell 必须在合理时间内识别这个状态。

一个永远转着的 Global Spinner 不是 Fallback。

受影响区域需要定义好的 Failure State,其余应用不能无限等待。

第二类错误发生在 Artifact 已经加载之后,但 Bootstrap、Mounting 或 Initialization 失败。

Initialization 可能抛 Exception;Platform Adapter 缺失;Mounting 失败;Integration Contract 不满足;也可能 Initialization 永远无法结束。

从 Failure Radius 来看,关键是 Shell 必须把 Activation 视为一个可能失败的操作,并在失败后把 Mount Area 转换成明确 Error State。

入口代码已经加载,并不意味着 Remote 已经成功启动。

第三类错误发生在 Activation 成功之后。Remote 已经可见、用户也在使用,但之后出现未处理 Error。

原因可能是 Render/Template Error、Uncaught Exception、错误的异步处理、意外 State,或者 Use Case 内没有正确处理的 Failure。

已经运行的产品区域同样需要有效 Boundary,避免后续 Error 直接摧毁整个可见 Application Tree。

Deployment Boundary 不是自动的 Error Boundary

Section titled “Deployment Boundary 不是自动的 Error Boundary”

独立 Repository、自己的 Build Artifact 和独立 Deployment,确实会形成组织和技术边界。

但它们不会自动形成隔离 Runtime。

独立 Deployment 不等于独立 Failure Domain。

运行在同一个 Browser Document、同一个 JavaScript Context 中的 Remote,会共享大量基础资源:Main Thread、Browser Object 以及可见 DOM Tree。

一个 Remote 仍然可以通过这些方式影响整个产品:

  • 在有效 Integration Boundary 之外抛出未捕获 Error;
  • 注册 Global Event Handler;
  • 写 Global CSS;
  • 修改 Shared Browser Object;
  • 执行阻塞 Main Thread 的同步逻辑;
  • 产生不受控 Global Side Effect。

这些风险需要不同对策。Render Error 与 Blocking Code 不是一回事;Global CSS 也不会因为有 Error Handler 就被隔离。

“Microfrontend” 这个词本身不会生成 Error Boundary。

Global Error Handler 可以记录 Error,因此对 Operations 很有价值。但它不会自动隔离故障区域。Framework 自己的 Error Boundary 可以捕获 Component Tree 内的一类错误,但不一定能覆盖 Async Error、Global Side Effect 或职责范围之外的问题。

有效 Error Boundary 必须与具体 Integration Model 匹配。

平台与 Remote 之间的 Integration Point,是建立显式 Failure Boundary 最自然的位置。Shell 在这里理解技术 Lifecycle,却不需要接管 Remote 的业务逻辑。

一个可能的模型是:

Shell
└── Remote Boundary
├── Loading
├── Mounted Remote
├── Activation Error
└── Runtime Fallback

Shell 或 Integration Adapter 控制技术状态转换:

  1. 开始加载。
  2. 识别 Timeout 或 Loading Error。
  3. 尝试 Mount。
  4. 捕获 Activation Failure。
  5. 出错后展示定义好的 Fallback。
  6. 可以提供 Retry。
  7. 切换或 Retry 前执行必要 Cleanup。

这属于 Platform Logic,不需要理解 Invoice、Task、Order 或 Planning 的业务规则。

平台必须知道 Remote 当前不可用,但不必知道其中哪一条业务操作失败了。

这就是重要分离:Shell 管技术 Lifecycle。只要 Remote 本身仍然能够工作,它继续负责自己的业务 State 与业务 Error。

Mounting Point 是平台最外层 Failure Boundary,而不是 Remote 唯一的 Error Handling。预期中的业务错误和内部 State 仍然由 Remote 处理;Integration Boundary 则负责 Loading、Activation、外层 Runtime Failure、Fallback 和 Recovery。

左侧是只有 Deployment Boundary 的 Remote,右侧增加显式 Integration Boundary,可以检测故障、展示本地 Fallback,同时让其余应用继续可用。

Shell 可以限制 Remote 的 Failure。

但 Shell 自己也是 Failure Architecture 的一部分。

Remote 失败
└── 一个产品区域不可用
Host 失败
└── 整个组合应用不可用

Host 是组合应用的共同入口。它提供 Navigation,激活 Remote,传递 Platform Contract,并展示 Fallback。如果 Host 整体失败,它也无法继续完成这些职责。

Shell 能够限制 Remote Failure,但无法在同一个应用内部接住自己的完全失败。

这是这个模型的真实边界:负责限制其他部件故障的那一层,本身必须可用。

这也让“Shell 应该业务上保持薄”多了一层意义。越多高变化频率的业务逻辑进入 Shell,它自己的 Failure Impact 就越大。

因此 Shell 应该具备:

  • 较低的业务 Volatility;
  • 稳定 Platform Contract;
  • 可靠 Delivery;
  • 尽可能少的不必要 Runtime Dependency;
  • 良好 Observability;
  • 受控 Change;
  • 清晰 Technical Ownership。

Shell 不必为了“纯洁”而无限变小,但作为共同技术框架,它应该非常可靠。

Remote Failure 只影响局部时,Navigation、其他 Remote 与本地 Fallback 仍然可用;Host 整体失败则会影响整个组合应用。

一个 Remote Artifact 还不是独立应用

Section titled “一个 Remote Artifact 还不是独立应用”

动态微前端里,即使真正 Host 不可用,Remote Artifact 理论上仍然可能可以访问。

但这只有在额外条件成立时才有意义。

remoteEntry.js
可以独立 Navigation 的产品

Remote Artifact 只是技术 Entry Point。它不会自动拥有 Navigation、Authentication Context 或全部 Platform Adapter。

不过从 Failure Analysis 来看,一个独立 Entry Point 仍然非常有价值:只有离开完整 Shell,团队才能有针对性地复现 Loading Failure、Activation Failure 和 Fallback,而不必为了一个局部 Error Case 先启动整套系统。如何系统化构建这样的独立入口,例如 Mini Host,以及它的边界,参见独立前端 Release 的验收

动态 Remote 如果另外提供独立 Entry Point,可以在真实 Host 不可用时继续单独访问。仅仅能访问 Remote Artifact 本身还不够。

这并不意味着每个 Remote 都必须在 Production 中脱离 Host 成为独立产品。是否提供这种产品级入口,是明确的 Product/Operations Decision,不是动态 Integration 的自动属性。

静态与动态 Integration 的失败方式不同

Section titled “静态与动态 Integration 的失败方式不同”

静态 Integration 不需要 Runtime 再去单独下载 Remote。集成状态共同 Build,并作为一个整体应用发布,因此不会出现“独立 Remote Manifest 或 Artifact 在运行时加载失败”这一类问题。

但静态集成区域仍然会在 Runtime 失败。Render Error、Exception 和 Global Side Effect 同样需要被限制。

同时,一个有问题的共同 Build 或共同 Release 可能直接影响整个应用。故障不再发生在某个 Remote Runtime Activation 上,而是已经被包含进整体交付版本。

动态 Integration 则允许单个 Remote 独立不可访问。Network、Manifest 和 Activation Error 会在 Runtime 才出现。Shell 可以识别这些状态,让局部产品区域 Degrade,并在问题修复后重新加载新的 Remote Artifact。

更小 Deployment Unit 可以更好限制某些 Delivery Failure,但它既不是小 Runtime Failure Domain 的必要条件,也不是保证。静态集成区域同样可以通过显式 Boundary 实现局部隔离。

动态 Integration 可以支持更小的 Failure Domain,但不会自动生成它。

两种 Integration 都需要 Failure Boundary。

主要差异只是:Error 在什么时候、以什么形式变得可见。

Graceful Degradation 意味着产品能够受控地退回受限状态。

它并不要求替代故障 Capability。

一个合理 Degraded State 可以:

  • 明确标识当前不可用区域;
  • 保持 Navigation 与其他 Remote 可用;
  • 提供 Retry;
  • 展示用户能理解的错误信息;
  • 提供 Status/Support 页面链接;
  • 如果继续展示旧数据,明确标为可能已过期;
  • 提供安全返回路径。

它不能假装一条 Write Operation 已经成功,也不能把旧数据冒充当前数据。它不应该凭空做业务决策,也不应该在没有可靠业务/技术模型的情况下,随手把未完成 Write Operation 存在本地以后再“补发”。

Graceful Degradation 保住的是产品,不一定保住已经失败的 Capability。

Degraded Product 仍然应该可理解、可操作,而不是由 Shell 偷偷重新实现一遍业务。

Fallback 是 Error State 的替代展示。

它并不自动成为失败产品区域的第二套实现。

Invoice Remote 不可用
├── Navigation 继续工作
├── 其他产品区域仍可访问
├── 展示 Error State
└── 可以 Retry

而下面这种思路就不合理:

Invoice Remote 不可用
└── Shell 临时重新实现 Invoice Function

Fallback 不替代 Remote。它的职责是避免 Remote Failure 把整个应用一起拖走。

Fallback 还应该与具体 Use Case 匹配。一个可选 Analytics 区域,也许一个局部提示 + Retry 就够。一个业务关键流程,可能还需要更多 Status Information、Support Path 或 Alternative Access。

所以 Universal Error Card 最多只能作为技术基础组件。

它不能替代业务层面对 Failure State 的设计。

Failure State 也是产品体验的一部分。

一句 “Something went wrong” 很难承担这个责任。它既不告诉用户哪个区域出了问题,也不说明现在还能做什么。

根据具体情况,一个好的 Error State 应该尽可能回答:

  • 当前哪个产品区域不可用?
  • 其他区域还能继续使用吗?
  • 可以 Retry 吗?
  • 用户已经输入的数据还在吗?
  • 这个 Action 是否有可能其实已经执行成功?
  • 是否存在替代路径?
  • 什么情况下应该联系 Support?

并不是每个 Failure 都需要回答全部问题。

关键是展示应该从用户当前处境出发,而不是简单把 Loader 的 Technical State 暴露出来。

Resilience 不是在完美状态中体现的。它体现在产品进入坏状态之后,是否仍然可理解。

有意识设计的 Degraded State 会让错误可见,却不封锁整个应用。

正常状态中的 Remote 与受控 Degraded State 对比:后者提供清晰说明、Retry/返回,同时 Navigation 和其余产品仍然可用。

Write Operation 需要诚实的 Error State

Section titled “Write Operation 需要诚实的 Error State”

对于 Read Use Case,Retry 通常比较简单。列表加载失败,展示 Error,稍后再加载即可。

Write Operation 更麻烦。

发生 Timeout 或 Connection Drop 后,可能不清楚:

  • Request 有没有到 Backend;
  • Operation 是否已经执行;
  • 只是 Response 丢失了;
  • Retry 是否会重复执行 Action。

因此 Fallback 不能在真实状态未知时轻率地告诉用户“操作失败”。

诚实的 Degraded State 应该在可靠可判断的前提下区分:未执行、已执行、状态未知。如果系统无法判断,就必须把这种不确定性本身清楚地表达出来。

Idempotent Operation 可以让 Retry 更安全。

但 UI 仍然不能把技术不确定性伪装成业务确定性。

这也是为什么 Write Operation 的 Automatic Retry 必须非常谨慎。悄悄重试一个 Read Request,与重复发起 Order、Booking 或 Approval 完全不是一回事。

Observability 是 Resilience 的组成部分

Section titled “Observability 是 Resilience 的组成部分”

局部 Fallback 会改善用户体验。

但只有 Error 同时保持可观察,它对 Operations 才真正有帮助。

Platform 应该能够记录必要技术上下文,例如:

  • 哪个 Remote 出错;
  • Failure 出现在 Lifecycle 哪个阶段;
  • 是 Loading、Activation 还是 Runtime;
  • 当时运行哪个 Version/Artifact;
  • Retry 是否成功;
  • Error 出现频率。

同时不应该因为 Debug 方便,就无意义记录 Personal Data、Token 或业务内容。好的 Diagnosis 来自有针对性的技术 Context,而不是最大化数据收集。

用户提示和技术 Diagnosis 有完全不同目标:

User
└── 得到可理解的 Product State
Operations
└── 得到技术 Diagnosis

一个没有 Observability 的 Fallback 保护了 UI,却没有保护 Operations。

Resilience 不能让 Error 消失。

它应该限制影响范围,同时保留可查原因。

对于短暂 Network Failure、临时不可访问 Artifact 或失败的 Dynamic Import,Retry 可能很合理。

但不是所有 Error 都会因为重试而变好。一个确定性失败的 Initialization,第十次通常和第一次一样失败。没有 Cleanup 就反复 Mount,甚至可能让 State 更糟。

尤其应该避免:

  • 无限 Loading State;
  • Aggressive Automatic Retry;
  • 没有 Cleanup 的重复 Mount;
  • 没有安全语义的重复 Write Operation。

Manual Retry 往往比不可见、无上限的自动重试更容易理解。用户获得控制,UI 也可以把它表达成清晰 State Transition。

如果 Failure Class、Retry Limit 和业务语义都足够明确,Automatic Recovery 当然可以使用。

但它必须在业务上可解释。

同一个 Document 中的 Remote 会共享大量 Runtime。更强技术隔离可以通过独立 Document 或 iFrame 实现。

这样确实能更清晰限制某些 Failure Effect。独立 Document 拥有自己的 JavaScript Context,与外围应用形成更明显技术 Boundary。

但更强 Isolation 也会提高 Integration Cost,包括 Navigation、Styling、Accessibility、Communication、Routing、Authentication 以及一致 Product Experience。

技术隔离越强,某些 Failure Boundary 越清晰;与此同时,Integration Cost 也越高。

需要多强 Isolation,取决于 Criticality、可接受 Failure Impact 以及 Integration Cost。

一套可持续的微前端架构不只有 Deployment Boundary。

它还需要被有意识构造出来的 Failure Boundary:位于 Mounting Point,有适合的 Fallback,并且 Product Design 真正认真对待坏状态。

如果 Remote 无法加载、无法激活或运行中失败,Error 应该限制在相关产品区域。Platform 继续提供 Navigation 和其他 Capability,展示可理解的 Degraded State,允许安全返回或受控 Retry,同时让 Error 对 Operations 可观察。

这条 Failure Boundary 本身也不是无限强:Shell 可以限制 Remote Failure,但 Shell 仍然是所有 Remote 的共同 Failure Domain。也正因为如此,Shell 更应该稳定、可观察,并尽量避免承载高变化频率业务逻辑。

Remote 的 Failure 可以让一个 Capability 暂时不可用,但不应该让整个产品一起不可用。

  1. Martin Fowler 与 James Lewis,“Microservices” – Design for failure;另见 Martin Fowler,“Microservice Trade-Offs”