【译】Golang友好的设计API参数可选项

翻译一篇老外大佬的文章,他的这篇核心观点也是Go语言创始人之一 Rob Pike 提出的。

最近无意间进入到一位 Go 社区大佬的博客,看见了这样一片博文,主要讲Golang的API设计特别是对于函数方法的可选参数相关的点,所以自己试着进行翻译吧。Functional options for friendly APIs

注意: 凡是()中的文字说明都是个人意见,与原作者无任何关系。

image

我想用一个场景故事来作为我文章的开始。

时间节点在2014年底,你的公司很明确的选择了用Go语言来作为贵公司正在研发的一种新型的分布式网络产品。

而你的任务就是编写一些关键的服务组件,就像这样的代码:

image

便于观看代码如下(亲手码的):

page gplusplus

import "net"

type Server struct {
    listener net.Listener
}

func (s *Server) Add() net.Addr
func (s *Server) Shutdown()

// NewServer returns a new Server listenering on addr.
func NewServer(addr string) (*Server, error) {
    l, err := net.Listen("tcp", addr)
    if err !=nil {
        return nil, err
    }
    
    srv := Server{listener: l}
    go src.run()
    return &srv, nil
}

上图代码中,有一些未导出的字段需要初始化,并且必须启动 goroutine 来为传入的请求提供服务。

嗯,该代码包拥有简单的API,并且使用非常简单。

但是,在你公布第一个测试版本发布不久后,一些功能需求就被提出来了(对,你要开始拓展你的代码了,因为产生了这该死的需求):

image

  • 移动客户端访问很慢或者无法访问-你需要添加能够断开这些访问过慢的客户。
  • 在这种高安全意识的大环境中,你的错误跟踪需要满足能够支持安全链接的需求,TLS安全传输层协议。
  • 然后,您从一个在非常小的VPS((Virtual Private Server 虚拟专用服务器)上运行服务器的用户收到报告。他们需要一种方法来限制并发的客户端数量。
  • 接下来就是,防止有人刻意DOS,对来自僵尸网络所针对的一组用户的并发连接进行速率限制的请求。

而现在,你就需要修改你的 API 以包含所有这些功能需求。

image

所以你就打算如下设计你的函数功能:

func NewServer(addr string,
clientTimeout time.Duration, 
maxcoons, maxconcurrent int, 
cert *tls.Cert){
    // XXXX.......
}

当你无法满足以上所有需求时,就表示你的事情进展的并不顺利。

想一想,谁使用过像这样的API?谁又曾写过像这样的API?

很显然,这样的解决方案既麻烦又脆弱。它也不是很容易被理解易读。

你的这种函数的写法,作为一个新手看到了也不会明白知道哪些参数是可选的,哪些参数是必传的。

例如,如果我想创建一个服务器实例进行测试,我是否需要提供真正的TLS证书?如果没有,我会提供什么?

如果我不知道也不关心参数 maxconns or maxconcurrent 那我到底要传什么值呢?是不是应该直接传int类型就给个 0 好了?但是根据上面的功能函数的实际意义,我传0的话就代表了这可能会限制你的总并发连接数为零。

在我看来,那样的 API 还是很容易写出来的;只要你能让你的调用者去可靠并正确的使用你的API。

所以现在我已经找出问题的关键了,让我们来看看一些解决方案吧:

image

  • 第一种解决方案,可以是创建一组函数,而不是尝试提供必须满足每个功能需求的单个函数里面塞满了入参。

用以上方案,当调用者需要安全服务时,他们就可以调用 TLS 相关函数了。

如果他们需要空闲链接建立最大持续时间时,可以调用超时函数 timenOut。

但是正如你所见的,这种写法逐个分开提供所有功能需求会变得很难维护和扩展。

让我们来看看下一种方案:

image

  • 第二种解决方案,上图代码中的方案是种很常见的通过定义一种可配置的结构体变量作为入参。

这样搞的优点在于:

使用这种可配置结构体的方法就可以随着要新增选项进而方便的扩展,而用于创建服务本身的公共API可以保持不变。

一旦 NewServer 出现大量的参数时,这样的结构体定义就会呈现出一种很好的阅读文档的体验方式。

更有利的是,它还使得调用者可以使用“零值”进行默认参数初始化操作。(其实这种方案也是我平时使用的方法,很多第三方包文件都是这样搞的。)

image

然而,这种模式并不完美!

它的问题在于,这种默认情况下给定一个定值像是上面的0,那么如果对于该字段对应的该定值有特殊的含义就会出现问题。

比如,在上图的结构体中 Port对应的默认值为0时,你想让 NewServer 返回 *Server 以监听端口8080。

这种缺点在于,你无法明确地将 Port 设置为 0 , 并且又想让操作系统自动选择一个空闲端口,因为显示的 0 与字段的 “零值”无法区分开。

image

大多数情况下,调用者使用你的API时都不想关心到底该传什么样的参数,所有他们会希望直接使用其默认行为。

即使他们不打算更改任何配置参数,这些调用者仍然需要为第二个参数传递一些东西。

因此,就会导致人们在阅读你写的测试或示例代码时,他们就是想找出如何来调用你的包,然后他们会看见这个十分奇葩的“空值”传入到你的包函数中去。(你会想问为什么我特么还要传一个没有意义的空值进去作为你包函数的入参,这样设计毫无意义@_@!!!)

对我来说,仅仅有这种感觉就是不对的。(就是说,你这样设计的API让作者用起来不舒服)

为什么要求调用该API的用户专门构造一个这样的空值传入,只是为了满足该API函数的入参签名???

image

这个空值问题的一个常见解决方案是将指针传递给该值,从而使调用者能够使用 nil 而不是构造空值。

但在我看来,这样的解决方案具有上一个例子中的所有问题,而且还有所增加。

我们仍然必须为这个函数的第二个参数传递一些东西,但是现在对于那些想要默认行为的人来说,这个值可能是nil。

这里又出现了一个问题,对于传 nil 和传一个指针结构体空值有什么的区别呢?

就像上图中的代码一样,对于传指针结构体值来说,调用者和包提供者也就是服务者,他们两者都可以对该值这进行共享操作(服务提供者可以更改包内部的配置这是正常的,但是调用者本不应该保留服务函数内部的配置的能力)。

我觉得编写良好的API不应该要求调用者创建虚拟值来满足那些罕见的用例。

我认为作为Go程序员,我们应该努力确保nil永远不是一个需要传递给任何公共函数的参数。(当然也包括更离谱的“空值”)

当我们确实想要传递配置信息时,它应该是尽可能自我解释和表达。(就是让调用者很明确的知道我到底应该传入什么样的参数,又或者在想要使用默认值时根本就不用传参)

所以现在考虑到这些要点,我想谈谈我认为更好的解决方案。(大佬的解决方案雏形出现了)

image

  • 第三种解决方案(作者的雏形版),为了消除强制但又经常不会使用的配置值的问题,我们可以更改NewServer函数以接受可变数量的参数。而不是传递 nil 或一些 零值 作为您想要默认值的信号,函数的可变特性意味着您根本不需要传递任何东西。

在我的书中这种方案解决了两大难题:

  1. 默认行为的调用变得尽可能的简洁了
  2. NewServer 现在只接受 Config 值,不接受配置值的指针(没有指针操作,你就无法改变元数据),删除 nil 作为可能的参数,并确保调用者不能保留对服务函数内部配置的引用。

我认为这是一个很大的进步。

但是我如果我们喜欢钻牛角尖,那么它仍然会有一个问题……

对于包的服务者来说显然,你希望的是调用者最多只传入一个 Config 值,但由于函数签名是可变参数,因此必须编写实现以应对调用者传递多个可能相互矛盾的配置结构。

有没有办法在需要时使用可变参数函数签名并提高配置参数的表现力?(说白了就是怎样才能解决上面提出的问题)

解决方案如下所示:

image

  • 第四种方案诞生了:去前者的主要区别在于,这些示例都是Server的自定义,不是像前者直接使用存储在结构体重的配置参数,而是使用对Server值本身进行操作的函数。

和以前一样,函数签名的可变性质为我们提供了默认情况下的严谨行为。

当需要配置时,我就传递给NewServer一些函数(以函数作为参数传递),这些函数对Server值作为参数进行操作。

超时功能 timeOut 函数 只是更改传递给它的任何* Server值的超时字段。

tls 函数就有点复杂。它需要* Server值并将原始侦听器值包装在tls.Listener中,从而将其转换为安全侦听器。

具体函数内部代码实现:

image

在NewServer内部,应用这些选项就非常简单了。

打开net.Listener之后,我们使用该侦听器声明一个Server实例。

然后,对于提供给NewServer的每个选项函数,我们调用该函数,传入一个指向刚刚声明的Server值的指针。

当然,如果没有提供选项函数,那么在这个循环中没有工作要做(就是根本不会进入到 for range 当中,从而也不会对srv的值对任何改变),所以srv没有改变。

以上这就是全部内容。

使用这种模式,我们为API带来的优点有:

  • 合理的默认值
  • 高度可配置的
  • 便于扩展添加功能需求
  • 自文档化,便于阅读
  • 对于新手调用者来调用是安全的
  • 并且永远不需要 nil 或 空值 来保持不恰当的函数签名

image

总结:

  • #### 功能选项允许您编写扩展性较强的API。
  • #### 它们使默认用例很简单。
  • #### 它们提供有意义的配置参数
  • #### 最后,它们使您可以访问语言的全部功能来初始化复杂值。

在这篇文章中,我提出了很多现有的API的配置模式,这些配置模式也是如今被大量运用的,并且在每个阶段我们都会提出如下问题:

  • 这可以变得更简单吗?
  • 这个参数是必要的吗?
  • 此功能函数的签名是否使其调用者易于安全的使用?
  • 这个API是否包含令人无法理解的问题或令人困惑的误导?

我希望我能激励你做同样的事情。重新审视您过去编写的代码,并为自己提出相同的问题,从而改进它。

image

相关资料链接:

  1. 原文章地址: https://dave.cheney.net/2014/10/17/functional-options-for-friendly-apis
  2. 原论文地址:(Rob Pike)就是Go的亲爹之一! https://commandcenter.blogspot.com/2014/01/self-referential-functions-and-design.html