}
这里比较有意思的是,获取当前时间的方式通过了一个 nowFunc 变量。
nowFunc 是一个函数变量,其本质上也是 time.Now 函数。
为什么不直接使用我们比较熟悉的 time.Now(),而是额外增加了一层呢?
* 为了方便测试。你试想一下,如果我们想测试函数在 300s 之后能否断开,那么我们的单元测试必须要等 300s 这么久吗?显然是不可能的,这样做效率太低了。
* Redigo 的做法是,通过修改 now 函数变量对应的值,我们可以任意修改当前时间,从而影响 GetContext 函数的行为。当时间未超过最大连接时间时,我们预期连接会被复用,达不到测试超时的效果,所以我们可以设置 now = now.Add(p.MaxConnLifetime + 1) ,巧妙地让当前时间超过最大连接时间,看连接是不是真的和预期一样被销毁。
// pool_test.go func TestPoolMaxLifetime(t *testing.T) { d := poolDialer{t: t} p := &redis.Pool{ MaxIdle: 2, MaxConnLifetime: 300 * time.Second, Dial: d.dial, } defer p.Close() // 设置now为当前时间 now := time.Now() redis.SetNowFunc(func() time.Time { return now }) defer redis.SetNowFunc(time.Now)
c := p.Get() _, err := c.Do("PING") require.NoError(t, err) c.Close()
d.check("1", p, 1, 1, 0)
// 设置now为最大连接时间+1 now = now.Add(p.MaxConnLifetime + 1)
c = p.Get() _, err = c.Do("PING") require.NoError(t, err) c.Close()
d.check("2", p, 2, 1, 0) }
## 接口底层
接口的底层结构如下,它分为 tab 和 data 两个字段。
type iface struct { tab *itab // 存储了接口的类型、接口中的动态数据类型、动态数据类型的函数指针等 data unsafe.Pointer // 存储了接口中动态类型的数据指针 }

接口能够容纳不同的类型的秘诀
* 存储当前接口的类型
* 存储动态数据类型
* 存储动态数据类型对应的数据
* 动态数据类型实现接口方法的指针。
这种为不同数据类型的实体提供统一接口的能力被称为多态。实际上,接口只是一个容器,当我们调用接口时,最终会找到接口中容纳的动态数据类型和它所对应方法的指针,并完成调用。
## 接口成本
由于动态数据类型对应的数据大小难以预料,接口中使用指针来存储数据。
同时,为了方便数据被寻址,平时分配在栈中的值一旦赋值给接口后,Go 运行时会在堆区为接口开辟内存,这种现象被称为内存逃逸,它是接口需要承担的成本之一。
**内存逃逸意味着堆内存分配时的时间消耗**。
接口的另一个成本是**调用时查找接口中容纳的动态数据类型和它对应的方法的指针带来的开销。**
这种开销的成本有多大呢?
这里我们用一个简单的 Benchmark 测试来说明一下。在下面这个例子中,BenchmarkDirect 测试直接调用调用的开销。BenchmarkInterface 测试进行接口调用的开销,但其函数接收者是一个非指针。BenchmarkInterfacePointer 也是测试接口调用的开销,但其函数接收者是一个指针。
package escape
import "testing"
type Sumifier interface{ Add(a, b int32) int32 }
type Sumer struct{ id int32 }
func (math Sumer) Add(a, b int32) int32 { return a + b }
type SumerPointer struct{ id int32 }
func (math *SumerPointer) Add(a, b int32) int32 { return a + b }
func BenchmarkDirect(b *testing.B) { adder := Sumer{id: 6754} b.ResetTimer() for i := 0; i < b.N; i++ { adder.Add(10, 12) } }
func BenchmarkInterface(b *testing.B) { adder := Sumer{id: 6754} b.ResetTimer() for i := 0; i < b.N; i++ { Sumifier(adder).Add(10, 12) } }
func BenchmarkInterfacePointer(b *testing.B) { adder := &SumerPointer{id: 6754} b.ResetTimer() for i := 0; i < b.N; i++ { Sumifier(adder).Add(10, 12) } }
在 Benchmark 测试中,我们静止编译器的优化和内联汇编,避免这两种因素对耗时产生的影响。测试结果如下。可以看到直接函数调用的速度最快,为 1.95 ns/op, 方法接收者为指针的接口调用和函数调用的速度类似,为 2.37 ns/op, 方法接收者为非指针的接口调用却慢了数倍,为 14.6 ns/op。
N: Windows直接运行\*\*`go test -gcflags "-N -l" -bench=.`\*\*好像不行
» go test -gcflags "-N -l" -bench=. BenchmarkDirect-12 535487740 1.95 ns/op BenchmarkInterface-12 76026812 14.6 ns/op BenchmarkInterfacePointer-12 517756519 2.37 ns/op
go test -gcflags "-N -l" -bench=. goos: linux goarch: amd64 pkg: github.com/funbinary/go_example/example/crawler/benchmark cpu: 12th Gen Intel(R) Core(TM) i7-12700F BenchmarkDirect-20 1000000000 0.9737 ns/op BenchmarkInterface-20 906270834 1.305 ns/op BenchmarkInterfacePointer-20 1000000000 1.080 ns/op PASS ok github.com/funbinary/go_example/example/crawler/benchmark 3.587s
\*\*方法接收者为非指针的接口调用速度之所以很慢是受到了内存拷贝的影响。\*\*由于接口中存储了数据的指针,而函数调用的是非指针,因此数据会从对堆内存拷贝到栈内存,让调用速度变慢。
启发:
* 在使用接口时,**方法接收者使用指针的形式能够带来速度的提升**
* **接口调用带来的性能损失很小**,在实际开发中,不必担心接口带来的效率损失
## 爬取技术
* 模拟浏览器访问
* 代理访问
## 爬取接口抽象
* 创建collect用于采集引擎, 存放与爬取相关代码
* 定义Fetcher接口,内部方法Get,参数为url N: 后续会修改,不用提前费劲设计
type Fetcher interface { Get(url string) ([]byte, error) }
* 定义一个结构体 BaseFetch,用最基本的爬取逻辑实现 Fetcher 接口:
func (BaseFetch) Get(url string) ([]byte, error) { resp, err := http.Get(url)
if err != nil { fmt.Println(err) return nil, err }
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { fmt.Printf("Error status code:%d", resp.StatusCode) } bodyReader := bufio.NewReader(resp.Body) e := DeterminEncoding(bodyReader) utf8Reader := transform.NewReader(bodyReader, e.NewDecoder()) return ioutil.ReadAll(utf8Reader) }
* 在 main.go 中定义一个类型为 BaseFetch 的结构体,用接口 Fetcher 接收并调用 Get 方法,这样就完成了使用接口来实现基本爬取的逻辑。
var f collect.Fetcher = collect.BaseFetch{} body, err := f.Get(url)
## 模拟浏览器访问
* 反爬机制
* User-Agent 字段: 表明当前正在使用的应用程序、设备类型和操作系统的类型与版本。
* R: [大多浏览器使用的User-Agent格式](https://gitee.com/vip204888)
Mozilla/5.0 (操作系统信息) 运行平台(运行平台细节) <扩展信息>
* 我的浏览器
Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/111.0.0.0 Safari/537.36
+ Mozilla/5.0 由于历史原因,是现在的主流浏览器都会发送的。
+ Windows NT 10.0; Win64; x64: 操作系统版本号。
+ AppleWebKit/537.36: 使用的 Web 渲染引擎标识符。
+ KHTML: 在 Safari 和 Chrome 上使用的引擎。
+ Chrome/111.0.0.0 Safari/537.36: 浏览器版本号
* 不同浏览器,User-Agent会不同
Lynx: Lynx/2.8.8pre.4 libwww-FM/2.14 SSL-MM/1.4.1 GNUTLS/2.12.23
Wget: Wget/1.15 (linux-gnu)
Curl: curl/7.35.0
Samsung Galaxy Note 4: Mozilla/5.0 (Linux; Android 6.0.1; SAMSUNG SM-N910F Build/MMB29M) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/4.0 Chrome/44.0.2403.133 Mobile Safari/537.36
Apple iPhone: Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_1 like Mac OS X) AppleWebKit/603.1.30 (KHTML, like Gecko) Version/10.0 Mobile/14E304 Safari/602.1
Apple iPad: Mozilla/5.0 (iPad; CPU OS 8_4_1 like Mac OS X) AppleWebKit/600.1.4 (KHTML, like Gecko) Version/8.0 Mobile/12H321 Safari/600.1.4
Microsoft Internet Explorer 11 / IE 11: Mozilla/5.0 (compatible, MSIE 11, Windows NT 6.3; Trident/7.0; rv:11.0) like Gecko
* 有时候,我们的爬虫服务需要动态生成 User-Agent 列表,方便在测试、或者在使用代理大量请求单一网站时,动态设置不同的 User-Agent。
### 实现BrowserFetch
* 创建一个 HTTP 客户端 http.Client
* 通过 http.NewRequest 创建一个请求。
* 在请求中调用 req.Header.Set 设置 User-Agent 请求头。
* 调用 client.Do 完成 HTTP 请求。
type BrowserFetch struct { }
func (b *BrowserFetch) Get(url string) ([]byte, error) { client := &http.Client{}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/111.0.0.0 Safari/537.36\t\n")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, errors.Errorf("Error status code:%v", resp.StatusCode)
}
r := bufio.NewReader(resp.Body)
e := DeterminEncoding(r)
utf8r := transform.NewReader(r, e.NewDecoder())
return io.ReadAll(utf8r)
}
## 远程访问浏览器
要借助浏览器的能力实现自动化爬取,目前依靠的技术有以下三种:
* 借助浏览器驱动协议(WebDriver protocol)远程与浏览器交互;
* 借助谷歌开发者工具协议(CDP,Chrome DevTools Protocol)远程与浏览器交互;
* 在浏览器应用程序中注入要执行的 JavaScript,典型的工具有 Cypress, TestCafe。
通常只用于测试,所以下面我们就重点来说说前面两种技术。
### Webdriver Protocol
Webdriver 协议是操作浏览器的一种远程控制协议。借助 Webdriver 协议完成爬虫的框架或库有 Selenium,WebdriverIO,Nightwatch,其中最知名的就是 [Selenium](https://gitee.com/vip204888)。Selenium 为每一种语言(例如 Java、Python、Ruby 等)都准备了一个对应的 clinet 库,它整合了不同浏览器的驱动(这些驱动由浏览器厂商提供,例如谷歌浏览器的驱动和火狐浏览器的驱动)。
Selenium 通过 W3C 约定的 WebDriver 协议与指定的浏览器驱动进行通信,之后浏览器驱动操作特定浏览器,从而实现开发者操作浏览器的目的。由于 Selenium 整合了不同的浏览器驱动,因此它对于不同的浏览器都具有良好的兼容性。
R: [W3C 约定的 WebDriver 协议](https://gitee.com/vip204888)
R: [Selenium](https://gitee.com/vip204888)
### Chrome DevTools Protocol(谷歌开发者工具协议)
该协议最初是由谷歌开发者工具团队维护的,负责**调试**、**操作浏览器**的协议。目前,现代大多数浏览器都支持谷歌开发者工具协议。我们经常使用到的谷歌浏览器的开发者工具(快捷键 CTRL + SHIFT + I 或者 F12)就是使用这个协议来操作浏览器的。
查看谷歌开发者工具与浏览器交互的协议的方式是:
* 打开谷歌浏览器,在开发者工具 →设置→ 实验中勾选 Protocol Monitor(协议监视器)。
* 我们要重启开发者工具,在右侧点击更多工具,这样就可以看到协议监视器面板了。
* 面板中有开发者工具通过协议与浏览器交互的细节。
与 Selenium 需要与浏览器驱动进行交互不同的是,Chrome DevTools 协议直接通过** Web Socket **协议与浏览器暴露的 API 进行通信,这使得 Chrome DevTools 协议操作浏览器变得更快。
在 Go 中实现了 Chrome DevTools 协议的知名第三方库是[chromedp](https://gitee.com/vip204888)。它的操作简单,也不需要额外的依赖。借助[chromedp](https://gitee.com/vip204888) 提供的能力与浏览器交互,我们就具有了许多灵活的能力,例如截屏、模拟鼠标点击、提交表单、下载 / 上传文件等。[chromedp](https://gitee.com/vip204888) 的一些操作样例你可以参考[example](https://gitee.com/vip204888) 代码库。
### 模拟鼠标点击事件
假设我们访问[Go time 包的说明文档](https://gitee.com/vip204888),例如 After 函数,会发现下图的参考代码是折叠的。

通过鼠标点击,折叠的代码可以展示出 time.After 函数的参考代码。

我们经常面临这种情况,即需要完成一些交互才能获取到对应的数据。要模拟上面的完整操作,代码如下所示:
package main
import ( "context" "log" "time"
"github.com/chromedp/chromedp" )
func main() { // 1、创建谷歌浏览器实例 ctx, cancel := chromedp.NewContext( context.Background(), ) defer cancel()
// 2、设置context超时时间 ctx, cancel = context.WithTimeout(ctx, 15*time.Second) defer cancel()
// 3、爬取页面,等待某一个元素出现,接着模拟鼠标点击,最后获取数据
var example string
err := chromedp.Run(ctx,
chromedp.Navigate(https://pkg.go.dev/time),
chromedp.WaitVisible(body > footer),
chromedp.Click(#example-After, chromedp.NodeVisible),
chromedp.Value(#example-After textarea, &example),
)
if err != nil {
log.Fatal(err)
}
log.Printf("Go's time.After example:\n%s", example)
}
* 首先我们导入了 chromedp 库,并调用 chromedp.NewContext 为我们创建了一个浏览器的实例。
实现原理: 查找当前系统指定路径下指定的谷歌应用程序,并默认用无头模式(Headless 模式)启动谷歌浏览器实例。
通过无头模式,我们肉眼不会看到谷歌浏览器窗口的打开过程,但它确实已经在后台运行了。
func findExecPath() string {
var locations []string
switch runtime.GOOS {
case "darwin":
locations = []string{
// Mac
"/Applications/Chromium.app/Contents/MacOS/Chromium",
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
}
case "windows":
locations = []string{
// Windows
"chrome",
"chrome.exe", // in case PATHEXT is misconfigured
C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe,
C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe,
filepath.Join(os.Getenv("USERPROFILE"), AppData\\Local\\Google\\Chrome\\Application\\chrome.exe),
filepath.Join(os.Getenv("USERPROFILE"), AppData\\Local\\Chromium\\Application\\chrome.exe),
}
default:
locations = []string{
// Unix-like
"headless_shell",
...
}
}
* 所以说,当前程序能够运行的重要前提是在指定路径中存在谷歌浏览器程序。当然,一般我们系统中可浏览的谷歌浏览器的大小都是比较大的,所以 chromedp 还好心地为我们提供了一个包含了无头谷歌浏览器的应用程序的镜像:[headless-shell](https://gitee.com/vip204888)。
* 用 context.WithTimeout 设置当前爬取数据的超时时间,这里我们设置成了 15s。
* 第三步,chromedp.Run 执行多个 action,chromedp 中抽象了 **`action`**** **和 \*\*`task `\*\*两种行为。
+ action : 爬取、等待、点击、获取数据这样的行为。
+ task 指的是一个任务,task 是多个 action 的集合。
+ 因此,chromedp.Run 会将多个 action 封装为一个任务,并依次执行。
```
func Run(ctx context.Context, actions ...Action) error {
...
return Tasks(actions).Do(cdp.WithExecutor(ctx, c.Target))
}
```
* chromedp.Navigate 指的是爬取指定的网址:https://pkg.go.dev/time。
* chromedp.WaitVisible 指的是“等待当前标签可见”,其参数使用的是 CSS 选择器的形式。在这个例子中,body > footer 标签可见,代表正文已经加载完毕。
* chromedp.Click 指的是“模拟对某一个标签的点击事件”。
* chromedp.Value 用于获取指定标签的数据。
想法: 使用这个库进行webrtc的压力测试
## 空接口
* 任何类型都隐式实现了空接口
* 通用的能力。
* 然而在处理接口的过程中却需要默默承受解析空接口带来的痛苦。
通过使用空接口,常见的 fmt.Println 函数提供了打印任何类型的功能。
如果不使用空接口,那么每一个类型都需要实现一个对应的 Println 函数,是非常不方便的。
func Println(a ...interface{}) (n int, err error) { return Fprintln(os.Stdout, a...) }
* 不过,空接口带来便利的同时,也意味着**我们必须在内部解析接口的类型**,并**对不同的类型进行相应的处理**。以 fmt.Println 为例,Println 函数内部通过检测接口的具体类型来调用不同的处理函数。如果是自定义类型,还需要使用反射、递归等手段完成复杂类型的打印功能。
func (p *pp) printArg(arg interface{}, verb rune) { switch f := arg.(type) { case bool: p.fmtBool(f, verb) case float32: p.fmtFloat(float64(f), 32, verb) case float64: p.fmtFloat(f, 64, verb) case complex64: p.fmtComplex(complex128(f), 64, verb) .... }
* 对于跨服务调用的 API,使用空接口可以提高它们的扩展性。因为在这种场景下,修改 API 的成本通常比较高,服务器需要改造并发布新的 SDK,客服端还需要适配新的 SDK 并联调测试。
如下所示,在 Info 结构体中增加扩展类型 map[string]interface{},新的功能如果需要传递新的信息,当前服务甚至可以不用修改 API。
type info struct{
ExtraData map[string]interface{} json:"extra\_data"
...
}
* 空接口为 API 带来了扩展性和灵活性
* 模块的内部处理增加了额外的成本。因为 API 内部处理空接口时使用了大量的反射,而反射通常比较消耗性能。在实际项目中,当我们 JSON 序列化一个复杂的结构体时,有时候会有上百毫秒的耗时。
* 空接口是实现反射的基础,因为空接口中会存储动态类型的信息,这为我们提供了复杂、意想不到的处理能力和灵活性。我们可以获取结构体变量内部的方法名、属性名,能够动态地检查函数或方法的参数个数和返回值个数,也可以在运行时通过函数名动态调用函数。这些能力不使用反射都无法做到。
### 反射实现sql query
func createQuery(q interface{}) string{ // 判断类型为结构体 if reflect.ValueOf(q).Kind() == reflect.Struct { // 获取结构体名字 t := reflect.TypeOf(q).Name() // 查询语句 query := fmt.Sprintf("insert into %s values(", t) v := reflect.ValueOf(q) // 遍历结构体字段 for i := 0; i < v.NumField(); i++ { // 判断结构体类型 switch v.Field(i).Kind() { case reflect.Int: if i == 0 { query = fmt.Sprintf("%s%d", query, v.Field(i).Int()) } else { query = fmt.Sprintf("%s, %d", query, v.Field(i).Int()) } case reflect.String: if i == 0 { query = fmt.Sprintf("%s\"%s\"", query, v.Field(i).String()) } else { query = fmt.Sprintf("%s, \"%s\"", query, v.Field(i).String()) } ... } } query = fmt.Sprintf("%s)", query) fmt.Println(query) return query } }
## 接口的陷阱
* 当接口中存储的是值,但是结构体是指针时,接口动态调用无法编译通过。如下所示:
type Binary struct { uint64 } type Stringer interface { String() string } func (i *Binary) String() string { return "hello world" } func main(){ a:= Binary{54} b := Stringer(a) b.String() }
Go 语言在编译时阻止了这样的写法,原因在于这种写法会让人产生困惑。如果转换为接口的是值, 那么由于内存逃逸,在转换为接口时必定已经把值拷贝到了堆区。因此如果允许这种写法存在,那么即便看起来在方法中修改了接口中的值,却无法修改原始值,这非常容易引起误解。
* 将类型切片转换为接口切片
func foo() []interface{} { return []int{1,2,3} }
编译时报错:Go 语言禁止了这种写法,就像前面所说的,批量转换为接口是效率非常低的操作。因为每个元素都需要完成内存逃逸的额外开销。
* 接口与 nil 之间的关系。当接口为 nil 时,接口中的动态类型 itab 和动态类型值 data 必须都为 nil,初学者常常会在这个问题上犯错。例如在下面的 foo 函数中,由于返回的 err 没有任何动态类型和动态值,因此 err 等于 nil。
func foo() error { var err error // nil return err } func main() { err := foo() fmt.Println(err == nil) // true }
然而,如果在 foo 函数中将错误类型定义为自定义类型,例如 \*os.PathError ,我们会发现 err 不等于 nil。
func foo() error { var err *os.PathError return err } func main() { err := foo() fmt.Println(err != nil) // true }
这是因为当接口为 nil 时,代表接口中的动态类型和动态类型值都为 nil,而当前由于接口 error 具有动态类型 \*os.PathError,接口的内部结构体 itab 不为空。如下图所示:




**既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,涵盖了95%以上Go语言开发知识点,真正体系化!**
**由于文件比较多,这里只是将部分目录截图出来,全套包含大厂面经、学习笔记、源码讲义、实战项目、大纲路线、讲解视频,并且后续会持续更新**
**[如果你需要这些资料,可以戳这里获取](https://gitee.com/vip204888)**