63 接口设计的准则 63 | 接口设计的准则你好,我是七牛云许式伟。

上一讲 “[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)”。它干了两个业务:

  • 启动 HTTP 测试服务;
  • 实现 HTTP 服务器请求的路由分派。

另一个是关于接口的 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)”,尽可能发现模块中多余的依赖。

如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们的话题是 “不断完善的架构范式”。

如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

参考资料

https://learn.lianglianglee.com/%e4%b8%93%e6%a0%8f/%e8%ae%b8%e5%bc%8f%e4%bc%9f%e7%9a%84%e6%9e%b6%e6%9e%84%e8%af%be/63%c2%a0%e6%8e%a5%e5%8f%a3%e8%ae%be%e8%ae%a1%e7%9a%84%e5%87%86%e5%88%99.md