这篇文章是Dave Cheney在2014年发表的,我认为在go语言的接口设计上,这篇文章起到了指明灯的作用,包括Micro在内的框架,都使用了这种方式提供API。原文看这里
正文开始:
下面的内容是我的一次演示的文字版本,这是我在dotGo上演讲的『Functional options for friendly APIs』,在这里已经编辑的可读了。
我想用一个故事作为开头。
在2014年的晚些时候,你的公司发布了一款革命性的分布式社交网络工具,很明智的,你选择了Go来开发你的产品。
你分配到的任务是编写极为重要的服务端组件,看起来可能像这样
这里有一些不可导出的字段需要初始化,通过一个goroutine运行起来,响应请求。
这个包有很简单的API,非常容易使用。
但有一个问题,当你发布了你的第一个版本后,新的需求不断的被提出来。
手机客户端经常是响应的很慢,甚至停止响应。你需要添加支持来对慢的客户端主动断开连接。
为了增加安全,新的需求是增加安全连接(TLS)。
然后,你的某些用户是在一个很小的服务器上运行服务,他们需要限制客户端数量的方式。
下面是想要对并发数进行限制。
不断的新需求…
限制你需要调整你的API来满足这一系列的新需求
还需要考虑不同版本直接接口的兼容性问题。
实话说,谁用过这样的API?
谁编写过这样的API?
谁的代码以为依赖了这样的包,而不能正常使用了?
明显的这种解决方式是笨重而脆弱的,同时也不容易发现问题。
你的包的新用户,不知道哪些参数是可选的,哪些是必须的。
比如说,如果我想创建一个服务的实例作为测试,我需要提供一个真实的TLS证书吗,如果不需要,我需要在接口中提供什么?
如果我不关心最大连接数,或者最大并发数,我应该在参数中设置什么值,我应该使用0?0听起来是合理的,但这依赖于具体的接口是怎样实现的,这也许真的会导致并发数限制为0。
在我看来,这样写API是容易的,同时你把正确使用接口的责任抛给了使用者。
这个例子甚至代码写的很糟糕,文档也不友好,我想这示范了一个看起来华丽,其实很脆弱的API设计。
现在我们定位了问题,我们看看解决方案。
与其提供一个单独的接口处理多种情况,一种解决方案是提供一系列的接口。
用户按需调用即可。
但你很快会发现,提供如此大量的接口,很快会让你不堪重负。
让我们看看另一种方式。
一种非常简单的方式是提供一个配置结构体。
这有一些优势。
使用这种方式,如果有新的需求加入,在结构体中增加选项即可。对外的公共API仍然保持不变。这也能让文档更加友好、可读。
在结构体上注明这是NewServer
的参数,文档上也很容易识别。
潜在的它也允许用户使用0作为参数的值。
但是这种模式并不完美。
对于默认值是有歧义的,特别是0的值如果有特别的含义。
比如在这里的配置结构中,如果port
没有被设置,NewServer
会监听8080端口。
但是这有一个负面影响,你也许想设置为0,然后服务端默认分配一个随机端口,但你设置的0与默认值是相同的。
大部分时候,你的API用户只是想使用你的默认值。
即使他们不想改变你的配置的任何内容,仍然不得不传入一些参数。
当你的用户读你的测试代码或者示例代码时,在想着怎样使用你的包,他们会看到这个魔幻的空字符串参数。
对我来说,这让我感觉很糟糕。
为什么你的API的用户需要传入一个空的值,只是简单的让你的函数满足声明需求?
一个常见的解决办法是传入一个结构体指针,这让调用者可以传入nil
,而不用考虑空值的问题。
在我看来,这个方案有前面的示例中的所有问题,甚至让问题更复杂了。
首先,我们仍然需要在第二个参数传入点什么,但目前,这个参数可以是nil了,而且大部分时候,对于默认的使用者,它就是nil。
使用指针的方式,包的作者和使用者都会担心的是,他们引用了同一份数据,随时有可能在运行中这份数据被修改而发生突变。
我想设计精良的API不应该要求用户传递这些额外的参数,只是为了应对一些罕见的情况。
我认为我们,Go程序员,应该努力确保不要求用户传递一个nil
作为参数。
如果我们想要传递配置信息时,这应该是自解释的,尽量的有表达性。
现在,我们怀着这样的理念,我讨论一下我认为更好的解决方案。
我们可以让API把不必须的参数作为一个变参。
不是传入nil,或者一些值为0的结构体,这种函数的设计发出了这样的信号:你不需要在config上传入任何参数。
在我看来这解决了两个问题。
首先,默认的调用方式变得简介命了。
其次,NewServer
现在只接受config的值,不是指针,移除了nil
和其他可能的参数,确保用户不会修改已经传入的参数。
我认为这个一个巨大的提升。
但我们深究一下,这仍然有问题。
明显对你的预期是提供最多一个config值,但这个参数是变参,实现的时候需要考虑用户传入多个参数的情况。
我们可以既能使用变参,同时也能提高我们的参数的表达性吗?
我认为这就是结局方案。
在这里我想要说清楚,函数式参数的想法是来自于Rob Pike的这篇文章:Self referential functions and design ,我鼓励每个人都去看看。
这种方式与上面的例子关键的不同在于,服务的定制化并不是通过传递参数实现的,而是通过函数来直接修改server
的配置本身。
正如前面看到的,不传递变参让我们使用默认的方式。
当需要进行配置时,我们传递一个操作server
的配置的函数。
上面的代码中,timeout
这个函数是用于改变server
的配置中的timeout字段。
在NewServer
的实现内部,直接应用这些函数即可。
在上面的代码中,我们调用了一个net.Listener
,在server的示例中,我们使用了这个默认的listener。
然后,对于每个传入的option,我们都调用它,把我们的配置传入进去。
很明显,如果没有option传递进来,我们就使用的是默认的server.
使用这种方式,我们可以让API有这样的特性
- 默认情况是实用的
- 高度可配置
- 配置可以不断增长
- 自解释的文档
- 对新的使用者很安全
- 不会要求传入一个nil的或者空值(只是为了让编译通过)
全文完。
Micro在几乎所有接口中使用了这样的方式,比如要创建一个micro server的实例,开发者通过一个option.go提供了所有可能的配置函数,当然你也可以自己实现。