我是 LEE,老李,一个在 IT 行业摸爬滚打 17 年的技术老兵。
在开始今天的话题之前,我想先从这段代码开始今天的内容分享。在日常的开发过程中,我们经常遇到需要对一些函数的执行过程进行重试的场景。
例如,我们需要调用一个远程服务,但由于网络原因,调用失败了,我们需要对这个调用进行重试。这时,我们可以使用循环来实现重试,但这样的代码会使我们的代码变得复杂且难以维护。今天,我将分享如何使用 Golang 的函数式编程来实现一个简单的重试机制。
我们的业务代码中经常会遇到如下类似的代码段:
go
func callRemoteService() error {
var err error
// 循环重试
for i := 0; i < 3; i++ {
// 调用远程服务
err = doCall()
if err == nil {
return nil
}
// 休眠一秒, 再重试
time.Sleep(time.Second)
}
// 返回最后一次错误
return err
}
这段代码的逻辑很简单,就是对 doCall
函数进行重试,最多重试 3 次。在业务开发过程中,如果这样的代码很多,我们的代码就会变得非常复杂且难以维护。
那么,如何使用 Golang 的函数式编程来实现一个简单的重试机制呢?
事件背景
元宵节过完了以后,所有业务研发团队都开始了新一年的工作。在新的一年里,我们的业务团队也开始了新一年的业务开发工作。在业务开发过程中,我正在审查部分小伙伴们提交的代码,其中有一部分代码是针对年前需要进行熔断和调试的模块升级。
通过一段时间的审查,我发现了一个模块的代码存在一些问题。这个模块的代码中有很多地方都使用了循环重试的方式来调用远程服务。这样的代码让我感到非常不舒服,因为这样的代码不仅不够优雅,而且不易维护。
经过了解,我得知公司内部的 Golang SDK
中没有提供类似的功能。为了赶紧修复问题并追上年后的工作进度,我们只能采用这种开发方式。然而,通过对 GitHub
进行调研后,我发现了 retry-go
这个项目。这个项目基本满足了我们的需求,但在阅读代码后,我发现它并不太适合我们的业务场景。
痛点分析
在之前提到的代码中,我们使用了循环重试的方式来调用远程服务。这样的代码不仅不够优雅,而且不易维护。如果这样的代码很多,我们的代码就会变得非常复杂。
考察以下代码片段:
go
func callRemoteService() error {
var err error
// 循环重试
for i := 0; i < 3; i++ {
// 调用远程服务
err = doCall()
if err == nil {
return nil
}
// 休眠一秒, 再重试
time.Sleep(time.Second)
}
// 返回最后一次错误
return err
}
以下是需要解决的问题和痛点:
- 重复编写
for
循环,判断doCall
的返回值,成功则返回,否则休眠后重试。 - 重试次数固定,不够灵活。
- 重试等待间隔固定,不够灵活。
- 代码写法单一,复用性低。
doCall
的返回值可能不是error
类型,处理不方便。- 编写模式单一,难以与复杂代码整合。
为解决以上问题,需要具备以下特点的工具:
- 可对任意函数进行重试调用。
- 可设置重试次数。
- 可设置重试间隔时间,最好自动控制间隔。
- 可设置重试条件,包括指定类型的错误重试。
- 支持多种开发模式,如函数式编程、面向对象编程等。
- 支持处理函数返回值,如处理或过滤返回值。
- 提供统一接口,方便使用。
项目介绍
Retry
:github.com/shengyanli1...
Retry
是一个基于 Golang
的函数式编程库,提供了对函数进行重试调用的功能。
Retry
项目的目标是提供一个简单易用的函数式编程库,让开发者可以更加方便地对函数进行重试调用。
Retry
能够提供以下功能:
- 指定的重试次数。
- 特定错误的指定次数。
- 支持动作回调函数。
- 支持延迟抖动因子。
- 支持指数回退延迟、随机延迟和固定延迟。
- 支持每次重试失败的详细错误。
Retry
支持两种工作模式,分别解决不同的问题和需求:
- 单例模式 :提供最简单的使用方式。引入函数包
retry
后,直接调用Do
或者DoWithDefault
函数即可。不论在struct
的方法中还是在普通函数中,都可以直接调用Do
或者DoWithDefault
函数。 - 工厂模式 :提供更加灵活的使用方式。通过
New
函数创建一个Retry
对象,然后调用TryOnConflict
函数即可。这个适用于一些复杂的场景中使用。
Tips :
Retry
中的 单例模式 实际上也是通过 工厂模式 实现的,只是对外提供了一个更加简单的使用方式。
依托这两种工作模式,Retry
可以帮助我们解决大部分的重试问题。当然,如果你有更加复杂的需求,也可以通过 Retry
提供的接口进行扩展。
尽管在 GitHub 上可以找到很多类似的库,但我发现这些库要么功能过于复杂,要么功能过于简单,要么代码过于复杂,要么代码过于简单。因此,我决定从零开始,自己动手实现一个轻量级的函数式重试库,这就是 Retry
。
我的设计初衷:
- 轻量 :
Retry
是一个代码量非常少的轻量级库,只有几百行代码。 - 简单 :
Retry
的使用非常简单,只需要几行代码即可完成任务处理逻辑编写。 - 高效 :
Retry
的执行效率非常高,可以快速、高效地处理各种重试任务。
架构设计
为了让 Retry
简单易用且高效执行,它的架构设计必须简洁可靠。
算法设计
退避模块
在 Retry
项目中,重点是实现 backoff
这个退避模块,而这个模块的设计重点在于如何根据重试次数生成具备随机能力的退避时间。Retry
的 backoff
实现了若干个退避方法,包括指数退避、随机退避和固定退避。
FixBackOff
: 固定退避方法RandomBackOff
: 随机退避方法ExponentialBackOff
: 指数退避方法
Tips :
Retry
项目最终采用的是混合模式的backoff
,具体方法是CombineBackOffs
,它将多个退避方法进行组合,然后生成多个重试间隔。
延迟计算
延迟计算
是每次函数执行过程中根据重试次数计算延迟时间的模块。它受到 backoff
、jitter
、factor
、initDelay
等参数的影响。
也就是说,通过上面提到的 退避模块
中的方法,我们可以计算出每次重试的延迟时间。
计算公式如下:
bash
backoff = backoffFunc(factor * count + jitter * rand.Float64()) * 100 * Millisecond + delay
上面的 backoffFunc
就是 退避模块
中的方法,factor
是延迟抖动因子,count
是重试次数,jitter
是随机延迟,rand.Float64()
是随机数,delay
是初始延迟。
当然,你可以通过 Config
结构体中的 WithFactor
、WithJitter
、WithInitDelay
方法来设置这些参数。最重要的是可以使用 WithBackOffFunc
方法来设置回退延迟方法,这样就可以实现自定义的延迟计算。
接口设计
Retry
的接口设计也非常简洁,只有几个接口,但这些接口可以帮助我们完成大部分处理重试任务。Retry
的属性控制通过 Config
结构体来实现,通过 WithXXX
方法来设置属性。
配置选项
WithCallback
: 设置回调函数。WithContext
: 设置上下文,可以使用这个Context
来取消重试任务。WithAttempts
: 设置重试次数。WithAttemptsByError
: 设置特定错误的重试次数。WithFactor
: 设置延迟抖动因子。WithInitDelay
: 设置初始延迟。WithJitter
: 设置随机延迟。WithRetryIfFunc
: 设置重试条件。WithBackOffFunc
: 设置指数回退延迟。WithDetail
: 设置是否返回每次重试失败的详细错误。
方法接口
Retry
的方法接口也非常简洁,只有几个方法,非常容易上手。
单例模式
Do
: 执行重试函数。DoWithDefault
: 执行重试函数,使用默认配置。
工厂模式
New
: 创建一个Retry
对象。TryOnConflict
: 执行重试函数。
Callback
OnRetry
: 在重试函数执行完毕时调用,入参为重试次数、当前被延迟时长和错误。
go
// Callback 方法用于定义重试回调函数
// The Callback method is used to define the retry callback function.
type Callback interface {
OnRetry(count int64, delay time.Duration, err error)
}
返回结果
当你使用 Retry
执行一个函数时,经过一段时间的执行和多次重试后,最终会返回一个 Result
结构体,其中包含函数的执行结果和错误信息。
go
// data 为执行结果,tryError 为尝试执行时的错误,execErrors 为执行过程中的错误
// data is the result of the execution, tryError is the error when trying to execute, and execErrors is the error during the execution.
type Result struct {
count uint64
data any
tryError error
execErrors []error
}
包含如下方法:
Count
: 获取重试次数。Data
: 获取执行结果。TryError
: 获取Retry
执行过程中的错误。ExecErrors
: 获取执行函数返回的所有错误(多次重试的错误)。LastExecError
: 获取最后一次函数执行的错误。IsSuccess
: 判断Retry
是否执行成功。
提示: 在创建
Retry
对象时,可以通过WithDetail
方法设置是否返回每次重试失败的详细错误。如果设置了WithDetail
方法,那么在Retry
执行完毕后,可以通过Result
结构体的ExecErrors
方法获取执行过程中的所有错误。
使用示例
下面通过简单的示例来演示 Retry
的使用方法。
单例模式
在这段代码中,我定义了一个重试函数 testFunc
,然后使用 Retry
的 DoWithDefault
方法来执行该函数。在示例中,将重试次数设置为默认值(3 次),使用默认的回退策略,最后打印函数的执行结果。
go
package main
import (
"fmt"
"github.com/shengyanli1982/retry"
)
// retryable function
func testFunc() (any, error) {
return "lee", nil
}
func main() {
// retry call
result := retry.DoWithDefault(testFunc)
// result
fmt.Println("result:", result.Data())
fmt.Println("tryError:", result.TryError())
fmt.Println("execErrors:", result.ExecErrors())
fmt.Println("isSuccess:", result.IsSuccess())
}
输出结果
bash
$ go run test.go
result: lee
tryError: <nil>
execErrors: []
isSuccess: true
工厂模式
在这段代码中,我定义了两个重试函数 testFunc1
和 testFunc2
,一个成功,一个失败。然后使用 Retry
的 New
方法创建一个 Retry
对象,然后使用该对象的 TryOnConflict
方法来执行 testFunc1
和 testFunc2
两个函数。在示例中,将重试次数设置为默认值(3 次),使用默认的回退策略,最后打印函数的执行结果。
go
package main
import (
"errors"
"fmt"
"github.com/shengyanli1982/retry"
)
// retryable function
func testFunc1() (any, error) {
return "testFunc1", nil
}
func testFunc2() (any, error) {
return nil, errors.New("testFunc2")
}
func main() {
// retry with config
r := retry.New(nil)
// try on conflict
result := r.TryOnConflict(testFunc1)
// result
fmt.Println("========= testFunc1 =========")
fmt.Println("result:", result.Data())
fmt.Println("tryError:", result.TryError())
fmt.Println("execErrors:", result.ExecErrors())
fmt.Println("isSuccess:", result.IsSuccess())
// try on conflict
result = r.TryOnConflict(testFunc2)
// result
fmt.Println("========= testFunc2 =========")
fmt.Println("result:", result.Data())
fmt.Println("tryError:", result.TryError())
fmt.Println("execErrors:", result.ExecErrors())
fmt.Println("isSuccess:", result.IsSuccess())
}
输出结果
bash
$ go run test.go
========= testFunc1 =========
result: testFunc1
tryError: <nil>
execErrors: []
isSuccess: true
========= testFunc2 =========
result: <nil>
tryError: retry attempts exceeded
execErrors: []
isSuccess: false
总结
Retry
是一个轻量级的库,用于对函数进行重试调用。它非常简单、易用、高效,上手成本低,学习时间短。
Retry
支持两种工作模式:单例模式 和工厂模式 。通过这两种模式,Retry
可以解决大部分重试问题。
通过设计和实现 Retry
,我们标准化了重试操作过程,实现了逻辑代码的多处复用。这大大减少了重复编写代码的时间,提高了开发质量,让整项目代码看起来更加清爽而不油腻 ,更加简洁而不简单。
最后,如果您有任何问题或建议,请在 Retry
的 GitHub
上提出 issue
。我将尽快回复您的问题。