6. API设计
我今天要给出的最后一条建议是设计, 我认为也是最重要的。
到目前为止我提出的所有建议都是建议。 这些是我尝试编写Go语言的方式,但我不打算在代码审查中拼命推广。
但是,在审查API时, 我就不会那么宽容了。 这是因为到目前为止我所谈论的所有内容都是可以修复而且不会破坏向后兼容性; 它们在很大程度上是实现的细节。
当涉及到软件包的公共API时,在初始设计中投入大量精力是值得的,因为稍后更改该设计对于已经使用API的人来说会是破坏性的。
6.1. 设计难以被误用的API。
APIs should be easy to use and hard to misuse.
(API应该易于使用且难以被误用)
— Josh Bloch [3]
如果你从这个演讲中带走任何东西,那应该是Josh Bloch
的建议。 如果一个API很难用于简单的事情,那么API的每次调用都会很复杂。 当API的实际调用很复杂时,它就会便得不那么明显,而且会更容易被忽视。
6.1.1. 警惕采用几个相同类型参数的函数
简单, 但难以正确使用的API是采用两个或更多相同类型参数的API。 让我们比较两个函数签名:
func Max(a, b int) int
func CopyFile(to, from string) error
这两个函数有什么区别? 显然,一个返回两个数字最大的那个,另一个是复制文件,但这不重要。
Max(8, 10) // 10
Max(10, 8) // 10
Max
是可交换的; 参数的顺序无关紧要。 无论是8比10还是10比8,最大的都是10。
但是,却不适用于CopyFile
。
CopyFile("/tmp/backup", "presentation.md")
CopyFile("presentation.md", "/tmp/backup")
这些声明中哪一个备份了presentation.md
,哪一个用上周的版本覆盖了presentation.md
? 没有文档,你无法分辨。 如果没有查阅文档,代码审查员也无法知道你写对了顺序。
一种可能的解决方案是引入一个helper
类型,它会负责如何正确地调用CopyFile
。
type Source string
func (src Source) CopyTo(dest string) error {
return CopyFile(dest, string(src))
}
func main() {
var from Source = "presentation.md"
from.CopyTo("/tmp/backup")
}
通过这种方式,CopyFile
总是能被正确调用 - 还可以通过单元测试 - 并且可以被设置为私有,进一步降低了误用的可能性。
贴士: 具有多个相同类型参数的API难以正确使用。
6.2. 为其默认用例设计API
几年前,我就对functional options
[7]进行过讨论[6],使API更易用于默认用例。
本演讲的主旨是你应该为常见用例设计API。 另一方面,API不应要求调用者提供他们不在乎参数。
6.2.1. 不鼓励使用nil
作为参数
本章开始时我建议是不要强迫提供给API的调用者他们不在乎的参数。 这就是我要说的为默认用例设计API。
这是net/http
包中的一个例子
package http
// ListenAndServe listens on the TCP network address addr and then calls
// Serve with handler to handle requests on incoming connections.
// Accepted connections are configured to enable TCP keep-alives.
//
// The handler is typically nil, in which case the DefaultServeMux is used.
//
// ListenAndServe always returns a non-nil error.
func ListenAndServe(addr string, handler Handler) error {
ListenAndServe
有两个参数,一个用于监听传入连接的TCP
地址,另一个用于处理HTTP
请求的http.Handler
。 Serve
允许第二个参数为nil
,需要注意的是调用者通常会传递nil
,表示他们想要使用http.DefaultServeMux
作为隐含参数。
现在,Serve
的调用者有两种方式可以做同样的事情。
http.ListenAndServe("0.0.0.0:8080", nil)
http.ListenAndServe("0.0.0.0:8080", http.DefaultServeMux)
两者完全相同。
这种nil
行为是病毒式的。 http
包也有一个http.Serve
帮助类,你可以合理地想象一下ListenAndServe
是这样构建的
func ListenAndServe(addr string, handler Handler) error {
l, err := net.Listen("tcp", addr)
if err != nil {
return err
}
defer l.Close()
return Serve(l, handler)
}
因为ListenAndServe
允许调用者为第二个参数传递nil
,所以http.Serve
也支持这种行为。 事实上,http.Serve
实现了如果handler
是nil
,使用DefaultServeMux
的逻辑。 参数可为nil
可能会导致调用者认为他们可以为两个参数都使用nil
。 像下面这样:
http.Serve(nil, nil)
会导致panic
。
贴士:
不要在同一个函数签名中混合使用可为nil
和不能为nil
的参数。
http.ListenAndServe
的作者试图在常见情况下让使用API的用户更轻松些,但很可能会让该程序包更难以被安全地使用。
使用DefaultServeMux
或使用nil
没有什么区别。
const root = http.Dir("/htdocs")
http.Handle("/", http.FileServer(root))
http.ListenAndServe("0.0.0.0:8080", nil)
对比
const root = http.Dir("/htdocs")
http.Handle("/", http.FileServer(root))
http.ListenAndServe("0.0.0.0:8080", http.DefaultServeMux)
这种混乱值得拯救吗?
const root = http.Dir("/htdocs")
mux := http.NewServeMux()
http.Handle("/", http.FileServer(root))
http.ListenAndServe("0.0.0.0:8080", mux)
贴士: 认真考虑
helper
函数会节省不少时间。 清晰要比简洁好。
贴士:
避免公共API使用测试参数
避免在公开的API上使用仅在测试范围上不同的值。 相反,使用Public wrappers
隐藏这些参数,使用辅助方式来设置测试范围中的属性。
6.2.2. 首选可变参数函数而非[]T
参数
编写一个带有切片参数的函数或方法是很常见的。
func ShutdownVMs(ids []string) error
这只是我编的一个例子,但它与我所写的很多代码相同。 这里的问题是他们假设他们会被调用于多个条目。 但是很多时候这些类型的函数只用一个参数调用,为了满足函数参数的要求,它必须打包到一个切片内。
另外,因为ids
参数是切片,所以你可以将一个空切片或nil
传递给该函数,编译也没什么错误。 但是这会增加额外的测试负载,因为你应该涵盖这些情况在测试中。
举一个这类API的例子,最近我重构了一条逻辑,要求我设置一些额外的字段,如果一组参数中至少有一个非零。 逻辑看起来像这样:
if svc.MaxConnections > 0 || svc.MaxPendingRequests > 0 || svc.MaxRequests > 0 || svc.MaxRetries > 0 {
// apply the non zero parameters
}
由于if
语句变得很长,我想将签出的逻辑拉入其自己的函数中。 这就是我提出的:
// anyPostive indicates if any value is greater than zero.
func anyPositive(values ...int) bool {
for _, v := range values {
if v > 0 {
return true
}
}
return false
}
这就能够向读者明确内部块的执行条件:
if anyPositive(svc.MaxConnections, svc.MaxPendingRequests, svc.MaxRequests, svc.MaxRetries) {
// apply the non zero parameters
}
但是anyPositive
还存在一个问题,有人可能会这样调用它:
if anyPositive() { ... }
在这种情况下,anyPositive
将返回false
,因为它不会执行迭代而是立即返回false
。 对比起如果anyPositive
在没有传递参数时返回true
, 这还不算世界上最糟糕的事情。
然而,如果我们可以更改anyPositive
的签名以强制调用者应该传递至少一个参数,那会更好。 我们可以通过组合正常和可变参数来做到这一点,如下所示:
// anyPostive indicates if any value is greater than zero.
func anyPositive(first int, rest ...int) bool {
if first > 0 {
return true
}
for _, v := range rest {
if v > 0 {
return true
}
}
return false
}
现在不能使用少于一个参数来调用anyPositive
。
6.3. 让函数定义它们所需的行为
假设我需要编写一个将Document
结构保存到磁盘的函数的任务。
// Save writes the contents of doc to the file f.
func Save(f *os.File, doc *Document) error
我可以指定这个函数Save
,它将*os.File
作为写入Document
的目标。 但这样做会有一些问题
Save
的签名排除了将数据写入网络位置的选项。 假设网络存储可能在以后成为需求,则此功能的签名必须改变,从而影响其所有调用者。
Save
测试起来也很麻烦,因为它直接操作磁盘上的文件。 因此,为了验证其操作,测试时必须在写入文件后再读取该文件的内容。
而且我必须确保f
被写入临时位置并且随后要将其删除。
*os.File
还定义了许多与Save
无关的方法,比如读取目录并检查路径是否是符号链接。 如果Save
函数的签名只用*os.File
的相关内容,那将会很有用。
我们能做什么 ?
// Save writes the contents of doc to the supplied
// ReadWriterCloser.
func Save(rwc io.ReadWriteCloser, doc *Document) error
使用io.ReadWriteCloser
,我们可以应用接口隔离原则来重新定义Save
以获取更通用文件形式。
通过此更改,任何实现io.ReadWriteCloser
接口的类型都可以替换以前的*os.File
。
这使Save
在其应用程序中更广泛,并向Save
的调用者阐明*os.File
类型的哪些方法与其操作有关。
而且,Save
的作者也不可以在*os.File
上调用那些不相关的方法,因为它隐藏在io.ReadWriteCloser
接口后面。
但我们可以进一步采用接口隔离原则。
首先,如果Save
遵循单一功能原则,它不可能读取它刚刚写入的文件来验证其内容 - 这应该是另一段代码的功能。
// Save writes the contents of doc to the supplied
// WriteCloser.
func Save(wc io.WriteCloser, doc *Document) error
因此,我们可以将我们传递给Save
的接口的规范缩小到只写和关闭。
其次,通过向Save
提供一个关闭其流的机制,使其看起来仍然像一个文件,这就提出了在什么情况下关闭wc
的问题。
可能Save
会无条件地调用Close
,或者在成功的情况下调用Close
。
这给Save
的调用者带来了问题,因为它可能希望在写入文档后将其他数据写入流。
// Save writes the contents of doc to the supplied
// Writer.
func Save(w io.Writer, doc *Document) error
一个更好的解决方案是重新定义Save
仅使用io.Writer
,它只负责将数据写入流。
将接口隔离原则应用于我们的Save
功能,同时, 就需求而言, 得出了最具体的一个函数 - 它只需要一个可写的东西 - 并且它的功能最通用,现在我们可以使用Save
将我们的数据保存到实现io.Writer
的任何事物中。
网友评论