summaryrefslogtreecommitdiff
path: root/content/article/locally-remotely-unknowingly.md
blob: 7e6644c6b9e8f3dc86e00bcda4ce8a60f35dd256 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
+++
title = 'Are We Interfacing Yet?'
subtitle = 'Locally and Remotely, Unknowingly.'
slug = 'locally-remotely-unknowingly'
tags = ["编程","Go"]
categories = '代码炼金术'
date = 2026-07-28 00:03:22
draft = false
toc = true
+++

我在自己的时间里一直坚持手写代码,但工作时难免与 Agents 打交道。一方面是公司推崇这种工具,另一方面是如果我不用的话,我就没办法按时交付工作。无论如何,有一类代码我在任何情况下都是自己设计和自己手写的,那就是接口定义。<!--more-->

我要讨论的不是 RESTful API,也不是 gRPC 的 Protobuf,我说的是 `interface` 类型。不同语言中的 `interface` 都略有不同,但一般都是一系列抽象方法(函数)的集合。抽象方法是说这个方法没有被实现,仅仅定义了函数名、形参数量和类型,也就是函数签名。

`interface` 一般用在面向对象编程中,实现 `interface` 就是定义一个类或命名空间,在其中实现 `interface` 定义的所有函数。在 Java 中,实现接口需要显式声明,例如 `class ConcreteWriter implements Writer`。在 Go 中,实现接口是隐式的,只要某个结构体的方法包含了某个接口定义的所有方法,就视作实现了这个接口。

动态类型语言中也有 `interface`,不过较少使用,我想原因是 `interface` 就是为静态类型系统服务的。实现了接口的某个对象可被视作同一个类型,而架构设计中的一个原则是:依赖关系应该向越来越抽象的方向流动,即抽象的不该依赖具体的、具体的应当依赖抽象的。原因在于,具体的软件本身是易变的(关于易变性带来的复杂性,可以阅读《[什么是工程问题?](/article/engineering-perspective/)》),如果抽象的业务逻辑依赖具体的部分,那么具体实现改变之后,业务逻辑也要频繁改变,这显然是不合理的。软件工程的一个重要问题就是把变更控制在软件的一小部分当中,`interface` 可以做到。

假设我们要编写的软件需要启动一个浏览器实例做某个操作,比起直接在主业务逻辑中添加寻找 Chrome 二进制文件的路径、管理生命周期、发送请求和解析响应体的逻辑,把这部分操作隔离开来是最好的。一般的做法是这样:

```go 
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),但既然都要写新的类定义了,为什么不直接写成接口呢?

```go
type Browser interface {
    Start()
    Stop()
    GetPage(url string) PageResult
}
```

修改前面的 `doBusinessLogic` 函数,不要直接使用 `Chrome` 或者 `Firefox`,而是依赖这个抽象接口。

```go 
func doBusinessLogic(browser Browser) {
    // ...

    url := parseFromUserInput(input)

    browser.Start()
    defer browser.Close()

    page := browser.GetPage(url)

    // ...
}
```

抽象的就是稳定的,`Browser` 大概率在未来仍然只会有启动、停止和获取页面这三个方法,即便我们替换了背后的具体实现,业务逻辑也不需要做任何改变,只要 `Chrome`、`Firefox` 和未来所有的浏览器类实现了上述三个方法,并且符合里氏替换原则。

具体怎么做呢,假设我们在 `main.go` 里初始化环境、读取配置文件、启动必要的服务,也包括业务逻辑。你发现我在 `doBusinessLogic()` 的函数签名里加了一个形参吗?我们只需要在入口文件里初始化浏览器,然后把浏览器传给 `doBusinessLogic()`。

```go
func main() {
    firefox := NewFirefoxInstance()
    doBusinessLogic(firefox)
}
```

```go 
func main() {   
    chrome := NewChromeInstance()
    doBusinessLogic(chrome)
}
```

至于 `NewFirefoxInstance()` 和 `NewChromeInstance()`,这两个函数里面可能封装了完全不同的初始化逻辑。有可能 Firefox 这边使用 Selenium 和 WebDriver 打开了一个受控制的浏览器实例,而 Chrome 这边用 `chromedp` 启动了资源占用更低的无头浏览器。要把什么样的具体实现喂给业务逻辑都没关系,只要实现了 `Browser` 接口,就能通过**类型检查**。

而 `Start()`、`Stop()` 和 `GetPage()` 封装什么样的代码都没有问题,它可以老老实实地在本地分配内存、维护上下文、启动并管理一个浏览器进程的生命周期,然后与它通信。

```go
type Chrome struct {}

func (c *chrome) Start() {
    // 分配内存
    // 以无头模式启动 Chrome 
    // 保留进程 ID
    // ...
}

func (c *chrome) Stop() {
    // 杀死进程
    // 回收资源
}

func (c *chrome) GetPage(url string) PageResult {
    // 与进程建立连接
    // chrome.Navigate(url)
    // 等待页面渲染完成
    // 返回页面结果
}
````

还能有什么方法呢?我们可以偷偷把 `Start()` 改为与某个远程服务器建立连接,用 `Stop()` 断开连接,而 `GetPage()` 则是向该服务器发送 HTTP 请求并解析响应体,然后封装成 `PageResult` 类型的结果返回,就把它命名为 `RemoteBrowser` 吧。显然,无论具体实现如何,`RemoteBrowser` 的确实现了 `Browser` 接口,可以直接扔进 `doBusinessLogic()` 函数里。

如此一来,我们的业务逻辑就在完全没有感知的情况下与另一个外部服务进行 HTTP 通信了。可见无论是网络通信、本地进程内部通信还是普通的函数调用,都只是技术细节,可以随时替换。

那外部服务器要怎么设计?会不会很麻烦?不会,我们不是已经把 `Chrome` 的具体实现写好了吗?假设我们刚才的代码全都放在 `internal/browser` 包里,只需要创建 `cmd/server` 包,让这个包依赖 `internal/browser`,复用 `Chrome` 的代码,再实现 HTTP 层的通信就好了。这些全都可以写在一个代码库里,只需要单独编译 `cmd/server`,把它部署到另一台机器上就好了。毕竟这是 Go,几分钟就可以编译好二进制文件,几行命令就可以把这个二进制文件传送到远程服务器上运行。

我们还可以再加上一层抽象,看看下面这个 `BrowserCluster` 接口。

```go
type BrowserCluster interface {
    Add(browser Browser)
    Delegate(url string) PageResult
}
```

`Delegate()` 的意思是委托,它的形参和返回值与 `Browser.GetPage()` 一模一样。我设想的是,`Delegate()` 函数遍历 `BrowserCluster` 保存的所有浏览器实例,找出当前第一个空闲的浏览器,把任务委派给它,也就是调用它的 `GetPage()` 方法。

显然我们需要类似 `Browser.IsAvailable()` 的函数检查浏览器是否可用,我们要动手改 `Browser` 接口的定义吗?

不,修改 `Browser` 接口意味着之前所有实现了这个接口的结构体,无论 `Chrome` 还是 `Firefox`,都无法再通过类型检查(因为它们不再是 `Browser` 了,缺少 `IsAvailable()` 方法)。此时可以用到组合复用原则,也就是再写一个接口。

```go
type BrowserResource interface {
    IsAvailable() bool
}
```

`BrowserCluster` 的具体实现可以依赖 `BrowserResource` 的抽象接口,如果需要的话,`Chrome` 和 `Firefox` 可以选择性地实现这个接口,也就是添加 `IsAvailable()` 方法。我们来实现 `BrowserCluster` 试试看。假设这个浏览器集群不限值类型,只要是浏览器都可以放进去,那就命名为 `AnyBrowserCluster` 好了。

```go
type AnyBrowserCluster struct {
    browsers []Browser
}

func (a *AnyBrowserCluster) Add(browser Browser) {
    a.browsers = append(a.browsers, browser)
}

func (a *AnyBrowserCluster) Delegate(url string) PageResult {
    for _, browser := range a.browsers {
        if resource, ok := browser.(BrowserResource); ok {
            if resource.IsAvailable() {
                return browser.GetPage(url)
            }
        }
    }
    return PageResult{}
}
```

`AnyBrowserCluster` 没有使用浏览器的 `Start()` 和 `Stop()` 方法,光从接口隔离原则(ISP)的角度来看,这段代码不太合格,因为我们不该依赖自己不使用的代码。我们只使用了 `Browser` 的 `GetPage()` 接口,更好的隔离是把 `Browser` 拆成 `Starter`、`Stopper` 和 `PageGetter` 三个接口,让 `AnyBrowserCluster` 只依赖 `PageGetter` 和 `BrowserResource`,因为我们只用到了 `PageGetter.GetPage()` 和 `BrowserResource.IsAvailable()` 方法。

不过,那样就有些复杂了,而且代码读起来也不那么顺畅。无论怎么说,`stopper.Stop()` 看起来都有点过于抽象了(双重意义上的),设计不可能同时遵循所有的设计原则。况且,我们很有可能需要在 `AnyBrowserCluster` 中管理浏览器的生命周期,比方说添加浏览器之后、或者在浏览器第一次被委派任务时,启动浏览器;在 `AnyBrowserCluster` 自身被摧毁时,遍历 `browsers` 调用它们的 `Stop()` 方法。上面仅仅是简化版的代码。

回到业务逻辑,把 `Browser` 换成 `BrowserCluster`。

```go 
func doBusinessLogic(cluster BrowserCluster) {
    // ...

    url := parseFromUserInput(input)
    page := cluster.Delegate(url)

    // ...
}
```

再回到入口函数,让我们创建几个浏览器实例,放进集群里,然后把集群交给业务逻辑。

```go
func main() {
    cluster := NewAnyBrowserCluster()
    cluster.Add(NewFirefoxInstance())
    cluster.Add(NewChromeInstance())
    cluster.Add(NewRemoteBrowser("https://imabrowser.dev:8081"))
    cluster.Add(NewRemoteBrowser("https://imabrowser.dev:8084"))
    cluster.Add(NewRemoteBrowser("https://imabrowser.dev:8082"))

    doBusinessLogic(cluster)
}
```

Voilà!这下业务逻辑就毫无感知地把资源消耗较大的任务委派给了当前空闲的浏览器,这个浏览器可能跑在本地,也可能跑在远程服务器上;可能是 Firefox 也可能是 Chrome,还可以是未来任何要添加到 `cluster` 当中的 `Browser` 实现。业务逻辑不知道自己用的是什么类型的集群,集群也不知道自己用的是什么类型的浏览器,它们仅仅是调用自己所依赖的接口实现的方法而已。任何一个环节需要改动,只需要修改那一部分的代码就好,变更不会扩散。

其实我们完全可以用 `AnyBrowserCluster` 实现 `Browser`,毕竟 `GetPage()` 和 `Delegate()` 的形参和返回值一模一样。那样的话,`doBusinessLogic()` 就完全不用修改,业务逻辑仍然以为自己用的是普通浏览器,只有入口函数知道它喂给业务逻辑的实际上是个浏览器集群。

几乎任何具体实现都可以稍作修改,或者加上一个包装层来实现某接口,把它接到任何适配接口的地方。总之,接口让静态类型语言变得非常灵活,如果没有 `interface`,我可能完全不想离开动态类型语言的怀抱!

最后,[接口越大,抽象越弱](https://www.youtube.com/watch?v=PAAkCSZUG1c&t=317s)。如果你在用 Go 写代码,记得写小接口,~~不要跟 Java 程序员学坏了!~~