我们前面的课程,由于一直都是不加限制地并发爬取目标网站,很容易导致被服务器封禁。为了能够正常稳定地访问服务器,我们这节课要给项目增加一个重要的功能:限速器。同时,我们还会介绍在 Go 中进行错误处理的最佳实践。
限速器
先来看限速器。很多情况下,不管你是想防止黑客的攻击,防止对资源的访问超过服务器的承载能力,亦或是防止在爬虫项目中被服务器封杀,我们都需要对服务进行限速。在爬虫项目中,保持合适的速率也有利于我们稳定地爬取数据。大多数限速的机制是令牌桶算法(Token Bucket)来完成的。
令牌桶算法的原理很简单,我们可以想象这样一个场景,你去海底捞吃饭,里面只有 10 个座位,我们可以将这 10 个座位看作是桶的容量。现在,由于座位已经满了,服务员就帮我们叫了个号,我们随即进入到了等待的状态。
一桌客人吃完之后,下一位并不能马上就座,因为服务员还需要收拾饭桌。由于服务员的数量有限,因此即便很多桌客人同时吃完,也不能立即释放出所有的座位。如果每 5 分钟收拾好一桌,那么“1 桌 /5 分钟”就叫做令牌放入桶中的速率。轮到我们就餐时,我们占据了一个座位,也就是占据了一个令牌,这时我们就可以开吃了。
通过上面简化的案例能够看到,令牌桶算法通过控制桶的容量和令牌放入桶中的速率,保证了系统能在最大的处理容量下正常工作。在 Go 中,我们可以使用官方的限速器实现:golang.org/x/time/rate,它提供了一些简单好用的 API。
在 golang.org/x/time/rate 库中,类型 Limit 表示速率,代表每秒钟放入到桶中的令牌个数。NewLimiter 函数中,第一个参数传递的是 Limit 速率,第二个参数 b 表示桶的数量。除此之外,库中还有 Every 函数,它的参数是两个令牌之间的时间间隔,它会转化为对应的 Limit 速率。
// Limit defines the maximum frequency of some events. Limit is
// represented as number of events per second. A zero Limit allows no
// events.
type Limit float64
// NewLimiter returns a new Limiter that allows events up to rate r
// and permits bursts of at most b tokens.
func NewLimiter(r Limit, b int) *Limiter
// Every converts a minimum time interval between events to a Limit.
func Every(interval time.Duration) Limit
生成了 Limiter 之后,我们一般会调用 Limiter 的 Wait 方法等待可用的令牌。其中,参数 ctx 可以设置超时退出的时间,它可以避免协程一直陷在堵塞状态中。
func (lim *Limiter) Wait(ctx context.Context) (err error) {
return lim.WaitN(ctx, 1)
}
如果没有可用的令牌,当前协程会陷入到堵塞状态。我们用一个例子来说明一下 Limiter 的使用方法。在下面的例子中,rate.NewLimiter 生成了一个限速器,其中桶的最大容量为 2,rate.Limit(1) 表示每隔 1 秒钟向桶中放入 1 个令牌。
package main
import (
"context"
"fmt"
"golang.org/x/time/rate"
"time"
)
func main() {
limit := rate.NewLimiter(rate.Limit(1), 2)
for {
if err := limit.Wait(context.Background()); err == nil {
fmt.Println(time.Now().Format("2006-01-02 15:04:05"))
}
}
}
我们用一个 for 循环打印当前时间会发现,前两次打印是在同一秒,这是因为桶中一开始有两个令牌可以用。之后,每一次打印都需要间隔一秒,因为每隔一秒钟才会往桶中填充一个令牌。
2022-11-22 22:31:51
2022-11-22 22:31:51
2022-11-22 22:31:52
2022-11-22 22:31:53
2022-11-22 22:31:54
我们再尝试使用 rate.Every 来生成 Limit 速率:
func main() {
limit := rate.NewLimiter(rate.Every(500*time.Millisecond), 2)
for {
if err := limit.Wait(context.Background()); err == nil {
fmt.Println(time.Now().Format("2006-01-02 15:04:05"))
}
}
}
其中,rate.Every(500*time.Millisecond) 表示每 500 毫秒放入一个令牌,换算过来就是每秒钟放入 2 个令牌。所以我们可以看到,现在每秒钟都会输出两条记录:
2022-11-22 22:39:28
2022-11-22 22:39:28
2022-11-22 22:39:29
2022-11-22 22:39:29
2022-11-22 22:39:30
2022-11-22 22:39:30
刚才我们已经看到了限速器 Limiter 的基本用法。但有时候我们还会有一些更复杂的需求,例如有多层限速器的需求(细粒度限速器限制每秒的请求,粗粒度限速器限制每分钟、每小时或每天的请求)。
假设我们的爬虫项目希望每分钟只能够访问 10 次目标网站,但是只有每分钟的限制是不够的。因为这样我们可能会一秒钟直接访问 10 次,这样服务器就能直接检测出我们是爬虫机器人了。所以,我们还需要控制一下瞬时的请求量,例如每秒钟访问的频率不超过 0.5 次。这里我也借鉴了《Concurrency in Go》中多层限速器的设计,在新的 Limiter 包中将限速器抽象为了 RateLimiter 接口,golang.org/x/time/rate实现的 Limiter 自动就实现了该接口:
package limiter
type RateLimiter interface {
Wait(context.Context) error
Limit() rate.Limit
}
多层限速器对应的 multiLimiter 如下:
type multiLimiter struct {
limiters []RateLimiter
}
func MultiLimiter(limiters ...RateLimiter) *multiLimiter {
byLimit := func(i, j int) bool {
return limiters[i].Limit() < limiters[j].Limit()
}
sort.Slice(limiters, byLimit)
return &multiLimiter{limiters: limiters}
}
func (l *multiLimiter) Wait(ctx context.Context) error {
for _, l := range l.limiters {
if err := l.Wait(ctx); err != nil {
return err
}
}
return nil
}
func (l *multiLimiter) Limit() rate.Limit {
return l.limiters[0].Limit()
}
其中,MultiLimiter 函数用于聚合多个 RateLimiter,并将速率由小到大排序。Wait 方法会循环遍历多层限速器 multiLimiter 中所有的限速器并索要令牌,只有当所有的限速器规则都满足后,才会正常执行后续的操作。
最后,我们生成多层限速器并把它放入爬虫任务 Task 中,每一个爬虫任务可能有不同的限速。这里生成速率的函数用 Per 进行了封装,例如 limiter.Per(20, 1*time.Minute) 代表速率是每 1 分钟补充 20 个令牌。
func main(){
//2秒钟1个
secondLimit := rate.NewLimiter(limiter.Per(1, 2*time.Second), 1)
//60秒20个
minuteLimit := rate.NewLimiter(limiter.Per(20, 1*time.Minute), 20)
multiLimiter := limiter.MultiLimiter(secondLimit, minuteLimit)
seeds := make([]*collect.Task, 0, 1000)
seeds = append(seeds, &collect.Task{
Property: collect.Property{
Name: "douban_book_list",
},
Fetcher: f,
Storage: storage,
Limit: multiLimiter,
})
}
func Per(eventCount int, duration time.Duration) rate.Limit {
return rate.Every(duration / time.Duration(eventCount))
}
到这里,我们就不再需要在爬取数据时固定休眠了,只要使用限速器来控制速度的就可以了。
随机休眠
不过,如果使用多层限速器,我们访问服务器的频率会过于稳定。为了模拟人类的行为,我们还可以在限速器的基础上,增加随机休眠。
如下所示,假设设置的 r.Task.WaitTime 为 2 秒,我们这里使用了随机数,获取 0-2000 毫秒之间的任何一个整数作为休眠时间。
func (r *Request) Fetch() ([]byte, error) {
if err := r.Task.Limit.Wait(context.Background()); err != nil {
return nil, err
}
// 随机休眠,模拟人类行为
sleeptime := rand.Int63n(r.Task.WaitTime * 1000)
time.Sleep(time.Duration(sleeptime) * time.Millisecond)
return r.Task.Fetcher.Get(r)
}
下面是我通过多层限速器和随机休眠机制成功爬取到的图书数据:
上述代码位于v0.2.8。
错误处理
介绍了限速器,我们再来看一看 Go 项目中另一个重要的话题:错误处理。在 Go 中,错误处理也是被人吐槽得比较多的地方。很多批评者根据自己过去的编程语言的经验,觉得在 Go 中有很多下面这样的错误处理语法。
if err != nil{
return err
}
然而,Go 这种错误处理方式实际上是深思熟虑的结果,它也是有软件工程的经验作为指导的。像 Java 或 C++ 中错误处理的 try…catch 语句被实践证明面临可读性差、难以精准地处理错误等问题。而在 Go 语言中,错误处理的哲学是强制调用者检查错误,这就保证了代码的可读性和健壮性。虽然在某些情况下,这确实使 Go 代码变得冗长,但幸运的是,我们可以使用一些技术来最大程度地降低错误处理的重复性。
基本的错误处理方式
由于 Go 中允许多返回值,因此最常见的错误处理方式是将函数的最后一个返回值作为 error 接口,接口中的 Error 方法返回错误的信息。
这种方式会用 errors.New 函数来生成一个新的错误,其中参数为本次的错误信息。
func (r *Request) Check() error {
if r.Depth > r.Task.MaxDepth {
return errors.New("max depth limit reached")
}
return nil
}
错误的信息需要清晰表明错误,方便定位问题,例如通过 open not_here.txt: no such file or directory ,我们可以清楚地知道打开的文件不存在。errors.New 的实现非常简单,它生成了一个内置的 errorString 结构体,而 errorString 则实现了 Error() 方法。
错误链处理方式
在实践中,我们可能还会遇到下面这样的特殊情况。例如 tasks 是任务的列表,我们希望所有任务都执行,即便前面任务执行失败也不退出。但是下面这样的写法就只能得到最后一个错误的信息:
func doSomething() error {
var reserr error
for _, task := range tasks {
if err = f(task);err != nil{
reserr = err
}
}
return reserr
}
还有一种情况是希望能够检测到错误链中的某一类特定错误。举一个例子,foo 函数通过读取 Read 来读取文件中的信息,当读取到最后,Read 函数会返回特定的错误类型:io.EOF。而在一些场景需要检测特殊的错误类型来进行额外的逻辑处理。
func foo() error {
f, _ := os.Open(file)
if _, err = f.Read(b); err != nil {
fmt.Println(err == io.EOF) // true
}
}
要解决这个问题,我们首先可能会想到在 foo 函数中使用 fmt.Errorf 包裹信息,但是很快会发现,现在已经失去了 io.EOF 这个原始的错误类型。
其实,为了解决这类问题,Go 标准库为我们实现了类似错误包装的机制,它可以将多个错误组成一个错误链。要使用错误包装机制有一个非常简单的方法,那就是在使用 fmt.Errorf 的时候加入一个特殊的格式化符号 %w,在下面这段代码中, fmt.Errorf("read failed,%w", err) 将错误 err 进行了包装。
这时,我们可以通过 errors.Is 来判断当前错误中是否包含了原始的 io.EOF 错误,errors.Is 会遍历整个错误链并查找是否有相同的错误。
func foo() error {
f, _ := os.Open(file)
if _, err = f.Read(b); err != nil {
warpErr := fmt.Errorf("read failed,%w", err)
fmt.Println(errors.Is(warpErr, io.EOF)) // true
}
}
另外,errors.Unwrap 还可以对错误进行解包,如下所示:
func foo() error {
f, _ := os.Open(file)
if _, err = f.Read(b); err != nil {
warpErr := fmt.Errorf("read failed,%w", err)
err = errors.Unwrap(warpErr)
fmt.Println(err == io.EOF) // true
}
}
一般我们会使用 errors.Is 来判断错误链中是否包含了指定的错误,而不是直接使用下面的== 来判断。
if err == ErrSomething {
...
}
要注意,尽量不要在自己的 API 中返回 syscall.ENOENT 以及 io.EOF 这样的特殊类型。因为这通常意味着调用者需要依赖我们代码库当中定义的特定类型。这在标准库中没有问题,但是如果是第三方库返回了一个新的错误类型,或者使用 fmt.Errorf 等方式进行了包裹,这种相等关系在后续就不再成立了。同时,这种方式还可能带来不必要的包之间的依赖。
减少错误处理的实践
为了避免冗长的错误处理,我们可以使用一些最佳实践。例如下面的 HTTP 服务器中,每一个路由函数都需要处理错误,这看起来很繁琐
func main() {
http.HandleFunc("/users", viewUsers)
http.HandleFunc("/companies", viewCompanies)
}
func viewUsers(w http.ResponseWriter, r *http.Request) {
...
if err := userTemplate.Execute(w, user); err != nil {
http.Error(w, err.Error(), 500)
}
}
func viewCompanies(w http.ResponseWriter, r *http.Request) {
...
if err := companiesTemplate.Execute(w, companies); err != nil {
http.Error(w, err.Error(), 500)
}
}
这时候,我们可以用一个类似中间件的函数 appHandler 来统一处理错误,改造如下:
func main() {
http.HandleFunc("/users", appHandler(viewUsers))
http.HandleFunc("/companies", appHandler(viewCompanies))
}
func viewUsers(w http.ResponseWriter, r *http.Request) {
return userTemplate.Execute(w, user)
}
func viewCompanies(w http.ResponseWriter, r *http.Request) {
return companiesTemplate.Execute(w, companies)
}
type appHandler func(http.ResponseWriter, *http.Request) error
func (fn appHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if err := fn(w, r); err != nil {
http.Error(w, err.Error(), 500)
}
}
第二个最佳实践是使用 defer 来减少错误处理。例如,下面的函数在错误返回时,包装函数的逻辑是相同的。
func DoSomeThings(val1 int, val2 string) (string, error) {
val3, err := doThing1(val1)
if err != nil {
return "", fmt.Errorf("in DoSomeThings: %w", err)
}
val4, err := doThing2(val2)
if err != nil {
return "", fmt.Errorf("in DoSomeThings: %w", err)
}
result, err := doThing3(val3, val4)
if err != nil {
return "", fmt.Errorf("in DoSomeThings: %w", err)
}
return result, nil
}
如果我们把这里的函数改造为使用 defer,会更为简洁:
func DoSomeThings(val1 int, val2 string) (_ string, err error) {
defer func() {
if err != nil {
err = fmt.Errorf("in DoSomeThings: %w", err)
}
}()
val3, err := doThing1(val1)
if err != nil {
return "", err
}
val4, err := doThing2(val2)
if err != nil {
return "", err
}
return doThing3(val3, val4)
}
panic
最后,我们再来讲讲一类特殊的错误:panic。当发生算术除 0 错误、内存无效访问、数组越界等问题时,会触发程序 panic,导致程序异常退出并打印出函数的堆栈信息。在 Go 语言中,有以下两个内置函数可以处理程序的异常情况:
func panic(interface{})
func recover() interface{}
panic 函数传递的参数为空接口 interface{},它可以存储任何形式的错误信息并进行传递,然后在异常退出时打印出来。
panic(42)
panic("unreachable")
panic(Error("cannot parse"))
Go 程序在 panic 时并不会直接导致程序异常退出,它会终止当前正在正常执行的函数,执行 defer 函数并逐级返回。
例如,对于函数调用链 a()→b()→c(),当函数 c 发生 panic 后,会返回函数 b。此时,函数 b 也像发生了 panic 一样返回函数 a。函数 c、b、a 中的 defer 函数都将正常执行。
func a() {
defer fmt.Println("defer a")
b()
fmt.Println("after a")
}
func b() {
defer fmt.Println("defer b")
c()
fmt.Println("after b")
}
func c() {
defer fmt.Println("defer c")
panic("this is panic")
fmt.Println("after c")
}
func main() {
a()
}
如下所示,当函数 c 触发了 panic 后,所有函数中的 defer 语句都会被正常调用,并且在 panic 时打印出堆栈信息。
defer c
defer b
defer a
panic: this is panic
goroutine 1 [running]:
main.c()
bookcode/panic/panic_chain.go:19 +0x95
main.b()
bookcode/panic/panic_chain.go:13 +0x96
main.a()
bookcode/panic/panic_chain.go:7 +0x96
main.main()
bookcode/panic/panic_chain.go:24 +0x20
通常,我们希望能够捕获这样的错误,然后让程序继续正常执行。要捕获这种异常,我们需要将 defer 与内置 recover 函数结合起来使用。
func executePanic() {
defer func() {
if errMsg := recover(); errMsg != nil {
fmt.Println(errMsg)
}
fmt.Println("This is recovery function...")
}()
panic("This is Panic Situation")
fmt.Println("The function executes Completely")
}
func main() {
executePanic()
fmt.Println("Main block is executed completely...")
}
从下面这段输出可以看出,尽管有 panic,main 函数仍然在正常执行后才退出。
This is Panic Situation
This is recovery function...
Main block is executed completely...
在实践中,我们常常看到一些开发者为了避免异常退出在各个函数调用的地方都加上了 recover 捕获,但其实这是没有必要的。借助上面的这个特性,我们只需要在最上层函数捕获异常就可以了。
有些同学可能会想,那我直接在 main 函数里加一个 recover 函数,这样捕获异常不就可以了吗?但这是不对的,因为我们讲的这个特性只适用于某一个单独的协程。所以我们应该对每一个重要的协程的最上层函数进行捕获,这样就可以避免程序的异常退出了。
下面这段代码中,我们在 CreateWork 方法中捕获到 panic 并打印到了日志中。一般这种日志会被额外的监控系统发现并报警。
func (s *Crawler) CreateWork() {
defer func() {
if err := recover(); err != nil {
s.Logger.Error("worker panic",
zap.Any("err", err),
zap.String("stack", string(debug.Stack())))
}
}()
...
}
本文章来源于极客时间《Go 进阶 · 分布式爬虫实战》