发布于 2026-01-06 2 阅读
0

Go gobreaker 中与 Circuit Breaker 的安全集成

与 Go 中的 Circuit Breaker 进行安全集成

破冰者

现代网络项目几乎都离不开外部服务集成。这可能包括短信验证码、合作伙伴的API、广告像素等等。但这会给我们带来哪些风险呢?

假设我们有一个高负载的在线商店,订单创建时会发送短信通知。消息通过某个外部 API 发送,但该 API 已离线。我们会遇到超时问题,我们的应用程序将如何处理?这取决于客户端配置,但无论如何,这都会导致响应速度降低、资源占用增加以及请求队列延长。

另一个例子:我们在主页上有一个个性化产品信息流,并使用 Redis 来降低响应速度(由于 Redis 的响应时间为 1 毫秒,因此包含 20 个产品的整个信息流只需 20 毫秒即可加载)。但是,如果 Redis 发生重新平衡(或者更糟糕的是,如果某个节点丢失),则每个产品的响应时间将增加到 500 毫秒,整个信息流的响应时间将增加到 10 秒。

有没有什么办法可以预防或尽量减少这种情况的发生?这时,“断路器”模式就能派上用场了。

模式概述

该模式的主要原理非常简单:

如果外部 API 不可用,向其发送请求是没用的,它不会响应。

反之亦然:如果有外部 API 可用,您可以向其提出请求,它或许会返回一些有用的信息。

为了便于理解,我们继续以短信网关为例。把所有逻辑想象成一个开关。当服务启动时,我们会向其发送请求。这种状态称为“关闭”,您可以将其理解为一个实际的电路开关。

图像

而且,当它宕机时——我们就断开链条(“开放”状态)。

图像

在“开放”状态下具体执行哪些操作取决于您,通常取决于集成目的。您可以:

  • 趁着上次成功响应还新鲜的时候返回它;
  • 返回默认值;
  • 采用不同的策略;
  • 直接返回错误信息即可。

既然我们要通知用户,我们可能会针对这种情况实施电子邮件通知。

最后一个需要解答的问题是:谁来改变电路状态?用“红色按钮”手动控制固然有用,但这并非唯一方案。我想没人愿意时刻关注电路状态。因此,我们需要制定规则来实现自动化。

假设我们要在 API 每次出错时都中断连接链。但何时才能将其恢复到“关闭”状态呢?为此,我们需要引入一个额外的状态——“半开放”。它的目的是发送一些请求来检查 API 是否正常运行。因此,它充当了从“开放”状态到“关闭”状态路径上的一个中间层。

图像

所以逻辑很简单:一旦出现任何错误,我们就切换到“打开”状态,并阻止 API 请求。当超过一定时间后,我们切换到“半打开”状态,并发送请求以检查一切是否正常。如果出现错误,我们就切换回“打开”状态;如果成功,则切换回“关闭”状态。

为了提高效率,我们还需要添加两样东西:

  • 错误策略——忽略一些预期的错误;
  • 阈值或制动策略——用来描述打破链条的规则。

综上所述,让我们来看一下活动图:

图像

动手实践

API

开始之前,我们先创建一个测试环境。我们的 Go 应用需要监听两个 HTTP 端点。一个用作短信网关 API 的模拟接口。另一个端点会切换服务器的状态,从“正常”到“故障”,然后再切换回来。我们把这个功能放在一个单独的文件中实现。



// server.go
package main

import (
    "log"
    "net/http"
    "os"
)

// ExampleServer is a test server to check the "CircuitBreaker" pattern
type ExampleServer struct {
    addr   string
    logger *log.Logger
    isEnabled bool
}

// NewExampleServer creates the instance of our server
func NewExampleServer(addr string) *ExampleServer {
    return &ExampleServer{
        addr: addr,
        logger: log.New(os.Stdout, "Server\t", log.LstdFlags),
        isEnabled: true,
    }
}

// ListenAndServe starts listening on the address provided 
// on creating the instance.
func (s *ExampleServer) ListenAndServe() error {
    // The main endpoint we will request to
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        if s.isEnabled {
            s.logger.Println("responded with OK")
            w.WriteHeader(http.StatusOK)
        } else {
            s.logger.Println("responded with Error")
            w.WriteHeader(http.StatusInternalServerError)
        }
    })

    // Toggle endpoint to switch on and off responses from the main one
    http.HandleFunc("/toggle", func(w http.ResponseWriter, r *http.Request) {
        s.isEnabled = !s.isEnabled
        s.logger.Println("toggled. Is enabled:", s.isEnabled)
        w.WriteHeader(http.StatusOK)
    })

    return http.ListenAndServe(s.addr, nil)
}


Enter fullscreen mode Exit fullscreen mode

客户

我们将创建一个简单的客户端结构,其中包含一个方法Send。我们将向端点发送请求/以模拟外部集成。访问localhost:8080/toggle将切换服务器的响应,使其显示错误信息。让我们创建客户端。



// client.go
package main

import (
    "errors"
    "net/http"
)

type NotificationClient interface {
    Send() error // We ignore all the arguments to simplify the demo
}

type SmsClient struct {
    baseUrl string
}

func NewSmsClient(baseUrl string) *SmsClient {
    return &SmsClient{
        baseUrl: baseUrl,
    }
}

func (s *SmsClient) Send() error {
    url := s.baseUrl + "/"
    resp, err := http.Get(url)
    if err != nil {
        return err
    }
    defer resp.Body.Close()
    if resp.StatusCode < 200 || resp.StatusCode >= 300 {
        return errors.New("bad response")
    }

    return nil
}


Enter fullscreen mode Exit fullscreen mode

客户端已准备就绪。我们只需要初始化文件中的所有内容即可使其正常工作main.go



// main.go
package main

import (
    "log"
    "os"
    "time"
)

func main() {
    logger := log.New(os.Stdout, "Main\t", log.LstdFlags)
    server := NewExampleServer(":8080")

    go func() {
        _ = server.ListenAndServe()
    }()

    client := NewSmsClient("http://127.0.0.1:8080")

    for {
        err := client.Send()
        time.Sleep(1 * time.Second)
        if err != nil {
            logger.Println("caught an error", err)
        }
    }
}


Enter fullscreen mode Exit fullscreen mode

就这样!我们已经创建了所有必需的环境,如果运行代码,它会将服务器的每个响应记录到标准输出中。



Server 2021/09/22 21:51:30 responded with OK
Server 2021/09/22 21:51:31 responded with OK
Server 2021/09/22 21:51:32 responded with OK
Server 2021/09/22 21:51:32 toggled. Is enabled: false
Server 2021/09/22 21:51:33 responded with Error
Main   2021/09/22 21:51:34 caught an error bad response
Server 2021/09/22 21:51:34 responded with Error
Main   2021/09/22 21:51:35 caught an error bad response


Enter fullscreen mode Exit fullscreen mode

一切都按预期运行。现在我们的 API 不稳定,客户端也不安全。为了改善这种情况,我们可以实现“熔断器”机制。

断路器

通常情况下,有用的模式都会有很多种实现方式。所以,如果你想创建自己的解决方案,可以使用上面的方案。但在本文中,我们将使用索尼公司一个很棒的库:

GitHub 标志 索尼/ gobreaker

用 Go 语言实现的断路器

破冰者

GoDoc

gobreaker在 Go 语言中实现了断路器模式

安装

go get github.com/sony/gobreaker/v2

用法

该结构体CircuitBreaker是一个状态机,用于防止发送可能失败的请求。该函数NewCircuitBreaker创建一个新的CircuitBreaker结构体。类型参数T指定请求的返回类型。

func NewCircuitBreaker[T any](st Settings) *CircuitBreaker[T]
Enter fullscreen mode Exit fullscreen mode

您可以CircuitBreaker通过结构体进行配置Settings

type Settings struct {
    Name          string
    MaxRequests   uint32
    Interval      time.Duration
    Timeout       time.Duration
    ReadyToTrip   func(counts Counts) bool
    OnStateChange func(name string, from State, to State)
    IsSuccessful  func(err error) bool
}
Enter fullscreen mode Exit fullscreen mode
  • Name是该名称CircuitBreaker

  • MaxRequestsCircuitBreaker当接口处于半开放状态时,允许通过的最大请求数。如果MaxRequests为 0,CircuitBreaker则只允许 1 个请求。

  • Interval是周期……

所以,要下载它,只需输入:



go get github.com/sony/gobreaker


Enter fullscreen mode Exit fullscreen mode

我们可以直接在客户端实现中连接,但这会有点混乱。我更倾向于使用代理来封装这类结构。让我们创建这个代理并实现NotificationClient接口,以确保交互方式一致。



// circuit_breaker.go
package main

import (
    "log"
    "os"
    "time"

    "github.com/sony/gobreaker"
)

type ClientCircuitBreakerProxy struct {
    client NotificationClient
    logger *log.Logger
    gb     *gobreaker.CircuitBreaker // downloaded lib structure
}

// shouldBeSwitchedToOpen checks if the circuit breaker should
// switch to the Open state
func shouldBeSwitchedToOpen(counts gobreaker.Counts) bool {
    failureRatio := float64(counts.TotalFailures) / float64(counts.Requests)
    return counts.Requests >= 3 && failureRatio >= 0.6
}

func NewClientCircuitBreakerProxy(client NotificationClient) *ClientCircuitBreakerProxy {
    logger := log.New(os.Stdout, "CB\t", log.LstdFlags)

    // We need circuit breaker configuration
    cfg := gobreaker.Settings{
        // When to flush counters int the Closed state
        Interval: 5 * time.Second,
        // Time to switch from Open to Half-open
        Timeout: 7 * time.Second,
        // Function with check when to switch from Closed to Open
        ReadyToTrip: shouldBeSwitchedToOpen,
        OnStateChange: func(_ string, from gobreaker.State, to gobreaker.State) {
            // Handler for every state change. We'll use for debugging purpose
            logger.Println("state changed from", from.String(), "to", to.String())
        },
    }

    return &ClientCircuitBreakerProxy{
        client: client,
        logger: logger,
        gb:     gobreaker.NewCircuitBreaker(cfg),
    }
}

func (c *ClientCircuitBreakerProxy) Send() error {
    // We call the Execute method and wrap our client's call
    _, err := c.gb.Execute(func() (interface{}, error) {
        err := c.client.Send()
        return nil, err
    })
    return err
}


Enter fullscreen mode Exit fullscreen mode

我们来看看新的代理服务器。以下是其中最重要的两点:

  1. ReadyToTrip设置定义了检测何时应该断开链的功能;
  2. Timeout设置描述了我们应该多久重新检查一次 API 的健康状况(并切换到半开放状态)。

完成所有配置后,我们只需简单地封装客户端方法即可。要开始使用代理,我们需要在main.go文件中添加几行代码。



// main.go
package main
// ...
func main() {
    // ...
    var client NotificationClient
    // Create a common Client
    client = NewSmsClient("http://127.0.0.1:8080")
    // And then wrap it
    client = NewClientCircuitBreakerProxy(client)

    for {
        err := client.Send()
        time.Sleep(1 * time.Second)
        if err != nil {
            logger.Println("caught an error", err)
        }
    }
}


Enter fullscreen mode Exit fullscreen mode

一切就绪!让我们运行代码并测试一下。服务器启动后,它和以前一样正常工作。



Server  2021/09/22 22:09:32 responded with OK
Server  2021/09/22 22:09:33 responded with OK
Server  2021/09/22 22:09:34 responded with OK


Enter fullscreen mode Exit fullscreen mode

但是,如果我们通过/toggle端点来切换它——我们的shouldBeSwitchedToOpen方法就派上用场了。



Server  2021/09/22 22:11:12 responded with OK
Server  2021/09/22 22:11:12 toggled. Is enabled: false
Server  2021/09/22 22:11:13 responded with Error
Main    2021/09/22 22:11:14 caught an error bad response
Server  2021/09/22 22:11:14 responded with Error
Main    2021/09/22 22:11:15 caught an error bad response
Server  2021/09/22 22:11:15 responded with Error
Main    2021/09/22 22:11:16 caught an error bad response
Server  2021/09/22 22:11:16 responded with Error
Main    2021/09/22 22:11:17 caught an error bad response
Server  2021/09/22 22:11:17 responded with Error
CB      2021/09/22 22:11:17 state changed from closed to open
Main    2021/09/22 22:11:18 caught an error bad response
Main    2021/09/22 22:11:19 caught an error circuit breaker is open
Main    2021/09/22 22:11:20 caught an error circuit breaker is open


Enter fullscreen mode Exit fullscreen mode

我们将其配置为每 5 秒重新检查一次 API 的健康状况。因此,我们可以通过切换服务器的行为来调试它。



CB      2021/09/22 22:13:13 state changed from closed to open 
Main    2021/09/22 22:13:14 caught an error bad response
Main    2021/09/22 22:13:15 caught an error circuit breaker is open
Server  2021/09/22 22:13:15 toggled. Is enabled: true
Main    2021/09/22 22:13:16 caught an error circuit breaker is open
Main    2021/09/22 22:13:17 caught an error circuit breaker is open
Main    2021/09/22 22:13:18 caught an error circuit breaker is open
Main    2021/09/22 22:13:19 caught an error circuit breaker is open
Main    2021/09/22 22:13:20 caught an error circuit breaker is open
CB      2021/09/22 22:13:20 state changed from open to half-open
Server  2021/09/22 22:13:20 responded with OK


Enter fullscreen mode Exit fullscreen mode

就是这样!就这么简单。

还要别的吗?

是的,使用这种模式有一些技巧。首先,你需要良好的监控。这意味着你需要所有客户端请求的日志,以便有机会重现错误。此外,你还需要指标来了解切换发生的频率,从而及时做出反应。

接下来要考虑的是使用场景。有些集成需要重试机制,有些则只需要使用默认值进行简单的错误处理,只有那些会拖慢项目速度甚至导致项目崩溃的集成才值得使用熔断器。

希望对你有所帮助。祝你编程愉快!<3

文章来源:https://dev.to/he110/Circuitbreaker-pattern-in-go-43cn