Are We Interfacing Yet?
我在自己的时间里一直坚持手写代码,但工作时难免与 Agents 打交道。一方面是公司推崇这种工具,另一方面是如果我不用的话,我就没办法按时交付工作。无论如何,有一类代码我在任何情况下都是自己设计和自己手写的,那就是接口定义。
我要讨论的不是 RESTful API,也不是 gRPC 的 Protobuf,我说的是 interface 类型。不同语言中的 interface 都略有不同,但一般都是一系列抽象方法(函数)的集合。抽象方法是说这个方法没有被实现,仅仅定义了函数名、形参数量和类型,也就是函数签名。
interface 一般用在面向对象编程中,实现 interface 就是定义一个类或命名空间,在其中实现 interface 定义的所有函数。在 Java 中,实现接口需要显式声明,例如 class ConcreteWriter implements Writer。在 Go 中,实现接口是隐式的,只要某个结构体的方法包含了某个接口定义的所有方法,就视作实现了这个接口。
动态类型语言中也有 interface,不过较少使用,我想原因是 interface 就是为静态类型系统服务的。实现了接口的某个对象可被视作同一个类型,而架构设计中的一个原则是:依赖关系应该向越来越抽象的方向流动,即抽象的不该依赖具体的、具体的应当依赖抽象的。原因在于,具体的软件本身是易变的(关于易变性带来的复杂性,可以阅读《 什么是工程问题? 》),如果抽象的业务逻辑依赖具体的部分,那么具体实现改变之后,业务逻辑也要频繁改变,这显然是不合理的。软件工程的一个重要问题就是把变更控制在软件的一小部分当中,interface 可以做到。
假设我们要编写的软件需要启动一个浏览器实例做某个操作,比起直接在主业务逻辑中添加寻找 Chrome 二进制文件的路径、管理生命周期、发送请求和解析响应体的逻辑,把这部分操作隔离开来是最好的。一般的做法是这样:
func doBusinessLogic() {
// ...
url := parseFromUserInput(input)
chrome := NewChromeInstance()
chrome.Start()
defer chrome.Close()
page := chrome.GetPage(url)
// ...
}
实际上这也不是最好的做法,如果我们某天决定不用 Chrome,改用 Firefox,那怎么办?我们要修改这个文件里的依赖引入,把 NewChromeInstance() 改成 NewFirefoxInstance(),假设 Firefox 没有 Close() 方法,用的是 Stop() 这个名字,也要修改。更何况,不同的浏览器可能还有不同的注意事项,需要额外增删代码。
你可能想用适配器设计模式(Adapter Pattern),但既然都要写新的类定义了,为什么不直接写成接口呢?
type Browser interface {
Start()
Stop()
GetPage(url string) PageResult
}
修改前面的 doBusinessLogic 函数,不要直接使用 Chrome 或者 Firefox,而是依赖这个抽象接口。
func doBusinessLogic(browser Browser) {
// ...
url := parseFromUserInput(input)
browser.Start()
defer browser.Close()
page := browser.GetPage(url)
// ...
}
抽象的就是稳定的,Browser 大概率在未来仍然只会有启动、停止和获取页面这三个方法,即便我们替换了背后的具体实现,业务逻辑也不需要做任何改变,只要 Chrome、Firefox 和未来所有的浏览器类实现了上述三个方法,并且符合里氏替换原则。
具体怎么做呢,假设我们在 main.go 里初始化环境、读取配置文件、启动必要的服务,也包括业务逻辑。你发现我在 doBusinessLogic() 的函数签名里加了一个形参吗?我们只需要在入口文件里初始化浏览器,然后把浏览器传给 doBusinessLogic()。
func main() {
firefox := NewFirefoxInstance()
doBusinessLogic(firefox)
}
func main() {
chrome := NewChromeInstance()
doBusinessLogic(chrome)
}
至于 NewFirefoxInstance() 和 NewChromeInstance(),这两个…