强制更改Go标准库的实现

Go标准库中有一些单例的实现,比如log包中默认的Loggernet.DefaultResolver, 这些对象提供了便利的方法,但是有的时候,我们需要做一些定制话的功能,需要更改这些对象,
甚至有的时候,我们需要更改标准库的特定方法,常规手段是不起作用的, 必须使用一些"骇客"的方式。

国庆北京连绵秋雨,整好我窝在家里,实现了一个原本想12月份实现的产品,在开发项目的过程中,也遇到了一些需要更改标准库默认行为的需求,所以在这方面做了一些探索,整理出这篇文章,以飨读者。

如果你想实现自己的一个日志库(事实上Go生态圈已经有很多很多的日志库了),你可能想"拦截"标准库的默认的Log,这样你的代码,或者第三方代码中通过标准库log输出的日志都能通过你自己的日志库输入出来。

其实标准库log默认的Logger是这样定义的: var std = New(os.Stderr, "", LstdFlags), std实现了一个输出到os.StderrLogger。Go标准库中的Logger不是一个接口,所以本身你可能还不能做太多的定制化的改造,但是至少,你可以改变日志输出的目的地,比如从标准err输出改到日志文件中。这里std是未输出的变量,但是标准库提供了func SetOutput(w io.Writer)方法,用来更改输出目的地。

这样看来,日志库还好,至少还暴露了一个更改定制化的方法,但是有很多情况下,标准库并没有提供定制的方法,或者说不方便定制的方法。

这几天我在实现项目的时候,遇到了一台机器有多个IP的情况。

一台机器配置了多个IP并不罕见,当你在这台机器连接其它的TCP服务器时, 本地到底使用的是哪一个IP地址呢?如serverfault有人提出的问题,在默认的情况下,Linux会依照子网的分类,选择和服务器在相同的子网的本地地址,但是如果同一个子网中配置了多个IP地址,那么Linux会选择此子网的"主"IP地址作为本地Ip地址连接服务器。

在我这个项目中,会有很多的网络连接,比如连接mysql,连接clickhouse,连接第三方的HTTP API服务,连接Kafka、连接 Redis等。不幸的事,当使用第三方库比如go-sql-driver/mysql、go-redis时,Linux所选择的本地IP地址并不是我期望的本地IP地址,导致权限验证失败无法连接。

本质上,无论是go-sql-driver/mysql或者go-redis,都是基于net.Dial或者net.DialContext建立的TCP连接。go-sql-driver/mysql 提供了 RegisterDialContext用于定制化Dial,go-redis提供了Dialer字段用来定制,你如果想指定本地的IP地址,可以通过定制的net.Dialer来实现:

1
2
3
localAddrDialier := &net.Dialer{
LocalAddr: localAddr,
}

这是一个不错的、传统的方法,唯一不爽的是,每种类型我都需要进行地址,访问mysql、访问redis、访问kafka、访问第三方库、访问服务器......, 有没有一劳永逸的方法呢?

有!

bouk/monkey是一个相当相当"骇客"的技术,当过运行时动态将方法的实现替换成JMP 新的函数, 来实现在运行时替换方法。经常我们会在单元测试的时候用来"Mock"一些方法,非常的有效,这一次,我要尝试使用它替换所有的net.Dialer.Dial或者net.Dialer.DialContext方法,来实现强制指定本地地址。

当然,agiledragon基于它的原理实现了agiledragon/gomonkey以方便调用,不过目前不支持临时恢复原函数。曹春晖基于它实现了cch123/supermonkey,通过解析符号表得到函数指针,可以替换未输出的函数,可以说功能更强大了。本文中还是使用原始的bouk/monkey,因为对于我来说,功能足够了。

不要使用bouk/monkey做恶。

可以使用bouk/monkey替换标准库的net.Dialer.Dial或者net.Dialer.DialContext函数,在建立TCP连接的时候,使用本地IP地址。这样,无论是mysql的库、还是redis的库,或者其它的第三方库,只要基于net.Dialer.Dial或者net.Dialer.DialContext函数,就会使用我们替换的方法。

相关代码也非常简单,如下所示,注释已经加到代码中:

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
// 指定要使用的本地地址. // by https://colobu.com
localAddr := &net.TCPAddr{
IP: net.ParseIP(localIP),
Port: 0,
}
var d *net.Dialer
// 替换Dialer.DialContext方法
dialContextGuard = monkey.PatchInstanceMethod(reflect.TypeOf(d), "DialContext", func(d *net.Dialer, ctx context.Context, network, address string) (net.Conn, error) {
// 临时恢复
dialContextGuard.Unpatch()
defer dialContextGuard.Restore()
if network == "tcp" || network == "tcp4" || network == "tcp6" {
localAddrDialier := &net.Dialer{
LocalAddr: localAddr,
}
// 使用指定本地地址的dialer
return localAddrDialier.DialContext(ctx, network, address)
}
// 其它情况,比如UDP、UnixDomain等,使用标准库的方法
return d.DialContext(ctx, network, address)
})
// 替换Dail方法
dialGuard = monkey.PatchInstanceMethod(reflect.TypeOf(d), "Dial", func(d *net.Dialer, network, address string) (net.Conn, error) {
// 临时恢复
dialGuard.Unpatch()
defer dialGuard.Restore()
if network == "tcp" || network == "tcp4" || network == "tcp6" {
localAddrDialier := &net.Dialer{
LocalAddr: localAddr,
}
// 使用指定本地地址的dialer
return localAddrDialier.Dial(network, address)
}
// 其它情况,比如UDP、UnixDomain等,使用标准库的方法
return d.Dial(network, address)
})

替换了这两个方法后,之后即使新建立net.Dailer对象,也是使用替换后的方法执行。

这里并没有考虑并发定位情况,如果你的程序有并发的调用Dial或者DialContext,你需要加锁。

这样,我们就一劳永逸的解决了指定本地IP地址创建TCP连接的问题,无需改动标准库的代码,无需逐个定制Dial方法。

同样的,你可以更改标准库的net.DefaultResolver, 这是标准库用来进行域名解析的实现,支持Go自己的解析实现和CGO方式查询。本身它是一个struct,而不是一个接口,所以虽然它是一个单例的对象,但是通常情况下你也没有多少定制化的可能。比如在调用LookupIP方法时,你想使用自己的一个协议返回IP列表,而不是查询本地文件或者DNS服务器,你基本是没有办法的。但是通过bouk/monkey,你可以更改LookupIP方法,这样你就可以定制了。

所以,bouk/monkey不仅仅可以用来在单元测试中mock对象和方法,还可以在应用运行中替换一些常规没有办法更改的函数。