你好,我是七牛云许式伟。
上一讲 “62 | 重新认识开闭原则 (OCP)” 我们介绍了开闭原则。这一讲的内容非常非常重要,可以说是整个架构课的灵魂。总结来说,开闭原则包含以下两层含义:
第一,模块的业务要稳定。模块的业务遵循 “只读” 设计,如果需要变化不如把它归档,放弃掉。这种模块业务只读的思想,是架构治理的基础哲学。我平常和小伙伴们探讨模块边界的时候,经常会说这样一句话:
每一个模块都应该是可完成的。
这实际上是开闭原则的业务范畴 “只读” 的架构治理思想的另一种表述方式。
第二,模块业务的变化点,简单一点的,通过回调函数或者接口开放出去,交给其他的业务模块。复杂一点的,通过引入插件机制把系统分解为 “最小化的核心系统+多个彼此正交的周边系统”。事实上回调函数或者接口本质上就是一种事件监听机制,所以它是插件机制的特例。
今天,我们想聊聊怎么做接口设计。
不过在探讨这个问题前,我想和大家探讨的第一个问题是:什么是接口?
你可能会觉得这个问题挺愚蠢的。毕竟这几乎是我们嘴巴里天天会提及的术语,会不知道?但让我们用科学家的严谨作风来看待这个问题。接口在不同的语义环境下,主要有两个不同含义。
一种是模块的使用界面,也就是规格,比如公开的类或函数的原型。我们前面在这个架构课中一直强调,模块的接口应该自然体现业务需求。这里的接口,指的就是模块的使用界面。
另一种是模块对依赖环境的抽象。这种情况下,接口是模块与模块之间的契约。在架构设计中我们经常也会听到 “契约式设计(Design by Contract)” 这样的说法,它鼓励模块与模块的交互基于接口作为契约,而不是依赖于具体实现。
对于这两类的接口语义,我们分别进行讨论。
对于模块的使用界面,最重要的是 KISS 原则,让人一眼就明白这个模块在做什么样的业务。
KISS 的全称是 Keep it Simple, Stupid,直译是简单化与傻瓜化。用土话来说,就是要 “让傻子也能够看得懂”,追求简单自然,符合惯例。
这样说比较抽象,我们拿七牛开源的 mockhttp 项目作为例子进行说明。
这个项目早期的项目地址为:
最新的项目地址变更为:
mockhttp 是做什么的呢?它用于启动 HTTP 服务作为测试用途。
当然 Go 的标准库 net/http/httptest 已经有自己的 HTTP 服务启动方法,如下:
package httptest
type Server struct {
URL string
...
}
func NewServer(service http.Handler) (ts *Server)
func (ts *Server) Close()
httptest.NewServer 分配一个空闲可用的 TCP 端口,并将它与传入的 HTTP 服务器关联起来。最后我们得到的 ts.URL 就是服务器的访问地址。使用样例如下:
import "net/http"
import "net/http/httptest"
func TestXXX(t *testing.T) {
service := ... // HTTP 业务服务器
ts := httphtest.NewServer(service)
defer ts.Close()
resp, err := http.Get(ts.URL + "/foo/bar")
...
}
mockhttp 有所不同,它并不真的启动 HTTP 服务,没有端口占用。这里我们不谈具体的原理,我们看接口。mockhttp.v1 版本的使用界面如下:
package mockhttp
var Client rpc.Client
func Bind(host string, service interface{})
这里比较古怪的是 service,它并不是 http.Handler 类型。它背后做了一件事情,就是帮 service 这个 HTTP 服务器自动实现请求的路由分派能力。这有一定的好处,使用上比较便捷:
import "github.com/qiniu/mockhttp.v1"
func TestXXX(t *testing.T) {
service := ... // HTTP 业务服务器
mockhttp.Bind("example.com", service)
resp, err := mockhttp.Client.Get("http://example.com/foo/bar")
...
}
但是它有两个问题。
一个问题是关于模块边界上的。严谨来说 mockhttp.v1 并不符合 “单一职责原则(SRP)”。它干了两个业务:
另一个是关于接口的 KISS 原则。mockhttp.Bind 虽然听起来不错,也很简单,但实际上并不符合 Go 语言的惯例语义。另外就是 mockhttp.Client 变量。按 Go 语义的惯例它可能叫 DefaultClient 会更好一些,另外它的类型是 rpc.Client,而不是 http.Client,这样方便是方便了,但却产生了多余的依赖。
mockhttp.v1 这种业务边界和接口的随意性,一定程度上是因为它是测试用途,所以有点怎么简单怎么来的意思。但是后来的发展表明,所有的偷懒总会还回来的。于是就有了 mockhttp.v2 版本。这个版本在我们做小型的 package 合并时,把它放到了https://github.com/qiniu/x 这个package 中。接口如下:
package mockhttp
var DefaultTransport *Transport
var DefaultClient *http.Client
func ListenAndServe(host string, service http.Handler)
这里暴露的方法和变量,一方面 Go 程序员一看即明其义,另一方面语义上和 Go 标准库既有的HTTP package 可自然融合。它的使用方式如下:
import "github.com/qiniu/x/mockhttp"
func TestXXX(t *testing.T) {
service := ... // HTTP 业务服务器
mockhttp.ListenAndServe("example.com", service)
resp, err := mockhttp.DefaultClient.Get("http://example.com/foo/bar")
...
}
从上面的例子可以看出,我们说接口要 KISS,要简单自然,这里很重要的一点是符合语言和社区的惯例。如果某类业务在语言中已经有约定俗成的接口,我们尽可能沿用相同的接口语义。
接口的另一种含义是模块对依赖环境的抽象,也就是模块与模块之间的契约。我们大部分情况下提到的接口,指的是这一点。
模块的环境依赖,也分两种,一种是使用界面依赖,一种是实现依赖。所谓使用界面依赖是指用户在使用该模块的使用界面时自然涉及的。所谓实现依赖则是指模块当前实现方案中涉及到的组件,它带来的依赖条件。如果我换一种实现方案,这类依赖可能就不再存在,或者变成另外的依赖。
在环境依赖上,我们遵循的是 “最小依赖原则”,或者叫 “最少知识原则(Least Knowledge Principle,LKP)”,去尽可能发现模块中多余的依赖。
具体到细节,使用界面依赖与实现依赖到处置方式往往还是有所不同。
从使用界面依赖来说,我们接口定义更多考虑的往往是对参数的泛化与抽象,以便让我们可以适应更广泛的场景。
比如,我们前面谈到 IO 系统的时候,把存盘与读盘的接口从 *.os.File 换成 io.Reader、io.Writer,以获得更强的通用性,比如对剪贴板的支持。
类似的情况还有很多,一个接口的参数类型稍加变化,就会获得更大的通用性。再比如,对于上面 mockhttp.v1 中 rpc.Client 这个接口就存在多余的依赖,改为 http.Client 会更好一些。
不过有的时候,我们看起来从接口定义似乎更加泛化,但是实际上却是场景的收紧,这需要特别注意避免的。比如上面 mockhttp.v1 的接口:
func Bind(host string, service interface{})
与 mockhttp.v2 的接口:
func ListenAndServe(host string, service http.Handler)
看似 v1 版本类型用的是 interface{},形式上更加泛化,但实际上 v1 版本有更强的假设,它内部通过反射机制实现了 HTTP 服务器请求的路由分派。而 v2 版本对 service 则用的是 HTTP 服务器的通用接口,是更加恰如其分的描述方式。
当然,在接口参数的抽象上,也不适合过度。如果某种泛化它不会发生,那就是过度设计。不要一开始就把系统设计得非常复杂,而陷入“过度设计”的深渊。应该让系统足够的简单,而却又不失扩展性,这其中的平衡完全依赖你对业务的理解,它是一个难点。
聊完使用界面依赖,我们接着聊实现依赖。
从模块实现的角度,我们环境依赖有两个选择:一个是直接依赖所基于的组件,一个是将所依赖的组件所有被引用的方法抽象成一个接口,让模块依赖接口而不是具体的组件。
那么,这两种方式应该怎么选择?
我的建议是,大部分情况下应该选择直接依赖组件,而不必去抽象它。
如无必要,勿增实体。
如果我们大量抽象所依赖的基础组件,意味着我们系统的可配置性(Configurable)更好,但学习成本也更高。
什么时候该当考虑把依赖抽象化?
其一,在需要提供多种选择的时候。比较典型的是日志的 Logger 组件。对于绝大部分的业务模块,都并不希望绑定 Logger 的选择,把决策权交给使用方。
但是有的时候,在这一点上过度设计也会比较常见。比如,不少业务模块会选择抽象对数据库的依赖,以便于在 MySQL 和 MongoDB 之间自由切换。但这种灵活性绝大部分情况下是一种过度设计。选择数据库应该是非常谨慎严谨的行为。
其二,在需要解除一个庞大的外部系统的依赖时。有时候我们并不是需要多个选择,而是某个外部依赖过重,我们测试或其他场景可能会选择 mock 一个外部依赖,以便降低测试系统的依赖。
其三,在依赖的外部系统为可选组件时。这个时候模块会实现一个 mock 的组件,并在初始化时将接口设置为 mock 组件。这样的好处是,除非用户关心,否则客户可以当模块不存在这个可选的配置项,这降低了学习门槛。
整体来说,对模块的实现依赖进行接口抽象,本质是对模块进行配置化,增加很多配置选项,这样的配置化需要谨慎,适可而止。
接口设计是一个老生常谈的话题。接口有分模块的使用界面和模块的环境依赖这两种理解。
对于模块的使用界面,我们推崇 KISS 原则,简单自然,符合业务表达的惯例。
对于模块的环境依赖,我们遵循的是 “最小依赖原则”,或者叫 “最少知识原则(Least Knowledge Principle,LKP)”,尽可能发现模块中多余的依赖。
如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们的话题是 “不断完善的架构范式”。
如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。