<audio id="audio" title="63 | 接口设计的准则" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/56/41/56yy241ddd18957aaf45aa7c489df141.mp3"></audio>

你好，我是七牛云许式伟。

上一讲 “[62 | 重新认识开闭原则 (OCP)](https://time.geekbang.org/column/article/175236)” 我们介绍了开闭原则。这一讲的内容非常非常重要，可以说是整个架构课的灵魂。总结来说，开闭原则包含以下两层含义：

第一，模块的业务要稳定。模块的业务遵循 “只读” 设计，如果需要变化不如把它归档，放弃掉。这种模块业务只读的思想，是架构治理的基础哲学。我平常和小伙伴们探讨模块边界的时候，经常会说这样一句话：

> 
每一个模块都应该是可完成的。


这实际上是开闭原则的业务范畴 “只读” 的架构治理思想的另一种表述方式。

第二，模块业务的变化点，简单一点的，通过回调函数或者接口开放出去，交给其他的业务模块。复杂一点的，通过引入插件机制把系统分解为 “最小化的核心系统+多个彼此正交的周边系统”。事实上回调函数或者接口本质上就是一种事件监听机制，所以它是插件机制的特例。

今天，我们想聊聊怎么做接口设计。

不过在探讨这个问题前，我想和大家探讨的第一个问题是：什么是接口？

你可能会觉得这个问题挺愚蠢的。毕竟这几乎是我们嘴巴里天天会提及的术语，会不知道？但让我们用科学家的严谨作风来看待这个问题。接口在不同的语义环境下，主要有两个不同含义。

一种是模块的使用界面，也就是规格，比如公开的类或函数的原型。我们前面在这个架构课中一直强调，模块的接口应该自然体现业务需求。这里的接口，指的就是模块的使用界面。

另一种是模块对依赖环境的抽象。这种情况下，接口是模块与模块之间的契约。在架构设计中我们经常也会听到 “契约式设计（Design by Contract）” 这样的说法，它鼓励模块与模块的交互基于接口作为契约，而不是依赖于具体实现。

对于这两类的接口语义，我们分别进行讨论。

## 模块的使用界面

对于模块的使用界面，最重要的是 KISS 原则，让人一眼就明白这个模块在做什么样的业务。

KISS 的全称是 Keep it Simple, Stupid，直译是简单化与傻瓜化。用土话来说，就是要 “让傻子也能够看得懂”，追求简单自然，符合惯例。

这样说比较抽象，我们拿七牛开源的 mockhttp 项目作为例子进行说明。

这个项目早期的项目地址为：

- 代码主页：[https://github.com/qiniu/mockhttp.v1](https://github.com/qiniu/mockhttp.v1)
- 文档主页：[https://godoc.org/github.com/qiniu/mockhttp.v1](https://godoc.org/github.com/qiniu/mockhttp.v1)

最新的项目地址变更为：

- 代码主页：[https://github.com/qiniu/x/tree/master/mockhttp](https://github.com/qiniu/x/tree/master/mockhttp)
- 文档主页：[https://godoc.org/github.com/qiniu/x/mockhttp](https://godoc.org/github.com/qiniu/x/mockhttp)

mockhttp 是做什么的呢？它用于启动 HTTP 服务作为测试用途。

当然 Go 的标准库 [net/http/httptest ](https://godoc.org/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 &quot;net/http&quot;
import &quot;net/http/httptest&quot;

func TestXXX(t *testing.T) {
  service := ... // HTTP 业务服务器
  ts := httphtest.NewServer(service)
  defer ts.Close()

  resp, err := http.Get(ts.URL + &quot;/foo/bar&quot;)
  ...
}

```

mockhttp 有所不同，它并不真的启动 HTTP 服务，没有端口占用。这里我们不谈具体的原理，我们看接口。mockhttp.v1 版本的使用界面如下：

```
package mockhttp

var Client rpc.Client

func Bind(host string, service interface{})

```

这里比较古怪的是 service，它并不是 http.Handler 类型。它背后做了一件事情，就是帮 service 这个 HTTP 服务器自动实现请求的路由分派能力。这有一定的好处，使用上比较便捷：

```
import &quot;github.com/qiniu/mockhttp.v1&quot;

func TestXXX(t *testing.T) {
  service := ... // HTTP 业务服务器
  mockhttp.Bind(&quot;example.com&quot;, service)
  resp, err := mockhttp.Client.Get(&quot;http://example.com/foo/bar&quot;)
  ...
}

```

但是它有两个问题。

一个问题是关于模块边界上的。严谨来说 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 &quot;github.com/qiniu/x/mockhttp&quot;

func TestXXX(t *testing.T) {
  service := ... // HTTP 业务服务器
  mockhttp.ListenAndServe(&quot;example.com&quot;, service)
  resp, err := mockhttp.DefaultClient.Get(&quot;http://example.com/foo/bar&quot;)
  ...
}

```

从上面的例子可以看出，我们说接口要 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）”，尽可能发现模块中多余的依赖。

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

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