Golang中的开闭原则

原文地址:这里,省略了一些非重点片段。

Open/Closed Principle(OCP,开闭原则)也不过多介绍了,一句话“对扩展开放,对修改封闭”。策略模式就是这一原则的一种实现。

不符合开闭原则的代码

import (
	"net/http"

	"github.com/ahmetb/go-linq"
	"github.com/gin-gonic/gin"
)

type PermissionChecker struct {
	//
	// some fields
	//
}

func (c *PermissionChecker) HasPermission(ctx *gin.Context, name string) bool {
	var permissions []string
	switch ctx.GetString("authType") {
	case "jwt":
		permissions = c.extractPermissionsFromJwt(ctx.Request.Header)
	case "basic":
		permissions = c.getPermissionsForBasicAuth(ctx.Request.Header)
	case "applicationKey":
		permissions = c.getPermissionsForApplicationKey(ctx.Query("applicationKey"))
	}
	
	var result []string
	linq.From(permissions).
		Where(func(permission interface{}) bool {
			return permission.(string) == name
		}).ToSlice(&result)

	return len(result) > 0
}

func (c *PermissionChecker) getPermissionsForApplicationKey(key string) []string {
	var result []string
	//
	// extract JWT from the request header
	//
	return result
}

func (c *PermissionChecker) getPermissionsForBasicAuth(h http.Header) []string {
	var result []string
	//
	// extract JWT from the request header
	//
	return result
}

func (c *PermissionChecker) extractPermissionsFromJwt(h http.Header) []string {
	var result []string
	//
	// extract JWT from the request header
	//
	return result
}

上面的代码里我们定义了一个结构体PermissionChecker用于判断请求是否有权限获取数据,这里主要的方法HasPermission通过从context中获取指定的字段来关联到不同的验证方法。根据单一职责原则,PermissionChecker的职责是在Context中获取权限,并没有任何授权行为。授权行为一定是在其他地方定义,比如其他的结构体甚至模块中。所以我们想在其他地方扩展授权过程,还需要在这里调整逻辑。

假设我们希望扩展授权逻辑并添加一些新的流程,例如将用户数据保存在会话中或使用摘要授权。在这种情况下,我们也需要在PermissionChecker中进行调整。这种写法暴露了一些问题:

  1. PermissionChecker保留了在其他地方初始化的逻辑。
  2. 授权逻辑的任何调整(可能是一个不同的模块)都需要在PermissionChecker中进行适配。
  3. 添加新的方法获取权限,也需要修改这个结构体。
  4. 伴随授权逻辑的添加,PermissionChecker内的逻辑不可避免的膨胀。
  5. PermissionChecker的单元测试包含了太多关于不同权限提取的技术细节。
  6. ……

来重构代码吧。

符合开闭原则的代码

在面向对象编程语言中,我们通过使用相同接口的不同实现来支持扩展,也就是多态。

type PermissionProvider interface {
	Type() string
	GetPermissions(ctx *gin.Context) []string
}

type PermissionChecker struct {
	providers []PermissionProvider
	//
	// some fields
	//
}

func (c *PermissionChecker) HasPermission(ctx *gin.Context, name string) bool {
	var permissions []string
	for _, provider := range c.providers {
		if ctx.GetString("authType") != provider.Type() {
			continue
		}
		
		permissions = provider.GetPermissions(ctx)
		break
	}

	var result []string
	linq.From(permissions).
		Where(func(permission interface{}) bool {
			return permission.(string) == name
		}).ToSlice(&result)

	return len(result) > 0
}

上面的代码是对开闭原则的一种实现,PermissionChecker并没有隐藏从Context提取权限的技术细节。同时声明了新的接口PermissionProvider,用于定义不同的权限提取逻辑。比如可以是JwtPermissionProviderApiKeyPermissionProviderAuthBasicPermissionProvider等,这样对授权负责的模块就可以包含权限的提取功能了,这也意味着关于已经被授权的用户相关逻辑可以放在一起而不是分散在各个地方。

另一方面,我们的主要目标——扩展PermissionChecker而不需要修改PermissionChecker本身代码,也通过传入不同的PermissionProviders来实现了。

假设我们需要添加一种从session获取权限的功能,在这里只需要提供新的SessionPermissionProvider,负责从context的cookie中提取权限并且从SessionStore中获取对应的数据即可。

更多例子

上面的问题还有另一种写法:

type PermissionProvider interface {
	Type() string
	GetPermissions(ctx *gin.Context) []string
}

type PermissionChecker struct {
	//
	// some fields
	//
}

func (c *PermissionChecker) HasPermission(ctx *gin.Context, provider PermissionProvider, name string) bool {
	permissions := provider.GetPermissions(ctx)

	var result []string
	linq.From(permissions).
		Where(func(permission interface{}) bool {
			return permission.(string) == name
		}).ToSlice(&result)

	return len(result) > 0
}

上面的代码中我们从PermissionChecker移除了PermissionProviders,而是通过参数的方式把provider传入给HasPermission函数。

我个人更喜欢第一种方式,但第二个也是种解决方式,取决于实际的场景。

同样,开闭原则也可以应用到方法层面:

func GetCities(sourceType string, source string) ([]City, error) {
	var data []byte
	var err error

	if sourceType == "file" {
		data, err = ioutil.ReadFile(source)
		if err != nil {
			return nil, err
		}
	} else if sourceType == "link" {
		resp, err := http.Get(source)
		if err != nil {
			return nil, err
		}

		data, err = ioutil.ReadAll(resp.Body)
		if err != nil {
			return nil, err
		}
		defer resp.Body.Close()
	}

	var cities []City
	err = yaml.Unmarshal(data, &cities)
	if err != nil {
		return nil, err
	}

	return cities, nil
}

函数GetCities从某些地方读取城市信息,来源可能是文件或者网络上的某个连接。未来我们想要从内存、Redis或者其他地方获取数据。

所以,让读取原始数据的过程更抽象一点会更好。也就是说,我们可以从外部提供一个读取策略作为方法参数。

type DataReader func(source string) ([]byte, error)

func ReadFromFile(fileName string) ([]byte, error) {
	data, err := ioutil.ReadFile(fileName)
	if err != nil {
		return nil, err
	}

	return data, nil
}

func ReadFromLink(link string) ([]byte, error) {
	resp, err := http.Get(link)
	if err != nil {
		return nil, err
	}

	data, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	return data, nil
}

func GetCities(reader DataReader, source string) ([]City, error) {
	data, err := reader(source)
	if err != nil {
		return nil, err
	}

	var cities []City
	err = yaml.Unmarshal(data, &cities)
	if err != nil {
		return nil, err
	}

	return cities, nil
}

在上面的代码中我们定义了一种新的类型DataReader来代表从某个地方获取原始数据,ReadFromFileReadFromLink则是具体的实现并且作为参数传递给GetCities

正如所见,开闭原则最主要的目的就是提高代码的灵活性。我们的库真正的价值,在于有人能够扩展我们的库,而不需要派生它们、为它们提供PR或以任何方式修改它们。

总结

开闭原则是SOLID中的第二个O,在源代码中,我们应该使用多态性来满足这一要求。我们的代码应该提供一个简单的接口来添加这种可扩展性。