东莞网站建设基本流程图,上海网站推广有哪些,合肥软件外包公司,长沙网页设计培训找沙大计教育预约网址接口污染
在Go语言中#xff0c;接口是我们设计和编写代码的基石。然而#xff0c;像很多概念一样#xff0c;滥用它是不好的。接口污染是指用不必要的抽象来编写代码#xff08;刻意使用接口#xff09;#xff0c;使得代码更难以理解。这是具有不同习惯#xff0c;特…接口污染
在Go语言中接口是我们设计和编写代码的基石。然而像很多概念一样滥用它是不好的。接口污染是指用不必要的抽象来编写代码刻意使用接口使得代码更难以理解。这是具有不同习惯特别是有其它语言开发经验的人会犯的一个常见错误。在深入讨论接口污染之前让我们重新梳理一下Go语言的接口然后分析何时使用接口以及在什么时候使用会存在污染问题。
接口
接口约定了对象的行为方法用于创建多个对象可以实现的通用抽象也就是说接口规范了对象的通用方法。Go语言中接口有点特别不像其它语言通过类似于implements关键字显示的标记对象X实现了接口Y, 它是隐式实现的。
接口如此灵活强大的原因是什么呢为了搞清楚这个问题我们从标准库中选两个广泛使用的接口: io.Reader 和 io.Writer 进行举例说明。
io包为I/O操作提供了抽象I/O有读写两类操作。如下图所示io.Reader是从数据源读取数据接口 io.Writer是将数据写入目标接口。 io.Reader接口包含一个Read方法。如果一个结构体要实现io.Reader接口则需要实现下面的Read方法该方法需要一个字节切片作为入参会将从数据源读取的数据填充到入参切片中同时返回读取的字节数和错误信息。
type Reader interface {Read(p []byte) (n int, err error)
}io.Writer接口包含一个Write方法。 如果一个结构体要实现io.Writer接口则需要实现下面的Write方法该方法也需要一个字节切片作为入参会将入参切片中的数据写入到目标中并返回写入的字节数和错误信息。
type Writer interface {Write(p []byte) (n int, err error)
}因此这两个接口都提供了对基本操作的抽象
io.Reader 从源读取数据iO.Writer 将数据写入到目标中
在编程时使用这两个接口合理性在什么地方呢创建这些抽象意义在哪里呢下面通过一个例子进行说明。假设我们需要实现将一个文件内容复制到另一个文件中的函数我们可以创建一个特定的函数将两个 *os.File作为输入 或者可以选择使用io.Reader和io.Writer接口创建一个更通用的函数。
func copySourceToDest(source io.Reader, dest io.Writer) error {// ...
}copySourceToDest函数可以使用*os.File作为入参因为*os.File实现了io.Reader和io.Writer)也可以使用任何其他实现了这些接口的类型。例如我们可以创建自己的io.Writer来将数据写入到数据库中并且可以不用修改copySourceToDest代码。这样增加了函数的通用性因此上述函数是可重用的。
使用接口除了使函数更有通用性还使得为这个函数编写单元测试更容易因为我们不必写文件可以使用标准库中strings包和bytes包提供的功能实现测试。下面程序中source变量是*strings.Buffer类型dest变量是*bytes.Buffer类型我们可以在不创建任何文件的情况下测试copySourceToDest的行为。
func TestCopySourceToDest(t *testing.T) {const input foosource : strings.NewReader(input)dest : bytes.NewBuffer(make([]byte, 0))err : copySourceToDest(source, dest)if err ! nil {t.FailNow()}got : dest.String()if got ! input {t.Errorf(expected: %s, got: %s, input, got)}
}在设计接口时不要忘了接口的粒度接口中包含多少方法, Go语言中有一句名言描述了接口粒度问题 接口越大抽象越弱 向接口中添加方法会降低它的可重用性。io.Reader和io.Writer具有强大的抽象因为它们都包含1个方法不能再变得更抽象了。可以组合细粒度的接口来创建更高级别的抽象。像下面的ReadWriter接口组合了Reader和Writer接口兼有读取和写入功能。
type ReadWriter interface {ReaderWriter
}NOTE:正如爱因斯坦所说“一切事情应该力求简单不过不能过于简单”。应用到接口上表示找到接口最佳粒度不一定是一个简单的事情。
什么时候使用接口
编写Go程序的时候在什么情况下该创建接口呢本文将深入研究三个具体的场景在这些场景中可以看到使用接口可以带给我们更多的收益。注意对于使用接口的场景本文没法全部列举完因为每个案例都依赖于上下文。虽然没法全部列举但本文列举的三个场景将给我们在什么情况应该使用接口提供一个指引。
共同行为解耦限制行为
第一个讨论的场景是在多种类型实现共同行为时使用接口。这种场景下将共同行为抽取到接口中。如果我们查看标准库可以找到许多此类场景的示例。例如可以通过实现排序接口的定义的方法对集合元素进行排序。
Len方法获取集合中元素的数量Less方法判断一个元素是否在另一个元素之前Swap方法将两个元素互换位置
因此在sort包中定义了如下接口
type Interface interface {Len() intLess(i, j int) boolSwap(i, j int)
}该接口具有强大的复用性因为它支持对任何基于索引的集合进行排序。在整个sort包中可以找到很多种实现。例如具体到某种类型在某个时候当我们计算出了集合中元素的个数之后我们需要对其进行排序我们是否一定对实现类型感兴趣采用的是什么排序算法是归并排序还是快速排序在很多情况下作为调用方并不在乎。因此排序行为可以被抽象化我们可以依赖于sort.Interface.
找到合适的抽象来分解操作也会带来很多好处例如sort包提供了同样依赖于sort.Interface的工具函数像检查一个集合是否已经是有序的。
func IsSorted(data Interface) bool {n : data.Len()for i : n - 1; i 0; i-- {if data.Less(i, i-1) {return false}}return true
}第二讨论的场景是对我们的代码实现进行解耦。如果我们依赖抽象而不是具体的实现那么实现本身就可以被另一个实现替换甚至不用更改当前的代码。这就是里氏替换原则SOLID中的L。 此外解耦可以带来单元测试的便利性。假设我们必须实现一个CreateNewCustomer方法来创建一个新客户并保存它的信息我们可以直接依赖具体的实现比如mysql.Store结构, 代码如下。
type CustomerService struct {store mysql.Store
}func (cs CustomerService) CreateNewCustomer(id string) error {customer : Customer{id: id}return cs.store.StoreCustomer(customer)
}现在如果我们要对这个函数进行单元测试由于CustomerService依赖于实际实现MySQL来存储客户信息。我们需要先启动MySQL数据库才能对其进行测试除非使用诸如go-sqlmock之类的替代方法。尽管集成测试很有帮助但它并不总是我们想要的。为了使得代码有更大的灵活性应该将CustomerService与实际实现分离可以通过如下接口完成
type customerStorer interface {StoreCustomer(Customer) error
}type CustomerService struct {storer customerStorer
}func (cs CustomerService) CreateNewCustomer(id string) error {customer : Customer{id: id}return cs.storer.StoreCustomer(customer)
}上述新版本存储客户信息是通过接口完成的我们现在可以灵活地对其进行单元测试
采用集成测试对其具体实现进行测试通过mock模拟接口的行为进行测试联合前面两种进行测试
第三个讨论的场景是通过接口限制特定的行为看起来有点违反直觉可以结合下面的例子进行理解。假设我们已经实现了一个自定义配置包来处理动态配置该包中定义了一个IntConfig结构体用于存储int配置信息该结构体对外暴露了Get和Set两个方法.
type IntConfig struct {// ...
}func (c *IntConfig) Get() int {// Retrieve configuration
}func (c *IntConfig) Set(value int) {// Update configuration
}现在假设我们获取到一个IntConfig对象它包含一些特定的配置例如阈值设定。但是在我们的代码中只对读取配置感兴趣并且希望不要对其进行修改操作。如果不想修改上面的配置包中的代码怎么限制执行这个配置是只读的呢可以创建一个将行为限制为仅读取配置值的抽象即接口。
type intConfigGetter interface {Get() int
}然后在代码中可以依赖 intConfigGetter 而不是具体的实现编码。配置getter被注入到NewFoo工厂方法中这样做甚至能够做到不会影响使用这个函数的客户端仍然可以传递一个IntConfig对象给NewFoo因为IntConfig实现了接口intConfigGetter并且能够实现在Bar方法中只能读取不能修改配置信息的目的。
type Foo struct {threshold intConfigGetter
}func NewFoo(threshold intConfigGetter) Foo {return Foo{threshold: threshold}
}func (f Foo) Bar() {threshold : f.threshold.Get()// ...
}通过上面的例子可以看到出于各种原因我们可以使用接口来限制对象的特定行为像上面强制设置为只读语义。
接口污染
有其他语言经验的人像C#或Java背景的人在具体类型之前创建接口对他们来说是很自然的。然而在Go项目中这是在过度使用接口不是推荐做法。
正如我们所讨论的接口是用来创建抽象的。当在编码中遇到抽象时记住一句话“应该发现抽象而不是创建抽象”这是什么意思呢 这句话想表达的意思是如果没有直接的原因我们不应该首先在代码中创建抽象不应该使用接口进行设计而是等待具体的需求。也就是说我们应该在需要时创建接口而不是在我们预见到可能需要它时就创建。
过度使用接口会产生什么问题呢答案是它使代码流更加复杂。添加无用的间接层不会带来任何价值创建了一个没有用的抽象使代码更难阅读和理解。如果没有充分的理由添加接口并且不清楚接口如何使代码变得更好我们应该主动对使用接口产生质疑为什么不直接调用具体实现非接口呢
NOTE:注意通过接口调用方法时的性能开销需要在哈希表数据结构中查找到实际指向的具体类型然而这在很多情况下不是什么问题因为这种开销很小。
总结在编码的过程中使用接口应该谨慎应该带着发现抽象而不是创建抽象的目的。对于软件开发人员来说根据当前情况猜测以后可能有什么需求来构建完美的抽象过度设计代码是很常见的应该避免这样做因为在大多数情况下会用不必要的抽象污染当前的代码。使其阅读起来更加复杂。我们不要试图通过抽象解决所有问题而是解决现在必须解决的问题。最后但同样重要的是如果不清楚接口如何使代码变得更好我们可能应该考虑删除它以使我们的代码更简单。