Go语言SOLID实践系列五之依赖反转原则
@ 归零 | 星期六,一月 22 日,2022 年 | 4 分钟阅读 | 更新于 星期六,一月 22 日,2022 年

引用wikipedia上对依赖反转原则的解释:

在面向对象编程领域中,依赖反转原则(Dependency inversion principle,DIP)是指一种特定的解耦(传统的依赖关系创建在高层次上,而具体的策略设置则应用在低层次的模块上)形式,使得高层次的模块不依赖于低层次的模块的实现细节,依赖关系被颠倒(反转),从而使得低层次模块依赖于高层次模块的需求抽象。

该原则规定:

  • 高层次的模块不应该依赖于低层次的模块,两者都应该依赖于抽象接口。
  • 抽象接口不应该依赖于具体实现。而具体实现则应该依赖于抽象接口。

不遵守依赖反转原则

高层次的模块不应依赖于低层次的模块。两者都应该依赖于抽象。抽象不应依赖于细节。细节应该取决于抽象。

我们可以在上面看到DIP的定义。Bob大叔在他的 论文中提出了这一点。他的博客中还有更多细节。

那么,如何理解这一点,特别是在Go的上下文中?首先,我们应该接受抽象作为一个OOP概念。我们使用这样的概念来公开基本行为并隐藏其实现的细节。

什么是高层次和低层次模块?Go 上下文中的高层次模块是应用程序顶部使用的软件组件,如用于演示的代码。

它也可以是接近顶层的代码,如业务逻辑代码或某些用例组件。必须将其理解为为我们的应用程序提供真正业务价值的层。

另一方面,低层次组件大多是支持较高级别的小代码段。他们隐藏了有关不同基础设施集成的技术细节。例如,它可以是一个结构体,用于保留从数据库中检索数据、发送 SQS 消息、从 Redis 获取值或向外部 API 发送 HTTP 请求的逻辑。

那么,当我们打破依赖反转原则并且我们的高层次组件依赖于一个低层次组件时,它看起来如何?让我们看一下下面的代码:

// infrastructure layer

type UserRepository struct {
	db *gorm.DB
}

func NewUserRepository(db *gorm.DB) *UserRepository {
	return &UserRepository{
		db: db,
	}
}

func (r *UserRepository) GetByID(id uint) (*domain.User, error) {
	user := domain.User{}
	err := r.db.Where("id = ?", id).First(&user).Error
	if err != nil {
		return nil, err
	}

	return &user, nil
}

// domain layer

type User struct {
	ID uint `gorm:"primaryKey;column:id"`
	// some fields
}

// application layer

type EmailService struct {
	repository *infrastructure.UserRepository
	// some email sender
}

func NewEmailService(repository *infrastructure.UserRepository) *EmailService {
	return &EmailService{
		repository: repository,
	}
}

func (s *EmailService) SendRegistrationEmail(userID uint) error {
	user, err := s.repository.GetByID(userID)
	if err != nil {
		return err
	}
	// send email
	return nil
}

在上面的代码片段中,我们定义了一个高层次组件 EmailService。此结构体属于应用程序层,它负责向新注册客户发送电子邮件。

这个结构体是有一个方法SendRegistrationEmail,它需要用户的ID。在后台,它从 UserRepository 检索用户,稍后(可能)将其传递到某个 EmailSender 服务以执行电子邮件传递。

EmailSender的部分现在不在我们的关注点。让我们专注于用户存储库。该结构体表示与数据库通信的存储库,因此它属于基础设施层。

因此,我们的高层次组件 EmailService 似乎依赖于低级组件 UserRepository。实际上,如果不定义与数据库的连接,我们就无法启动用例结构。

这种反模式会立即影响我们在 Go 中的单元测试。让我们假设我们要测试电子邮件服务,如下面的代码片段所示:

import (
	"testing"
	// some dependencies
	"github.com/DATA-DOG/go-sqlmock"
	"github.com/stretchr/testify/assert"
	"gorm.io/driver/mysql"
	"gorm.io/gorm"
)

func TestEmailService_SendRegistrationEmail(t *testing.T) {
	db, mock, err := sqlmock.New()
	assert.NoError(t, err)

	dialector := mysql.New(mysql.Config{
		DSN:        "dummy",
		DriverName: "mysql",
		Conn:       db,
	})
	finalDB, err := gorm.Open(dialector, &gorm.Config{})
	
	repository := infrastructure.NewUserRepository(finalDB)
	service := NewEmailService(repository)
	//
	// a lot of code to define mocked SQL queries
	//
	// and then actual test
}

Go 中的mock依赖于接口的使用,我们可以为其定义mock实现,但我们不能对结构体执行相同的操作。

因此,我们不能嘲笑UserRepository,因为它是一个结构体。在这种情况下,我们需要在较低层次(在本例中)在 Gorm 连接对象上进行mock,我们可以使用 SQLMock 包执行此操作。

但即使有了它,它也不是一种可靠的,也不是有效的测试方法。我们需要mock太多的SQL查询,并且对数据库方案依赖太多。数据库内部的任何变化,我们都需要调整单元测试。

单元测试方面,现在我们有一个更大的问题。如果我们决定将存储切换到其他内容(如 Cassandra),会发生什么情况?主要是如果我们的存储对象是客户,我们计划在未来将存储作为分布式存储?

如果出现这样的场景,并且我们使用 UserRepository 的这个实现,那么接下来会进行许多重构。

现在,我们看到了高层组件的含义取决于低层组件的含义。但是依赖于细节的抽象呢?让我们检查下面的代码:


// domain layer

type User struct {
	ID uint `gorm:"primaryKey;column:id"`
	// some fields
}

type UserRepository interface {
	GetByID(id uint) (*User, error)
}

要解决高层和低层组件的第一个问题,我们应该从定义一些接口开始。在这种情况下,我们可以将 UserRepository 定义为domain层上的接口。

因此,它提供了将电子邮件服务与数据库分离的机会,但仍未完全脱钩。查看用户结构。它仍然提出了映射到数据库的定义。

而且,即使这样的结构位于domain层内,它仍然具有基础结构细节。我们的新界面 UserRepository(抽象)依赖于用户结构和数据库方案(详细信息),我们仍然会中断 DIP。

更改数据库方案不可避免地会更改我们的接口。该接口仍可使用相同的 User 结构,但它将保存来自低级层的更改。

最后,通过这种重构,我们一无所获。我们仍然处于错误的位置。带来许多后果:

  • 我们无法正确测试我们的业务或应用程序逻辑。
  • 对数据库引擎或表结构的任何更改都会影响我们的最高级别。
  • 我们不能轻易切换到不同类型的存储。
  • 我们的模型与存储紧密耦合。

因此,让我们再次重构这段代码。

遵守依赖反转原则

高层模块不应依赖于低层模块。两者都应该依赖于抽象。抽象不应依赖于细节。细节应该取决于抽象。

让我们回到依赖关系反转的原始指令,并检查粗体句子。他们已经给出了一些重构的方向。

我们应该定义一些抽象(一个接口),我们的组件EmailService和UserRepository都将依赖于这些抽象。此外,这种抽象不应依赖于任何技术细节(如Gorm对象)。

让我们从下面检查代码:

// infrastructure layer

type UserGorm struct {
	// some fields
}

func (g UserGorm) ToUser() *domain.User {
	return &domain.User{
		// some fields
	}
}

type UserDatabaseRepository struct {
	db *gorm.DB
}

var _ domain.UserRepository = &UserDatabaseRepository{}

/*
type UserRedisRepository struct {
	
}
type UserCassandraRepository struct {
}
*/

func NewUserDatabaseRepository(db *gorm.DB) UserRepository {
	return &UserDatabaseRepository{
		db: db,
	}
}

func (r *UserDatabaseRepository) GetByID(id uint) (*domain.User, error) {
	user := UserGorm{}
	err := r.db.Where("id = ?", id).First(&user).Error
	if err != nil {
		return nil, err
	}

	return user.ToUser(), nil
}

// domain layer

type User struct {
	// some fields
}

type UserRepository interface {
	GetByID(id uint) (*User, error)
}

// application layer

type EmailService struct {
	repository domain.UserRepository
	// some email sender
}

func NewEmailService(repository domain.UserRepository) *EmailService {
	return &EmailService{
		repository: repository,
	}
}

func (s *EmailService) SendRegistrationEmail(userID uint) error {
	user, err := s.repository.GetByID(userID)
	if err != nil {
		return err
	}
	// send email
	return nil
}

在新的代码结构中,我们可以将 UserRepository 接口视为依赖于 User 结构的组件,并且它们都在domain层内。

用户结构体不再反映数据库方案,但我们为此使用UserGorm结构。该结构位于基础结构层上。它提供了一个 ToUser 方法,该方法将其映射到实际的用户结构。

在这种情况下,我们可以将 UserGorm 用作 UserDatabaseRepository 内部使用的详细信息的一部分,作为 UserRepository 的实际实现。

在domain和application中,我们仅依赖于domain中的 UserRepository 接口和用户实体。

在基础结构层中,我们可以根据需要为 UserRepository 定义尽可能多的实现。例如,这可以是UserFileRepository或UserCassandraRepository。

高层组件(EmailService)依赖于抽象 — 它包含一个类型为 UserRepository 的字段。不过,低层组件如何依赖于抽象?

在 Go 中,结构隐式实现接口。这意味着我们不需要添加 UserDatabaseRepository 显式实现 UserRepository 的代码,但我们可以添加一个具有空白标识符的检查。

通过这种方法,我们可以更轻松地控制依赖关系。我们的结构体依赖于接口,每当我们想要改变整体依赖关系时,我们都可以定义不同的实现并注入它们。

这种技术在任何框架中都很常见,我们用依赖注入模式来解决它。在 Go 中,有许多 DI 库,例如来自 Facebook的 Wire 或 Dingo 的库。

我们的单元测试情况如何?让我们来检查一下。

import (
	"errors"
	"testing"
)

type GetByIDFunc func(id uint) (*User, error)

func (f GetByIDFunc) GetByID(id uint) (*User, error) {
	return f(id)
}

func TestEmailService_SendRegistrationEmail(t *testing.T) {
	service := NewEmailService(GetByIDFunc(func(id uint) (*User, error) {
		return nil, errors.New("error")
	}))
	//
	// and just to call the service
}

通过这种重构,我们可以提供一个简单的模拟,GetByIDFunc,作为一个新类型,它定义了我们想要模拟的UserRepository函数。以下是 Go 中定义函数类型并为其分配方法以实现接口的常用方法。

现在,我们的测试更加优雅和高效。我们可以为UserRepository注入不同的实现,用于任何用例,并控制测试结果。

更多例子

我们可以体验到破坏其他组件中的 DIP,而不仅仅是结构。例如,可以使用纯独立的函数


type User struct {
	// some fields
}

type UserJSON struct {
	// some fields
}

func (j UserJSON) ToUser() *User {
	return &User{
		// some fields
	}
}

func GetUser(id uint) (*User, error) {
	filename := fmt.Sprintf("user_%d.json", id)
	data, err := ioutil.ReadFile(filename)
	if err != nil {
		return nil, err
	}
	
	var user UserJSON
	err = json.Unmarshal(data, &user)
	if err != nil {
		return nil, err
	}
	
	return user.ToUser(), nil
}

因此,我们希望读取用户的数据。为此,我们可以使用文件和JSON格式。GetUser 方法从文件中读取文件并将文件内容转换为实际的 User。

该方法本身取决于文件的存在,如果我们想要正确测试它,我们需要依赖这些文件。因此,如果我们稍后将测试添加到 GetUser 方法中,则为此方法编写测试(例如,测试验证规则)并不方便。

再一次,我们的代码依赖于太多的细节,最好做一些抽象:

type User struct {
// some fields
}

type UserJSON struct {
	// some fields
}

func (j UserJSON) ToUser() *User {
	return &User{
		// some fields
	}
}

func GetUserFile(id uint) (io.Reader, error) {
	filename := fmt.Sprintf("user_%d.json", id)
	file, err := os.Open(filename)
	if err != nil {
		return nil, err
	}

	return file, nil
}

func GetUserHTTP(id uint) (io.Reader, error) {
	uri := fmt.Sprintf("http://some-api.com/users/%d", id)
	resp, err := http.Get(uri)
	if err != nil {
		return nil, err
	}

	return resp.Body, nil
}

func GetDummyUser(userJSON UserJSON) (io.Reader, error) {
	data, err := json.Marshal(userJSON)
	if err != nil {
		return nil, err
	}

	return bytes.NewReader(data), nil
}

func GetUser(reader io.Reader) (*User, error) {
	data, err := ioutil.ReadAll(reader)
	if err != nil {
		return nil, err
	}

	var user UserJSON
	err = json.Unmarshal(data, &user)
	if err != nil {
		return nil, err
	}

	return user.ToUser(), nil
}

通过新的实现,我们使 GetUser 方法依赖于 Reader 接口的实例。它是Go核心包IO的接口。

在这里,我们可以定义许多不同的方法来为Reader接口提供实现,例如GetUserFile,GetUserHTTP,GetDummyUser(我们可以使用它们来测试GetUser方法)。

这种方法我们可以在许多不同的情况下使用。每当我们在进行适当的单元测试时遇到困难,甚至在Go中遇到依赖循环时,我们都应该尝试通过提供接口和尽可能多的实现来解耦它。

总结

依赖关系反转原则是最后一个 SOLID 原则,它代表单词 SOLID 中的字母 D。它指出高层组件不应依赖于低层组件。

相反,我们所有的组件都应该依赖于抽象,或者更好地说,接口。这样的抽象使我们能够更灵活地使用我们的代码,并对其进行适当的测试。

参考:

1、https://levelup.gitconnected.com/practical-solid-in-golang-dependency-inversion-principle-8cbd4eed484b

2、https://zh.wikipedia.org/wiki/%E4%BE%9D%E8%B5%96%E5%8F%8D%E8%BD%AC%E5%8E%9F%E5%88%99

© 2014 - 2022 Lionel's Blog

Powered by Hugo with theme Dream.