利用本地 Envoy 代理为 SwiftUI 应用实现透明的 API 断路器模式


一个看似健壮的SwiftUI应用,其真正的考验往往发生在网络环境恶化时。假设我们正在构建一个macOS数据分析工具,它依赖于一组RESTful API来拉取和处理数据。最初的网络层实现可能非常直接:

// MARK: - UnstableApiService.swift (Initial Version)
import Foundation
import Combine

// 一个典型但不具备韧性的API服务层
final class UnstableApiService {
    private let baseURL = URL(string: "http://localhost:8080")!
    
    enum ApiError: LocalizedError {
        case networkError(URLError)
        case serverError(statusCode: Int)
        case decodingError(Error)
        case unknown
        
        var errorDescription: String? {
            switch self {
            case .networkError(let urlError):
                return "Network unavailable: \(urlError.localizedDescription)"
            case .serverError(let statusCode):
                return "Server error with status code: \(statusCode)"
            case .decodingError:
                return "Failed to decode the response."
            default:
                return "An unknown error occurred."
            }
        }
    }

    func fetchData(endpoint: String) -> AnyPublisher<Data, ApiError> {
        let url = baseURL.appendingPathComponent(endpoint)
        
        return URLSession.shared.dataTaskPublisher(for: url)
            .tryMap { data, response in
                guard let httpResponse = response as? HTTPURLResponse else {
                    throw ApiError.unknown
                }
                guard (200...299).contains(httpResponse.statusCode) else {
                    // 后端服务直接返回错误
                    throw ApiError.serverError(statusCode: httpResponse.statusCode)
                }
                return data
            }
            .mapError { error -> ApiError in
                if let urlError = error as? URLError {
                    // 网络层面的错误,如超时或无法连接
                    return .networkError(urlError)
                } else if let apiError = error as? ApiError {
                    return apiError
                } else {
                    return .decodingError(error)
                }
            }
            .eraseToAnyPublisher()
    }
}

在ViewModel中,我们会这样调用它,并处理加载和错误状态:

// MARK: - DataViewModel.swift (Coupled with network fragility)
import Foundation
import Combine

@MainActor
final class DataViewModel: ObservableObject {
    @Published var content: String = "Fetching data..."
    @Published var hasError: Bool = false
    @Published var errorMessage: String = ""
    
    private let apiService = UnstableApiService()
    private var cancellables = Set<AnyCancellable>()
    
    func loadData() {
        self.content = "Fetching..."
        self.hasError = false
        
        // 每次点击都发起新的请求,无论后端状态如何
        apiService.fetchData(endpoint: "/unstable-data")
            .receive(on: DispatchQueue.main)
            .sink(receiveCompletion: { [weak self] completion in
                if case .failure(let error) = completion {
                    self?.hasError = true
                    self?.errorMessage = error.localizedDescription
                    self?.content = "Failed to load data."
                }
            }, receiveValue: { [weak self] data in
                self?.content = String(data: data, encoding: .utf8) ?? "Could not decode string."
            })
            .store(in: &cancellables)
    }
}

这段代码在理想网络下工作得很好。但在真实项目中,后端的某个微服务可能因为负载过高、部署问题或下游依赖故障而开始频繁返回500错误或请求超时。此时,我们的macOS应用会发生什么?用户每次点击刷新按钮,应用都会执着地向那个已经“病入膏肓”的服务发起请求,然后等待漫长的超时(URLSession默认60秒),最终在UI上显示一个错误。这种体验是灾难性的。用户会觉得应用卡顿、无响应。

更糟糕的是,这种密集的重试请求会加剧后端服务的崩溃,形成“重试风暴”。

传统的客户端解决方案是在ApiService中实现更复杂的逻辑:指数退避重试、请求队列、甚至是一个简易的断路器。但这会迅速让网络代码变得臃肿不堪,状态管理复杂,且难以测试。业务逻辑和网络韧性逻辑紧紧耦合在一起。这里的坑在于,我们把一个纯粹的网络基础设施问题,泄漏到了应用层代码中。

一个更优的思路是,将网络韧性策略从应用代码中剥离出去,交给一个专门的组件处理。在后端微服务架构中,服务网格(Service Mesh)的边车代理(Sidecar Proxy)如Envoy或Linkerd正是为此而生。我们完全可以将这个模式应用到客户端,特别是在macOS这样的受控环境中。

我们的架构构想是:在本地运行一个Envoy实例作为代理。SwiftUI应用的所有出站API请求都发往本地Envoy,由Envoy负责将请求转发到真实的后端服务。当Envoy检测到后端服务连续出现故障时,它会自动“熔断”——在一段时间内,所有发往该服务的请求都会被Envoy立即拒绝,而不是真正地发送出去。这实现了“快速失败”,保护了后端,也极大地改善了前端的用户体验。

graph TD
    A[SwiftUI App] -- API Request --> B{Local Envoy Proxy 
localhost:10000}; B -- Forwards Request --> C[Flaky RESTful API
localhost:8080]; subgraph "Circuit Breaker Logic (Outlier Detection)" B end C -- 5xx Error / Timeout --> B; B -- Monitors Failures --> B; B -- Trips the breaker --> B; A -- Subsequent API Request --> B; B -- Immediately returns 503 --> A;

选择Envoy而非在Swift中实现一个断路器库,是出于几个务实的考量:

  1. 关注点分离: SwiftUI代码只需关心业务逻辑,网络健壮性由Envoy的YAML配置 declarative地定义。应用代码几乎无需改动。
  2. 语言无关: 韧性策略与编程语言解耦。如果团队还有其他语言编写的客户端工具,它们可以复用相同的Envoy配置。
  3. 生产级可靠性: Envoy是经过大规模生产环境验证的。它的断路器(在Envoy中称为“异常点检测”或Outlier Detection)功能远比我们自己实现的要成熟和强大。
  4. 动态配置: Envoy的配置可以动态更新,无需重新编译和部署客户端应用。

接下来,我们将分步实现这个架构。

第一步:模拟一个不稳定的后端服务

为了验证我们的方案,首先需要一个行为可控的后端服务。我们使用Node.js和Express创建一个简单的API,它可以被配置为在一定比例的请求中返回503错误。

// MARK: - flaky-server.js
const express = require('express');
const app = express();
const port = 8080;

let requestCount = 0;
// 每3次请求中,就有2次会失败
const FAILURE_RATE = 2 / 3; 

app.get('/unstable-data', (req, res) => {
    requestCount++;
    console.log(`[${new Date().toISOString()}] Received request #${requestCount}`);

    if (Math.random() < FAILURE_RATE) {
        console.log(` -> Responding with 503 Service Unavailable.`);
        res.status(503).send('Service is intentionally unavailable.');
    } else {
        console.log(` -> Responding with 200 OK.`);
        const data = { 
            message: `Success on attempt #${requestCount}`,
            timestamp: new Date().toISOString() 
        };
        res.status(200).json(data);
    }
});

app.get('/stable-data', (req, res) => {
    console.log(`[${new Date().toISOString()}] Received request to stable endpoint.`);
    res.status(200).json({ message: "This endpoint is always reliable." });
});


app.listen(port, () => {
    console.log(`Flaky server listening on port ${port}`);
    console.log(`Failure rate for /unstable-data is set to ${FAILURE_RATE * 100}%`);
});

在终端运行 node flaky-server.js 即可启动此服务。

第二步:配置并运行Envoy代理

Envoy的配置核心是一个YAML文件。我们将创建一个envoy.yaml文件,定义监听、路由和最重要的——断路器策略。

# MARK: - envoy.yaml
# 这是一个用于本地客户端代理的生产级Envoy配置
static_resources:
  listeners:
  - name: listener_0
    address:
      socket_address:
        address: 0.0.0.0
        port_value: 10000 # SwiftUI应用将连接到这个端口
    filter_chains:
    - filters:
      - name: envoy.filters.network.http_connection_manager
        typed_config:
          "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
          stat_prefix: ingress_http
          route_config:
            name: local_route
            virtual_hosts:
            - name: local_service
              domains: ["*"]
              routes:
              - match:
                  prefix: "/"
                route:
                  # 将所有流量路由到我们定义的后端服务集群
                  cluster: backend_service
                  # 为路由设置一个合理的超时
                  timeout: 5s
          http_filters:
          - name: envoy.filters.http.router
            typed_config:
              "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router

  clusters:
  - name: backend_service
    connect_timeout: 2s
    type: STRICT_DNS
    # 注意:在Docker容器内访问宿主机服务时,使用host.docker.internal
    # 如果Envoy直接在宿主机运行,则使用localhost
    load_assignment:
      cluster_name: backend_service
      endpoints:
      - lb_endpoints:
        - endpoint:
            address:
              socket_address:
                address: host.docker.internal
                port_value: 8080 # 指向我们不稳定的Node.js服务

    # --- 断路器(异常点检测)核心配置 ---
    outlier_detection:
      # 连续出现5xx错误的次数,达到该值则触发驱逐。
      # 在真实项目中,这个值需要根据服务的正常错误率来调整。
      # 对于关键服务,设置为1可能过于敏感,这里设为2作为示例。
      consecutive_5xx: 2
      
      # 检测周期,Envoy会每隔这么久检查一次主机状态。
      # 10秒是一个比较均衡的值,既能及时反应,又不会过于频繁。
      interval: 10s
      
      # 节点被驱逐的基础时长。
      # 实际驱逐时间会是 base_ejection_time * 驱逐次数。
      # 首次驱逐30秒,第二次60秒,以此类推。
      base_ejection_time: 30s
      
      # 集群中允许被驱逐的最大节点百分比。
      # 对于只有一个节点的本地代理场景,可以设为100。
      # 在后端集群中,通常会设一个较低的值(如10-20%)以防雪崩。
      max_ejection_percent: 100
      
      # 强制执行驱逐的最小健康节点百分比。
      # 设置为0意味着即使所有节点都不健康,也继续执行驱逐。
      enforcing_success_rate: 0

      # 同样,为连续网关故障(502, 503, 504)设置独立的检测器
      # 这对于区分服务本身错误和网络分区问题很有用。
      consecutive_gateway_failure: 2
      enforcing_consecutive_gateway_failure: 100

使用Docker运行Envoy是最便捷的方式:
docker run --rm -it -p 10000:10000 -p 9901:9901 -v $(pwd)/envoy.yaml:/etc/envoy/envoy.yaml envoyproxy/envoy:v1.27.0

  • -p 10000:10000 映射我们的监听端口。
  • -p 9901:9901 映射Envoy的管理端口,方便我们查看状态。
  • -v 将本地的envoy.yaml挂载到容器内。

第三步:改造SwiftUI网络层以使用代理

现在,唯一需要修改的应用代码就是ApiServicebaseURL。我们要让它指向本地的Envoy代理,而不是直接访问后端服务。

// MARK: - ResilientApiService.swift (Final Version)
import Foundation
import Combine

// 一个通过Envoy代理实现韧性的API服务层
final class ResilientApiService {
    // *** 关键改动 ***
    // 将请求目标从后端服务地址改为本地Envoy代理地址
    private let baseURL = URL(string: "http://localhost:10000")!
    
    // ApiError定义保持不变
    enum ApiError: LocalizedError {
        // ... (同上) ...
    }

    // fetchData方法签名和实现几乎完全不变
    func fetchData(endpoint: String) -> AnyPublisher<Data, ApiError> {
        let url = baseURL.appendingPathComponent(endpoint)
        
        // URLSession的配置和使用与之前完全相同。
        // 应用层代码对代理的存在是无感的。
        return URLSession.shared.dataTaskPublisher(for: url)
            .tryMap { data, response in
                guard let httpResponse = response as? HTTPURLResponse else {
                    throw ApiError.unknown
                }
                // 这里的statusCode现在可能是来自Envoy(例如503),也可能是来自真实后端
                guard (200...299).contains(httpResponse.statusCode) else {
                    throw ApiError.serverError(statusCode: httpResponse.statusCode)
                }
                return data
            }
            .mapError { error -> ApiError in
                if let urlError = error as? URLError {
                    return .networkError(urlError)
                } else if let apiError = error as? ApiError {
                    return apiError
                } else {
                    return .decodingError(error)
                }
            }
            .eraseToAnyPublisher()
    }
}

ViewModel的代码完全不需要任何改动。它只需要使用新的ResilientApiService即可。这就是关注点分离带来的巨大好处。

第四步:观察断路器行为

现在,我们把所有部分组合起来:

  1. 运行flaky-server.js
  2. 运行配置了outlier_detection的Envoy Docker容器。
  3. 运行我们的macOS SwiftUI应用。

场景一:断路器打开前

连续点击刷新按钮。

  • Node.js服务日志: 会看到请求进入,部分成功返回200,部分失败返回503。
  • SwiftUI应用: UI会间歇性地显示成功获取的数据,或者显示503错误信息。请求响应可能较慢,因为每次失败都实际触达了后端。

场景二:断路器触发

当我们在短时间内连续点击,导致Envoy观察到2次(根据我们的consecutive_5xx配置)连续的5xx错误后,断路器“跳闸”了。

  • Envoy日志: 你会看到类似这样的日志,表明一个上游主机被驱逐了。
    [...][debug][upstream] [...|...|...] outlier_detection: ejecting host [IPv4:172.17.0.1:8080] for 30000ms
  • SwiftUI应用: 此时再点击刷新按钮,UI会立即显示错误,状态码是503。这个503是Envoy自己生成的,表示“no healthy upstream”。请求根本没有被转发到后端的Node.js服务。用户体验从“卡顿后报错”变成了“立即报错”,这是一个质的提升。
  • Node.js服务日志: 在Envoy断路器打开的30秒(base_ejection_time)内,Node.js服务不会收到任何来自/unstable-data的请求。它得到了喘息的机会。

场景三:断路器关闭

等待30秒后,Envoy会自动将该后端节点恢复到健康池中,进入“半开”状态。它会尝试放行一个请求到后端。

  • 如果这次请求成功,断路器会完全关闭(复位),恢复正常流量转发。
  • 如果这次请求仍然失败,断路器会再次跳闸,并且下一次的驱逐时间可能会更长(base_ejection_time * 驱逐次数)。

这个完整的闭环——从检测故障,到隔离故障,再到自动恢复——全部由Envoy在应用外部透明地完成。SwiftUI应用代码的纯净性得到了最大程度的保留。

// MARK: - Final SwiftUI View
import SwiftUI

struct ContentView: View {
    // ViewModel的实现无需任何更改
    @StateObject private var viewModel = DataViewModel()

    var body: some View {
        VStack(spacing: 20) {
            Text("Client-Side Circuit Breaker Demo")
                .font(.title)

            ScrollView {
                Text(viewModel.content)
                    .font(.body)
                    .padding()
                    .frame(minHeight: 100, alignment: .topLeading)
                    .background(Color.secondary.opacity(0.1))
                    .cornerRadius(8)
            }

            if viewModel.hasError {
                Text(viewModel.errorMessage)
                    .foregroundColor(.red)
                    .lineLimit(2)
            }

            Button(action: {
                viewModel.loadData()
            }) {
                Text("Fetch Unstable Data")
                    .padding()
                    .background(Color.blue)
                    .foregroundColor(.white)
                    .cornerRadius(10)
            }
        }
        .padding()
        .frame(width: 400, height: 300)
        .onAppear {
            viewModel.loadData()
        }
    }
}

最终的UI代码和ViewModel依然简单、可预测。所有的复杂性都被优雅地封装在了基础设施层面。

方案的局限性与扩展

这种将边车代理模式应用于客户端的架构并非万能。它的主要适用场景是桌面应用(macOS, Windows, Linux),因为在这些平台上,我们可以方便地管理一个本地的Envoy进程。对于iOS或Android,实现类似的机制需要借助Network Extension或VPN Profile等技术,其复杂度和维护成本会显著增加。

此外,该方案引入了一个额外的运维点——本地Envoy的生命周期管理和配置更新。对于企业内部分发的工具,可以通过启动脚本或MDM(移动设备管理)方案来统一管理。

当前实现仅仅触及了Envoy能力的冰山一角。基于这个架构,我们可以轻松地扩展出更多高级功能,而无需触碰Swift代码:

  • 自动重试: 在Envoy的路由配置中加入retry_policy,可以实现对失败请求(例如503或网络超时)的自动、带指数退避的重试。
  • 请求限流: 使用Envoy的本地或全局速率限制过滤器,可以防止应用在用户无意识的频繁操作下对API造成DDoS攻击。
  • 可观测性: Envoy本身能产生极为丰富的Metrics、Access Log和分布式追踪数据。我们可以将这些数据接入Prometheus和Grafana,从而获得对客户端API调用行为的深度洞察,这对于排查那些只在特定用户环境下出现的问题至关重要。
  • 流量镜像: 将生产流量的一小部分复制并发送到一个测试环境的后端服务,用于线上回归测试,而这一切对用户完全透明。

  目录