【Golang】从语言规范角度看单元测试和Clean Architecture

概况

我在使用Golang创建服务时,总结了单元测试和清晰架构所面临的障碍。

在Golang中的测试和模拟

为了进行单元测试,模块间的引用必须使用接口。

在使用Golang编写单元测试时,遇到的困难是模拟(mock)一部分内容。
例如,在Java中,无论什么类型的类,都可以(强行)创建一个继承该类的类,并将目标代码中的依赖对象替换为模拟对象。
例如,在Python或JavaScript中,由于本身没有类型,可以自由地将依赖对象替换为模拟对象。

然而,在Golang中,

type Sample struct{}
var hoge = &Sample{}

如果这样做,就无法将hoge替换为除了Sample结构体以外的内容。对于结构体的引用没有多态性!

当测试一个模块时,如果该模块直接引用了其他模块的结构体,那么这意味着我们无法将其替换为模拟对象。

所以,我们必须这样写。

type Sample interface {
   hoge()
}
type sample{} struct
func (s *sample) hoge() {
   // hoge
}
var hoge Sample = &sample{}

必须通过接口而不是结构体来引用,以此来构建引用。

这就是使用Golang编写程序时的规定。

构造函数的依赖注入

当一个模块引用另一个模块时,模块间协作必须通过构造函数依赖注入(DI)来实现。

type A interface {
   Hoge() string
}
type a struct {
}
func (*a) Hoge() string {
   return "hoge"
}
type Sample struct {
   a A
}
func NewSample(a A) *Sample {
   return &Sample{a}
}

下面的选项只需要用中文进行释义:
如果这样做,就可以在测试代码中使用NewSample函数自由地将mock_a注入到Sample中来创建。

def main(){
   a := &mockA{}
   sut := NewSample(a)
}

然后,在生产代码中,在最外层的main.go文件中进行依赖注入。

有时候会想要DI容器,但这是另一回事了。

提供用于替换函数为模拟对象的选项

在中文中,我认为在方法中经常会有调用time.New()或uuid.NewV4()之类的代码。如果想在测试中模拟这些功能,就需要使用monkey.Patch。但是我觉得monkey.Patch的运行不太稳定。这可能是因为

type Sample struct {
   uuid : func() (uuid.UUID, error)
   now : func() time.Time
}

func NewSample(opts ...SampleOption) *Sample {
   s := &Sample{
      uuid: uuid.NewV4
      now: time.Now
   }
   for _, f := range opts {
      f(s)
   }
   return s
}
type SampleOption func(*Sample)

func WithUUID(uuid : func() (uuid.UUID, error)) SampleOption {
   return func(s *Sample){
      s.uuid = uuid
   }
}
func WithNow(now : func() time.Time) SampleOption {
   return func(s *Sample){
      s.now = now
   }
}

func (s *Sample) Hoge() Fuga {
   now := s.now()
   id, err := s.uuid()
}

在进行测试的时候,可以这样做。

   s := NewSample(
      WithUUID(func() {
         return uuid.FromString("aaaa-aaaaa-...aaaaaa-aaaa")
      }),
      WithNow(func() {
         return time.Date(2000, 1, 1)
      }),
   )

通过构造函数,无论模块的依赖是struct还是func,都可以将其替换为模拟实现。

循环导入和目录分割

在使用Golang编写代码时,经常会遇到循环引用的情况。其中最常见的模式如下:

界面引用了实现

// interface

type Sample interface {
  hoge(options ...Option)
}
// impl
type sample struct{}
func (s *sample) hoge(option Option){
   // hoge
}
type Option func(s Sample)

如果以这样的方式写下去

界面 ==> 实现 ==> 界面

导致循环引用。换句话说。

由于impl必须引用interface,所以interface不能引用impl。在interface中定义的像option和enum这样的值必须在interface中定义。

從循環引入問題來看,以下一點可以說明(儘管這種語言不算罕見,所以不能說這是Golang獨有的)。

在目录内的文件之间不存在依赖性问题。如果要分割目录,则必须提前确定目录之间的依赖方向。

尽管我认为无需特别强调,但在同一目录下的文件之间,不存在导入问题,所以循环导入问题仅在尝试将项目分成多个目录时才会出现。

在Golang的开发前提中,我们需要定义每个目录,并预先确定每个目录之间引用的方向性,以避免循环导入问题。

我认为的干净架构

看到以上内容,Clean Architecture实际上不就是Golang开发的前提条件加一些额外的要求吗?

不会解释关于Clean Architecture,但同心圆图案最终是为了控制目录间的依赖方向以避免循环引用的建议。这些名称并不意味着每个模块都是必需的。如果不需要,只有处理程序也可以,即使处理程序引用了存储库也没关系。

右下的图表仅表达了在模块之间进行协作时必须经过接口。这在Goland开发中是理所当然的。

得出结论

由于Golang的语言规范相对特殊,在开发过程中存在一定的限制。为了应对这些限制,我认为必须采用类似于Clean Architecture的概念。

使用类似于Spring Boot或Django的框架,框架会规定目录结构,但是在Golang开发中似乎没有这样的规定。

我发现大部分主要的库(包括aws-sdk-go在内)通常都包含接口。如果有想使用的外部库,可以尝试找到接口而不必直接引用。

bannerAds