在Go中使用界面组合作为护栏简易教程

96 阅读5分钟

Go作为一种编程语言,更倾向于简单性。在 Go中编写抽象时,接口是开发人员可用的一些最强大的工具,为您的应用程序和表达式包提供了一整套有用的功能。

在Go中使用接口的一个未受重视的模式是能够对其进行组合以建立更复杂的抽象。接口组合允许开发者创建小型的构建块,只暴露必要的方法。这种模式是限制对危险方法的访问的一种强有力的方式,有助于保护开发者。

简要介绍一下接口组合

如果你已经熟悉了接口组成,请跳过前面的内容

接口是内置的类型,旨在定义一组潜在类型的行为,如结构。如何实现该行为由符合接口要求的特定结构体决定。Go中的接口的好处是它们是可组合的。这意味着你可以用基本的构件建立起相当复杂的接口。例如,假设我们想为一个File 类型定义一些接口:

type File interface {
  Read(p []byte) (n int, err error)
  Write(p []byte) (n int, err error)
  Close() error
  Name() string
}

读取和写入字节在Go程序中是非常常见的,标准库提供了非常简约但功能强大的接口来满足这些需求。即:io.Readerio.Writer

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

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

有时,读的对象也喜欢写,所以我们可以把它们组合起来,如下图所示,减少我们必须重复的代码量。

type ReadWriter interface {
  Reader
  Writer
}

甚至还有一个ReadWriteCloser 接口,将原始的io.Closer 类型也组合起来。我们可以通过组合基本接口来重写我们的文件接口,如下所示:

import "io"

type File interface {
  io.ReadWriteCloser
  Name() string
}

因为文件是一个io.ReadWriteCloser ,它可以被任何接受该接口作为参数的Go函数使用。这使得编写测试、集成到第三方包中变得非常简单,并为Go项目提供了足够接近标准的东西,尤其是使用内置的io 包来重用代码。接口组合是一个强大的工具,尤其是在处理小的接口时,你可以将其组合成更有表现力的抽象。

这种风味的组合与传统的、面向对象的继承有很大不同。因为我们的文件接口是一个io.ReadWriteCloser ,它也是一个io.Closer ,一个io.Reader ,和一个io.Writer ,所以它不乏可以被使用和立即集成的有用的地方。这就是组合比其他编程语言中的传统继承的强大之处。实现io.Reader 接口的其他流行类型的例子有HTTP请求、websocket连接、流等等。

现实生活中的用例:使用增量接口组合的代码护栏

在我的公司,我们维护一个名为Prysm的以太坊共识节点的开源实现。目前的以太坊股权证明链保障了许多亿美元,网络中很大比例的节点选择运行我们的软件,这意味着我们必须有最高的质量保障和低的错误率。

多年来,一个特别的问题多次伤害了我们,就是允许不受限制的数据库访问。例如,我们有一个单一的Database 接口,将在运行时为我们关心的数据定义getters和setters:

type Database interface {
  SaveBlock(block *pb.BeaconBlock) error
  BlockByRoot(root [32]byte) (*pb.BeaconBlock, error)
  SaveState(state *pb.BeaconState, blockRoot [32]byte) error
  StateByRoot(blockRoot [32]byte) (*pb.BeaconState, error)
  ... // A few other critical methods.
}

在一个大型代码库中的问题是,尽管我们相信我们所有的队友不会滥用代码,但拥有像这个接口这样的公共API可能是非常危险的。我们会把这个Database 接口传递给所有需要它的服务,任何人都可以调用危险的访问方法,如SaveBlock 。即使是过度地访问诸如StateByRoot 等方法,也会在运行时导致内存使用的瓶颈。事实上,我们在本地测试网络中的一次共识失败是由于我们在多个地方保存状态,导致了灾难的发生。

增量访问限制:只使用你需要的东西

即使你可能相信自己能负责任地使用自己的代码,但新的开发者和贡献者会加入你的项目,并且会认为任何公共方法都可以免费使用,只要能帮助他们解决实际问题。我们没有非正式地对队友执行一些武断的规则,而是重新设计了如何使用我们的Database 接口,以提高安全性。我们注意到绝大多数情况下只需要对某些数据类型进行读取访问。除此之外,我们也很少需要对状态进行读取访问。我们使用接口组合来重组我们的代码,如下所示:

type NoStateAccessReadOnlyDB interface {
  BlockByRoot(root [32]byte) (*pb.BeaconBlock, error)
}

type ReadOnlyDB interface {
  NoStateAccessReadOnlyDB
  StateByRoot(blockRoot [32]byte) (*pb.BeaconState, error)
}

type ReadWriteDB interface {
  ReadOnlyDB  
  SaveState(state *pb.BeaconState, blockRoot [32]byte) error
  StateByRoot(blockRoot [32]byte) (*pb.BeaconState, error)
}

这很强大,因为这样我们只把我们需要的东西传给需要数据库访问的服务。这就变得很容易审计,并确保即使有人试图使用危险的Save 方法,他们甚至不会有这个选项。他们可以自由地按照自己的意愿进行编码。在这个改进之后,我们再也没有遇到过任何不受限制地访问数据库写入的问题。

谢谢你的阅读!