本文介绍了 Golang 状态机模式的一个实现示例,通过该模式,可以解耦调用链,有助于实现测试友好的代码,提高代码质量。原文: Go State Machine Patterns
导言
在我们开发的许多项目中,都需要依赖某种运行状态从而实现连续操作。
这方面的例子包括:
很久以前,Rob Pike 有一个关于 Go 中词法扫描的演讲,内容很讲座,我看了好几遍才真正理解。但演讲中介绍的最基本知识之一就是某个版本的 Go 状态机。
该状态机利用了 Go 的能力,即从函数中创建类型并将函数赋值给变量。
他在演讲中介绍的状态机功能强大,打破了让函数执行 if/else
并调用下一个所需函数的逻辑。取而代之的是,每个状态都会返回下一个需要调用的函数。
这样就能将调用链分成更容易测试的部分。
调用链
下面是一个用简单的调用链来完成任务的例子:
func Caller(args Args) {
callA(args)
callB(args)
}
复制代码
或
func Caller(args Args) {
callA(args)
}
func callA(args Args) {
callB(args)
}
func callB(args Args) {
return
}
复制代码
两种方法都表示调用链,其中 Caller()
调用 callA()
,并最终调用 callB()
,从中可以看到这一系列调用是如何执行的。
当然,这种设计没有任何问题,但当调用者远程调用其他系统时,必须对这些远程调用进行模拟/打桩,以提供密封测试。
你可能还想实现条件调用链,即根据某些参数或状态,在特定条件下通过 if/else
调用不同函数。
这就意味着,要对 Caller()
进行密封测试,可能需要处理整个调用链中的桩函数。如果有 50 个调用层级,则可能需要对被测函数下面每个层级的所有函数进行模拟/打桩。
这正是 Pike 的状态机设计大显身手的地方。
状态机模式
首先定义状态:
type State[T any] func(ctx context.Context, args T) (T, State[T], error)
复制代码
状态表示为函数/方法,接收一组参数(任意类型 T
),并返回下一个状态及其参数或错误信息。
如果返回的状态为 nil
,那么状态机将停止运行。如果设置了 error
,状态机也将停止运行。因为返回的是下一个要运行的状态,所以根据不同的条件,会有不同的下一个状态。
这个版本与 Pike 的状态机的不同之处在于这里包含了泛型并返回 T
。这样我们就可以创建纯粹的函数式状态机(如果需要的话),可以返回某个类型,并将其传递给下一个状态。Pike 最初实现状态机设计时还没有使用泛型。
为了实现这一目标,需要一个状态驱动程序:
func Run[T any](ctx context.Context, args T, start State[T]) (T, error) {
var err error
current := start
for {
if ctx.Err() != nil {
return args, ctx.Err()
}
args, current, err = current(ctx, args)
if err != nil {
return args, err
}
if current == nil {
return args, nil
}
}
}
复制代码
寥寥几行代码,我们就有了一个功能强大的状态驱动程序。
下面来看一个例子,在这个例子中,我们为集群中的服务关闭操作编写了状态机:
package remove
...
// storageClient provides the methods on a storage service
// that must be provided to use Remove().
type storageClient interface {
RemoveBackups(ctx context.Context, service string, mustKeep int) error
RemoveContainer(ctx context.Context, service string) error
}
// serviceClient provides methods to do operations for services
// within a cluster.
type servicesClient interface {
Drain(ctx context.Context, service string) error
Remove(ctx context.Context, service string) error
List(ctx context.Context) ([]string, error)
HasStorage(ctx context.Context, service string) (bool, error)
}
复制代码
这里定义了几个需要客户实现的私有接口,以便从集群中移除服务。
我们定义了私有接口,以防止他人使用我们的定义,但会通过公有变量公开这些接口。这样,我们就能与客户保持松耦合,保证只使用我们需要的方法。
// Args are arguments to Service().
type Args struct {
// Name is the name of the service.
Name string
// Storage is a client that can remove storage backups and storage
// containers for a service.
Storage storageClient
// Services is a client that allows the draining and removal of
// a service from the cluster.
Services servicesClient
}
func (a Args) validate(ctx context.Context) error {
if a.Name == "" {
return fmt.Errorf("Name cannot be an empty string")
}
if a.Storage == nil {
return fmt.Errorf("Storage cannot be nil")
}
if a.Services == nil {
return fmt.Errorf("Services cannot be nil")
}
return nil
}
复制代码
这里设置了要通过状态传递的参数,可以将在一个状态中设置并传递到另一个状态的私有字段包括在内。
请注意,Args
并非指针。
由于我们修改了 Args
并将其传递给每个状态,因此不需要给垃圾回收器增加负担。对于像这样操作来说,这点节约微不足道,但在工作量大的 ETL 管道中,节约的时间可能就很明显了。
实现中包含 validate()
方法,用于测试参数是否满足使用的最低基本要求。
// Service removes a service from a cluster and associated storage.
// The last 3 storage backups are retained for whatever the storage retainment
// period is.
func Service(ctx context.Context, args Args) error {
if err := args.validate(); err != nil {
return err
}
start := drainService
_, err := Run[Args](ctx, args, start)
if err != nil {
return fmt.Errorf("problem removing service %q: %w", args.Name, err)
}
return nil
}
复制代码
用户只需调用 Service()
,传入 Args
,如果出错就会收到错误信息。用户不需要看到状态机模式,也不需要理解状态机模式就能执行操作。
我们只需验证 Args
是否正确,将状态机的起始状态设置为名为 drainService
的函数,然后调用上面定义的 Run()
函数即可。
func drainService(ctx context.Context, args Args) (Args, State[Args], error) {
l, err := args.Services.List(ctx)
if err != nil {
return args, nil, err
}
found := false
for _, entry := range l {
if entry == args.Name {
found = true
break
}
}
if !found {
return args, nil, fmt.Errorf("the service was not found")
}
if err := args.Services.Drain(ctx, args.Name); err != nil {
return args, nil, fmt.Errorf("problem draining the service: %w", err)
}
return args, removeService, nil
}
复制代码
我们的第一个状态叫做 drainService()
,实现了上面定义的状态类型。
它使用 Args
中定义的 Services
客户端列出集群中的所有服务,如果找不到服务,就会返回错误并结束状态机。
如果找到服务,就会对服务执行关闭。一旦完成,就进入下一个状态,即 removeService()
。
func removeService(ctx context.Context, args Args) (Args, State[Args], error) {
if err := args.Services.Remove(ctx, args.Name); err != nil {
return args, nil, fmt.Errorf("could not remove the service: %w", err)
}
hasStorage, err := args.Services.HasStorage(ctx, args.Name)
if err != nil {
return args, nil, fmt.Errorf("HasStorage() failed: %w", err)
}
if hasStorage{
return args, removeBackups, nil
}
return args, nil, nil
}
复制代码
removeService()
使用我们的 Services
客户端将服务从群集中移除。
调用 HasStorage()
方法确定是否有存储,如果有,就会进入 removeBackups()
状态,否则就会返回 args
, nil
, nil
,这将导致状态机在无错误的情况下退出。
这个示例说明如何根据 Args
中的信息或代码中的远程调用在状态机中创建分支。
其他状态调用由你自行决定。我们看看这种设计如何更适合测试此类操作。
测试优势
这种模式首先鼓励的是小块的可测试代码,模块变得很容易分割,这样当代码块变得太大时,只需创建新的状态来隔离代码块。
但更大的优势在于无需进行大规模端到端测试。由于操作流程中的每个阶段都需要调用下一阶段,因此会出现以下情况:
顶层调用者按一定顺序调用所有子函数
每个调用者都会调用下一个函数
两者的某种混合
两者都会导致某种类型的端到端测试,而这种测试本不需要。
如果我们对顶层调用者方法进行编码,可能看起来像这样:
func Service(ctx context.Context, args Args) error {
...
if err := drainService(ctx, args); err != nil {
return err
}
if err := removeService(ctx, args); err != nil {
return err
}
hasStorage, err := args.Services.HasStorage(ctx, args.Name)
if err != nil {
return err
}
if hasStorage{
if err := removeBackups(ctx, args); err != nil {
return err
}
if err := removeStorage(ctx, args); err != nil {
return err
}
}
return nil
}
复制代码
如你所见,可以为所有子函数编写单独的测试,但要测试 Service()
,现在必须对调用的所有客户端或方法打桩。这看起来就像是端到端测试,而对于这类代码来说,通常不是好主意。
如果转到功能调用链,情况也不会好到哪里去:
func Service(ctx context.Context, args Args) error {
...
return drainService(ctx, args)
}
func drainService(ctx context.Context, args Args) (Args, error) {
...
return removeService(ctx, args)
}
func removeService(ctx context.Context, args Args) (Args, error) {
...
hasStorage, err := args.Services.HasStorage(ctx, args.Name)
if err != nil {
return args, fmt.Errorf("HasStorage() failed: %w", err)
}
if hasStorage{
return removeBackups(ctx, args)
}
return nil
}
...
复制代码
当我们测试时,越接近调用链的顶端,测试的实现就变得越困难。在 Service()
中,必须测试 drainService()
、removeService()
以及下面所有调用。
有几种方法可以做到,但都不太好。
如果使用状态机,只需测试每个阶段是否按要求运行,并返回想要的下一阶段。
顶层调用者甚至不需要测试,它只是调用 validate()
方法,并调用应该能够被测试的 Run()
函数。
我们为 drainService()
编写一个表驱动测试,这里会拷贝一份 drainService()
代码,这样就不用返回到前面看代码了。
func drainService(ctx context.Context, args Args) (Args, State[Args], error) {
l, err := args.Services.List(ctx)
if err != nil {
return args, nil, err
}
found := false
for _, entry := range l {
if entry == args.Name {
found = true
break
}
}
if !found {
return args, nil, fmt.Errorf("the service was not found")
}
if err := args.Services.Drain(ctx, args.Name); err != nil {
return args, nil, fmt.Errorf("problem draining the service: %w", err)
}
return args, removeService, nil
}
func TestDrainSerivce(t *testing.T) {
t.Parallel()
tests := []struct {
name string
args Args
wantErr bool
wantState State[Args]
}{
{
name: "Error: Services.List() returns an error",
args: Args{
Services: &fakeServices{
list: fmt.Errorf("error"),
},
},
wantErr: true,
},
{
name: "Error: Services.List() didn't contain our service name",
args: Args{
Name: "myService",
Services: &fakeServices{
list: []string{"nope", "this", "isn't", "it"},
},
},
wantErr: true,
},
{
name: "Error: Services.Drain() returned an error",
args: Args{
Name: "myService",
Services: &fakeServices{
list: []string{"yes", "mySerivce", "is", "here"},
drain: fmt.Errorf("error"),
},
},
wantErr: true,
},
{
name: "Success",
args: Args{
Name: "myService",
Services: &fakeServices{
list: []string{"yes", "myService", "is", "here"},
drain: nil,
},
},
wantState: removeService,
},
}
for _, test := range tests {
_, nextState, err := drainService(context.Background(), test.args)
switch {
case err == nil && test.wantErr:
t.Errorf("TestDrainService(%s): got err == nil, want err != nil", test.name)
continue
case err != nil && !test.wantErr:
t.Errorf("TestDrainService(%s): got err == %s, want err == nil", test.name, err)
continue
case err != nil:
continue
}
gotState := methodName(nextState)
wantState := methodName(test.wantState)
if gotState != wantState {
t.Errorf("TestDrainService(%s): got next state %s, want %s", test.name, gotState, wantState)
}
}
}
复制代码
可以在 Go Playground玩一下。
如你所见,这避免了测试整个调用链,同时还能确保测试调用链中的下一个函数。
这些测试很容易划分,维护人员也很容易遵循。
其他可能性
这种模式也有变种,即根据 Args
中设置的字段确定状态,并跟踪状态的执行以防止循环。
在第一种情况下,状态机软件包可能是这样的:
type State[T any] func(ctx context.Context, args T) (T, State[T], error)
type Args[T] struct {
Data T
Next State
}
func Run[T any](ctx context.Context, args Args[T], start State[T]) (T, error) {
var err error
current := start
for {
if ctx.Err() != nil {
return args, ctx.Err()
}
args, current, err = current(ctx, args)
if err != nil {
return args, err
}
current = args.Next // Set our next stage
args.Next = nil // Clear this so to prevent infinite loops
if current == nil {
return args, nil
}
}
}
复制代码
可以很容易的将分布式跟踪或日志记录集成到这种设计中。
如果希望推送大量数据并利用并发优势,不妨试试 stagedpipe 软件包,其内置了大量高级功能,可以看视频和 README 学习如何使用。
希望这篇文章能让你充分了解 Go 状态机设计模式,现在你的工具箱里多了一个强大的新工具。
你好,我是俞凡,在 Motorola 做过研发,现在在 Mavenir 做技术工作,对通信、网络、后端架构、云原生、DevOps、CICD、区块链、AI 等技术始终保持着浓厚的兴趣,平时喜欢阅读、思考,相信持续学习、终身成长,欢迎一起交流学习。为了方便大家以后能第一时间看到文章,请朋友们关注公众号"DeepNoMind",并设个星标吧,如果能一键三连(转发、点赞、在看),则能给我带来更多的支持和动力,激励我持续写下去,和大家共同成长进步!
评论