Go语言SOLID实践系列四之接口隔离原则
@ 归零 | 星期日,一月 2 日,2022 年 | 4 分钟阅读 | 更新于 星期日,一月 2 日,2022 年

引用wikipedia上对接口隔离原则的解释:

接口隔离原则(英语:interface-segregation principles, 缩写:ISP)指明客户(client)不应被迫使用对其而言无用的方法或功能。接口隔离原则(ISP)拆分非常庞大臃肿的接口成为更小的和更具体的接口,这样客户将会只需要知道他们感兴趣的方法。这种缩小的接口也被称为角色接口(role interfaces)。接口隔离原则(ISP)的目的是系统解开耦合,从而容易重构,更改和重新部署。接口隔离原则是在SOLID中五个面向对象设计(OOD)的原则之一,类似于在GRASP中的高内聚性。

保持小的接口,这样用户就不会最终依赖他们不需要的东西了。 Bob大叔创造了这个原则,关于它的更多细节你可以在他的博客上找到。这个原则清楚地定义了它的要求,可能是所有其他SOLID原则中最好的一个。 我们不应该把它简单地理解为仅仅是一个方法接口,而应该更多地从接口所拥有的功能内聚的角度来看待它。让我们来看看下面的代码。

type User interface {
	AddToShoppingCart(product Product)
	IsLoggedIn() bool
	Pay(money Money) error
	HasPremium() bool
	HasDiscountFor(product Product) bool
	//
	// some additional methods
	//
}

type Guest struct {
	cart ShoppingCart
	//
	// some additional fields
	//
}

func (g *Guest) AddToShoppingCart(product Product) {
	g.cart.Add(product)
}

func (g *Guest) IsLoggedIn() bool {
	return false
}

func (g *Guest) Pay(Money) error {
	return errors.New("user is not logged in")
}

func (g *Guest) HasPremium() bool {
	return false
}

func (g *Guest) HasDiscountFor(Product) bool {
	return false
}

type NormalCustomer struct {
	cart   ShoppingCart
	wallet Wallet
	//
	// some additional fields
	//
}

func (c *NormalCustomer) AddToShoppingCart(product Product) {
	c.cart.Add(product)
}

func (c *NormalCustomer) IsLoggedIn() bool {
	return true
}

func (c *NormalCustomer) Pay(money Money) error {
	return c.wallet.Deduct(money)
}

func (c *NormalCustomer) HasPremium() bool {
	return false
}

func (c *NormalCustomer) HasDiscountFor(Product) bool {
	return false
}

type PremiumCustomer struct {
	cart     ShoppingCart
	wallet   Wallet
	policies []DiscountPolicy
	//
	// some additional fields
	//
}

func (c *PremiumCustomer) AddToShoppingCart(product Product) {
	c.cart.Add(product)
}

func (c *PremiumCustomer) IsLoggedIn() bool {
	return true
}

func (c *PremiumCustomer) Pay(money Money) error {
	return c.wallet.Deduct(money)
}

func (c *PremiumCustomer) HasPremium() bool {
	return true
}

func (c *PremiumCustomer) HasDiscountFor(product Product) bool {
	for _, p := range c.policies {
		if p.IsApplicableFor(c, product) {
			return true
		}
	}
	
	return false
}

type UserService struct {
	//
	// some fields
	//
}

func (u *UserService) Checkout(ctx context.Context, user User, product Product) error {
	if !user.IsLoggedIn() {
		return errors.New("user is not logged in")	
	}
	
	var money Money
	//
	// some calculation
	//
	if user.HasDiscountFor(product) {
		//
		// apply discount
		//
	}
	
	return user.Pay(money)
}

让我们假设我们想提供一个购物的应用程序。其中一种方法是定义一个接口User,就像我们在代码例子中做的那样。这个界面拥有一个用户可以拥有的许多功能。

在我们的平台上,一个用户可以在ShoppingCart上添加一个产品。他们可以购买它。他们可以获得某个产品的折扣。唯一的问题是,只有一个特定的用户可以做所有这些事情。

这个接口的实际实现是三个结构。第一个是Guest结构。它应该是一个没有登录到我们系统的用户,但至少他们可以向ShoppingCart添加产品。

第二个实现是NormalCustomer。它可以做任何Guest可以做的事情,另外还可以购买产品。

第三个实现是PremiumCustomer,它可以使用我们系统的所有功能。

现在,请看所有这三个结构。只有PremiumCustomer需要所有三个方法。也许我们可以把所有这些方法都分配给NormalCustomer,但可以肯定的是,Guest需要的方法不超过两个。

HasPremium和HasDiscountFor方法对Guest没有任何意义。如果该结构代表未登录的用户,我们为什么还要考虑实现折扣方法?

在这里,我们甚至可以调用带有“method is not implemented”的panic方法。在通常情况下,我们甚至不应该从Guest调用HasPremium方法。

我们所做的这一切是为了在UserService内部增加通用性,以便在同一个地方用相同的代码处理所有类型的用户。但是,正因为如此,我们需要实现一堆不用的方法。

所以,为了有更好的通用性,导致:

  • 许多struct有未使用的方法。
  • 需要以某种方式标记方法,以便其他人不使用这些方法。
  • 额外的单元测试代码。
  • 奇怪的多态性。

下面,我们遵循接口隔离原则重构下以上代码。

如何遵守接口隔离

围绕最小的内聚特性组构建接口。

我们不需要在这里发明一些空间科学。唯一需要的是定义一个提供完整功能集的最小接口。让我们检查下面的代码:

type User interface {
	AddToShoppingCart(product Product)
	//
	// some additional methods
	//
}

type LoggedInUser interface {
	User
	Pay(money Money) error
	//
	// some additional methods
	//
}

type PremiumUser interface {
	LoggedInUser
	HasDiscountFor(product Product) bool
	//
	// some additional methods
	//
}

type Guest struct {
	cart ShoppingCart
	//
	// some additional fields
	//
}

func (g *Guest) AddToShoppingCart(product Product) {
	g.cart.Add(product)
}

type NormalCustomer struct {
	cart   ShoppingCart
	wallet Wallet
	//
	// some additional fields
	//
}

func (c *NormalCustomer) AddToShoppingCart(product Product) {
	c.cart.Add(product)
}

func (c *NormalCustomer) Pay(money Money) error {
	return c.wallet.Deduct(money)
}

type PremiumCustomer struct {
	cart     ShoppingCart
	wallet   Wallet
	policies []DiscountPolicy
	//
	// some additional fields
	//
}

func (c *PremiumCustomer) AddToShoppingCart(product Product) {
	c.cart.Add(product)
}

func (c *PremiumCustomer) Pay(money Money) error {
	return c.wallet.Deduct(money)
}

func (c *PremiumCustomer) HasDiscountFor(product Product) bool {
	for _, p := range c.policies {
		if p.IsApplicableFor(c, product) {
			return true
		}
	}

	return false
}

type UserService struct {
	//
	// some fields
	//
}

func (u *UserService) Checkout(ctx context.Context, user User, product Product) error {
	loggedIn, ok := user.(LoggedInUser)
	if !ok {
		return errors.New("user is not logged in")
	}

	var money Money
	//
	// some calculation
	//
	if premium, ok := loggedIn.(PremiumUser); ok && premium.HasDiscountFor(product)  {
		//
		// apply discount
		//
	}

	return loggedIn.Pay(money)
}

现在,我们有三个接口,而不是一个。PremiumUser嵌入LoggedInUser,后者嵌入User。此外,它们中的每一个都引入了一种方法。

User现在仅代表尚未在我们的平台上进行身份验证的客户。对于此类类型,我们知道他们可以使用购物车的功能。 新的LoggedInUser接口代表所有经过身份验证的客户,而PremiumUser接口代表所有具有付费高级帐户的经过身份验证的客户。

请注意这一点:我们确实添加了另外两个接口,但我们删除了两个方法:IsLoggedIn和HasPremium。这些方法不是我们接口签名的一部分。但是,没有它们,我们怎么能工作呢?

正如您在UserService中看到的那样,我们没有使用bool返回值方法,而是阐明了用户接口的子类型。如果User实现了 LoggedInUser,我们就知道该客户是经过身份验证的。

此外,如果用户实现PremiumUser,我们知道我们谈论的是具有高级帐户的客户。因此,通过转换,我们已经检查了一些业务规则。

除了这两种方法之外,之前的所有struct现在都更加轻量级。他们每个人都有五种方法,其中许多方法根本没有使用,现在他们只有他们真正需要的方法。

其他例子

尽管提供小而灵活的接口总是好的,但我们应该考虑到它们的目的来介绍它们。添加小接口以使它们更简单,但仍然在同一结构中将它们一起实现并没有太多意义。

让我们检查下面的例子:

// too much splitting
type UserWithFirstName interface {
	FirstName() string
}

type UserWithLastName interface {
	LastName() string
}

type UserWithFullName interface {
	FullName() string
}

// optimal splitting
type UserWithName interface {
	FirstName() string
  	LastName() string
  	FullName() string
}

当我们过多地拆分接口时,就是这种情况。是的,我们可以为每个方法提供一个接口,现在将它们定义为角色接口。这样的单方法接口有时很好,但在这里不是。

显然,如果客户在我们的平台上注册,他们需要提供他们的名字和姓氏以用于计费目的。因此,我们的用户将需要"名字"和"姓氏"方法,当然,还有"全名"。

在这种情况下,将这三种方法拆分为三个接口没有意义,因为这三种方法总是在一起。因此,这不是单方法接口的正确示例。

但是,一个很好的例子是:

package io

type Reader interface {
	Read(p []byte) (n int, err error)
}

type Writer interface {
	Write(p []byte) (n int, err error)
}

type Closer interface {
	Close() error
}

type Seeker interface {
	Seek(offset int64, whence int) (int64, error)
}

type WriteCloser interface {
	Writer
	Closer
}

type ReadWriteCloser interface {
	Reader
	Writer
	Closer
}

Go中的完美例子是IO包。它提供了许多用于处理 I/O 操作的代码和接口,并且可能所有 Go 开发人员都至少使用过一次此包。

它提供了Reader, Writer, Closer, Seeker接口。它们中的每一个只定义一个方法: Read, Write, CloseSeek。我们使用这些方法来读取,写入,从特定源搜索和搜索一段字节,并关闭该源。

为了使此类源具有更大的灵活性,所有功能都放置在其接口中。后来,他们一起构建更复杂的接口,如WriteCloserReadWriteCloser等。

总结

接口隔离原则是第四个SOLID原则,它代表单词SOLID中的字母I。它教会我们尽可能小地定义接口。

每当我们想要涵盖更多类型时,我们应该使用不同的接口来保护它们。我们应该避免将接口做得太小,而是提供完整的功能。

参考:

1、https://levelup.gitconnected.com/practical-solid-in-golang-interface-segregation-principle-f272c2a9a270

2、https://zh.wikipedia.org/wiki/%E6%8E%A5%E5%8F%A3%E9%9A%94%E7%A6%BB%E5%8E%9F%E5%88%99

© 2014 - 2022 Lionel's Blog

Powered by Hugo with theme Dream.