分类 编程语言 下的文章

实战 Spring Cloud Gateway 之限流篇

话说在 Spring Cloud Gateway 问世之前,Spring Cloud 的微服务世界里,网关一定非 Netflix Zuul 莫属。但是由于 Zuul 1.x 存在的一些问题,比如阻塞式的 API,不支持 WebSocket 等,一直被人所诟病,而且 Zuul 升级新版本依赖于 Netflix 公司,经过几次跳票之后,Spring 开源社区决定推出自己的网关组件,替代 Netflix Zuul。

从 18 年 6 月 Spring Cloud 发布的 Finchley 版本开始,Spring Cloud Gateway 逐渐崭露头角,它基于 Spring 5.0、Spring Boot 2.0 和 Project Reactor 等技术开发,不仅支持响应式和无阻塞式的 API,而且支持 WebSocket,和 Spring 框架紧密集成。尽管 Zuul 后来也推出了 2.x 版本,在底层使用了异步无阻塞式的 API,大大改善了其性能,但是目前看来 Spring 并没有打算继续集成它的计划

根据官网的描述,Spring Cloud Gateway 的主要特性如下:

  • Built on Spring Framework 5, Project Reactor and Spring Boot 2.0
  • Able to match routes on any request attribute
  • Predicates and filters are specific to routes
  • Hystrix Circuit Breaker integration
  • Spring Cloud DiscoveryClient integration
  • Easy to write Predicates and Filters
  • Request Rate Limiting
  • Path Rewriting

可以看出 Spring Cloud Gateway 可以很方便的和 Spring Cloud 生态中的其他组件进行集成(比如:断路器和服务发现),而且提供了一套简单易写的 断言Predicates,有的地方也翻译成 谓词)和 过滤器Filters)机制,可以对每个 路由Routes)进行特殊请求处理。

最近在项目中使用了 Spring Cloud Gateway,并在它的基础上实现了一些高级特性,如限流和留痕,在网关的使用过程中遇到了不少的挑战,于是趁着项目结束,抽点时间系统地学习并总结下。这篇文章主要学习限流技术,首先我会介绍一些常见的限流场景和限流算法,然后介绍一些关于限流的开源项目,学习别人是如何实现限流的,最后介绍我是如何在网关中实现限流的,并分享一些实现过程中的经验和遇到的坑。

一、常见的限流场景

缓存降级限流 被称为高并发、分布式系统的三驾马车,网关作为整个分布式系统中的第一道关卡,限流功能自然必不可少。通过限流,可以控制服务请求的速率,从而提高系统应对突发大流量的能力,让系统更具弹性。限流有着很多实际的应用场景,比如双十一的秒杀活动, 12306 的抢票等。

1.1 限流的对象

通过上面的介绍,我们对限流的概念可能感觉还是比较模糊,到底限流限的是什么?顾名思义,限流就是限制流量,但这里的流量是一个比较笼统的概念。如果考虑各种不同的场景,限流是非常复杂的,而且和具体的业务规则密切相关,可以考虑如下几种常见的场景:

  • 限制某个接口一分钟内最多请求 100 次
  • 限制某个用户的下载速度最多 100KB/S
  • 限制某个用户同时只能对某个接口发起 5 路请求
  • 限制某个 IP 来源禁止访问任何请求

从上面的例子可以看出,根据不同的请求者和请求资源,可以组合出不同的限流规则。可以根据请求者的 IP 来进行限流,或者根据请求对应的用户来限流,又或者根据某个特定的请求参数来限流。而限流的对象可以是请求的频率,传输的速率,或者并发量等,其中最常见的两个限流对象是请求频率和并发量,他们对应的限流被称为 请求频率限流(Request rate limiting)和 并发量限流(Concurrent requests limiting)。传输速率限流 在下载场景下比较常用,比如一些资源下载站会限制普通用户的下载速度,只有购买会员才能提速,这种限流的做法实际上和请求频率限流类似,只不过一个限制的是请求量的多少,一个限制的是请求数据报文的大小。这篇文章主要介绍请求频率限流和并发量限流。

1.2 限流的处理方式

在系统中设计限流方案时,有一个问题值得设计者去仔细考虑,当请求者被限流规则拦截之后,我们该如何返回结果。一般我们有下面三种限流的处理方式:

  • 拒绝服务
  • 排队等待
  • 服务降级

最简单的做法是拒绝服务,直接抛出异常,返回错误信息(比如返回 HTTP 状态码 429 Too Many Requests),或者给前端返回 302 重定向到一个错误页面,提示用户资源没有了或稍后再试。但是对于一些比较重要的接口不能直接拒绝,比如秒杀、下单等接口,我们既不希望用户请求太快,也不希望请求失败,这种情况一般会将请求放到一个消息队列中排队等待,消息队列可以起到削峰和限流的作用。第三种处理方式是服务降级,当触发限流条件时,直接返回兜底数据,比如查询商品库存的接口,可以默认返回有货。

1.3 限流的架构

针对不同的系统架构,需要使用不同的限流方案。如下图所示,服务部署的方式一般可以分为单机模式和集群模式:

单机模式的限流非常简单,可以直接基于内存就可以实现,而集群模式的限流必须依赖于某个“中心化”的组件,比如网关或 Redis,从而引出两种不同的限流架构:网关层限流中间件限流

网关作为整个分布式系统的入口,承担了所有的用户请求,所以在网关中进行限流是最合适不过的。网关层限流有时也被称为 接入层限流。除了我们使用的 Spring Cloud Gateway,最常用的网关层组件还有 Nginx,可以通过它的 ngx_http_limit_req_module 模块,使用 limit_conn_zone、limit_req_zone、limit_rate 等指令很容易的实现并发量限流、请求频率限流和传输速率限流。这里不对 Nginx 作过多的说明,关于这几个指令的详细信息可以 参考 Nginx 的官方文档

另一种限流架构是中间件限流,可以将限流的逻辑下沉到服务层。但是集群中的每个服务必须将自己的流量信息统一汇总到某个地方供其他服务读取,一般来说用 Redis 的比较多,Redis 提供的过期特性和 lua 脚本执行非常适合做限流。除了 Redis 这种中间件,还有很多类似的分布式缓存系统都可以使用,如 HazelcastApache IgniteInfinispan 等。

我们可以更进一步扩展上面的架构,将网关改为集群模式,虽然这还是网关层限流架构,但是由于网关变成了集群模式,所以网关必须依赖于中间件进行限流,这和上面讨论的中间件限流没有区别。

二、常见的限流算法

通过上面的学习,我们知道限流可以分为请求频率限流和并发量限流,根据系统架构的不同,又可以分为网关层限流和分布式限流。在不同的应用场景下,我们需要采用不同的限流算法。这一节将介绍一些主流的限流算法。

有一点要注意的是,利用池化技术也可以达到限流的目的,比如线程池或连接池,但这不是本文的重点。

2.1 固定窗口算法(Fixed Window)

固定窗口算法是一种最简单的限流算法,它根据限流的条件,将请求时间映射到一个时间窗口,再使用计数器累加访问次数。譬如限流条件为每分钟 5 次,那么就按照分钟为单位映射时间窗口,假设一个请求时间为 11:00:45,时间窗口就是 11:00:00 ~ 11:00:59,在这个时间窗口内设定一个计数器,每来一个请求计数器加一,当这个时间窗口的计数器超过 5 时,就触发限流条件。当请求时间落在下一个时间窗口内时(11:01:00 ~ 11:01:59),上一个窗口的计数器失效,当前的计数器清零,重新开始计数。

计数器算法非常容易实现,在单机场景下可以使用 AtomicLongLongAdderSemaphore 来实现计数,而在分布式场景下可以通过 Redis 的 INCREXPIRE 等命令并结合 EVAL 或 lua 脚本来实现,Redis 官网提供了几种简单的实现方式。无论是请求频率限流还是并发量限流都可以使用这个算法。

不过这个算法的缺陷也比较明显,那就是存在严重的临界问题。由于每过一个时间窗口,计数器就会清零,这使得限流效果不够平滑,恶意用户可以利用这个特点绕过我们的限流规则。如下图所示,我们的限流条件本来是每分钟 5 次,但是恶意用户在 11:00:00 ~ 11:00:59 这个时间窗口的后半分钟发起 5 次请求,接下来又在 11:01:00 ~ 11:01:59 这个时间窗口的前半分钟发起 5 次请求,这样我们的系统就在 1 分钟内承受了 10 次请求。(图片来源

fixed-window.png

2.2 滑动窗口算法(Rolling Window 或 Sliding Window)

为了解决固定窗口算法的临界问题,可以将时间窗口划分成更小的时间窗口,然后随着时间的滑动删除相应的小窗口,而不是直接滑过一个大窗口,这就是滑动窗口算法。我们为每个小时间窗口都设置一个计数器,大时间窗口的总请求次数就是每个小时间窗口的计数器的和。如下图所示,我们的时间窗口是 5 秒,可以按秒进行划分,将其划分成 5 个小窗口,时间每过一秒,时间窗口就滑过一秒:(图片来源

rolling-window.png

每次处理请求时,都需要计算所有小时间窗口的计数器的和,考虑到性能问题,划分的小时间窗口不宜过多,譬如限流条件是每小时 N 个,可以按分钟划分为 60 个窗口,而不是按秒划分成 3600 个。当然如果不考虑性能问题,划分粒度越细,限流效果就越平滑。相反,如果划分粒度越粗,限流效果就越不精确,出现临界问题的可能性也就越大,当划分粒度为 1 时,滑动窗口算法就退化成了固定窗口算法。由于这两种算法都使用了计数器,所以也被称为 计数器算法(Counters)

进一步思考我们发现,如果划分粒度最粗,也就是只有一个时间窗口时,滑动窗口算法退化成了固定窗口算法;那如果我们把划分粒度调到最细,又会如何呢?那么怎样才能让划分的时间窗口最细呢?时间窗口细到一定地步时,意味着每个时间窗口中只能容纳一个请求,这样我们可以省略计数器,只记录每个请求的时间,然后统计一段时间内的请求数有多少个即可。具体的实现可以参考 这里的 Redis sorted set 技巧这里的 Sliding window log 算法

2.3 漏桶算法(Leaky Bucket)

除了计数器算法,另一个很自然的限流思路是将所有的请求缓存到一个队列中,然后按某个固定的速度慢慢处理,这其实就是漏桶算法(Leaky Bucket)。漏桶算法假设将请求装到一个桶中,桶的容量为 M,当桶满时,请求被丢弃。在桶的底部有一个洞,桶中的请求像水一样按固定的速度(每秒 r 个)漏出来。我们用下面这个形象的图来表示漏桶算法:(图片来源

leaky-bucket.jpg

桶的上面是个水龙头,我们的请求从水龙头流到桶中,水龙头流出的水速不定,有时快有时慢,这种忽快忽慢的流量叫做 Bursty flow。如果桶中的水满了,多余的水就会溢出去,相当于请求被丢弃。从桶底部漏出的水速是固定不变的,可以看出漏桶算法可以平滑请求的速率。

漏桶算法可以通过一个队列来实现,如下图所示:

leaky-bucket-impl.jpg

当请求到达时,不直接处理请求,而是将其放入一个队列,然后另一个线程以固定的速率从队列中读取请求并处理,从而达到限流的目的。注意的是这个队列可以有不同的实现方式,比如设置请求的存活时间,或将队列改造成 PriorityQueue,根据请求的优先级排序而不是先进先出。当然队列也有满的时候,如果队列已经满了,那么请求只能被丢弃了。漏桶算法有一个缺陷,在处理突发流量时效率很低,于是人们又想出了下面的令牌桶算法。

2.4 令牌桶算法(Token Bucket)

令牌桶算法(Token Bucket)是目前应用最广泛的一种限流算法,它的基本思想由两部分组成:生成令牌消费令牌

  • 生成令牌:假设有一个装令牌的桶,最多能装 M 个,然后按某个固定的速度(每秒 r 个)往桶中放入令牌,桶满时不再放入;
  • 消费令牌:我们的每次请求都需要从桶中拿一个令牌才能放行,当桶中没有令牌时即触发限流,这时可以将请求放入一个缓冲队列中排队等待,或者直接拒绝;

令牌桶算法的图示如下:(图片来源

token-bucket.jpg

在上面的图中,我们将请求放在一个缓冲队列中,可以看出这一部分的逻辑和漏桶算法几乎一模一样,只不过在处理请求上,一个是以固定速率处理,一个是从桶中获取令牌后才处理。

仔细思考就会发现,令牌桶算法有一个很关键的问题,就是桶大小的设置,正是这个参数可以让令牌桶算法具备处理突发流量的能力。譬如将桶大小设置为 100,生成令牌的速度设置为每秒 10 个,那么在系统空闲一段时间的之后(桶中令牌一直没有消费,慢慢的会被装满),突然来了 50 个请求,这时系统可以直接按每秒 50 个的速度处理,随着桶中的令牌很快用完,处理速度又会慢慢降下来,和生成令牌速度趋于一致。这是令牌桶算法和漏桶算法最大的区别,漏桶算法无论来了多少请求,只会一直以每秒 10 个的速度进行处理。当然,处理突发流量虽然提高了系统性能,但也给系统带来了一定的压力,如果桶大小设置不合理,突发的大流量可能会直接压垮系统。

通过上面对令牌桶的原理分析,一般会有两种不同的实现方式。第一种方式是启动一个内部线程,不断的往桶中添加令牌,处理请求时从桶中获取令牌,和上面图中的处理逻辑一样。第二种方式不依赖于内部线程,而是在每次处理请求之前先实时计算出要填充的令牌数并填充,然后再从桶中获取令牌。下面是第二种方式的一种经典实现,其中 capacity 表示令牌桶大小,refillTokensPerOneMillis 表示填充速度,每毫秒填充多少个,availableTokens 表示令牌桶中还剩多少个令牌,lastRefillTimestamp 表示上一次填充时间。

public class TokenBucket {

    private final long capacity;
    private final double refillTokensPerOneMillis;
    private double availableTokens;
    private long lastRefillTimestamp;

    public TokenBucket(long capacity, long refillTokens, long refillPeriodMillis) {
        this.capacity = capacity;
        this.refillTokensPerOneMillis = (double) refillTokens / (double) refillPeriodMillis;
        this.availableTokens = capacity;
        this.lastRefillTimestamp = System.currentTimeMillis();
    }

    synchronized public boolean tryConsume(int numberTokens) {
        refill();
        if (availableTokens < numberTokens) {
            return false;
        } else {
            availableTokens -= numberTokens;
            return true;
        }
    }

    private void refill() {
        long currentTimeMillis = System.currentTimeMillis();
        if (currentTimeMillis > lastRefillTimestamp) {
            long millisSinceLastRefill = currentTimeMillis - lastRefillTimestamp;
            double refill = millisSinceLastRefill * refillTokensPerOneMillis;
            this.availableTokens = Math.min(capacity, availableTokens + refill);
            this.lastRefillTimestamp = currentTimeMillis;
        }
    }
}

可以像下面这样创建一个令牌桶(桶大小为 100,且每秒生成 100 个令牌):

TokenBucket limiter = new TokenBucket(100, 100, 1000);

从上面的代码片段可以看出,令牌桶算法的实现非常简单也非常高效,仅仅通过几个变量的运算就实现了完整的限流功能。核心逻辑在于 refill() 这个方法,在每次消费令牌时,计算当前时间和上一次填充的时间差,并根据填充速度计算出应该填充多少令牌。在重新填充令牌后,再判断请求的令牌数是否足够,如果不够,返回 false,如果足够,则减去令牌数,并返回 true。

在实际的应用中,往往不会直接使用这种原始的令牌桶算法,一般会在它的基础上作一些改进,比如,填充速率支持动态调整,令牌总数支持透支,基于 Redis 支持分布式限流等,不过总体来说还是符合令牌桶算法的整体框架,我们在后面学习一些开源项目时对此会有更深的体会。

三、一些开源项目

有很多开源项目中都实现了限流的功能,这一节通过一些开源项目的学习,了解限流是如何实现的。

3.1 Guava 的 RateLimiter

Google Guava 是一个强大的核心库,包含了很多有用的工具类,例如:集合、缓存、并发库、字符串处理、I/O 等等。其中在并发库中,Guava 提供了两个和限流相关的类:RateLimiterSmoothRateLimiter。Guava 的 RateLimiter 基于令牌桶算法实现,不过在传统的令牌桶算法基础上做了点改进,支持两种不同的限流方式:平滑突发限流(SmoothBursty)平滑预热限流(SmoothWarmingUp)

下面的方法可以创建一个平滑突发限流器(SmoothBursty):

RateLimiter limiter = RateLimiter.create(5); 

RateLimiter.create(5) 表示这个限流器容量为 5,并且每秒生成 5 个令牌,也就是每隔 200 毫秒生成一个。我们可以使用 limiter.acquire() 消费令牌,如果桶中令牌足够,返回 0,如果令牌不足,则阻塞等待,并返回等待的时间。我们连续请求几次:

System.out.println(limiter.acquire());
System.out.println(limiter.acquire());
System.out.println(limiter.acquire());
System.out.println(limiter.acquire());

输出结果如下:

0.0
0.198239
0.196083
0.200609

可以看出限流器创建之后,初始会有一个令牌,然后每隔 200 毫秒生成一个令牌,所以第一次请求直接返回 0,后面的请求都会阻塞大约 200 毫秒。另外,SmoothBursty 还具有应对突发的能力,而且 还允许消费未来的令牌,比如下面的例子:

RateLimiter limiter = RateLimiter.create(5);
System.out.println(limiter.acquire(10));
System.out.println(limiter.acquire(1));
System.out.println(limiter.acquire(1));

会得到类似下面的输出:

0.0
1.997428
0.192273
0.200616

限流器创建之后,初始令牌只有一个,但是我们请求 10 个令牌竟然也通过了,只不过看后面请求发现,第二次请求花了 2 秒左右的时间把前面的透支的令牌给补上了。

Guava 支持的另一种限流方式是平滑预热限流器(SmoothWarmingUp),可以通过下面的方法创建:

RateLimiter limiter = RateLimiter.create(2, 3, TimeUnit.SECONDS);
System.out.println(limiter.acquire(1));
System.out.println(limiter.acquire(1));
System.out.println(limiter.acquire(1));
System.out.println(limiter.acquire(1));
System.out.println(limiter.acquire(1));

第一个参数还是每秒创建的令牌数量,这里是每秒 2 个,也就是每 500 毫秒生成一个,后面的参数表示从冷启动速率过渡到平均速率的时间间隔,也就是所谓的热身时间间隔(warm up period)。我们看下输出结果:

0.0
1.329289
0.994375
0.662888
0.501287

第一个请求还是立即得到令牌,但是后面的请求和上面平滑突发限流就完全不一样了,按理来说 500 毫秒就会生成一个令牌,但是我们发现第二个请求却等了 1.3s,而不是 0.5s,后面第三个和第四个请求也等了一段时间。不过可以看出,等待时间在慢慢的接近 0.5s,直到第五个请求等待时间才开始变得正常。从第一个请求到第五个请求,这中间的时间间隔就是热身阶段,可以算出热身的时间就是我们设置的 3 秒。

关于热身的算法很有意思,也比较烧脑,有兴趣的同学可以参考 这里这里 的过程分析。

3.2 Bucket4j

Bucket4j 是一个基于令牌桶算法实现的强大的限流库,它不仅支持单机限流,还支持通过诸如 HazelcastIgniteCoherenceInfinispan 或其他兼容 JCache API (JSR 107) 规范的分布式缓存实现分布式限流。

在使用 Bucket4j 之前,我们有必要先了解 Bucket4j 中的几个核心概念:

  • Bucket
  • Bandwidth
  • Refill

Bucket 接口代表了令牌桶的具体实现,也是我们操作的入口。它提供了诸如 tryConsumetryConsumeAndReturnRemaining 这样的方法供我们消费令牌。可以通过下面的构造方法来创建 Bucket

Bucket bucket = Bucket4j.builder().addLimit(limit).build();
if(bucket.tryConsume(1)) {
    System.out.println("ok");
} else {
    System.out.println("error");
}

Bandwidth 的意思是带宽,可以理解为限流的规则。Bucket4j 提供了两种方法来创建 Bandwidth:simpleclassic。下面是 simple 方式创建的 Bandwidth,表示桶大小为 10,填充速度为每分钟 10 个令牌:

Bandwidth limit = Bandwidth.simple(10, Duration.ofMinutes(1));

simple 方式桶大小和填充速度是一样的,classic 方式更灵活一点,可以自定义填充速度,下面的例子表示桶大小为 10,填充速度为每分钟 5 个令牌:

Refill filler = Refill.greedy(5, Duration.ofMinutes(1));
Bandwidth limit = Bandwidth.classic(10, filler);

其中,Refill 用于填充令牌桶,可以通过它定义填充速度,Bucket4j 有两种填充令牌的策略:间隔策略(intervally)贪婪策略(greedy)。在上面的例子中我们使用的是贪婪策略,如果使用间隔策略可以像下面这样创建 Refill

Refill filler = Refill.intervally(5, Duration.ofMinutes(1));

所谓间隔策略指的是每隔一段时间,一次性的填充所有令牌,比如上面的例子,会每隔一分钟,填充 5 个令牌,如下所示:

intervally.png

而贪婪策略会尽可能贪婪的填充令牌,同样是上面的例子,会将一分钟划分成 5 个更小的时间单元,每隔 12 秒,填充 1 个令牌,如下所示:

greedy.png

在了解了 Bucket4j 中的几个核心概念之后,我们再来看看官网介绍的一些特性:

  • 基于令牌桶算法
  • 高性能,无锁实现
  • 不存在精度问题,所有计算都是基于整型的
  • 支持通过符合 JCache API 规范的分布式缓存系统实现分布式限流
  • 支持为每个 Bucket 设置多个 Bandwidth
  • 支持同步和异步 API
  • 支持可插拔的监听 API,用于集成监控和日志
  • 不仅可以用于限流,还可以用于简单的调度

Bucket4j 提供了丰富的文档,推荐在使用 Bucket4j 之前,先把官方文档中的 基本用法高级特性 仔细阅读一遍。另外,关于 Bucket4j 的使用,推荐这篇文章 Rate limiting Spring MVC endpoints with bucket4j,这篇文章详细的讲解了如何在 Spring MVC 中使用拦截器和 Bucket4j 打造业务无侵入的限流方案,另外还讲解了如何使用 Hazelcast 实现分布式限流;另外,Rate Limiting a Spring API Using Bucket4j 这篇文章也是一份很好的入门教程,介绍了 Bucket4j 的基础知识,在文章的最后还提供了 Spring Boot Starter 的集成方式,结合 Spring Boot Actuator 很容易将限流指标集成到监控系统中。

和 Guava 的限流器相比,Bucket4j 的功能显然要更胜一筹,毕竟 Guava 的目的只是用作通用工具类,而不是用于限流的。使用 Bucket4j 基本上可以满足我们的大多数要求,不仅支持单机限流和分布式限流,而且可以很好的集成监控,搭配 Prometheus 和 Grafana 简直完美。值得一提的是,有很多开源项目譬如 JHipster API Gateway 就是使用 Bucket4j 来实现限流的。

Bucket4j 唯一不足的地方是它只支持请求频率限流,不支持并发量限流,另外还有一点,虽然 Bucket4j 支持分布式限流,但它是基于 Hazelcast 这样的分布式缓存系统实现的,不能使用 Redis,这在很多使用 Redis 作缓存的项目中就很不爽,所以我们还需要在开源的世界里继续探索。

3.3 Resilience4j

Resilience4j 是一款轻量级、易使用的高可用框架。用过 Spring Cloud 早期版本的同学肯定都听过 Netflix Hystrix,Resilience4j 的设计灵感就来自于它。自从 Hystrix 停止维护之后,官方也推荐大家使用 Resilience4j 来代替 Hystrix。

hystrix.png

Resilience4j 的底层采用 Vavr,这是一个非常轻量级的 Java 函数式库,使得 Resilience4j 非常适合函数式编程。Resilience4j 以装饰器模式提供对函数式接口或 lambda 表达式的封装,提供了一波高可用机制:重试(Retry)熔断(Circuit Breaker)限流(Rate Limiter)限时(Timer Limiter)隔离(Bulkhead)缓存(Caceh)降级(Fallback)。我们重点关注这里的两个功能:限流(Rate Limiter)隔离(Bulkhead),Rate Limiter 是请求频率限流,Bulkhead 是并发量限流。

Resilience4j 提供了两种限流的实现:SemaphoreBasedRateLimiterAtomicRateLimiterSemaphoreBasedRateLimiter 基于信号量实现,用户的每次请求都会申请一个信号量,并记录申请的时间,申请通过则允许请求,申请失败则限流,另外有一个内部线程会定期扫描过期的信号量并释放,很显然这是令牌桶的算法。AtomicRateLimiter 和上面的经典实现类似,不需要额外的线程,在处理每次请求时,根据距离上次请求的时间和生成令牌的速度自动填充。关于这二者的区别可以参考这篇文章 Rate Limiter Internals in Resilience4j

Resilience4j 也提供了两种隔离的实现:SemaphoreBulkheadThreadPoolBulkhead,通过信号量或线程池控制请求的并发数,具体的用法参考官方文档,这里不再赘述。

下面是一个同时使用限流和隔离的例子:

// 创建一个 Bulkhead,最大并发量为 150
BulkheadConfig bulkheadConfig = BulkheadConfig.custom()
    .maxConcurrentCalls(150)
    .maxWaitTime(100)
    .build();
Bulkhead bulkhead = Bulkhead.of("backendName", bulkheadConfig);

// 创建一个 RateLimiter,每秒允许一次请求
RateLimiterConfig rateLimiterConfig = RateLimiterConfig.custom()
    .timeoutDuration(Duration.ofMillis(100))
    .limitRefreshPeriod(Duration.ofSeconds(1))
    .limitForPeriod(1)
    .build();
RateLimiter rateLimiter = RateLimiter.of("backendName", rateLimiterConfig);

// 使用 Bulkhead 和 RateLimiter 装饰业务逻辑
Supplier<String> supplier = () -> backendService.doSomething();
Supplier<String> decoratedSupplier = Decorators.ofSupplier(supplier)
  .withBulkhead(bulkhead)
  .withRateLimiter(rateLimiter)
  .decorate();

// 调用业务逻辑
Try<String> try = Try.ofSupplier(decoratedSupplier);
assertThat(try.isSuccess()).isTrue();

Resilience4j 在功能特性上比 Bucket4j 强大不少,而且还支持并发量限流。不过最大的遗憾是,Resilience4j 不支持分布式限流。

3.4 其他

网上还有很多限流相关的开源项目,不可能一一介绍,这里列出来的只是冰山之一角:

可以看出,限流技术在实际项目中应用非常广泛,大家对实现自己的限流算法乐此不疲,新算法和新实现层出不穷。但是找来找去,目前还没有找到一款开源项目完全满足我的需求。

我的需求其实很简单,需要同时满足两种不同的限流场景:请求频率限流和并发量限流,并且能同时满足两种不同的限流架构:单机限流和分布式限流。下面我们就开始在 Spring Cloud Gateway 中实现这几种限流,通过前面介绍的那些项目,我们取长补短,基本上都能用比较成熟的技术实现,只不过对于最后一种情况,分布式并发量限流,网上没有搜到现成的解决方案,在和同事讨论了几个晚上之后,想出一种新型的基于双窗口滑动的限流算法,我在这里抛砖引玉,欢迎大家批评指正,如果大家有更好的方法,也欢迎讨论。

四、在网关中实现限流

在文章一开始介绍 Spring Cloud Gateway 的特性时,我们注意到其中有一条 Request Rate Limiting,说明网关自带了限流的功能,但是 Spring Cloud Gateway 自带的限流有很多限制,譬如不支持单机限流,不支持并发量限流,而且它的请求频率限流也是不尽人意,这些都需要我们自己动手来解决。

4.1 实现单机请求频率限流

Spring Cloud Gateway 中定义了关于限流的一个接口 RateLimiter,如下:

public interface RateLimiter<C> extends StatefulConfigurable<C> {
    Mono<RateLimiter.Response> isAllowed(String routeId, String id);
}

这个接口就一个方法 isAllowed,第一个参数 routeId 表示请求路由的 ID,根据 routeId 可以获取限流相关的配置,第二个参数 id 表示要限流的对象的唯一标识,可以是用户名,也可以是 IP,或者其他的可以从 ServerWebExchange 中得到的信息。我们看下 RequestRateLimiterGatewayFilterFactory 中对 isAllowed 的调用逻辑:

    @Override
    public GatewayFilter apply(Config config) {
        // 从配置中得到 KeyResolver
        KeyResolver resolver = getOrDefault(config.keyResolver, defaultKeyResolver);
        // 从配置中得到 RateLimiter
        RateLimiter<Object> limiter = getOrDefault(config.rateLimiter,
                defaultRateLimiter);
        boolean denyEmpty = getOrDefault(config.denyEmptyKey, this.denyEmptyKey);
        HttpStatusHolder emptyKeyStatus = HttpStatusHolder
                .parse(getOrDefault(config.emptyKeyStatus, this.emptyKeyStatusCode));

        return (exchange, chain) -> resolver.resolve(exchange).defaultIfEmpty(EMPTY_KEY)
                .flatMap(key -> {
                    // 通过 KeyResolver 得到 key,作为唯一标识 id 传入 isAllowed() 方法
                    if (EMPTY_KEY.equals(key)) {
                        if (denyEmpty) {
                            setResponseStatus(exchange, emptyKeyStatus);
                            return exchange.getResponse().setComplete();
                        }
                        return chain.filter(exchange);
                    }
                    // 获取当前路由 ID,作为 routeId 参数传入 isAllowed() 方法
                    String routeId = config.getRouteId();
                    if (routeId == null) {
                        Route route = exchange
                                .getAttribute(ServerWebExchangeUtils.GATEWAY_ROUTE_ATTR);
                        routeId = route.getId();
                    }
                    return limiter.isAllowed(routeId, key).flatMap(response -> {

                        for (Map.Entry<String, String> header : response.getHeaders()
                                .entrySet()) {
                            exchange.getResponse().getHeaders().add(header.getKey(),
                                    header.getValue());
                        }
                        // 请求允许,直接走到下一个 filter
                        if (response.isAllowed()) {
                            return chain.filter(exchange);
                        }
                        // 请求被限流,返回设置的 HTTP 状态码(默认是 429)
                        setResponseStatus(exchange, config.getStatusCode());
                        return exchange.getResponse().setComplete();
                    });
                });
    }

从上面的逻辑可以看出,通过实现 KeyResolver 接口的 resolve 方法就可以自定义要限流的对象了。

public interface KeyResolver {
    Mono<String> resolve(ServerWebExchange exchange);
}

比如下面的 HostAddrKeyResolver 可以根据 IP 来限流:

public class HostAddrKeyResolver implements KeyResolver {
    @Override
    public Mono<String> resolve(ServerWebExchange exchange) {
        return Mono.just(exchange.getRequest().getRemoteAddress().getAddress().getHostAddress());
    }
}

我们继续看 Spring Cloud Gateway 的代码发现,RateLimiter 接口只提供了一个实现类 RedisRateLimiter

redis-rate-limiter.png

很显然是基于 Redis 实现的限流,虽说通过 Redis 也可以实现单机限流,但是总感觉有些大材小用,而且对于那些没有 Redis 的环境很不友好。所以,我们要实现真正的本地限流。

我们从 Spring Cloud Gateway 的 pull request 中发现了一个新特性 Feature/local-rate-limiter,而且看提交记录,这个新特性很有可能会合并到 3.0.0 版本中。我们不妨来看下这个 local-rate-limiter 的实现:LocalRateLimiter.java,可以看出它是基于 Resilience4j 实现的:

    public Mono<Response> isAllowed(String routeId, String id) {
        Config routeConfig = loadConfiguration(routeId);

        // How many requests per second do you want a user to be allowed to do?
        int replenishRate = routeConfig.getReplenishRate();

        // How many seconds for a token refresh?
        int refreshPeriod = routeConfig.getRefreshPeriod();

        // How many tokens are requested per request?
        int requestedTokens = routeConfig.getRequestedTokens();

        final io.github.resilience4j.ratelimiter.RateLimiter rateLimiter = RateLimiterRegistry
                .ofDefaults()
                .rateLimiter(id, createRateLimiterConfig(refreshPeriod, replenishRate));

        final boolean allowed = rateLimiter.acquirePermission(requestedTokens);
        final Long tokensLeft = (long) rateLimiter.getMetrics().getAvailablePermissions();

        Response response = new Response(allowed, getHeaders(routeConfig, tokensLeft));
        return Mono.just(response);
    }

有意思的是,这个类 还有一个早期版本,是基于 Bucket4j 实现的:

    public Mono<Response> isAllowed(String routeId, String id) {

        Config routeConfig = loadConfiguration(routeId);

        // How many requests per second do you want a user to be allowed to do?
        int replenishRate = routeConfig.getReplenishRate();

        // How much bursting do you want to allow?
        int burstCapacity = routeConfig.getBurstCapacity();

        // How many tokens are requested per request?
        int requestedTokens = routeConfig.getRequestedTokens();

        final Bucket bucket = bucketMap.computeIfAbsent(id,
                (key) -> createBucket(replenishRate, burstCapacity));

        final boolean allowed = bucket.tryConsume(requestedTokens);

        Response response = new Response(allowed,
                getHeaders(routeConfig, bucket.getAvailableTokens()));
        return Mono.just(response);
    }

实现方式都是类似的,在上面对 Bucket4j 和 Resilience4j 已经作了比较详细的介绍,这里不再赘述。不过从这里也可以看出 Spring 生态圈对 Resilience4j 是比较看好的,我们也可以将其引入到我们的项目中。

4.2 实现分布式请求频率限流

上面介绍了如何实现单机请求频率限流,接下来再看下分布式请求频率限流。这个就比较简单了,因为上面说了,Spring Cloud Gateway 自带了一个限流实现,就是 RedisRateLimiter,可以用于分布式限流。它的实现原理依然是基于令牌桶算法的,不过实现逻辑是放在一段 lua 脚本中的,我们可以在 src/main/resources/META-INF/scripts 目录下找到该脚本文件 request_rate_limiter.lua

local tokens_key = KEYS[1]
local timestamp_key = KEYS[2]

local rate = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])

local fill_time = capacity/rate
local ttl = math.floor(fill_time*2)

local last_tokens = tonumber(redis.call("get", tokens_key))
if last_tokens == nil then
  last_tokens = capacity
end

local last_refreshed = tonumber(redis.call("get", timestamp_key))
if last_refreshed == nil then
  last_refreshed = 0
end

local delta = math.max(0, now-last_refreshed)
local filled_tokens = math.min(capacity, last_tokens+(delta*rate))
local allowed = filled_tokens >= requested
local new_tokens = filled_tokens
local allowed_num = 0
if allowed then
  new_tokens = filled_tokens - requested
  allowed_num = 1
end

if ttl > 0 then
  redis.call("setex", tokens_key, ttl, new_tokens)
  redis.call("setex", timestamp_key, ttl, now)
end

return { allowed_num, new_tokens }

这段代码和上面介绍令牌桶算法时用 Java 实现的那段经典代码几乎是一样的。这里使用 lua 脚本,主要是利用了 Redis 的单线程特性,以及执行 lua 脚本的原子性,避免了并发访问时可能出现请求量超出上限的现象。想象目前令牌桶中还剩 1 个令牌,此时有两个请求同时到来,判断令牌是否足够也是同时的,两个请求都认为还剩 1 个令牌,于是两个请求都被允许了。

有两种方式来配置 Spring Cloud Gateway 自带的限流。第一种方式是通过配置文件,比如下面所示的代码,可以对某个 route 进行限流:

spring:
  cloud:
    gateway:
      routes:
      - id: test
        uri: http://httpbin.org:80/get
        filters:
        - name: RequestRateLimiter
          args:
            key-resolver: '#{@hostAddrKeyResolver}'
            redis-rate-limiter.replenishRate: 1
            redis-rate-limiter.burstCapacity: 3

其中,key-resolver 使用 SpEL 表达式 #{@beanName} 从 Spring 容器中获取 hostAddrKeyResolver 对象,burstCapacity 表示令牌桶的大小,replenishRate 表示每秒往桶中填充多少个令牌,也就是填充速度。

第二种方式是通过下面的代码来配置:

@Bean
public RouteLocator myRoutes(RouteLocatorBuilder builder) {
  return builder.routes()
    .route(p -> p
      .path("/get")
      .filters(filter -> filter.requestRateLimiter()
        .rateLimiter(RedisRateLimiter.class, rl -> rl.setBurstCapacity(3).setReplenishRate(1)).and())
      .uri("http://httpbin.org:80"))
    .build();
}

这样就可以对某个 route 进行限流了。但是这里有一点要注意,Spring Cloud Gateway 自带的限流器有一个很大的坑,replenishRate 不支持设置小数,也就是说往桶中填充的 token 的速度最少为每秒 1 个,所以,如果我的限流规则是每分钟 10 个请求(按理说应该每 6 秒填充一次,或每秒填充 1/6 个 token),这种情况 Spring Cloud Gateway 就没法正确的限流。网上也有人提了 issue,support greater than a second resolution for the rate limiter,但还没有得到解决。

4.3 实现单机并发量限流

上面学习 Resilience4j 的时候,我们提到了 Resilience4j 的一个功能特性,叫 隔离(Bulkhead)。Bulkhead 这个单词的意思是船的舱壁,利用舱壁可以将不同的船舱隔离起来,这样如果一个船舱破损进水,那么只损失这一个船舱,其它船舱可以不受影响。借鉴造船行业的经验,这种模式也被引入到软件行业,我们把它叫做 舱壁模式(Bulkhead pattern)。舱壁模式一般用于服务隔离,对于一些比较重要的系统资源,如 CPU、内存、连接数等,可以为每个服务设置各自的资源限制,防止某个异常的服务把系统的所有资源都消耗掉。这种服务隔离的思想同样可以用来做并发量限流。

正如前文所述,Resilience4j 提供了两种 Bulkhead 的实现:SemaphoreBulkheadThreadPoolBulkhead,这也正是舱壁模式常见的两种实现方案:一种是带计数的信号量,一种是固定大小的线程池。考虑到多线程场景下的线程切换成本,默认推荐使用信号量。

在操作系统基础课程中,我们学习过两个名词:互斥量(Mutex)信号量(Semaphores)。互斥量用于线程的互斥,它和临界区有点相似,只有拥有互斥对象的线程才有访问资源的权限,由于互斥对象只有一个,因此任何情况下只会有一个线程在访问此共享资源,从而保证了多线程可以安全的访问和操作共享资源。而信号量是用于线程的同步,这是由荷兰科学家 E.W.Dijkstra 提出的概念,它和互斥量不同,信号允许多个线程同时使用共享资源,但是它同时设定了访问共享资源的线程最大数目,从而可以进行并发量控制。

下面是使用信号量限制并发访问的一个简单例子:

public class SemaphoreTest {

    private static ExecutorService threadPool = Executors.newFixedThreadPool(100);
    private static Semaphore semaphore = new Semaphore(10);

    public static void main(String[] args) {
        for (int i = 0; i < 100; i++) {
            threadPool.execute(new Runnable() {
                @Override
                public void run() {
                    try {
                        semaphore.acquire();
                        System.out.println("Request processing ...");
                        semaphore.release();
                    } catch (InterruptedException e) {
                        e.printStack();
                    }
                }
            });
        }
        threadPool.shutdown();
    }
}

这里我们创建了 100 个线程同时执行,但是由于信号量计数为 10,所以同时只能有 10 个线程在处理请求。说到计数,实际上,在 Java 里除了 Semaphore 还有很多类也可以用作计数,比如 AtomicLongLongAdder,这在并发量限流中非常常见,只是无法提供像信号量那样的阻塞能力:

public class AtomicLongTest {

    private static ExecutorService threadPool = Executors.newFixedThreadPool(100);
    private static AtomicLong atomic = new AtomicLong();

    public static void main(String[] args) {
        for (int i = 0; i < 100; i++) {
            threadPool.execute(new Runnable() {
                @Override
                public void run() {
                    try {
                        if(atomic.incrementAndGet() > 10) {
                            System.out.println("Request rejected ...");
                            return;
                        }
                        System.out.println("Request processing ...");
                        atomic.decrementAndGet();
                    } catch (InterruptedException e) {
                        e.printStack();
                    }
                }
            });
        }
        threadPool.shutdown();
    }
}

4.4 实现分布式并发量限流

通过在单机实现并发量限流,我们掌握了几种常用的手段:信号量、线程池、计数器,这些都是单机上的概念。那么稍微拓展下,如果能实现分布式信号量、分布式线程池、分布式计数器,那么实现分布式并发量限流不就易如反掌了吗?

关于分布式线程池,是我自己杜撰的词,在网上并没有找到类似的概念,比较接近的概念是资源调度和分发,但是又感觉不像,这里直接忽略吧。

关于分布式信号量,还真有这样的东西,比如 Apache Ignite 就提供了 IgniteSemaphore 用于创建分布式信号量,它的使用方式和 Semaphore 非常类似,参考这里。使用 Redis 的 ZSet 也可以实现分布式信号量,比如 这篇博客介绍的方法,还有《Redis in Action》这本电子书中也提到了这样的例子,教你如何实现 Counting semaphores。另外,Redisson 也实现了基于 Redis 的分布式信号量 RSemaphore,用法也和 Semaphore 类似。使用分布式信号量可以很容易实现分布式并发量限流,实现方式和上面的单机并发量限流几乎是一样的。

最后,关于分布式计数器,实现方案也是多种多样。比如使用 Redis 的 INCR 就很容易实现,更有甚者,使用 MySQL 数据库也可以实现。只不过使用计数器要注意操作的原子性,每次请求时都要经过这三步操作:取计数器当前的值、判断是否超过阈值,超过则拒绝、将计数器的值自增。这其实和信号量的 P 操作是一样的,而释放就对应 V 操作。

所以,利用分布式信号量和计数器就可以实现并发量限流了吗?问题当然没有这么简单。实际上,上面通过信号量和计数器实现单机并发量限流的代码片段有一个严重 BUG:

                        semaphore.acquire();
                        System.out.println("Request processing ...");
                        semaphore.release();

想象一下如果在处理请求时出现异常了会怎么样?很显然,信号量被该线程获取了,但是却永远不会释放,如果请求异常多了,这将导致信号量被占满,最后一个请求也进不来。在单机场景下,这个问题可以很容易解决,加一个 finally 就行了:

                    try {
                        semaphore.acquire();
                        System.out.println("Request processing ...");
                    } catch (InterruptedException e) {
                        e.printStack();
                    } finally {
                        semaphore.release();
                    }

由于无论出现何种异常,finally 中的代码一定会执行,这样就保证了信号量一定会被释放。但是在分布式系统中,就不是加一个 finally 这么简单了。这是因为在分布式系统中可能存在的异常不一定是可被捕获的代码异常,还有可能是服务崩溃或者不可预知的系统宕机,就算是正常的服务重启也可能导致分布式信号量无法释放。

对于这个问题,我和几个同事连续讨论了几个晚上,想出了两种解决方法:第一种方法是使用带 TTL 的计数器,第二种方法是基于双窗口滑动的一种比较 tricky 的算法。

第一种方法比较容易理解,我们为每个请求赋予一个唯一 ID,并在 Redis 里写入一个键值对,key 为 requests_xxx(xxx 为请求 ID),value 为 1,并给这个 key 设置一个 TTL(如果你的应用中存在耗时非常长的请求,譬如对于一些 WebSockket 请求可能会持续几个小时,还需要开一个线程定期去刷新这个 key 的 TTL)。然后在判断并发量时,使用 KEYS 命令查询 requests_* 开头的 key 的个数,就可以知道当前一共有多少个请求,如果超过并发量上限则拒绝请求。这种方法可以很好的应对服务崩溃或重启的问题,由于每个 key 都设置了 TTL,所以经过一段时间后,这些 key 就会自动消失,就不会出现信号量占满不释放的情况了。但是这里使用 KEYS 命令查询请求个数是一个非常低效的做法,在请求量比较多的情况下,网关的性能会受到严重影响。我们可以把 KEYS 命令换成 SCAN,性能会得到些许提升,但总体来说效果还是很不理想的。

针对第一种方法,我们可以进一步优化,不用为每个请求写一个键值对,而是为每个分布式系统中的每个实例赋予一个唯一 ID,并在 Redis 里写一个键值对,key 为 instances_xxx(xxx 为实例 ID),value 为这个实例当前的并发量。同样的,我们为这个 key 设置一个 TTL,并且开启一个线程定期去刷新这个 TTL。每接受一个请求后,计数器加一,请求结束,计数器减一,这和单机场景下的处理方式一样,只不过在判断并发量时,还是需要使用 KEYSSCAN 获取所有的实例,并计算出并发量的总和。不过由于实例个数是有限的,性能比之前的做法有了明显的提升。

第二种方法我称之为 双窗口滑动算法,结合了 TTL 计数器和滑动窗口算法。我们按分钟来设置一个时间窗口,在 Redis 里对应 202009051130 这样的一个 key,value 为计数器,表示请求的数量。当接受一个请求后,在当前的时间窗口中加一,当请求结束,在当前的时间窗口中减一,注意,接受请求和请求结束的时间窗口可能不是同一个。另外,我们还需要一个本地列表来记录当前实例正在处理的所有请求和请求对应的时间窗口,并通过一个小于时间窗口的定时线程(如 30 秒)来迁移过期的请求,所谓过期,指的是请求的时间窗口和当前时间窗口不一致。那么具体如何迁移呢?我们首先需要统计列表中一共有多少请求过期了,然后将列表中的过期请求时间更新为当前时间窗口,并从 Redis 中上一个时间窗口移动相应数量到当前时间窗口,也就是上一个时间窗口减 X,当前时间窗口加 X。由于迁移线程定期执行,所以过期的请求总是会被移动到当前窗口,最终 Redis 中只有当前时间窗口和上个时间窗口这两个时间窗口中有数据,再早一点的窗口时间中的数据会被往后迁移,所以可以给这个 key 设置一个 3 分钟或 5 分钟的 TTL。判断并发量时,由于只有两个 key,只需要使用 MGET 获取两个值相加即可。下面的流程图详细描述了算法的运行过程:

concurrent limiter.jpg

其中有几个需要注意的细节:

  1. 请求结束时,直接在 Redis 中当前时间窗口减一即可,就算是负数也没关系。请求列表中的该请求不用急着删除,可以打上结束标记,在迁移线程中统一删除(当然,如果请求的开始时间和结束时间在同一个窗口,可以直接删除);
  2. 迁移的时间间隔要小于时间窗口,一般设置为 30s;
  3. Redis 中的 key 一定要设置 TTL,时间至少为 2 个时间窗口,一般设置为 3 分钟;
  4. 迁移过程涉及到“从上一个时间窗口减”和“在当前时间窗口加”两个操作,要注意操作的原子性;
  5. 获取当前并发量可以通过 MGET 一次性读取两个时间窗口的值,不用 GET 两次;
  6. 获取并发量和判断并发量是否超限,这个过程也要注意操作的原子性。

总结

网关作为微服务架构中的重要一环,充当着一夫当关万夫莫开的角色,所以对网关服务的稳定性要求和性能要求都非常高。为保证网关服务的稳定性,一代又一代的程序员们前仆后继,想出了十八般武艺:限流、熔断、隔离、缓存、降级、等等等等。这篇文章从限流入手,详细介绍了限流的场景和算法,以及源码实现和可能踩到的坑。尽管限流只是网关的一个非常小的功能,但却影响到网关的方方面面,在系统架构的设计中至关重要。虽然我试着从不同的角度希望把限流介绍的更完全,但终究是管中窥豹,只见一斑,还有很多的内容没有介绍到,比如阿里开源的 Sentinel 组件也可以用于限流,因为篇幅有限未能展开。另外前文提到的 Netflix 不再维护 Hystrix 项目,这是因为他们把精力放到另一个限流项目 concurrency-limits 上了,这个项目的目标是打造一款自适应的,极具弹性的限流组件,它借鉴了 TCP 拥塞控制的算法(TCP congestion control algorithm),实现系统的自动限流,感兴趣的同学可以去它的项目主页了解更多内容。

本文篇幅较长,难免疏漏,如有问题,还望不吝赐教。

参考

  1. 微服务网关实战——Spring Cloud Gateway
  2. 《亿级流量网站架构核心技术》张开涛
  3. 聊聊高并发系统之限流特技
  4. 架构师成长之路之限流
  5. 微服务接口限流的设计与思考
  6. 常用4种限流算法介绍及比较
  7. 来谈谈限流-从概念到实现
  8. 高并发下的限流分析
  9. 计数器算法
  10. 基于Redis的限流系统的设计
  11. API 调用次数限制实现
  12. Techniques to Improve QoS
  13. An alternative approach to rate limiting
  14. Scaling your API with rate limiters
  15. Brief overview of token-bucket algorithm
  16. Rate limiting Spring MVC endpoints with bucket4j
  17. Rate Limiter Internals in Resilience4j
  18. 高可用框架Resilience4j使用指南
  19. 阿里巴巴开源限流系统 Sentinel 全解析
  20. spring cloud gateway 之限流篇
  21. 服务容错模式
  22. 你的API会自适应「弹性」限流吗?
扫描二维码,在手机上阅读!

新技术学习笔记:RabbitMQ

在分布式系统中,消息队列(Message Queue,简称 MQ) 用于交换系统之间的信息,是一个非常重要的中间组件。早在上世纪 80 年代,就已经有消息队列的概念了,不过当时叫做 TIB(The Information Bus),当时的消息队列大多是商业产品,直到 2001 年 Java 标准化组织(JCP)提出 JSR 914: Java Message Service (JMS) API,这是一个与平台无关的 API,为 Java 应用提供了统一的消息操作。 JMS 提供了两种消息模型:点对点(peer-2-peer)和发布订阅(publish-subscribe)模型,当前的大多数消息队列产品都可以支持 JMS,譬如:Apache ActiveMQ、RabbitMQ、Kafka 等。

不过,JMS 毕竟是一套 Java 规范,是和编程语言绑定在一起的,只能在 Java 类语言(比如 Scala、Groovy)中具有互用性,也就是说消息的生产者(Producer)和消费者(Consumer)都得用 Java 来编写。如何让不同的编程语言或平台相互通信呢?对于这个问题,摩根大通的 John O'Hara 在 2003 年提出了 AMQP(Advanced Message Queuing Protocol,高级消息队列协议)的概念,可以解决不同平台之间的消息传递交互问题,2004 到 2006 年之间,摩根大通和 iMatrix 公司一起着手 AMQP 标准的开发,并于 2006 年发布 AMQP 规范。AMQP 和 JMS 最大的区别在于它是一种通用的消息协议,更准确的说是一种 Wire Protocol(链接协议),AMQP 并不去限定 API 层的实现,而是只定义网络交换的数据格式,这和 HTTP 协议是类似的,使得 AMQP 天然就是跨平台的。

在之后的 2007 年,Rabbit 技术公司基于 AMQP 标准发布了 RabbitMQ 第一个版本。RabbitMQ 采用了 Erlang 语言开发,这是一种通用的面向并发的编程语言,使得 RabbitMQ 具有高性能、高并发的特点,不仅如此,RabbitMQ 还提供了集群扩展的能力,易于使用以及强大的开源社区支持,这让 RabbitMQ 在开源消息队列的产品中占有重要的一席之地。

rabbitmq.png

一、RabbitMQ 安装

RabbitMQ 是用 Erlang 语言开发的,所以安装 RabbitMQ 之前,首先要安装 Erlang,在 Windows 上安装 Erlang 非常简单,直接去官网下载 Erlang OTP 的安装包文件并按提示点击安装即可。安装完成之后,我们就可以从 RabbitMQ 的官网下载和安装 RabbitMQ。其他操作系统的安装参考 Downloading and Installing RabbitMQ

一切就绪后,我们运行 RabbitMQ Command Prompt,如果你采用的是 RabbitMQ 的默认安装路径,命令提示符会显示:

C:\Program Files\RabbitMQ Server\rabbitmq_server-3.7.7\sbin>

我们使用命令 rabbitmqctl status 查看 RabbitMQ 的服务状态:

$ rabbitmqctl status
Status of node rabbit@LAPTOP-MBA74KRU ...
[{pid,4248},
 {running_applications,
     [{rabbitmq_management,"RabbitMQ Management Console","3.7.7"},
      {rabbitmq_web_dispatch,"RabbitMQ Web Dispatcher","3.7.7"},
      {cowboy,"Small, fast, modern HTTP server.","2.2.2"},
      {amqp_client,"RabbitMQ AMQP Client","3.7.7"},
      {rabbitmq_management_agent,"RabbitMQ Management Agent","3.7.7"},
      {rabbit,"RabbitMQ","3.7.7"},
      {rabbit_common,"Modules shared by rabbitmq-server and rabbitmq-erlang-client","3.7.7"},
      {recon,"Diagnostic tools for production use","2.3.2"},
      {ranch_proxy_protocol,"Ranch Proxy Protocol Transport","1.5.0"},
      {ranch,"Socket acceptor pool for TCP protocols.","1.5.0"},
      {ssl,"Erlang/OTP SSL application","9.0"},
      {mnesia,"MNESIA  CXC 138 12","4.15.4"},
      {public_key,"Public key infrastructure","1.6"},
      {asn1,"The Erlang ASN1 compiler version 5.0.6","5.0.6"},
      {os_mon,"CPO  CXC 138 46","2.4.5"},
      {cowlib,"Support library for manipulating Web protocols.","2.1.0"},
      {jsx,"a streaming, evented json parsing toolkit","2.8.2"},
      {xmerl,"XML parser","1.3.17"},
      {inets,"INETS  CXC 138 49","7.0"},
      {crypto,"CRYPTO","4.3"},
      {lager,"Erlang logging framework","3.6.3"},
      {goldrush,"Erlang event stream processor","0.1.9"},
      {compiler,"ERTS  CXC 138 10","7.2.1"},
      {syntax_tools,"Syntax tools","2.1.5"},
      {syslog,"An RFC 3164 and RFC 5424 compliant logging framework.","3.4.2"},
      {sasl,"SASL  CXC 138 11","3.2"},
      {stdlib,"ERTS  CXC 138 10","3.5"},
      {kernel,"ERTS  CXC 138 10","6.0"}]},
 {listeners,
     [{clustering,25672,"::"},
      {amqp,5672,"::"},
      {amqp,5672,"0.0.0.0"},
      {http,15672,"::"},
      {http,15672,"0.0.0.0"}]},
 {vm_memory_calculation_strategy,rss},
 {vm_memory_high_watermark,0.4},
 {vm_memory_limit,3380019200},
 {disk_free_limit,50000000},
 {disk_free,358400446464},
 {run_queue,1},
 {uptime,6855},
 {kernel,{net_ticktime,60}}]

一般情况下,我们还会安装 RabbitMQ Management Plugin,先用 rabbitmq-plugins list 列出所有支持的插件:

$ rabbitmq-plugins list
Listing plugins with pattern ".*" ...
 Configured: E = explicitly enabled; e = implicitly enabled
 | Status: * = running on rabbit@LAPTOP-MBA74KRU
 |/
[  ] rabbitmq_amqp1_0                  3.7.7
[  ] rabbitmq_auth_backend_cache       3.7.7
[  ] rabbitmq_auth_backend_http        3.7.7
[  ] rabbitmq_auth_backend_ldap        3.7.7
[  ] rabbitmq_auth_mechanism_ssl       3.7.7
[  ] rabbitmq_consistent_hash_exchange 3.7.7
[  ] rabbitmq_event_exchange           3.7.7
[  ] rabbitmq_federation               3.7.7
[  ] rabbitmq_federation_management    3.7.7
[  ] rabbitmq_jms_topic_exchange       3.7.7
[E*] rabbitmq_management               3.7.7
[e*] rabbitmq_management_agent         3.7.7
[  ] rabbitmq_mqtt                     3.7.7
[  ] rabbitmq_peer_discovery_aws       3.7.7
[  ] rabbitmq_peer_discovery_common    3.7.7
[  ] rabbitmq_peer_discovery_consul    3.7.7
[  ] rabbitmq_peer_discovery_etcd      3.7.7
[  ] rabbitmq_peer_discovery_k8s       3.7.7
[  ] rabbitmq_random_exchange          3.7.7
[  ] rabbitmq_recent_history_exchange  3.7.7
[  ] rabbitmq_sharding                 3.7.7
[  ] rabbitmq_shovel                   3.7.7
[  ] rabbitmq_shovel_management        3.7.7
[  ] rabbitmq_stomp                    3.7.7
[  ] rabbitmq_top                      3.7.7
[  ] rabbitmq_tracing                  3.7.7
[  ] rabbitmq_trust_store              3.7.7
[e*] rabbitmq_web_dispatch             3.7.7
[  ] rabbitmq_web_mqtt                 3.7.7
[  ] rabbitmq_web_mqtt_examples        3.7.7
[  ] rabbitmq_web_stomp                3.7.7
[  ] rabbitmq_web_stomp_examples       3.7.7

使用下面的命令启用 Management Plugin

$ rabbitmq-plugins enable rabbitmq_management
Enabling plugins on node rabbit@LAPTOP-MBA74KRU:
rabbitmq_management
The following plugins have been configured:
  rabbitmq_management
  rabbitmq_management_agent
  rabbitmq_web_dispatch
Applying plugin configuration to rabbit@LAPTOP-MBA74KRU...
The following plugins have been enabled:
  rabbitmq_management
  rabbitmq_management_agent
  rabbitmq_web_dispatch

started 3 plugins.

然后访问 http://localhost:15672/ 就可以通过 Web UI 对 RabbitMQ 进行管理了(默认的用户名和密码是:guest/guest):

rabbitmq-webui-management.jpg

在生产环境安装 RabbitMQ 时,为了安全起见,我们最好在 Admin 标签下的 Users 里添加新的用户,并将 guest 用户移除。或者通过 rabbitmqctl 命令行:

$ rabbitmqctl add_vhost [vhost]
$ rabbitmqctl add_user [username] [password]  
$ rabbitmqctl set_user_tags [username] administrator  
$ rabbitmqctl set_permissions -p [vhost] [username] ".*" ".*" ".*"

关于 RabbitMQ 的安装,我们常常采用集群的形式,并且要保证消息队列服务的高可用性。这里有一篇文章可以参考《RabbitMQ集群安装配置+HAproxy+Keepalived高可用》

二、RabbitMQ 核心概念

RabbitMQ 中有一些概念需要我们在使用前先搞清楚,主要包括以下几个:Broker、Virtual Host、Exchange、Queue、Binding、Routing Key、Producer、Consumer、Connection、Channel。这些概念之间的关系如下图所示(图片来源):

rabbitmq-model.jpg

  1. Broker
    简单来说就是消息队列服务器的实体,类似于 JMS 规范中的 JMS provider。它用于接收和分发消息,有时候也称为 Message Broker 或者更直白的称为 RabbitMQ Server。
  2. Virtual Host
    和 Web 服务器中的虚拟主机(Virtual Host)是类似的概念,出于多租户和安全因素设计的,可以将 RabbitMQ Server 划分成多个独立的空间,彼此之间互相独立,这样就可以将一个 RabbitMQ Server 同时提供给多个用户使用,每个用户在自己的空间内创建 Exchange 和 Queue。
  3. Exchange
    交换机用于接收消息,这是消息到达 Broker 的第一站,然后根据交换机的类型和路由规则(Routing Key),将消息分发到特定的队列中去。常用的交换机类型有:direct (point-to-point)、topic (publish-subscribe) 和 fanout (multicast)。
  4. Queue
    生产者发送的消息就是存储在这里,在 JMS 规范里,没有 Exchange 的概念,消息是直接发送到 Queue,而在 AMQP 中,消息会经过 Exchange,由 Exchange 来将消息分发到各个队列中。消费者可以直接从这里取走消息。
  5. Binding
    绑定的作用就是把 Exchange 和 Queue 按照路由规则绑定起来,路由规则可由下面的 Routing Key 指定。
  6. Routing Key
    路由关键字,Exchange 根据这个关键字进行消息投递。
  7. Producer/Publisher
    消息生产者或发布者,产生消息的程序。
  8. Consumer/Subscriber
    消息消费者或订阅者,接收消息的程序。
  9. Connection
    生产者和消费者和 Broker 之间的连接,一个 Connection 实际上就对应着一条 TCP 连接。
  10. Channel
    由于 TCP 连接的创建和关闭开销非常大,如果每次访问 Broker 都建立一个 Connection,在消息量大的时候效率会非常低。Channel 是在 Connection 内部建立的逻辑连接,相当于一次会话,如果应用程序支持多线程,通常每个线程都会创建一个单独的 Channel 进行通讯,各个 Channel 之间完全隔离,但这些 Channel 可以公用一个 Connection。

关于 RabbitMQ 中的这些核心概念,实际上也是 AMQP 协议中的核心概念,可以参考官网上对 AMQP 协议的介绍:AMQP 0-9-1 Model ExplainedAMQP 0-9-1 Quick Reference

三、RabbitMQ 实战

这一节通过一些简单的 RabbitMQ 实例学习上面介绍的各个概念,这样可以对 RabbitMQ 的理念有个更深入的了解。

想要完整的学习 RabbitMQ,建议把 官网的 6 个例子 挨个实践一把,这 6 个例子非常经典,网上很多 RabbitMQ 的教程都是围绕这 6 个例子展开的。我们知道 AMQP 是跨平台的,支持绝大多数的编程语言,所以官网提供的这些例子也几乎囊括了绝大多数的编程语言,如:Python、Java、Ruby、PHP、C# 等,而且针对 Java 甚至还提供了 Spring AMQP 的版本,实在是非常贴心了。你可以根据需要选择相应编程语言的例子,这里以 Java 为例,分别是:

如果觉得阅读英文比较费劲,网上也有大量的中文教程,譬如:RabbitMQ 中文文档轻松搞定RabbitMQ专栏:RabbitMQ从入门到精通RabbitMQ指南,内容都是围绕这 6 个例子展开的。

rabbitmq-examples.png

上面是这几个例子的示意图。

第一个例子实现了一个最简单的生产消费模型,介绍了生产者(Producer)、消费者(Consumer)、队列(Queue)和消息(Message)的基本概念和关系,通过这个例子,我们可以学习如何发送消息,如何接受消息,这是最基础的消息队列的功能,只有一个生产者,也只有一个消费者,虽然简单,但是在日常工作中,有时也会使用这样的模型来做系统模块之间的解耦。

当发送的消息是一个复杂的任务,消费者在接受到这个任务后需要进行大量的计算时,这个队列叫做工作队列(Work Queue)或者任务队列(Task Queue),消费者被称之为 Worker,一个工作队列一般需要多个 Worker 对任务进行分发处理,这种设计具有良好的扩展性,如果要处理的任务太多,出现积压,只要简单的增加 Worker 数目即可。在第二个例子中实现了一个简单的工作队列模型,并介绍了两种任务调度的方法:循环调度公平调度,另外还学习了 消息确认消息持久化 的概念。

在第三个例子中介绍了发布/订阅模型(Publish/Subscribe)并构建了一个简单的日志系统,和前两个例子不一样的是,在这个例子中,所有的消费者都可以接受到生产者发送的消息,换句话说也就是,生产者发送的消息被广播给所有的消费者。在这个例子中我们学习了 交换机(Exchange) 的概念,在 RabbitMQ 的核心理念里,生产者不会直接发送消息给队列,而是发送给交换机,再由交换机将消息推送给特定的队列。消息从交换机推送到队列时会遵循一定的规则,这些规则就是 交换机类型(Exchange Type),常用的交换机类型有四种:直连交换机(direct)、主题交换机(topic)、头交换机(headers)和 扇型交换机(fanout)。值得注意的是,在前面的例子中没有指定交换机,实际上使用的是匿名交换机,这是一种特殊的直连交换机。而这个例子要实现的发布/订阅模型,实际上是扇型交换机。

在第四个例子中介绍了 路由(Routing)绑定(Bindings) 的概念。使用扇形交换机只能用来广播消息,没有足够的灵活性,可以使用直连交换机和路由来实现非常灵活的消息转发,在这个日志系统的例子中,我们根据日志的严重程度将消息投递到两个队列中,一个队列只接受 error 级别的日志,将日志保存到文件中,另一个队列接受所有级别的日志,并将日志输出到控制台。路由指的是生产者如何通过交换机将消息投递到特定队列,生产者一般首先通过 exchangeDeclare 声明好交换机,然后通过 basicPublish 将消息发送给该交换机,发送的时候可以指定一个 Routing Key 参数,交换机会根据交换机的类型和 Routing Key 参数将消息路由到某个队列。绑定是用来表示交换机和队列的关系,一般在消费者的代码中先通过 exchangeDeclarequeueDeclare 声明好交换机和队列,然后通过 queueBind 来将两者关联起来。在关联时,也可以指定一个 Routing Key 参数,为了和生产者的 Routing Key 区分开来,有时也叫做 Binding Key。只有生产者发送消息时指定的 Routing Key 和消费者绑定队列时指定的 Binding Key 完全一致时,消息才会被投递给该消费者声明的队列中。

从扇形交换机到直连交换机,再到主题交换机,实际上并没有太大的区别,只是路由的规则越来越细致和灵活。在第五个例子中,我们继续学习和改进这个简单的日志系统,消费者在订阅日志时,不仅要根据日志的严重程度,同时还希望根据日志的来源,像这种同时基于多个标准执行路由操作的情况,我们就要用到主题交换机。和直连交换机一样,在发送消息也需要指定一个 Routing Key,只不过这个 Routing Key 必须是以点号分割的特殊字符串,譬如 cron.info,kern.warn 等,消费者在绑定交换机和队列时也需要指定一个 Routing Key(Binding Key),这个 Binding Key 具有同样的格式,而且还可以使用一些特殊的匹配符来匹配路由(星号 * 匹配一个单词,井号 # 匹配任意数量单词),譬如 *.warn 可以用来匹配所有来源的警告日志。

在最后一个例子中,我们将学习更高级的主题,使用 RabbitMQ 实现一个远程过程调用(RPC)系统。这个例子和第二个例子介绍的工作队列是一样的,只不过在生产者将任务发送给消费者之后,还希望能从消费者那里得到任务的执行结果。这里生产者充当 RPC 系统中的客户端的角色,而消费者充当 RPC 系统中的服务器的角色。要实现 RPC 系统,必须声明两个队列,一个用来发送消息,一个用来接受回调。生产者在发送消息时,可以设置消息的属性,AMQP 协议中给消息预定义了 14 个属性,其中有一个属性叫做 reply_to,就是这里的回调队列。另外还有一个属性 correlation_id,可以将 RPC 的响应和请求关联起来。

所有例子的源码可以参考 这里,我就不一一列出了。下面仅对第二个例子(工作队列模型)的源码进行分析,因为这个例子很常用,我们在日常工作中会经常遇到。

首先我们来看生产者,我们省略掉创建和关闭 Connection、Channel 的部分,无论是生产者还是消费者,这个都是类似的。(完整代码

        channel.queueDeclare("hello-queue", false, false, false, null);
        for (int i = 1; i <= 10; i++) {
            String message = "Hello World" + StringUtils.repeat(".", i);
            channel.basicPublish("", "hello-queue", null, message.getBytes());
            System.out.println("Message Sent: " + message);
        }

可以看出生产者的核心代码实际上只有这两个函数:queueDeclare()basicPublish(),首先通过 queueDeclare() 函数声明一个队列 hello-queue,然后使用 basicPublish() 函数向这个队列发送消息。看到这里的代码你可能会有疑问,我们之前不是说在 RabbitMQ 里,生产者不会直接向队列发送消息,而是发送给交换机,再由交换机转发到各个队列吗?实际上,这里用到了 RabbitMQ 的 匿名转发(Nameless Exchange) 特性,在 RabbitMQ 里已经预置了几个交换机,比如:amq.direct、amq.fanout、amq.headers、amq.topic,它们的类型和它们的名字是一样的,amq.direct 就是 direct 类型的交换机,另外,还有一个空交换机,它也是 direct 类型,这个是 RabbitMQ 默认的交换机类型。一般情况下,我们在用 queueDeclare() 声明一个队列之后,还要用 queueBind() 绑定队列到某个交换机上,如下所示:

        channel.exchangeDeclare("hello-exchange", BuiltinExchangeType.DIRECT);
        channel.queueDeclare("hello-queue", false, false, false, null);
        channel.queueBind("hello-queue", "hello-exchange", "hello-key");

如果一个队列没有任何绑定,那么这个队列默认是绑定在空交换机上的。所以这里的生产者是将消息发送到空交换机,再由空交换机转发到 hello-queue 队列的。我们再来看消费者,下面的代码实现了任务的循环调度:(完整代码

        System.out.println(" [*] Waiting for messages. To exit press CTRL+C");
        channel.queueDeclare("hello-queue", false, false, false, null);
        channel.basicConsume("hello-queue", true, new DefaultConsumer(channel) {
            @Override
            public void handleDelivery(
                    String consumerTag,
                    Envelope envelope,
                    AMQP.BasicProperties properties,
                    byte[] body) throws IOException {
                try {
                    String message = new String(body, "UTF-8");
                    System.out.println("Message Recv: " + message);
                    int c = message.lastIndexOf(".") - message.indexOf(".");
                    if (c % 2 == 0) {
                        Thread.sleep(1000 * 5);
                    } else {
                        Thread.sleep(1000);
                    }
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        });

在消费者的代码里,我们也用 queueDeclare() 声明了 hello-queue 队列,和生产者的代码是一样的。这里为什么既要在生产者里声明队列,又要在消费者里声明队列呢?而且我们在看其他例子的代码时也会发现,如果要用 exchangeDeclare() 声明交换机 也会同时出现在生产者和消费者中。为了搞清楚它的作用,我们可以把生产者或消费者的这行代码去掉,看看会发生什么:如果在消费者里不声明队列,下面的 basicConsume() 函数会直接抛出 NOT_FOUND 异常;如果在生产者里不声明队列,basicPublish() 发送的消息会全部丢失。所以,无论是生产者发送消息,还是消费者消费消息,都需要先创建队列才行。那么这个队列到底是谁创建的呢?答案是:谁先执行谁创建。创建队列的操作是 幂等 的,也就是说调用多次只会创建一次队列。要注意的是,如果两次创建的时候参数不一样,后创建的会报错:PRECONDITION_FAILED - inequivalent arg。

使用 basicConsume() 函数对某个队列的消息进行消费非常简单,它会一直阻塞,等待消息的到来,这个函数接受一个 DefaultConsumer 对象参数,可以重写该对象的 handleDelivery() 函数,一旦消息到来,就会使用这个回调函数对消息进行处理。我们启动多个消费者实例,由于这些消费者同时消费 hello-queue 队列,RabbitMQ 会将消息挨个分配给消费者,而且是提前一次性分配好,这样每个消费者得到的消息数量是均衡的,所以叫做 循环调度

这里要特别说明的是 basicConsume() 函数的第二个参数 autoAck,这个参数表示是否开启 消息自动确认,这是 RabbitMQ 的 消息确认(Message Acknowledgment) 特性。消息确认机制可以保证消息不会丢失,默认情况下,一旦 RabbitMQ 将消息发送给了消费者,就会从内存中删除,如果这时消费者挂掉,所有发给这个消费者的正在处理或尚未处理的消息都会丢失掉。如果我们让消费者在处理完成之后,发送一个消息确认(也就是 ACK),通知 RabbitMQ 这个消息已经接收并且处理完毕了,那么 RabbitMQ 才可以安全的删除该消息。很显然我们这里把 autoAck 参数设置为 true,是没有消息确认机制的,可能会出现消息丢失的情况。

循环调度有一个明显的缺陷,因为每个任务的处理时间是不一样的,所以按任务的先后顺序依次分配很可能会导致消费者消费的任务是不平衡的。我这里简单的模拟了这种不平衡的场景,首先生产者发送了 10 个任务,消费者处理奇数任务的执行时间设置为 5s,偶数任务执行时间设置为 1s,然后启动两个消费者实例,按循环调度算法,每个消费者都会领到 5 个任务,从任务数量上看是平衡的。但是从执行结果看,第一个消费者跑了 25s 才执行完所有任务,而第二个消费者 5s 就跑完了所有任务。对于这种情况,我们引入了公平调度方式。

如何实现公平调度呢?如果能让 RabbitMQ 不提前分配任务,而是在消费者处理完一个任务时才给它分配,不就可以了么?其实这里就要用到上面提到的消息确认机制了,RabbitMQ 提供了 basicQos() 函数用于设置消费者支持同时处理多少个任务,basicQos(1) 表示消费者最多只能同时处理一个任务,所以 RabbitMQ 每次都只分配一个任务给它,而且在这个任务没有处理完成之前,RabbitMQ 也不会给它推送新的任务。

公平调度的实现代码如下:(完整代码

        System.out.println(" [*] Waiting for messages. To exit press CTRL+C");
        channel.basicQos(1);
        channel.queueDeclare("hello-queue", false, false, false, null);
        channel.basicConsume("hello-queue", false, new DefaultConsumer(channel) {
            @Override
            public void handleDelivery(
                    String consumerTag,
                    Envelope envelope,
                    AMQP.BasicProperties properties,
                    byte[] body) throws IOException {
                try {
                    String message = new String(body, "UTF-8");
                    System.out.println("Message Recv: " + message);
                    int c = message.lastIndexOf(".") - message.indexOf(".");
                    if (c % 2 == 0) {
                        Thread.sleep(1000 * 5);
                    } else {
                        Thread.sleep(1000);
                    }
                } catch (Exception e) {
                    e.printStackTrace();
                } finally {
                    channel.basicAck(envelope.getDeliveryTag(), false);
                }
            }
        });

在这里 basicConsume() 函数的第二个参数设置成了 false,表示开启消息确认机制,而且在 handleDelivery() 函数中处理完消息后,通过 basicAck() 手工确认消息完成。确认的方法除了 basicAck,还有 basicNack 和 basicReject,它们的区别在于 basicNack 一次可以拒绝多条消息,而 basicReject 一次只能拒绝一条消息。

四、RabbitMQ 高级特性

通过上一节的学习,我们已经可以在我们的系统中使用 RabbitMQ 了,合理的采用消息队列,可以在程序中实现异步处理、应用解耦、流量削峰、消息通讯等等功能。除了这些消息队列的常规功能,RabbitMQ 还具有很多高级特性,这些特性大多是 RabbitMQ 对 AMQP 协议的扩展实现,更多的特性可以参考 官网文档:Protocol Extensions。这一节我们将学习延迟队列、优先级队列和持久化。

4.1 延迟队列

有时候我们不希望我们的消息立即被消费者消费,比如在网上购物时,如果用户下单完成后超过三十分钟未付款,订单需要自动取消,这个是延迟队列的一种典型应用场景,要实现这个功能,我们可以使用定时任务来实现,每隔一分钟扫描一次订单状态,但是这种做法显然效率太低了。当然,我们也可以用 DelayQueue、Timer、ScheduledExecutorService、Quartz 等带有调度功能的工具来实现,可以参考这篇博客中的相应实现:你真的了解延时队列吗。不过今天我们的重点是用 RabbitMQ 实现延迟队列。

延迟队列一般分为两种:基于消息的延迟和基于队列的延迟。基于消息的延迟是指为每条消息设置不同的延迟时间,那么每当队列中有新消息进入的时候就会重新根据延迟时间排序,显然这样做效率不是很高。实际应用中大多采用基于队列的延迟,每个队列中消息的延迟时间都是相同的,这样可以省去排序消息的工作,只需要检测超时时间按顺序投递即可。

事实上,RabbitMQ 并没有直接支持延迟队列,但是可以通过它的两个特性模拟出延迟队列来,这两个特性是:Time-To-Live ExtensionsDead Letter Exchanges

Time-To-Live Extensions 让我们可以在 RabbitMQ 里为消息或者队列设置过期时间(TTL,time to live),单位为毫秒,当一条消息被设置了 TTL 或者进入设置了 TTL 的队列时,这条消息会在经过 TTL 毫秒后成为 死信(Dead Letter)。我们可以像下面这样通过 x-message-ttl 参数定义一个延迟队列:

Map<String, Object> args = new HashMap<String, Object>();
args.put("x-message-ttl", 60 * 1000);
channel.queueDeclare(queueName, false, false, false, args);

上面这个延迟队列的 TTL 为 60 秒,也就是说,在这个队列中的消息,超过 60 秒就会变成死信。在 RabbitMQ 中,除了过期的消息,还有两种情况消息可能会变成死信,第一种情况是消息被拒绝,并且没有设置 requeue,第二种情况是消息队列如果已满,再往该队列投递消息也会变成死信。那么 RabbitMQ 是如何处理这些死信的呢?

在上面的例子中,我们为队列设置了一个 x-message-ttl 参数,我们还可以给队列添加另一个参数 x-dead-letter-exchange,这个就是 Dead Letter Exchange(DLX),这个参数决定了当某个队列中出现死信时会被转移到哪?DLX 是一个普通的交换机,和其他的交换机没有任何区别,死信被投递到 DLX 后,通过 DLX 再路由到其他队列,这取决于你给 DLX 绑定了哪些队列。另外,死信被投递到 DLX 时还可以通过参数 x-dead-letter-routing-key 指定 Routing Key。下面这个图很好的阐述了这个过程:(图片来源

rabbitmq-ttl-dlx.png

把 TTL 和 DLX 综合起来实现一个延迟队列如下:

// 创建 DLX
channel.exchangeDeclare("this-is-my-dlx", "direct");

// 设置队列的 TTL 和 DLX
Map<String, Object> args = new HashMap<String, Object>();
args.put("x-message-ttl", 60 * 1000);
args.put("x-dead-letter-exchange", "this-is-my-dlx");
args.put("x-dead-letter-routing-key", "");
channel.queueDeclare(queueName, false, false, false, args);

这里省略了消费者的代码,消费者可以创建一个队列,并绑定到 this-is-my-dlx 这个交换机上,当这个队列中有消息到达时,说明有消息超时了,譬如订单创建超过 30 分钟了,这时去判断订单是否已经付款,如果未付款,则取消订单。

如前文所述,不仅可以设置队列的超时时间,我们也可以设置消息的超时时间:

AMQP.BasicProperties.Builder properties = new AMQP.BasicProperties().builder().expiration("60000");
channel.basicPublish("exchangeName", "routeKey", properties.build(), "Hello".getBytes());

4.2 优先级队列

在 RabbitMQ 中我们可以使用 x-max-priority 参数将队列标记为优先级队列,优先级的值是一个整数,优先级的值越大,越被提前消费。x-max-priority 参数的值限制了优先级的最大值,一般不宜设置的太大。

Map<String, Object> args= new HashMap<String, Object>();
args.put("x-max-priority", 10);
channel.queueDeclare("priority-queue", false, false, false, args);

优先级队列在 RabbitMQ 管理页面的 Features 里可以看到 Pri 标志:

rabbitmq-priority-queue.jpg

我们按优先级 1 ~ 5 依次发送 5 条消息到这个队列:

for (int i = 1; i <= 5; i++) {
    AMQP.BasicProperties.Builder properties = new AMQP.BasicProperties().builder().priority(i);
    channel.basicPublish("", "priority-queue", properties.build(), ("Hello World" + i).getBytes());
}

然后启动消费者,可以看到 5 条消息并不是按顺序接受的,而是按优先级从大到小排序的:

 [*] Waiting for messages. To exit press CTRL+C
Message Recv: Hello World5
Message Recv: Hello World4
Message Recv: Hello World3
Message Recv: Hello World2
Message Recv: Hello World1

发送消息时,优先级不要超过 x-max-priority 的值,超过 x-max-priority 时按 x-max-priority 处理。另外有一点要注意:在这个例子里,我们不能先启动消费者,否则我们还是会看到消息是按顺序接受的,这是因为消息的优先级是在有消息堆积的时候才会有意义,如果消费者的消费速度比生产者的生产速度快,那么生产者刚发送完一条消息就被消费者消费了,队列中最多只有一条消息,还谈什么优先级呢。

4.3 持久化

在前面的例子里,我们学习了 RabbitMQ 的消息确认机制,这个机制可以保证消息不会由于消费者的崩溃而丢失。但是如果是 RabbitMQ 服务崩溃退出了呢?我们该如何保证交换机、队列以及队列中的消息在 RabbitMQ 服务崩溃之后不丢失呢?这就是持久化要解决的问题。在声明交换机和队列时,可以把 durable 设置为 true,在发送消息时,可以设置消息的 deliveryMode 属性为 2,如下:

持久化的交换机:

channel.exchangeDeclare("durable-exchange", BuiltinExchangeType.DIRECT, /*durable*/true);

持久化的队列:

channel.queueDeclare("durable-queue", /*durable*/true, false, false, null);

持久化的消息:

AMQP.BasicProperties.Builder properties = new AMQP.BasicProperties().builder().deliveryMode(2);
channel.basicPublish("", "durable-queue", properties.build(), "Hello World".getBytes());

为方便起见,也可以直接使用内置的 MessageProperties.PERSISTENT_TEXT_PLAIN 静态变量,可以看一下它的实现,实际上就是 deliveryMode = 2 的一个简单封装:

channel.basicPublish("", "durable-queue", MessageProperties.PERSISTENT_TEXT_PLAIN, "Hello World".getBytes());

关于持久化的话题,我们可以再深入研究一下。为了防止消费者丢消息,我们采取了消息确认机制;为了防止服务器丢消息,我们将交换机、队列和消息都设置成持久化的。但是这样就能万无一失了吗?答案是否定的。问题就在于持久化是需要将消息保存到磁盘的,如果在保存到磁盘的过程中 RabbitMQ 崩溃,消息一样会丢失。要解决这个问题,一个可选的方案是使用 RabbitMQ 的事务机制,不过事务机制会带来大量的开销,性能不高,所以又引入了 Publisher Confirm 机制。推荐王磊的这篇博客 《RabbitMQ事务和Confirm发送方消息确认——深入解读》

总结

通过这篇博客,我们学习了 AMQP 协议 和 RabbitMQ 的基本概念,并学习了 RabbitMQ 的安装和管理,通过官网的 6 个例子,掌握了交换机的几种常见类型:direct、fanout 和 topics,最后通过延迟队列、优先级队列和消息的持久化,我们还学习了 RabbitMQ 的一些高级特性。可以看出消息队列的功能非常丰富,我们常常在消息队列选型时,要综合考虑各种因素,功能是最重要的一条,InfoQ 上的这篇文章 《消息中间件选型分析:从Kafka与RabbitMQ的对比看全局》 介绍了更多要考虑的点。另外,限于篇幅,很多 RabbitMQ 的知识点没有展开,比如 RabbitMQ 的管理和监控,集群安装,事务和 Publisher Confirm 机制等。本文中所有代码使用的都是 amqp-client,如果你在用 Spring Boot,推荐使用 spring-boot-starter-amqp,这里 是官网的教程。

参考

  1. RabbitMQ Tutorials
  2. RabbitMQ中文 文档站
  3. Messaging with RabbitMQ
  4. 消息队列之JMS和AMQP对比
  5. RabbitMQ入门指南
  6. RabbitMQ与AMQP协议详解
  7. RabbitMQ从入门到精通
  8. 消息队列之 RabbitMQ
  9. 高可用RabbitMQ集群安装配置
  10. 基于 RabbitMQ 的实时消息推送
  11. 消息中间件选型分析:从Kafka与RabbitMQ的对比看全局
  12. 详细介绍Spring Boot + RabbitMQ实现延迟队列
  13. 你真的了解延时队列吗(一)
  14. RabbitMQ入门教程(十):队列声明queueDeclare
  15. Introducing Publisher Confirms
扫描二维码,在手机上阅读!

新技术学习笔记:ZooKeeper

第一次接触 ZooKeeper 是在使用 Dubbo 服务框架的时候,当时对 ZooKeeper 只是停留在知道和了解的层面,公司的 ZooKeeper 都是由运维统一安装和管理,对于我们开发人员来说就是在程序的配置文件中加一行注册中心的地址而已。后来又在另一个分布式的项目中使用了 ZooKeeper 来进行配置的管理,可还是对其一知半解,从来没有深入学习过 ZooKeeper 的知识。最近在工作中接触了 IaaS 和 PaaS,被各种新技术转的晕乎不已,在做技术决策的时候,之前学过的东西都太肤浅了,根本没办法对各种技术方案做横向对比。所以决定花一点时间好好的学习和总结下这些技术,今天就从 ZooKeeper 开始。

ZooKeeper 由 Apache Hadoop 的子项目发展而来,并且在 2010 年 11 月正式成为了 Apache 的顶级项目。关于 ZooKeeper 的命名很有意思,动物园管理员,显然管理着一园的动物,比如:Hadoop(大象)、Hive(蜜蜂)、Pig(小猪)等等。

zookeeper-name.png

根据官网的介绍ZooKeeper is a high-performance coordination service for distributed applications,它是为分布式应用提供的一种高性能协调服务。基于对 ZAB 协议(ZooKeeper Atomic Broadcast,ZooKeeper 原子消息广播协议)的实现,它能够很好地保证分布式环境中数据的一致性。也正是基于这样的特性,使得 ZooKeeper 成为了解决分布式数据一致性问题的利器。利用 ZooKeeper,可以很容易的在分布式环境下实现统一命名服务、配置管理、锁和队列、状态同步、集群管理等功能。

一、ZooKeeper 的安装

ZooKeeper 的安装分单机模式和集群模式两种。单机模式非常简单,直接从 Apache ZooKeeper™ Releases 下载最新版本到本地并解压,就可以在 bin 目录下找到 ZooKeeper 的服务端(zkServer)和客户端(zkCli),在 Windows 环境对应 .cmd 后缀的文件,在 Linux 环境对应 .sh 后缀的文件。在运行之前,还需要做两步配置:

  1. 配置 JAVA_HOME 环境变量
  2. 修改配置文件,将 conf/zoo_sample.cfg 修改为 conf/zoo.cfg

准备就绪后,直接运行 zkServer 文件,如果看到下面的显示,就说明 ZooKeeper 服务已经启动好了。

2018-08-04 10:06:22,525 [myid:] - INFO  [main:ZooKeeperServer@829] - tickTime set to 2000
2018-08-04 10:06:22,525 [myid:] - INFO  [main:ZooKeeperServer@838] - minSessionTimeout set to -1
2018-08-04 10:06:22,527 [myid:] - INFO  [main:ZooKeeperServer@847] - maxSessionTimeout set to -1
2018-08-04 10:06:23,149 [myid:] - INFO  [main:NIOServerCnxnFactory@89] - binding to port 0.0.0.0/0.0.0.0:2181

为了保证服务的稳定和可靠,生产环境大多是部署 ZooKeeper 的集群模式,集群模式和单机模式相比,有两点不同:

  1. 配置文件中要指定集群中所有机器的信息,形如:server.id=host:port1:port2
  2. dataDir 目录下要配置一个 myid 文件

一个典型的 ZooKeeper 配置文件如下:

#常规配置
tickTime=2000
initLimit=10
syncLimit=5
clientPort=2181
dataDir=/zookeeper/data
dataLogDir=/zookeeper/logs
 
# 集群配置
server.1=192.168.0.101:2888:3888
server.2=192.168.0.102:2888:3888
server.3=192.168.0.103:2888:3888

关于 ZooKeeper 集群模式的部署和各参数的意思,可以参考 Zookeeper集群部署

二、ZooKeeper 核心概念

在安装好 ZooKeeper 服务之后,我们就可以进行体验了。但是在体验它之前,我们还需要了解相关的几个核心概念,比如它的数据模型,四种不同类型的节点,节点监听等等。

ZooKeeper 的数据模型是一个类似文件系统的树形结构,树的每一个节点叫做 znode,它像一个小型文件一样,可以存储少量的数据(一般不多于 1M,这是因为 ZooKeeper 的设计目标并不是传统的数据库,而是用来存储协同数据的),但它并不是一个文件,因为每个节点还可以有多个子节点,看上去又好像是一个文件夹一样。和文件系统一样,ZooKeeper 的根节点名字为 /,并使用节点的路径来唯一标识一个节点,比如 /app1/p_1。另外,还提供了命令 getset 来读写节点内容,命令 ls 来获取子节点列表,命令 createdelete 来创建和删除节点。但是要注意的是 ZooKeeper 中的路径只有绝对路径,没有相对路径,所以路径 ../data 是不合法的,也不存在 cd 这样的命令。下图是 ZooKeeper 数据模型的示意图(图片来源):

zookeeper-znodes.jpg

另外,一共有四种不同类型的节点:

  • 持久节点(PERSISTENT):默认的节点类型,节点一旦创建,除非显式的删除,否则一直存在;
  • 临时节点(EPHEMERAL):ZooKeeper 的客户端和服务器之间是采用长连接方式进行通信的,并通过心跳来保持连接,这个连接状态称为 session,客户端在创建临时节点之后,如果一直保持连接则这个节点有效,一旦连接断开,该节点就会被自动删除;注意,临时节点不能有子节点;
  • 持久顺序节点(PERSISTENT_SEQUENTIAL):默认情况下,ZooKeeper 是不允许创建同名节点的,如果该节点是顺序节点,ZooKeeper 就会自动在节点路径末尾添加递增的序号;
  • 临时顺序节点(EPHEMERAL_SEQUENTIAL):顺序节点,但是只有在客户端连接有效时存在;

准确来说,节点的类型只有持久和临时两种,顺序节点是指在创建节点时可以指定一个顺序标志,让节点名称添加一个递增的序号,但是节点一旦创建好了,它要么是持久的,要么是临时的,只有这两种类型。这几种类型的节点虽然看上去很平常,但是它们正是实现 ZooKeeper 分布式协调服务的关键,如果再加上节点监听的特性,可以说是无所不能。节点监听(Watch)可以用于监听节点的变化,包括节点数据的修改或者子节点的增删变化,一旦发生变化,可以立即通知注册该 Watch 的客户端。我们在后面的例子中将会看出这些特性结合在一起的强大威力。譬如我们在执行 get 命令查询节点数据时指定一个 Watch,那么当该节点内容发生变动时,就会触发该 Watch,要注意的是 Watch 只能被触发一次,如果要一直获得该节点数据变动的通知,那么就需要在触发 Watch 时重新指定一个 Watch。只有节点的读操作(例如:getlsstat)可以注册 Watch,写操作(例如:setcreatedelete)会触发 Watch 事件。

三、使用 ZooKeeper 客户端

接下来我们使用 ZooKeeper 客户端来体验下 ZooKeeper 的基本功能。如果是访问本地环境的 ZooKeeper 服务,直接运行 zkCli 脚本即可。如果是访问远程的 ZooKeeper 服务,则使用 -server 参数:

$ zkCli.sh -server 192.168.0.101:2181

如果成功连接,客户端会出现类似下面的命令提示符:

[zk: localhost:2181(CONNECTED) 0] 

这时你就可以执行 ZooKeeper 命令了,譬如使用 help 查看可用命令列表:

[zk: localhost:2181(CONNECTED) 0] help
ZooKeeper -server host:port cmd args
        stat path [watch]
        set path data [version]
        ls path [watch]
        delquota [-n|-b] path
        ls2 path [watch]
        setAcl path acl
        setquota -n|-b val path
        history
        redo cmdno
        printwatches on|off
        delete path [version]
        sync path
        listquota path
        rmr path
        get path [watch]
        create [-s] [-e] path data acl
        addauth scheme auth
        quit
        getAcl path
        close
        connect host:port

从这个列表中,我们可以看到上面提到的几个基本命令:getsetlscreatedelete 等。譬如我们通过 ls 命令查看根节点 / 的子节点:

[zk: localhost:2181(CONNECTED) 1] ls /
[zookeeper]

通过 create 命令创建新节点:

[zk: localhost:2181(CONNECTED) 2] create /data Hello
Created /data
[zk: localhost:2181(CONNECTED) 3] get /data
Hello
cZxid = 0xa
ctime = Sat Aug 04 14:03:51 CST 2018
mZxid = 0xa
mtime = Sat Aug 04 14:03:51 CST 2018
pZxid = 0xa
cversion = 0
dataVersion = 0
aclVersion = 0
ephemeralOwner = 0x0
dataLength = 5
numChildren = 0

create 默认创建的是持久节点,可以指定参数 -e 创建临时节点或 -s 创建顺序节点。ZooKeeper 的基本命令都很简单,可以参考 ZooKeeper命令行操作

使用客户端命令行管理 ZooKeeper 节点和数据只是 ZooKeeper 客户端的一种方式,实际上,ZooKeeper 还提供了另一种客户端交互方式,可以直接使用 telnetnc 向 ZooKeeper 发送命令,用来获取 ZooKeeper 服务当前的状态信息。这些命令都是由四个字母组成,因此又叫做 四字命令(The Four Letter Words)

譬如,下面通过 ruok(Are you OK?) 命令查询 ZooKeeper 服务是否正常,ZooKeeper 返回 imok(I’m OK)表示服务状态正常。

$ echo ruok | nc localhost 2181
imok

四字命令按功能可以划分为四类:

  • 服务状态相关:ruok、conf、envi、srvr、stat、srst、isro
  • 客户连接相关:dump、cons、crst
  • 节点监听相关:wchs、wchc、wchp
  • 监控相关:mntr

关于四字命令的详细信息可以参考 ZooKeeper 官网手册 ZooKeeper Administrator's Guide

四、ZooKeeper 常见功能实现

如果只是使用命令行对 ZooKeeper 上的数据做些增删改查,还不足以说明 ZooKeeper 有什么特别的,无非就是一个小型的文件系统而已,只有把它用于我们的分布式项目中,才能看出它真正的作用。

4.1 第一个 ZooKeeper 应用

我们先从最简单的代码开始,连接 ZooKeeper 并创建一个节点:

public static void main(String[] args) throws Exception {
    ZooKeeper zookeeper = new ZooKeeper("localhost:2181", 5000, null);
    zookeeper.create("/data", "Hello world".getBytes(), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);
    zookeeper.close();
}

上面的代码首先创建一个连接,连接超时时间设置为 5 秒,然后创建一个名为 /data 的持久节点(PERSISTENT),并写入数据 "Hello World",最后关闭连接。上面的代码和 zkCli -server localhost:2181 create /data "Hello world" 命令是一样的。

实际上,这里的代码虽然简单的不能再简单了,但是却存在着一个 BUG,因为 new ZooKeeper() 只是向服务端发起连接,此时连接并没有创建成功,如果在连接创建之前调用了 zookeeper.create(),由于超时时间是 5 秒,如果在 5 秒内和服务端的连接还没有创建完成,此时就会抛出 ConnectionLossException

Caused by: org.apache.zookeeper.KeeperException$ConnectionLossException: KeeperErrorCode = ConnectionLoss for /data

这里正确的做法是使用 ZooKeeper 提供的 Watch 机制。上面在创建连接时 new ZooKeeper("localhost:2181", 5000, null),这里的第三个参数可以指定一个实现 Watcher 接口的对象,Watcher 接口只有一个方法 void process(WatchedEvent watchedEvent),这个方法会在连接创建成功的时候被调用。所以我们可以在 new ZooKeeper() 时设置一个 Watcher,然后通过 CountDownLatch.await() 阻塞程序执行,直到连接创建成功时,Watcher 的 process() 方法调用 CountDownLatch.countDown() 才开始执行下面的 create() 操作。下面是示例代码:

public class Simple implements Watcher {
    private CountDownLatch connectedSignal = new CountDownLatch(1);
    @Override
    public void process(WatchedEvent watchedEvent) {
        if (watchedEvent.getState() == Watcher.Event.KeeperState.SyncConnected) {
            connectedSignal.countDown();
        }
    }
    public void createNode() throws Exception {
        ZooKeeper zookeeper = new ZooKeeper("localhost:2181", 1000, this);
        connectedSignal.await();
        zookeeper.create("/data", "Hello world".getBytes(), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);
        zookeeper.close();
    }
    public static void main(String[] args) throws Exception {
        Simple simple = new Simple();
        simple.createNode();
    }
}

至此,我们可以成功连接 ZooKeeper 并创建节点了,对代码稍加改造就可以实现更多功能,譬如创建子节点,删除节点,修改节点数据等等。有了这些基础,接下来,就让我们来看看 ZooKeeper 可以实现哪些分布式高阶技巧。

4.2 统一命名服务(Name Service)

所谓命名服务,就是帮助我们对资源进行统一命名的服务,通常需要有一套完整的命名规则,既能够产生唯一的名称又便于人们识别和记住,通常情况下用树形的名称结构是一个理想的选择,树形的名称结构是一个有层次的目录结构,既对人友好又不会重复。使用命名服务可以更方便的对资源进行定位,比如计算机地址、应用提供的服务地址或者远程对象等。

想象一下 DNS,它就是一种命名服务,可以将域名转换为 IP 地址,这里的域名就是全局唯一的名称,方便人们记忆,而 IP 地址就是该名称对应的资源。再想象一下 JNDI,这也是一种命名服务,JNDI 的全称为 Java Naming and Directory Interface(Java 命名和目录接口),它是 J2EE 中重要的规范之一,标准的 J2EE 容器都提供了对 JNDI 规范的实现,它也是将有层次的目录结构关联到一定资源上。譬如我们在配置数据源时一般会在 JDBC 连接字符串中指定数据库的 IP 、端口、数据库名、用户名和密码等信息,这些信息如果散落在分布式应用的各个地方,不仅会给资源管理带来麻烦,比如当数据库 IP 发生变动时要对各个系统进行修改,而且数据库的用户名密码暴露在外,也存在安全隐患。使用 JNDI 可以方便的解决这两方面的问题。

在 ZooKeeper 中创建的所有节点都具有一个全局唯一的路径,其对应的节点可以保存一定量的信息,这个特性和命名服务不谋而合。所以如果你在分布式应用中需要用到自己的命名服务,使用 ZooKeeper 是个比较合适的选择。

4.3 配置管理(Configuration Management)

正如上面所说的数据库配置一样,在应用程序中一般还会用到很多其他的配置,这些配置往往都是写在某个配置文件中,程序在运行时从配置文件中读取。如果程序是单机应用,并且配置文件数量不多,变动也不频繁,这种做法倒没有什么大问题。但是在分布式系统中,每个系统都有大量的配置文件,而且某些配置项是相同的,如果这些配置项发生变动时,让运维人员在每台服务器挨个修改配置文件,这样的维护成本就太高了,不仅麻烦也容易出错。

配置管理(Configuration Management)在分布式系统中很常见,一般也叫做 发布与订阅,我们将所有的配置项统一放置在一个集中的地方,所有的系统都从这里获取相应的配置项,如果配置项发生变动,运维人员只需要在一个地方修改,其他系统都可以从这里获取变更。在 ZooKeeper 中可以创建一个节点,比如:/configuration,并将配置信息放在这个节点里,在应用启动的时候通过 getData() 方法,获取该节点的数据(也就是配置信息),并且在节点上注册一个 Watch,以后每次配置变动时,应用都会实时得到通知,应用程序获取最新数据并更新配置信息即可。

要实现配置管理的管理,我们首先实现配置数据的发布:

public class ConfigWriter {
    private ZooKeeper zookeeper;
    private String configPath;
    public ConfigWriter(ZooKeeper zookeeper, String configPath) {
        this.zookeeper = zookeeper;
        this.configPath = configPath;
    }
    public void writeConfig(String configData) throws KeeperException, InterruptedException {
        Stat stat = zookeeper.exists(configPath, false);
        if (stat == null) {
            zookeeper.create(configPath, configData.getBytes(), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);
        } else {
            zookeeper.setData(configPath, configData.getBytes(), -1);
        }
    }
    public static void main(String[] args) throws Exception {
        ZooKeeper zookeeper = new ZooKeeper("localhost:2181", 30000, null);
        ConfigWriter writer = new ConfigWriter(zookeeper, "/configuration");
        writer.writeConfig("Hello");
        zookeeper.close();
    }
}

先通过 exists() 方法判断 /configuration 节点是否存在,如果不存在,就使用 create() 方法创建一个并写入配置数据,如果已经存在,直接修改该节点的数据即可。每次配置变更时,我们就调用一次 updateConfig(zk, "/configuration", configData) 方法。然后我们再实现配置数据的订阅:

public class ConfigReader implements Watcher {
    private ZooKeeper zookeeper;
    private String configPath;
    public ConfigReader(ZooKeeper zookeeper, String configPath) {
        this.zookeeper = zookeeper;
        this.configPath = configPath;
    }
    @Override
    public void process(WatchedEvent watchedEvent) {
        if (watchedEvent.getType() == Watcher.Event.EventType.NodeDataChanged) {
            readConfig();
        }
    }
    public void readConfig() {
        try {
            byte[] data = zookeeper.getData(configPath, this, null/*stat*/);
            System.out.println(new String(data));
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
    public static void main(String[] args) throws Exception {
        ZooKeeper zookeeper = new ZooKeeper("localhost:2181", 30000, null);
        ConfigReader reader = new ConfigReader(zookeeper, "/configuration");
        reader.readConfig();
        Thread.sleep(Long.MAX_VALUE);
    }
}

和上面的创建 ZooKeeper 连接一样,我们的 ConfigReader 类实现了 Watcher 接口,并在调用 getData() 方法获取配置数据时注册这个 Watch,这样可以在节点数据发生变动时得到通知,得到通知之后,我们重新获取配置数据,并重新注册 Watch。

4.4 集群管理(Group Membership)

在分布式系统中,我们常常需要将多台服务器组成一个集群,这时,我们就需要对这个集群中的服务器进行管理,譬如:我们需要知道当前集群中有多少台服务器,当集群中某台服务器下线时需要及时知道,能方便的向集群中添加服务器。利用 Zookeeper 可以很容易的实现集群管理的功能,实现方法很简单,首先我们创建一个目录节点 /groups,用于管理所有集群中的服务器,然后每个服务器在启动时在 /groups 节点下创建一个 EPHEMERAL 类型的子节点,譬如 /member-1member-2 等,并在父节点 /groups 上调用 getChildren() 方法并设置 Watch,这个 Watch 会在 /groups 节点的子节点发生变化(增加或删除)时触发通知,由于每个服务器创建的子节点是 EPHEMERAL 类型的,当创建它的服务器下线时,这个子节点也会随之被删除,从而触发 Watch 通知,这样其它的所有服务器就知道集群中少了一台服务器,可以使用 getChildren() 方法获取集群的最新服务器列表,并重新注册 Watch。

我们实现一个最简单的集群管理程序:

public class GroupMember implements Watcher {
    private ZooKeeper zookeeper;
    private String groupPath;
    public GroupMember(ZooKeeper zookeeper, String groupPath) {
        this.zookeeper = zookeeper;
        this.groupPath = groupPath;
    }
    @Override
    public void process(WatchedEvent watchedEvent) {
        if (watchedEvent.getType() == Event.EventType.NodeChildrenChanged) {
            this.list();
        }
    }
    public void list() {
        try {
            List<String> members = zookeeper.getChildren(this.groupPath, this);
            System.out.println("Members: " + String.join(",", members));
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
    public void join(String memberName) {
        try {
            String path = zookeeper.create(
                    this.groupPath + "/" + memberName, null, ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL);
            System.out.println("Created: " + path);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
    public static void main(String[] args) throws Exception {
        ZooKeeper zookeeper = new ZooKeeper("localhost:2181", 30000, null);
        GroupMember member = new GroupMember(zookeeper, "/groups");
        member.join("member-" + new Random().nextInt(1000));
        member.list();
        Thread.sleep(Long.MAX_VALUE);
    }
}

程序启动时首先加入 /groups 集群,加入集群的方法是在 /groups 节点下创建一个 CreateMode.EPHEMERAL 类型的子节点。然后再获取该集群中的成员列表,同时我们注册了一个 Watch。我们每启动一个 GroupMember 实例,都会在 /groups 集群中添加一个成员,这将触发一个 NodeChildrenChanged 类型的事件,我们在 list() 方法中重新获取成员列表并注册 Watch。这样不仅可以监测到集群中有新成员加入,而且也可以对集群中成员的下线做监控。这里有一点要注意的是,当集群中有新成员加入时,Watch 可以及时通知,但有成员下线时,并不会及时通知,因为我们这里 new ZooKeeper() 时指定了连接的超时时间是 30 秒,ZooKeeper 只有在 30 秒超时之后才会触发 Watch 通知。

4.5 集群选主(Leader Election)

在上面的集群管理一节,我们看到了可以使用 EPHEMERAL 类型的节点,对集群中的成员进行管理和监控,其实集群管理除了成员的管理和监控功能之外,还有另一个功能,那就是:集群选主(Leader Election),也叫做 Leader 选举或 Master 选举。这个功能在分布式系统中往往很有用,比如,应用程序部署在不同的服务器上,它们都运行着相同的业务,如果我们希望某个业务逻辑只在集群中的某一台服务器上运行,就需要选择一台服务器出来作为主服务器。一般情况下,在一个集群中只有一台主服务器(Master 或 Leader),其他的都是从服务器(Slave 或 Follower)。我们刚刚已经在目录节点 /groups 下创建出一堆的成员节点 /member-1member-2 了,那么怎么知道哪个节点才是 Master 呢?

实现方法很简单,和前面一样,我们还是为每个集群成员创建一个 EPHEMERAL 节点,不同的是,它还是一个 SEQUENTIAL 节点,这样我们就可以给每个成员编号,然后选择编号最小的成员作为主服务器。这样做的好处是,如果主服务器下线,这个编号的节点也会被删除,然后通知集群中所有的成员,这些成员中又会出现一个编号是最小的,继而被选择当作新的主服务器。

我们把集群管理的代码稍微改造一下,就可以实现集群选主的功能:

    public void list() {
        try {
            List<String> members = zookeeper.getChildren(this.groupPath, this);
            System.out.println("Members: " + String.join(",", members));
            members.sort(Comparator.naturalOrder());
            if (this.currentNode.equals(this.groupPath + "/" + members.get(0))) {
                System.out.println("I'm the master");
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
    public void join(String memberName) {
        try {
            this.currentNode = zookeeper.create(
                    this.groupPath + "/" + memberName, null, ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL_SEQUENTIAL);
            System.out.println("Created: " + this.currentNode);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

我们在创建节点时,选择 CreateMode.EPHEMERAL_SEQUENTIAL 模式,并将创建的节点名称保存下来。使用 getChildren() 方法获取集群成员列表时,按序号排序,取序号最小的一个成员,如果和自己的节点名称一样,则可以认为自己就是主服务器。

上面介绍的这个方法可以动态的选出集群中的主服务器,所以又叫 动态选主,实际上,还有一种 静态选主 的方法,这个方法利用了 ZooKeeper 节点的全局唯一性,如果多个服务器同时创建 /master 节点,最终一定只有一个服务器创建成功,利用这个特性,谁创建成功,谁就是主服务器。这种方法非常简单粗暴,如果对可靠性要求不高,不需要考虑主服务器下线问题,可以考虑采用这种方法。

4.6 分布式锁(Locks)

在单个应用中,锁可以防止多个线程同时访问同一个资源,常用的编程语言都提供了锁机制很容易实现,但是在分布式系统中,要防止多个服务器同时访问同一个资源,就不好实现了。不过在上一节中,我们刚刚介绍了如何使用 ZooKeeper 来做集群选主,可以在多个服务器中选择一个服务器作为主服务器,这和分布式锁要求的多个服务器中只有一个服务器可以访问资源的概念是完全一样的。

我们介绍了两种集群选主的方法,刚好对应锁服务的两种类型:静态选主方法是让所有的服务器同时创建一个相同的节点 lock,最终只有一个服务器创建成功,那么创建成功的这个服务器就相当于获取了一个独占锁。动态选主方法是在某个目录节点 locks 下创建 EPHEMERAL_SEQUENTIAL 类型的子节点,譬如,lock-1lock-2 等,然后调用 getChildren() 方法获取子节点列表,将这些子节点按序号排序,编号最小的即获得锁,同时监听目录节点变化;释放锁就是将该子节点删除即可,那么其他所有服务器都会收到通知,每个服务器检查自己创建的节点是不是序号最小的,序号最小的服务器再次获取锁,依次反复。

我们假设有 100 台服务器试图获取锁,这些服务器都会在目录节点 locks 上监听变化,每次锁的释放和获取,也就是子节点的删除和新增,都会触发节点监听,所有的服务器都会得到通知,但是节点新增并不会发生锁变化,节点删除也只有序号最小的那个节点可以获取锁,其他节点都不会发生锁变化,像这种有大量的服务器得到通知而只有很小的一部分服务器对通知做出响应的现象,有时候又被称为 羊群效应(Herd Effect),这无疑对 ZooKeeper 服务器造成了很大的压力。

为了解决这个问题,我们可以不用关注 locks 目录节点下的子节点变化(删除和新增),也就是说不使用 getChildren() 方法注册节点监听,而是只关注比自己节点小的那个节点的变化,我们通过使用 exists() 方法注册节点监听,这里有一副流程图说明了整个加锁的过程(图片来源):

zookeeper-locks.png

下面是关键代码,完整代码参见这里

    public void lock() {
        try {
            String currentNode = zookeeper.create(
                    this.lockPath + "/lock-", null, ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL_SEQUENTIAL);
            List<String> members = zookeeper.getChildren(this.lockPath, false);
            members.sort(Comparator.naturalOrder());

            // 当前节点序号最小,成功获取锁
            String lowestNode = this.lockPath + "/" + members.get(0);
            if (currentNode.equals(lowestNode)) {
                return;
            }

            // 取序号比自己稍小一点的节点,对该节点注册监听,当该节点删除时获取锁
            String lowerNode = null;
            for (int i = 1; i < members.size(); i++) {
                String node = this.lockPath + "/" + members.get(i);
                if (currentNode.equals(node)) {
                    lowerNode = this.lockPath + "/" + members.get(i-1);
                    break;
                }
            }
            if (lowerNode != null && zookeeper.exists(lowerNode, this) != null) {
                latch.await();
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

4.7 栅栏和双栅栏(Barrier & Double Barrier)

栅栏(Barrier) 是用于阻塞一组线程执行的一种同步机制,只有当这组线程全部都准备就绪时,才开始继续执行,就好像赛马比赛,先要等所有的赛马都来到起跑线前准备就绪,然后才能开始比赛。如下图所示:

zookeeper-barrier.png

双栅栏的意思不言而喻,就是两道栅栏,第一道栅栏用于同步一组线程的开始动作,让一组线程同时开始,第二道栅栏用于同步一组线程的结束动作,让它们同时结束,这就好像在赛马比赛中,要等所有的赛马都跑到终点比赛才真正结束一样。

使用 ZooKeeper 实现栅栏很简单,和上面的集群选主和分布式锁类似,都是先创建一个目录节点 /barrier,然后每个线程挨个在这个节点下创建 EPHEMERAL_SEQUENTIAL 类型的子节点,譬如 node-1node-2 等,表示这个线程已经准备就绪,然后调用 getChildren() 方法获取子节点的个数,并设置节点监听,如果节点个数大于等于所有的线程个数,则表明所有的线程都已经准备就绪,然后开始执行后续逻辑。Barrier 的实现可以参考 ZooKeeper 官方的开发者文档

实际上这个算法还可以优化,使用 getChildren() 监听节点存在上文提到的羊群效应(Herd Effect)问题,我们可以在创建子节点时,根据子节点个数是否达到所有线程个数,来单独创建一个节点,譬如 /barrier/enter,表示所有线程都准备就绪,没达到的话就调用 exists() 方法监听 /barrier/enter 节点。这样只有在 /barrier/enter 节点创建时才需要通知所有线程,而不需要每加入一个节点都通知一次。双栅栏的算法可以采用同样的方法增加一个 /barrier/leave 节点来实现。

4.8 队列(Queue)

队列是一种满足 FIFO 规则的数据结构,在分布式应用中,队列经常用于实现生产者和消费者模型。使用 ZooKeeper 实现队列的思路是这样的:首先创建目录节点 /queue,然后生产者线程往该节点下写入 SEQUENTIAL 类型的子节点,比如 node-1node-2 等,由于是顺序节点,ZooKeeper 可以保证创建的子节点是按顺序递增的。消费者线程则是一直通过 getChildren() 方法读取 /queue 节点的子节点,取序号最小的节点(也就是最先入队的节点)进行消费。这里我们要注意的是,消费者首先需要调用 delete() 删除该节点,如果有多个线程同时删除该节点,ZooKeeper 的一致性可以保证只会有一个线程删除成功,删除成功的线程才可以消费该节点,而删除失败的线程通过 getChildren() 的节点监听继续等待队列中新元素。

总结

通过这篇文章我们学习了 ZooKeeper 的基本知识,可以使用命令行对 ZooKeeper 进行管理和监控,并实现了 ZooKeeper 一些常见的功能。实际上 ZooKeeper 提供的机制非常灵活,除了本文介绍的几种常用应用场景,ZooKeeper 能实现的功能还有很多,可以参考 ZooKeeper Recipes and SolutionsApache Curator Recipes

本文介绍的 ZooKeeper 功能都是基于官方提供的原生 API org.apache.zookeeper 来实现的,但是原生的 API 有一个问题,就是太底层了,不方便使用,而且很容易出错。因此 Netflix 的 Jordan Zimmerman 开发了 Curator 项目,并在 GitHub 上采用 Apache 2.0 协议开源了。在生产环境推荐直接使用 Curator 而不是原生的 API,可以大大简化 ZooKeeper 的开发流程,可以参考 Apache Curator Getting Started

本文偏重 ZooKeeper 的实践,通过本文的学习,对工作中遇到的常见场景应该基本能应付了。不过这篇文章缺少对其原理的深入分析,比如 ZooKeeper 的一致性是如何保证的,ZAB 协议和 Paxos 协议,恢复模式(选主)和广播模式(同步)是如何工作的等等,这些后面还需要继续学习。

参考

  1. Apache ZooKeeper documentation
  2. Zookeeper集群部署
  3. ZooKeeper: Because Coordinating Distributed Systems is a Zoo
  4. Programming with ZooKeeper - A basic tutorial
  5. ZooKeeper Recipes and Solutions
  6. 分布式服务框架 Zookeeper -- 管理分布式环境中的数据
  7. ZooKeeper介绍及典型使用场景
  8. 中小型研发团队架构实践:分布式协调服务ZooKeeper
  9. ZooKeeper基础知识
  10. Zookeeper 入门
  11. ZooKeeper命令行操作
  12. 关于命名服务的知识点都在这里了
  13. 分布式系统阅读笔记(十三)-----命名服务
扫描二维码,在手机上阅读!

最简单的一个 Spring Boot 项目

最近在项目中使用 Spring Boot,对它的简单易用印象很深刻。Spring Boot 最大的特点是它大大简化了传统 Spring 项目的配置,使用 Spring Boot 开发 Web 项目,几乎没有任何的 xml 配置。而且它最方便的地方在于它内嵌了 Servlet 容器(可以自己选择 Tomcat、Jetty 或者 Undertow),这样我们就不需要以 war 包来部署项目,直接使用 java -jar hello.jar 就可以运行一个 Web 项目。

我们以 Maven 项目为例,Spring Boot 除了支持 Maven,还支持 Gradle 项目。一个最简单的 Spring Boot Web 项目只有 3 个文件(其实如果想要更简单一点,入口和控制器类甚至可以写在同一个文件中)。首先是一个入口文件:

@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

然后再写一个控制器类:

@RestController
public class HelloController {
    @RequestMapping("/")
    public String index() {
        return "Hello World!";
    }
}

最后是这个项目的 POM(Project Object Model,项目对象模型) 文件:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.stonie</groupId>
    <artifactId>spring-boot-sample</artifactId>
    <version>1.0-SNAPSHOT</version>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.0.2.RELEASE</version>
    </parent>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
    </dependencies>

    <properties>
        <java.version>1.8</java.version>
    </properties>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

在新建 Spring Boot 项目的时候要注意一点,入口类必须放在某个包下面,而不能放在默认包(也就是说不能直接放在 srcmainjava 目录下),否则会导致项目启动失败(其实原因很简单,因为 Spring Boot 通过 @ComponentScan 来扫描 Bean,如果入口类放在默认包下,也就意味着 Spring Boot 要扫描所有 jar 包中的所有的类):

** WARNING ** : Your ApplicationContext is unlikely to start due to a @ComponentScan of the default package.

至此,我们就写好了一个 Spring Boot 项目,完整的源码可以参考 这里。从代码上看项目非常简单,但是这里有很多值得我们学习的地方。

一、从 SpringBootApplication 注解看 Spring Boot 自动配置原理

Spring Boot 项目通常都有一个入口类,入口类中的 main 方法和标准的 Java 应用入口方法是一样的,在上面的例子中,这个 main 方法中只有一行代码:SpringApplication.run(),这是一个静态方法,用于启动整个 Spring Boot 项目。和其他 Java 程序不一样的是,入口类上多了一个 @SpringBootApplication 注解,这是非常重要的一个注解,它由多个注解组合而成,包括了:@SpringBootConfiguration@EnableAutoConfiguration@ComponentScan 和其他一些注解。Spring Boot 是如何做到不需要任何配置文件的,看名字也可以猜出来,其秘密就在于 @EnableAutoConfiguration 这个注解实现了自动配置。这个注解的实现如下:

@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@AutoConfigurationPackage
@Import({AutoConfigurationImportSelector.class})
public @interface EnableAutoConfiguration {
    String ENABLED_OVERRIDE_PROPERTY = "spring.boot.enableautoconfiguration";
    Class<?>[] exclude() default {};
    String[] excludeName() default {};
}

其中最为重要的一行代码为:@Import({AutoConfigurationImportSelector.class}),其中 @Import 是 Spring 提供的一个注解,可以导入配置类或者 Bean 到当前类中。AutoConfigurationImportSelector 类的实现比较复杂,简单来说就是扫描所有 jar 包中的 META-INF/spring-factories 文件,这个文件中声明了有哪些自动配置。我们可以打开 spring-boot-autoconfigure.jar 文件,这里就有这个文件,其中定义了一个属性 org.springframework.boot.autoconfigure.EnableAutoConfiguration 如下所示(有删减):

# Auto Configure
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
org.springframework.boot.autoconfigure.admin.SpringApplicationAdminJmxAutoConfiguration,\
org.springframework.boot.autoconfigure.aop.AopAutoConfiguration,\
org.springframework.boot.autoconfigure.amqp.RabbitAutoConfiguration,\
...
org.springframework.boot.autoconfigure.data.ldap.LdapRepositoriesAutoConfiguration,\
org.springframework.boot.autoconfigure.data.mongo.MongoDataAutoConfiguration,\
org.springframework.boot.autoconfigure.data.mongo.MongoReactiveDataAutoConfiguration,\
org.springframework.boot.autoconfigure.data.mongo.MongoReactiveRepositoriesAutoConfiguration,\
org.springframework.boot.autoconfigure.data.mongo.MongoRepositoriesAutoConfiguration,\
org.springframework.boot.autoconfigure.data.neo4j.Neo4jDataAutoConfiguration,\
org.springframework.boot.autoconfigure.data.neo4j.Neo4jRepositoriesAutoConfiguration,\
org.springframework.boot.autoconfigure.data.solr.SolrRepositoriesAutoConfiguration,\
org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration,\
org.springframework.boot.autoconfigure.data.redis.RedisReactiveAutoConfiguration,\
org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration,\
org.springframework.boot.autoconfigure.data.rest.RepositoryRestMvcAutoConfiguration,\
org.springframework.boot.autoconfigure.data.web.SpringDataWebAutoConfiguration,\
...
org.springframework.boot.autoconfigure.ldap.embedded.EmbeddedLdapAutoConfiguration,\
org.springframework.boot.autoconfigure.ldap.LdapAutoConfiguration,\
org.springframework.boot.autoconfigure.liquibase.LiquibaseAutoConfiguration,\
org.springframework.boot.autoconfigure.mail.MailSenderAutoConfiguration,\
org.springframework.boot.autoconfigure.mail.MailSenderValidatorAutoConfiguration,\
org.springframework.boot.autoconfigure.mongo.embedded.EmbeddedMongoAutoConfiguration,\
org.springframework.boot.autoconfigure.mongo.MongoAutoConfiguration,\
org.springframework.boot.autoconfigure.mongo.MongoReactiveAutoConfiguration,\
org.springframework.boot.autoconfigure.mustache.MustacheAutoConfiguration,\
org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration,\
org.springframework.boot.autoconfigure.quartz.QuartzAutoConfiguration,\
...
org.springframework.boot.autoconfigure.web.client.RestTemplateAutoConfiguration,\
org.springframework.boot.autoconfigure.web.embedded.EmbeddedWebServerFactoryCustomizerAutoConfiguration,\
org.springframework.boot.autoconfigure.web.reactive.HttpHandlerAutoConfiguration,\
org.springframework.boot.autoconfigure.web.reactive.ReactiveWebServerFactoryAutoConfiguration,\
org.springframework.boot.autoconfigure.web.reactive.WebFluxAutoConfiguration,\
org.springframework.boot.autoconfigure.web.reactive.error.ErrorWebFluxAutoConfiguration,\
org.springframework.boot.autoconfigure.web.reactive.function.client.WebClientAutoConfiguration,\
org.springframework.boot.autoconfigure.web.servlet.DispatcherServletAutoConfiguration,\
org.springframework.boot.autoconfigure.web.servlet.ServletWebServerFactoryAutoConfiguration,\
org.springframework.boot.autoconfigure.web.servlet.error.ErrorMvcAutoConfiguration,\
org.springframework.boot.autoconfigure.web.servlet.HttpEncodingAutoConfiguration,\
org.springframework.boot.autoconfigure.web.servlet.MultipartAutoConfiguration,\
org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration,\
org.springframework.boot.autoconfigure.websocket.reactive.WebSocketReactiveAutoConfiguration,\
org.springframework.boot.autoconfigure.websocket.servlet.WebSocketServletAutoConfiguration,\
org.springframework.boot.autoconfigure.websocket.servlet.WebSocketMessagingAutoConfiguration,\
org.springframework.boot.autoconfigure.webservices.WebServicesAutoConfiguration

可以看到 Spring Boot 已经内置了大量的自动配置,我们查看我们这个项目的依赖关系,如下图:

spring-boot-dependency.png

我们这个项目中使用了 spring-boot-starter-web,可以看出它依赖于 spring-boot-starter-tomcat 和 spring-webmvc,所以这里会自动对 Tomcat 和 Spring MVC 进行配置。但是这里有一个问题,这里列出来的自动配置有那么多,难道 Spring Boot 都要一个个的去加载配置吗?当然不是,Spring Boot 也没那么傻,所以这里就要重点介绍一下从 Spring 4.x 开始引入的一个新特性:@Conditional(也叫 条件注解)。

@Conditional 可以根据条件来创建 Bean,譬如随便拿上面一个自动配置类 RedisAutoConfiguration 来看,其中用到的条件注解为 @ConditionalOnClass({RedisOperations.class}) 说明只有在 RedisOperations 类存在时才会自动配置,而我们这个项目并没有引入 redis,所以并不会加载 redis 的配置。

@Configuration
@ConditionalOnClass({RedisOperations.class})
@EnableConfigurationProperties({RedisProperties.class})
@Import({LettuceConnectionConfiguration.class, JedisConnectionConfiguration.class})
public class RedisAutoConfiguration {
}

那么我们的程序在启动的时候都自动加载了哪些配置呢?我们可以通过命令行参数 --debug 来启动 Spring Boot 应用:

$ java -jar hello.jar --debug

启动时控制台会打印出详情的信息,类似于下面这样(实际打印的日志会非常多,有兴趣的同学可以自行挖掘):

============================
CONDITIONS EVALUATION REPORT
============================

Positive matches:
-----------------

   EmbeddedWebServerFactoryCustomizerAutoConfiguration.TomcatWebServerFactoryCustomizerConfiguration matched:
      - @ConditionalOnClass found required classes 'org.apache.catalina.startup.Tomcat', 'org.apache.coyote.UpgradeProtocol'; @ConditionalOnMissingClass did not find unwanted class (OnClassCondition)

   ServletWebServerFactoryAutoConfiguration matched:
      - @ConditionalOnClass found required class 'javax.servlet.ServletRequest'; @ConditionalOnMissingClass did not find unwanted class (OnClassCondition)
      - found ConfigurableWebEnvironment (OnWebApplicationCondition)

   ServletWebServerFactoryAutoConfiguration#tomcatServletWebServerFactoryCustomizer matched:
      - @ConditionalOnClass found required class 'org.apache.catalina.startup.Tomcat'; @ConditionalOnMissingClass did not find unwanted class (OnClassCondition)

   ServletWebServerFactoryConfiguration.EmbeddedTomcat matched:
      - @ConditionalOnClass found required classes 'javax.servlet.Servlet', 'org.apache.catalina.startup.Tomcat', 'org.apache.coyote.UpgradeProtocol'; @ConditionalOnMissingClass did not find unwanted class (OnClassCondition)
      - @ConditionalOnMissingBean (types: org.springframework.boot.web.servlet.server.ServletWebServerFactory; SearchStrategy: current) did not find any beans (OnBeanCondition)

   WebMvcAutoConfiguration matched:
      - @ConditionalOnClass found required classes 'javax.servlet.Servlet', 'org.springframework.web.servlet.DispatcherServlet', 'org.springframework.web.servlet.config.annotation.WebMvcConfigurer'; @ConditionalOnMissingClass did not find unwanted class (OnClassCondition)
      - found ConfigurableWebEnvironment (OnWebApplicationCondition)
      - @ConditionalOnMissingBean (types: org.springframework.web.servlet.config.annotation.WebMvcConfigurationSupport; SearchStrategy: all) did not find any beans (OnBeanCondition)

Negative matches:
-----------------

   ActiveMQAutoConfiguration:
      Did not match:
         - @ConditionalOnClass did not find required classes 'javax.jms.ConnectionFactory', 'org.apache.activemq.ActiveMQConnectionFactory' (OnClassCondition)

我从日志中挑选中我们这里比较感兴趣的 EmbeddedWebServerFactoryCustomizerAutoConfiguration,我们看看它的实现:

embedded-webserver-auto-config.png

从这里就可以看出 Spring Boot 支持三种嵌入的 Web Server:Undertow、Jetty 和 Tomcat。根据上面的依赖关系 spring-boot-starter-web 默认是加载 spring-boot-starter-tomcat 的,所以这里会自动加载 Tomcat 的配置。

如果我们想改变默认的 Web Server,譬如改成轻量级的 Undertow,可以在 POM 文件中使用 exclusion 移除对 spring-boot-starter-tomcat 的引用,并加上对 spring-boot-starter-undertow 的引用,如下:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
        <exclusions>
            <exclusion>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-tomcat</artifactId>
            </exclusion>
        </exclusions>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-undertow</artifactId>
    </dependency>
</dependencies>

二、探究 Spring MVC 如何映射请求?

第二个文件是控制器类,粗看上去就是一个普通的类,外加上一个方法,只不过加上了两个注解 @RestController@RequestMapping("/") 这个方法竟然就可以处理 Web 请求了。是不是觉得这有点神奇?为什么在浏览器里访问 http://localhost:8080 时页面会显示出这里返回的 Hello World!

其实,这一切都是 Spring MVC 的功劳。只不过在 Spring Boot 项目里,Spring MVC 的配置被简化了。我们先回忆一下在传统的 Spring MVC 里如何实现一个控制器类,首先,我们要先在 web.xml 里定义 DispatcherServlet,并为这个 Servlet 配置相应的 servlet-mapping,类似于下面这样:

<web-app>
    <display-name>appName</display-name>

    <context-param>
        <param-name>contextConfigLocation</param-name>
        <param-value>
            classpath:/applicationContext.xml
        </param-value>
    </context-param>

    <listener>
        <listener-class>
            org.springframework.web.context.ContextLoaderListener
        </listener-class>
    </listener>

    <servlet>
        <servlet-name>dispatcher</servlet-name>
        <servlet-class>
            org.springframework.web.servlet.DispatcherServlet
        </servlet-class>
        <load-on-startup>1</load-on-startup>
    </servlet>

    <servlet-mapping>
        <servlet-name>dispatcher</servlet-name>
        <url-pattern>/*</url-pattern>
    </servlet-mapping>
</web-app>

可以说 DispatcherServlet 是 Spring MVC 的核心,通过上面这个配置,它就可以截获 Web 应用的所有请求并将其分派给相应的处理器进行处理。在 Servlet 3.0 之后,还可以通过编程的方式来配置 Servlet 容器,Spring MVC 提供了一个接口 WebApplicationInitializer,通过实现这个接口也可以达到 web.xml 配置文件的目的,如下所示:

public class AppInitializer implements WebApplicationInitializer {
   @Override
   public void onStartup(ServletContext container) {
     XmlWebApplicationContext appContext = new XmlWebApplicationContext();
     appContext.setConfigLocation("/WEB-INF/spring/dispatcher-config.xml");
     ServletRegistration.Dynamic dispatcher =
       container.addServlet("dispatcher", new DispatcherServlet(appContext));
     dispatcher.setLoadOnStartup(1);
     dispatcher.addMapping("/");
   }
}

那么 DispatcherServlet 是如何把 HTTP 请求映射到控制器的某个方法的呢?感兴趣的可以看看 DispatcherServlet 的源码,其实在 DispatcherServlet 初始化的时候,会扫描当前容器所有的 Bean,将包含 @Controller@RequestMapping 注解的类和方法,映射到 HandleMappering,为了实现这一点,Spring MVC 一般都有一个 dispatch-servlet.xml 配置文件:

<beans>
    <context:component-scan base-package="com.stonie.hello" />
    <bean class="org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping" />
    <bean class="org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter" />
</beans> 

其中,component-scan 用于开启注解扫描,RequestMappingHandlerMapping 叫做 处理器映射RequestMappingHandlerAdapter 叫做 处理器适配器(在老版本的 Spring MVC 中你可能看到的是 DefaultAnnotationHandlerMappingAnnotationMethodHandlerAdapter)。这两个类负责将 HTTP 方法,HTTP 路径,HTTP 参数匹配到具体的 @RequestMapping 注解的类和方法上。

@RequestMapping 注解的方法所支持的参数类型和返回类型非常丰富和灵活,从这里也可以看出 Spring MVC 的强大之处。这样虽然让开发人员可以根据需要任意选择,但是也会给开发人员带来困惑,可以参考这篇博客的总结:Spring MVC @RequestMapping 方法所支持的参数类型和返回类型详解

上面就是 Spring MVC 实现请求映射的原理,在传统的 Spring MVC 项目中,这样的配置文件是很常见的,但是在 Spring Boot 项目中,这些配置都自动实现了,可以再深入研究下 DispatcherServletAutoConfigurationWebMvcAutoConfiguration 这两个类。

三、解读 POM 文件

POM 的 全称叫做 Project Object Model,翻译过来就是项目对象模型,它用来定义项目的基本信息,构建步骤,依赖信息等等。pom.xml 文件作为 Maven 项目的核心,和 Make 的 Makefile、Ant 的 build.xml 文件一样。

在这篇博客的最后,让我们来看看这个项目的 pom.xml 文件。首先我们定义了三个元素:groupIdartifactIdversion,这被称为 Maven 坐标,Maven 坐标保证了每个项目都有一个唯一的坐标值,当我们需要在其他项目中引用这个项目时,通过坐标就可以很方便的定位到该项目。

然后下面定义了一个依赖 spring-boot-starter-web,并声明这个 POM 继承自 spring-boot-starter-parent,别小看这一句继承,里面可是另有乾坤。你可以打开 spring-boot-starter-parent 的 POM 文件,可以发现它又继承自 spring-boot-dependencies。在 spring-boot-starter-parent 中定义了一堆的插件,这些插件让 Maven 也能构建 Spring Boot 项目,其中最重要的一个插件是 spring-boot-maven-plugin,这就是我们项目后面要用到的插件。另外,在 spring-boot-dependencies 中定义了一堆的依赖,足足有 3000+ 行,我们前面介绍 Spring Boot 的自动配置原理时就说过,它定义了很多自动配置类,几乎能用到的依赖它都依赖了。

在 pom.xml 文件的 <build> 元素中定义了 spring-boot-maven-plugin 插件之后,就可以运行下面的命令和平常的 jar 包一样进行打包了:

$ mvn clean package

而如果在这里没有定义 <build> 元素,也可以通过下面的命令来打包:

$ mvn clean package spring-boot:repackage

如果不用这个命令,打出来的包里只有我们写的两个类文件,所有依赖的 jar 包都没有包含进去,这样的 jar 包是无法运行的。而 spring-boot:repackage 插件会在执行完 mvn package 之后再次进行打包为可执行的软件包,并且将 mvn package 打的原始的包命名为 *.jar.original。

我们可以打开 *.jar.original 里的 META-INFMANIFEST.MF 文件:

Manifest-Version: 1.0
Implementation-Title: spring-boot-sample
Implementation-Version: 1.0-SNAPSHOT
Built-By: aneasystone
Implementation-Vendor-Id: com.stonie
Created-By: Apache Maven 3.3.9
Build-Jdk: 1.8.0_111
Implementation-URL: https://projects.spring.io/spring-boot/#/spring-bo
 ot-starter-parent/spring-boot-sample

然后再打开 *.jar 里的 META-INFMANIFEST.MF 文件:

Manifest-Version: 1.0
Implementation-Title: spring-boot-sample
Implementation-Version: 1.0-SNAPSHOT
Built-By: aneasystone
Implementation-Vendor-Id: com.stonie
Spring-Boot-Version: 2.0.2.RELEASE
Main-Class: org.springframework.boot.loader.JarLauncher
Start-Class: com.stonie.Application
Spring-Boot-Classes: BOOT-INF/classes/
Spring-Boot-Lib: BOOT-INF/lib/
Created-By: Apache Maven 3.3.9
Build-Jdk: 1.8.0_111
Implementation-URL: https://projects.spring.io/spring-boot/#/spring-bo
 ot-starter-parent/spring-boot-sample

可以发现新打的包里多了五行代码:

Spring-Boot-Version: 2.0.2.RELEASE
Main-Class: org.springframework.boot.loader.JarLauncher
Start-Class: com.stonie.Application
Spring-Boot-Classes: BOOT-INF/classes/
Spring-Boot-Lib: BOOT-INF/lib/

并且我们可以在 BOOT-INF/lib/ 目录下找到项目依赖的所有 jar 包,说明 spring-boot-maven-plugin 插件已经自动帮我们把 jar 包转换成了一个可运行的 Spring Boot 应用。

要理解 Spring Boot 是如何通过 Maven 打包的,这里有两个非常重要的概念:生命周期插件目标。Maven 定义了三套生命周期:clean、default 和 site,其中 clean 用于清理项目,default 用于执行构建项目需要的具体步骤,site 用于发布项目站点。其中 clean 和 default 是最常使用的。譬如我们平常执行 mvn clean compile 来清理并编译项目时就用到了 clean 和 default 生命周期,其中,mvn clean 调用的是 clean 生命周期的 clean 阶段,mvn compile 调用的是 default 生命周期的 compile 阶段。

通过 mvn 命令不仅可以直接调用生命周期的某个阶段,还可以调用某个插件目标,譬如上面的 mvn spring-boot:repackage 就是调用 spring-boot 插件的 repackage 目标。实际上,Maven 的核心就是插件,它是一款基于插件的框架,所有的工作其实都是交给插件完成的,包括上面说的 clean 和 compile 实际上就是通过 clean:cleancompiler:compile 这两个插件完成的。

不过上面的命令中还有一个问题,执行 mvn spring-boot:repackage 时,Maven 为什么可以根据 spring-boot 这个名字定位到 spring-boot-maven-plugin 这个插件的?这是因为 spring-boot 就是 spring-boot-maven-plugin 插件,这被称为 插件前缀,为了方便书写 mvn 命令,可以给每个插件都定义一个插件前缀,这样就不用在命令行中写那么长的插件名称了。

总结

越是看似简单的东西,背后越是蕴含着无限玄机,从平时的开发工作中,要善于从细节中发现问题。虽然这个 Spring Boot 项目只有三个非常简单的文件,但是想彻底弄懂每个文件,绝对不是那么容易。

参考

  1. Building an Application with Spring Boot
  2. Spring application does not start outside of a package
  3. Spring Boot 的自动配置
  4. Spring Boot 学习笔记 02 -- 深入了解自动配置
  5. Difference between spring @Controller and @RestController annotation
  6. Spring6:基于注解的 Spring MVC(上篇)
  7. Spring MVC @RequestMapping 方法所支持的参数类型和返回类型详解
  8. Spring Boot 的 Maven 插件 Spring Boot Maven plugin 详解
  9. Spring Boot Maven Plugin
扫描二维码,在手机上阅读!

使用 Python + Selenium 破解滑块验证码

在前面一篇博客《使用 Python + Selenium 打造浏览器爬虫》中,我介绍了 Selenium 的基本用法和爬虫开发过程中经常使用的一些小技巧,利用这些写出一个浏览器爬虫已经完全没有问题了。看了前一篇博客,可能有人会有疑惑,浏览器爬虫的优势感觉并不比传统爬虫多多少啊,特别是通过遍历页面元素来获取爬虫数据的方式和传统爬虫解析 HTML 文档结构的方式如出一辙。为了体现浏览器爬虫的优越性,我特意准备了这篇博客,来看看如果要破解滑块验证码,浏览器爬虫比传统爬虫要容易多少。

一、滑块验证码简述

有爬虫,自然就有反爬虫,就像病毒和杀毒软件一样,有攻就有防,两者彼此推进发展。反爬技术历经多年,从最简单的检测 UserAgent 或者 Referrer 等头部,到限制访问频率封 IP 等手段,到关键路径的行为识别,到前端页面的混淆和加密,到目前最流行的验证码技术,可以说,为了防止网络上大量爬虫的肆意妄为,特别是一些垃圾机器人,技术人员真的是绞尽脑汁。但是道高一尺魔高一丈,直到目前为止,也并没有完全无懈可击的反爬方案。

目前最流行的反爬技术是验证码,几乎所有网站的注册页面都会用到验证码技术,为了防止爬虫自动注册,批量生成垃圾账号。验证码技术从一诞生,就是黑客们最感兴趣的话题,验证码的英文为 CAPTCHA(Completely Automated Public Turing test to tell Computers and Humans Apart),翻译成中文就是 全自动区分计算机和人类的公开图灵测试,它是一种可以区分用户是计算机还是人的测试,只要能通过 CAPTCHA 测试,该用户就可以被认为是人类。使用计算机模拟人类的行为一直以来都是黑客们最热衷的事情,也是黑客们梦寐以求的理想。所以验证码技术从一提出,就有大量的人尝试破解,其实这些人并不是为了制造垃圾爬虫,他们只是相信计算机可以和人一样,阿西莫夫的机器人世界在未来是可能的。

最初的验证码只是一张图片,图片上显示扭曲变形的文字和数字,这样的验证码通过图像处理和识别的技术可以达到很高的识别率。后来验证码技术又在图片上加入了各种干扰项,并且将字符粘连在一起,增加了字符切割和识别的难度,但是很快人们就想出了很多种不同的去噪方法,并使用骨架算法切割粘连字符,还有些人提出使用机器学习算法来切割字符。和图片验证码类似的是语音验证码,不过这种验证码只是在表现形式上有所区别,实质上和图片并没有太大的变化,采用语音识别技术破解也不是难事。而且语音和图片比起来缺乏交互,花样要少很多,识别难度也要低一些,所以只有在给盲人或者对颜色分辨有障碍的人提供服务时才可能会使用语音验证码,一般情况下使用的比较少。在静态的图片验证码被破解之后,又出现了动态的图片验证码,将字符动态的显示在 gif 动画上,不过这也没什么用,通过图像识别技术一样可以破解,实在破解不了的,还可以通过网上一些廉价的打码平台来人肉识别。

打码平台的诞生可以说是验证码领域的一件大事,它虽然不是什么高科技,只是把全世界廉价的劳动力汇集在了一起,就这样,再复杂的验证码都不在话下。这虽然不是什么光荣的事,但是它推动了验证码技术的发展,交互式验证码被开发出来。传统的图片验证码采用一问一答的形式,只要答案正确,就认为验证通过,它并不关心答案是怎么来的,所以出现了一些人工打码平台,你提供一个问题,它们提供一个答案,仅此而已。如果不仅仅关注答案的正确性,还将提交答案的过程记录下来,通过分析提交答案的过程,完全可以识别出这是不是一个人在操作,这就是交互式验证码的基本思路。这种验证码很难通过打码平台来破解,因为你必须对着浏览器,使用鼠标对验证码进行一系列的交互操作。

最耳熟能详的交互验证码莫过于 12306 的了,这种验证码叫做 图中点选 式验证码,同时提供多个图像,让用户根据条件点击选择。也有些验证码是同时显示 N 个变形的汉字让你选,原理与 12306 的类似,但这种验证码以其极差的用户体验遭到很多人的唾弃,这也是大多数产品不愿意选用的一个原因。滑块验证码 比图中点选体验好很多,它只需要用户使用鼠标将滑块从某个位置拖动到另一个位置即可。程序通过记录用户拖动滑块的轨迹,这一串的轨迹数据采用模式识别的手段就可以判断出这是否是真人在操作。最简单的滑块验证码是用户拖动滑块从左拖到右即可,后来又出现了 拼图式 的滑块,滑块作为图的一部分,然后背景图中有一个缺口刚好和滑块相同形状,需要用户将滑块拖到缺口中拼成一张完整的图片。现在比较流行的滑块验证码有 极验网易云易盾,本篇博客以极验的滑块验证码为例,其他的滑块验证码原理是类似的。

最新的交互式验证码甚至只需要用户点击一个按钮即可验证,不需要做任何其他的操作,譬如 极验的第三代行为验证技术易盾的智能无感知验证码。这种验证码的破解方式和滑块验证码不一样,我目前也没有太多的了解,后面有时间再研究研究吧。

最后不得不说的是,还有一种交互式验证码为短信或电话验证码,通过将验证码以短信的形式发送到你的手机,或者使用语音机器人自动打电话播报验证码,更有甚者,需要用户自己编辑短信将验证码发送到某个号码。对于这种验证码我认为并不能算作是 CAPTCHA,因为它利用的是用户的有限资源(手机号)这个客观限制,而并非是从技术角度来区分人和机器人的区别。如果某个人拥有大量的手机号(其实,黑产中确实也有专门养卡卖卡的),这种验证手段就形同虚设了。

二、破解思路

目前,极验正在推广其第三代行为验证技术,滑块验证码貌似已经没有前两年那么流行了,不过仍然有很多网站还在使用滑块验证码。譬如我这篇博客就以 春秋航空的会员注册页面 为例。

chunqiu-register.png

好了,上面讲了那么多,下面就开始我们的破解之旅吧。

2.1 传统爬虫

如果采用传统爬虫的方式来破解,首先我们需要测试下正常验证的情况是什么样的。在 Chrome 浏览器中按 F12 打开开发者工具,然后拖动滑块到正确位置,可以观察到 Network 面板发送的 Ajax 请求。

chunqiu-register-network.png

可以看到这个请求的参数非常复杂,每个参数的含义也完全没有头绪,如果要破解这个验证码,则必须模拟发送这个请求,这个请求的每个参数都必须弄清楚,于是我们在代码中搜索发送这个请求的地方。但事实上到这里我们就遇到了困难,ajax.php 这个请求根本就搜不到,甚至在浏览器中下 XHR 断点也不行(很显然它并不是一个 Ajax 请求),这是因为极验的核心代码经过了代码混淆。

geetest.js.png

这样的代码也就只有机器能读懂,大多数人肯定是直接放弃了。不过网上也有大量的分析文章,如果你感兴趣可以自己研究下,譬如 Windows应用开发 的知乎专栏上就有几篇介绍 极验验证码破解的系列文章,还有 FanhuaandLuomu这篇破解文章 也写得很好,推荐。

我在这里跳过对混淆代码的分析,总结下破解这样的滑块验证码的思路:

  1. 捕获所有关键请求;
  2. 分析调试混淆的代码,弄懂每个请求每个参数的含义,其中肯定会有一个参数,是拖动滑块的轨迹;
  3. 验证码图片是打乱的,需要解析页面上的样式,并使用图像处理方法还原出原始图像;
  4. 根据原始的图像和滑块位置得到缺口的偏移量;
  5. 滑块轨迹的模拟;
  6. 如果参数有加密处理,还需要模拟它的加密过程;实在不行可以直接在代码里模拟执行页面上的 JS;
  7. ...

可见这里的工作量非常大,破解难度可想而知,而且混淆的代码随时可能会发布新的版本,一旦版本升级,参数都有可能发生变化,之前的所有分析工作都可能前功尽弃。

除非实在是迫不得已,我并不推荐传统的这种破解方法。首先这样的破解方法太脆弱,不够通用,随时可能失效;其次这样的破解工作费时费力,就算破解出来也得不到成就感和满足感,对程序员的打击太大,他可能再也不会玩第二次了(除非他是极客中的极客,就以破解混淆代码为乐)。所以,还是让我们来看看浏览器爬虫如何。

2.2 浏览器爬虫

由于浏览器爬虫完全是以人为第一视角,你所看到的,就是浏览器爬虫看到的,甚至,它能比你看到更多。我们可以大概的总结下浏览器爬虫的破解思路:

  1. 图像识别,找到滑块的位置和缺口的位置;
  2. 模拟鼠标拖动,将滑块拖到缺口位置;

没错,就两步。虽然其中会遇到一些坑,但真的就这两步。使用上一篇博客中介绍的 Selenium 技巧,可以很快的写下下面的代码:

chrome_options = webdriver.ChromeOptions()
chrome_options.add_argument("--start-maximized")
browser = webdriver.Chrome(
    executable_path="./drivers/chromedriver.exe",
    chrome_options=chrome_options
)
browser.get('https://account.ch.com/NonRegistrations-Regist')
Wait(browser, 60).until(
    Expect.visibility_of_element_located((By.CSS_SELECTOR, "div[data-target='account-login']"))
)
email = browser.find_element_by_css_selector("div[data-target='account-login']")
email.click()

Wait(browser, 60).until(
    Expect.visibility_of_element_located((By.ID, "emailRegist"))
)
register = browser.find_element_by_id("emailRegist")
register.click()

offset = get_gap_offset(browser)
drag_and_drop(browser, offset)

关键就在于最后两个方法 get_gap_offset()drag_and_drop(),下面就来看下这两个方法的实现。

三、验证码图片处理

审查验证码图片元素,可以看到下面这样的 HTML 代码:

<div class="gt_cut_fullbg_slice" style="background-image: url('https://static.geetest.com/pictures/gt/3999642ae/3999642ae.webp'); background-position: -157px -58px;"></div>

这样的代码一共有 52 行,每一个 div 都是 10px * 58px 的小块。我们打开这个 background-image 对应的图片可以看出这是一张乱序的图片,这里的 background-position 用于显示出正确的图片。在代码上面,可以发现和这里完全类似的代码,background-position 都完全一样,只是 background-image 不一样,我们打开对应的图片,也是乱序的,但和上一张图片对比,可以猜测出,这是带有缺口的背景图片。

<div class="gt_cut_bg_slice" style="background-image: url('https://static.geetest.com/pictures/gt/3999642ae/bg/fbdb18152.webp'); background-position: -157px -58px;"></div>

一个很自然的想法就是把这两张乱序的图片根据 background-position 重组成两张看得懂的图片,然后对比两张图片,得到缺口的偏移量,然后将缺口偏移量减去滑块偏移量,就可以得到要拖动的偏移量。如下图所示:

geetest-image-process.png

其中滑块的偏移量可以通过下面的代码得到(其中,left: 12px 就是滑块的偏移量):

<div class="gt_slice gt_show" style="left: 12px; background-image: url('https://static.geetest.com/pictures/gt/e6e7e0440/slice/fa2d5bbd8.png'); width: 55px; height: 55px; top: 20px;"></div>

这里需要用到一点点图像处理的知识,我们采用大名鼎鼎的 Pillow,Pillow 是 Python 里的图像处理库(PIL:Python Image Library),在开始之前,可以先看下它的官网教程,这里有一份中文文档也可以参考。计算缺口偏移量的关键代码如下:

def get_slider_offset(image_url, image_url_bg, css):
    image_file = io.BytesIO(requests.get(image_url).content)
    im = Image.open(image_file)
    image_file_bg = io.BytesIO(requests.get(image_url_bg).content)
    im_bg = Image.open(image_file_bg)

    # 10*58 26/row => background image size = 260*116
    captcha = Image.new('RGB', (260, 116))
    captcha_bg = Image.new('RGB', (260, 116))
    for i, px in enumerate(css):
        offset = convert_css_to_offset(px)
        region = im.crop(offset)
        region_bg = im_bg.crop(offset)
        offset = convert_index_to_offset(i)
        captcha.paste(region, offset)
        captcha_bg.paste(region_bg, offset)
    diff = ImageChops.difference(captcha, captcha_bg)
    return get_slider_offset_from_diff_image(diff)

代码很好理解,就是根据 css 将两张背景图片重新排序生成两张新图片,然后通过 ImageChops.difference() 方法得到两张图片的差值图像,最后通过差值图像得到缺口的偏移量。其中有一点要注意的是,Pillow 的 Image.open() 方法只支持文件,不支持 URL,所以将图片转换为 BytesIO 对象,BytesIO 和 StringIO 一样,是 Python 提供的在内存中操作 bytes 和 str 的类,并且和读写文件具有一致的接口。

这种计算缺口位置的方法需要解析页面源码以及图片的 CSS 样式,其实还有一种更简单的方法:在显示验证码图片时对浏览器进行截图,这个时候的图像是完整的背景图像;然后再点击滑块,这个时候滑块和缺口都会显示出来,再对浏览器进行截图;分析两次的截图也可以计算出拖动的偏移量。有兴趣的同学可以一试。

四、模拟滑块拖动

在得到拖动偏移量后,我们就可以通过 Selenium 提供的方法来拖动滑块了:

def drag_and_drop(browser, offset):
    knob = browser.find_element_by_class_name("gt_slider_knob")
    ActionChains(browser).drag_and_drop_by_offset(knob, offset, 0).perform()

Selenium 将一系列连续的动作封装在 ActionChains 类 中,其中 drag_and_drop() 方法可以将一个元素拖到另一个元素上,drag_and_drop_by_offset() 方法可以指定拖动的偏移,正是我们这里所需要的。

到这里,我们已经看到希望了,胜利就在前方。不过,还不能高兴得太早,上面的方法虽然成功将滑块拖到缺口位置了,但是并没有验证通过,页面提示拼图被怪物吃掉了。。。

geetest-retry.png

很显然,这种方法很容易被检测出来是机器所为,因为人不可能拖那么快。于是我稍微调整了下程序,改成每次拖 10px,然后等待 1s 再拖 10px,依次循环,不过可惜的是,这种拖法拼图依然被怪物吃掉了,想想也是,人怎么可能拖的这么有规律呢?

于是继续调整我的程序,在中间加入了随机数的成分,改成每次拖 1~20px(随机),然后等待 0~2s(随机),本想着这种方式应该能成功了,但是事与愿违,还是被怪物吃掉,不过,在测试的时候,10 次里面竟然也成功了一次。

看来极验对拖动轨迹的验证还是很厉害的,它是如何识别出是机器拖动的还是人拖动的呢?人在拖动的时候,又有什么样的规律呢?为了搞清楚这一点,我在网上找了一款用于记录鼠标位置的小工具 MouseController,运行之后按 F9 就可以开始或停止记录,并可以将鼠标轨迹保存到一个 mcd 文件中。使用这个工具我将手工拖动滑块的轨迹记录下来,并写了一个脚本(脚本代码参见这里)画出手工拖动滑块的轨迹图,如下:

hand_tracks.png

看到这个轨迹图,我们应该能想出手工拖动的规律了:先快速向右拖动,快到缺口位置时,再减速慢调。接下来的问题就是如何通过算法来生成这样的轨迹了。

模拟滑块拖动的算法网上也有很多,有的直接根据手工拖动的轨迹按比例生成程序要拖动的轨迹,有的根据物理学中的加速度减速度来模拟轨迹,还有根据正切函数图像来模拟轨迹的,可说各有千秋。不过它们的成功率都不能达到很满意,我在这里介绍一种与众不同的方法,而且成功率可以高达 99%。

我看到上面这个轨迹图的时候,第一反应不是上述任何一种算法,而是 jquery.easing,可能是由于最近刚用 jquery.easing 实现了几个动画效果吧。我们知道,jQuery 可以实现很多不同的动画效果,譬如淡入淡出移动等等,为了让动画有好的过渡变化过程,官方提供了一个 easing 属性,但是官方没有给出很多过渡效果。于是就有了 jquery.easing 这个插件,这个插件增加了很多种过渡效果,引入之后可以让动画过渡过程更加多样化。Easing 有时又叫做 缓动函数,用于指定动画效果在执行时的速度,使其看起来更加真实。这里有一份缓动函数速查表,你可以在这里找到常见的缓动函数(还可以体验各种缓动函数的效果):

easing-functions.png

和上面的轨迹图做个对比就可以发现,轨迹图明显和 easeOut 类 的缓动函数很类似,如:easeOutQuadeaseOutQuarteaseOutExpo 等等。那么我们能不能写个 Python 版的 easing 函数呢?说干就干,我们参考 jquery.easing 的源码 实现了三种 easeOut 函数如下:

import numpy as np
import math

def ease_out_quad(x):
    return 1 - (1 - x) * (1 - x)

def ease_out_quart(x):
    return 1 - pow(1 - x, 4)

def ease_out_expo(x):
    if x == 1:
        return 1
    else:
        return 1 - pow(2, -10 * x)

def get_tracks(distance, seconds, ease_func):
    tracks = [0]
    offsets = [0]
    for t in np.arange(0.0, seconds, 0.1):
        ease = globals()[ease_func]
        offset = round(ease(t/seconds) * distance)
        tracks.append(offset - offsets[-1])
        offsets.append(offset)
    return offsets, tracks

其中 get_tracks() 方法可以根据滑块的偏移,需要的时间(相对时间,并不是准确时间),以及要采用的缓动函数生成拖动轨迹。然后就可以通过下面的方法,实现出想要的拖动效果了:

def drag_and_drop(browser, offset):
    knob = browser.find_element_by_class_name("gt_slider_knob")
    offsets, tracks = easing.get_tracks(offset, 12, 'ease_out_expo')
    ActionChains(browser).click_and_hold(knob).perform()
    for x in tracks:
        ActionChains(browser).move_by_offset(x, 0).perform()
    ActionChains(browser).pause(0.5).release().perform()

如果你感兴趣,还可以模拟出更多的效果,我们甚至可以实现出 easeOutBounce 这种类似小球落地时的弹跳效果。而且更有意思的是,用这种方法来拖动滑块,竟然也可以通过验证。(极验的验证算法还真是让人摸不清啊)

def ease_out_bounce(x):
    n1 = 7.5625
    d1 = 2.75
    if x < 1 / d1 :
        return n1 * x * x
    elif x < 2 / d1:
        x -= 1.5 / d1
        return n1 * x*x + 0.75
    elif x < 2.5 / d1:
        x -= 2.25 / d1
        return n1 * x*x + 0.9375
    else:
        x -= 2.625 / d1
        return n1 * x*x + 0.984375

总结

通过本文可以看出,破解滑块验证码,浏览器爬虫要比传统爬虫简单的多。不仅仅是破解滑块验证码,在遇到传统爬虫很难解决的问题时,浏览器爬虫都可以提供一种更方便的解决方案。但是俗话说得好,针无双头利,蔗无两头甜,凡事有利必有弊,并没有万能的解决方案,还是需要根据需求来取舍,譬如你的生产环境没有浏览器,那么你不得不使用传统爬虫。但是在正常情况下,我还是推荐最简单的那个选择。

本文的完整源码在 这里

参考

  1. 滑块验证码(滑动验证码)相比图形验证码,破解难度如何?
  2. 以GeeTest为例的滑动验证码破解
  3. 极验滑动验证码的识别
  4. 爬虫笔记(10)插曲 挑战极限验证码
  5. Pillow 中文文档
  6. 关于动画,你需要知道的
  7. 缓动函数速查表
  8. jQuery Easing 使用方法及其图解
扫描二维码,在手机上阅读!

使用 Python + Selenium 打造浏览器爬虫

Selenium 是一款强大的基于浏览器的开源自动化测试工具,最初由 Jason Huggins 于 2004 年在 ThoughtWorks 发起,它提供了一套简单易用的 API,模拟浏览器的各种操作,方便各种 Web 应用的自动化测试。它的取名很有意思,因为当时最流行的一款自动化测试工具叫做 QTP,是由 Mercury 公司开发的商业应用。Mercury 是化学元素汞,而 Selenium 是化学元素硒,汞有剧毒,而硒可以解汞毒,它对汞有拮抗作用。

Selenium 的核心组件叫做 Selenium-RC(Remote Control),简单来说它是一个代理服务器,浏览器启动时通过将它设置为代理,它可以修改请求响应报文并向其中注入 Javascript,通过注入的 JS 可以模拟浏览器操作,从而实现自动化测试。但是注入 JS 的方法存在很多限制,譬如无法模拟键盘和鼠标事件,处理不了对话框,不能绕过 JavaScript 沙箱等等。就在这个时候,于 2006 年左右,Google 的工程师 Simon Stewart 发起了 WebDriver 项目,WebDriver 通过调用浏览器提供的原生自动化 API 来驱动浏览器,解决了 Selenium 的很多疑难杂症。不过 WebDriver 也有它不足的地方,它不能支持所有的浏览器,需要针对不同的浏览器来开发不同的 WebDriver,因为不同的浏览器提供的 API 也不尽相同,好在经过不断的发展,各种主流浏览器都已经有相应的 WebDriver 了。最终 Selenium 和 WebDriver 合并在一起,这就是 Selenium 2.0,有的地方也直接把它称作 WebDriver。Selenium 目前最新的版本已经是 3.9 了,WebDriver 仍然是 Selenium 的核心。

一、Selenium 爬虫入门

Selenium 的初衷是打造一款优秀的自动化测试工具,但是慢慢的人们就发现,Selenium 的自动化用来做爬虫正合适。我们知道,传统的爬虫通过直接模拟 HTTP 请求来爬取站点信息,由于这种方式和浏览器访问差异比较明显,很多站点都采取了一些反爬的手段,而 Selenium 是通过模拟浏览器来爬取信息,其行为和用户几乎一样,反爬策略也很难区分出请求到底是来自 Selenium 还是真实用户。而且通过 Selenium 来做爬虫,不用去分析每个请求的具体参数,比起传统的爬虫开发起来更容易。Selenium 爬虫唯一的不足是慢,如果你对爬虫的速度没有要求,那使用 Selenium 是个非常不错的选择。Selenium 提供了多种语言的支持(Java、.NET、Python、Ruby 等),不论你是用哪种语言开发爬虫,Selenium 都适合你。

我们第一节先通过 Python 学习 Selenium 的基础知识,后面几节再介绍我在使用 Selenium 开发浏览器爬虫时遇到的一些问题和解决方法。

1.1 Hello World

一个最简单的 Selenium 程序像下面这样:

from selenium import webdriver
browser = webdriver.Chrome()
browser.get('http://www.baidu.com/')

这段代码理论上会打开 Chrome 浏览器,并访问百度首页。但事实上,如果你第一次使用 Selenium,很可能会遇到下面这样的报错:

selenium.common.exceptions.WebDriverException: 
Message: 'chromedriver' executable needs to be in PATH. 
Please see https://sites.google.com/a/chromium.org/chromedriver/home

报错提示很明确,要使用 Chrome 浏览器,必须得有 chromedriver,而且 chromedriver 文件位置必须得配置到 PATH 环境变量中。chromedriver 文件可以通过错误提示中的地址下载。不过在生产环境,我并不推荐这样的做法,使用下面的方法可以手动指定 chromedriver 文件的位置:

from selenium import webdriver
browser = webdriver.Chrome(executable_path="./drivers/chromedriver.exe")
browser.get('http://www.baidu.com/')

这里给出的例子是 Chrome 浏览器,Selenium 同样可以驱动 Firefox、IE、Safari 等。这里列出了几个流行浏览器webdriver的下载地址。Selenium 的官网也提供了大多数浏览器驱动的下载信息,你可以参考 Third Party Drivers, Bindings, and Plugins 一节。

1.2 输入和输出

通过上面的一节,我们已经可以自动的通过浏览器打开某个页面了,作为爬虫,我们还需要和页面进行更多的交互,归结起来可以分为两大类:输入和输出。

  • 输入指的是用户对浏览器的所有操作,譬如上面的直接访问某个页面也是一种输入,或者在输入框填写,下拉列表选择,点击某个按钮等等;
  • 输出指的是根据输入操作,对浏览器所产生的数据进行解析,得到我们需要的数据;这里 浏览器所产生的数据 不仅包括可见的内容,如页面上显示的信息,也还包括不可见的内容,如 HTML 源码,甚至浏览器所发生的所有 HTTP 请求报文。

下面还是以百度为例,介绍几种常见的输入输出方式。

1.2.1 输入

我们打开百度进行搜索,如果是人工操作,一般有两种方式:第一种,在输入框中输入搜索文字,然后回车;第二种,在输入框中输入搜索文字,然后点击搜索按钮。Selenium 和人工操作完全一样,可以模拟这两种方式:

方式一 send keys with return

from selenium.webdriver.common.keys import Keys

kw = browser.find_element_by_id("kw")
kw.send_keys("Selenium", Keys.RETURN)

其中 find_element_by_id 方法经常用到,它根据元素的 ID 来查找页面某个元素。类似的方法还有 find_element_by_namefind_element_by_class_namefind_element_by_css_selectorfind_element_by_xpath 等,都是用于定位页面元素的。另外,也可以同时定位多个元素,例如 find_elements_by_namefind_elements_by_class_name 等,就是把 find_element 换成 find_elements,具体的 API 可以参考 Selenium 中文翻译文档中的 查找元素 一节。

通过 find_element_by_id 方法拿到元素之后,就可以对这个元素进行操作,也可以获取元素的属性或者它的文本。kw 这个元素是一个 input 输入框,可以通过 send_keys 来模拟按键输入。不仅可以模拟输入可见字符,也可以模拟一些特殊按键,譬如回车 Keys.RETURN,可模拟的所有特殊键可以参考 这里

针对不同的元素,有不同的操作,譬如按钮,可以通过 click 方法来模拟点击,如下。

方式二 send keys then click submit button

kw = browser.find_element_by_id("kw")
su = browser.find_element_by_id("su")
kw.send_keys("Selenium")
su.click()

如果这个元素是在一个表单(form)中,还可以通过 submit 方法来模拟提交表单。

方式三 send keys then submit form

kw = browser.find_element_by_id("kw")
kw.send_keys("Selenium")
kw.submit()

submit 方法不仅可以直接应用在 form 元素上,也可以应用在 form 元素里的所有子元素上,submit 会自动查找离该元素最近的父 form 元素然后提交。这种方式是程序特有的,有点类似于直接在 Console 里执行 $('form').submit() JavaScript 代码。由此,我们引出第四种输入方法,也是最最强大的输入方法,可以说几乎是无所不能,直接在浏览器里执行 JavaScript 代码:

方式四 execute javascript

browser.execute_script(
    '''
    var kw = document.getElementById('kw');
    var su = document.getElementById('su');
    kw.value = 'Selenium';
    su.click();
    '''
)

这和方式二非常相似,但是要注意的是,方式四是完全通过 JavaScript 来操作页面,所以灵活性是无限大的,几乎可以做任何操作。除了这些输入方式,当然还有其他方式,譬如,先在输入框输入搜索文字,然后按 Tab 键将焦点切换到提交按钮,然后按回车,原理都是大同小异,此处不再赘述,你可以自己写程序试一试。

另外,对于 select 元素,Selenium 单独提供了一个类 selenium.webdriver.support.select.Select 可以方便元素的选取。其他类型的元素,都可以通过上述四种方式来处理。

1.2.2 输出

有输入就有输出,当点击搜索按钮之后,如果我们要爬取页面上的搜索结果,我们有几种不同的方法。

方式一 parse page_source

html = browser.page_source
results = parse_html(html)

第一种方式最原始,和传统爬虫几无二致,直接拿到页面源码,然后通过源码解析出我们需要的数据。但是这种方式存在缺陷,如果页面数据是通过 Ajax 动态加载的,browser.page_source 获取到的是最初返回的 HTML 页面,这个 HTML 页面可能啥都没有。这种情况,我们可以通过遍历页面元素来获取数据,如下:

方式二 find & parse elements

results = browser.find_elements_by_css_selector("#content_left .c-container")
for result in results:
    link = result.find_element_by_xpath(".//h3/a")
    print(link.text)

这种方式需要充分利用上面介绍的 查找元素 技巧,譬如这里如果要解析百度的搜索页面,我们可以根据 #content_left .c-container 这个 CSS 选择器定位出每一条搜索结果的元素节点。然后在每个元素下,通过 XPath .//h3/a 来取到搜索结果的标题的文本。XPath 在定位一些没有特殊标志的元素时特别有用。

方式三 intercept & parse ajax

方式二在大多数情况下都没问题,但是有时候还是有局限的。譬如页面通过 Ajax 请求动态加载,某些数据在 Ajax 请求的响应中有,但在页面上并没有体现,而我们恰恰想要爬取 Ajax 响应中的那些数据,这种情况上面两种方式都无法实现。我们能不能拦截这些 Ajax 请求,并对其响应进行解析呢?这个问题我们放在后面一节再讲。

1.3 处理 Ajax 页面

上面也提到过,如果页面上有 Ajax 请求,使用 browser.page_source 得到的是页面最原始的源码,无法爬到百度搜索的结果。事实上,不仅如此,如果你试过上面 方式二 find & parse elements 的例子,你会发现用这个方式程序也爬不到搜索结果。这是因为 browser.get() 方法并不会等待页面完全加载完毕,而是等到浏览器的 onload 方法执行完就返回了,这个时候页面上的 Ajax 可能还没加载完。如果你想确保页面完全加载完毕,当然可以用 time.sleep() 来强制程序等待一段时间再处理页面元素,但是这种方法显然不够优雅。或者自己写一个 while 循环定时检测某个元素是否已加载完,这个做法也没什么问题,但是我们最推荐的还是使用 Selenium 提供的 WebDriverWait 类。

WebDriverWait 类经常和 expected_conditions 搭配使用,注意 expected_conditions 并不是一个类,而是一个文件,它下面有很多类,都是小写字母,看起来可能有点奇怪,但是这些类代表了各种各样的等待条件。譬如下面这个例子:

from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions

WebDriverWait(browser, 10).until(
    expected_conditions.presence_of_element_located((By.ID, "kw"))
)

代码的可读性很好,基本上能看明白这是在等待一个 id 为 kw 的元素出现,超时时间为 10s。不过代码看起来还是怪怪的,往往我们会给 expected_conditions 取个别名,譬如 Expect,这样代码看起来更精简了:

from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait as Wait
from selenium.webdriver.support import expected_conditions as Expect

Wait(browser, 10).until(
    Expect.presence_of_element_located((By.ID, "kw"))
)

我们再以一个实际的例子来看看 expected_conditions 的强大之处,譬如在 途牛网上搜索上海到首尔的航班,这个页面的航班结果都是以 Ajax 请求动态加载的,我们如何等待航班全部加载完毕之后再开始爬取我们想要的航班结果呢?通过观察可以发现,在 “开始搜索”、“搜索中” 以及 “搜索结束” 这几个阶段,页面显示的内容存在比较明显的差异,如下图所示:

tuniu-search-flight.png

我们就可以通过这些差异来写等待条件。要想等到航班加载完毕,页面上应该会显示 “共搜索xx个航班” 这样的文本,而这个文本在 id 为 loadingStatus 的元素中。expected_conditions 提供的类 text_to_be_present_in_element 正满足我们的要求,可以像下面这样:

Wait(browser, 60).until(
    Expect.text_to_be_present_in_element((By.ID, "loadingStatus"), u"共搜索")
)

下面是完整的代码,可见一个浏览器爬虫跟传统爬虫比起来还是有些差异的,浏览器爬虫关注点更多的在页面元素的处理上。

from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait as Wait
from selenium.webdriver.support import expected_conditions as Expect

browser = webdriver.Chrome(executable_path="./drivers/chromedriver.exe")
browser.get('http://www.tuniu.com/flight/intel/sha-sel')
Wait(browser, 60).until(
    Expect.text_to_be_present_in_element((By.ID, "loadingStatus"), u"共搜索")
)

flight_items = browser.find_elements_by_class_name("flight-item")
for flight_item in flight_items:
    flight_price_row = flight_item.find_element_by_class_name("flight-price-row")
    print(flight_price_row.get_attribute("data-no"))

除了上面提到的 presence_of_element_locatedtext_to_be_present_in_element 这两个等待条件,Selenium 还提供了很多有用的条件类,参见 Selenium 的 WebDriver API

二、Selenium 如何使用代理服务器?

通过上一节的介绍,相信你也可以用 Selenium 写一个简单的爬虫了。虽然 Selenium 完全模拟了人工操作,给反爬增加了点困难,但是如果网站对请求频率做限制的话,Selenium 爬虫爬快了一样会遭遇被封杀,所以还得有代理。

代理是爬虫开发人员永恒的话题。所以接下来的问题就是怎么在 Selelium 里使用代理,防止被封杀?我在很久之前写过几篇关于传统爬虫的博客,其中也讲到了代理的话题,有兴趣的同学可以参考一下 Java 和 HTTP 的那些事(二) 使用代理

在写代码之前,我们要了解一点,Selenium 本身是和代理没关系的,我们是要给浏览器设置代理而不是给 Selenium 设置,所以我们首先要知道浏览器是怎么设置代理的。浏览器大抵有五种代理设置方式,第一种是直接使用系统代理,第二种是使用浏览器自己的代理配置,第三种通过自动检测网络的代理配置,这种方式利用的是 WPAD 协议,让浏览器自动发现代理服务器,第四种是使用插件控制代理配置,譬如 Chrome 浏览器的 Proxy SwitchyOmega 插件,最后一种比较少见,是通过命令行参数指定代理。这五种方式并不是每一种浏览器都支持,而且设置方式可能也不止这五种,如果还有其他的方式,欢迎讨论。

直接使用系统代理无需多讲,这在生产环境也是行不通的,除非写个脚本不断的切换系统代理,或者使用自动拨号的机器,也未尝不可,但这种方式不够 programmatically。而浏览器自己的配置一般来说基本上都会对应命令行的某个参数开关,譬如 Chrome 浏览器可以通过 --proxy-server 参数来指定代理:

chrome.exe http://www.ip138.com --proxy-server=127.0.0.1:8118

注:执行这个命令之前,要先将现有的 Chrome 浏览器窗口全部关闭,如果你的 Chrome 安装了代理配置的插件如 SwitchyOmega,还需要再加一个参数 --disable-extensions 将插件禁用掉,要不然命令行参数不会生效。

2.1 通过命令行参数指定代理

使用 Selenium 启动浏览器时,也可以指定浏览器的启动参数。像下面这样即可:

chrome_options = webdriver.ChromeOptions()
chrome_options.add_argument('--proxy-server=127.0.0.1:8118')

browser = webdriver.Chrome(
    executable_path="./drivers/chromedriver.exe",
    chrome_options=chrome_options
)
browser.get('http://ip138.com')

这里的 --proxy-server 参数格式为 ip:port,注意它不支持这种带用户名密码的格式 username:password@ip:port,所以如果代理服务器需要认证,访问网页时就会弹出一个认证对话框来。虽然使用 Selenium 也可以在对话框中填入用户名和密码,不过这种方式略显麻烦,而且每次 Selenium 启动浏览器时,都会弹出代理认证的对话框。更好的做法是,把代理的用户名和密码都提前设置好,对于 Chrome 浏览器来说,我们可以通过它的插件来实现。

2.2 使用插件控制代理

Chrome 浏览器下最流行的代理配置插件是 Proxy SwitchyOmega,我们可以先配置好 SwitchyOmega,然后 Selenium 启动时指定加载插件,Chrome 提供了下面的命令行参数用于加载一个或多个插件:

chrome.exe http://www.ip138.com --load-extension=SwitchyOmega

不过要注意的是,--load-extension 参数只能加载插件目录,而不能加载打包好的插件 *.crx 文件,我们可以把它当成 zip 文件直接解压缩到 SwitchyOmega 目录即可。代码如下:

chrome_options = webdriver.ChromeOptions()
chrome_options.add_argument('--load-extension=SwitchyOmega')

browser = webdriver.Chrome(
    executable_path="./drivers/chromedriver.exe",
    chrome_options=chrome_options
)
browser.get('http://ip138.com')

另外,Selenium 的 ChromeOptions 类还提供了一个方法 add_extension 用于直接加载未解压的插件文件,如下:

chrome_options.add_extension('SwitchyOmega.crx')

这种做法应该是可行的,不过我没有具体去尝试,因为这种做法依赖于 SwitchyOmega 的配置,如何在加载插件之前先把代理都配好?如何运行时动态的切换代理?这对爬虫来说至关重要,以后有时候再去研究吧。不过很显然,直接使用 SwitchyOmega 插件有点重了,我们能不能自己写一个简单的插件来实现代理控制呢?

当然可以。而且这个插件只需要两行代码即可。

关于 Chrome 插件的编写,我之前有过两篇博客:我的第一个Chrome扩展:Search-faster我的第二个Chrome扩展:JSONView增强版,感兴趣的同学可以先看看这两篇了解下如何写一个 Chrome 插件。这里略过不提,我们这个插件需要有两个文件,一个是 manifest.json 文件,为插件的清单文件,每个插件都要有,另一个是 background.js 文件,它是背景脚本,类似于后台驻留进程,它就是代理配置插件的核心。

下面我们就来看看这两行代码,第一行如下:

chrome.proxy.settings.set({
    value: {
        mode: "fixed_servers",
        rules: {
            singleProxy: {
                scheme: "http",
                host: "127.0.0.1",
                port: 8118
            },
            bypassList: ["foobar.com"]
        }
    },
    scope: "regular"
}, function() {});

chrome.proxy 是用于管理 Chrome 浏览器的代理服务器设置的 API,上面的代码通过其提供的方法 chrome.proxy.settings.set() 设置了一个代理服务器地址,mode 的值为 fixed_servers 表示根据下面的 rules 来指定某个固定的代理服务器,代理类型可以是 HTTP 或 HTTPS,还可以是 SOCKS 代理。mode 的值还可以是 direct(无需代理),auto_detect(通过 WPAD 协议自动检测代理),pac_script(通过 PAC 脚本动态选取代理)和 system(使用系统代理)。关于这个 API 的详细说明可以参看 Chrome 的 官方文档,这里有一份 中文翻译

通过上面的代码也只是设置了代理服务器的 IP 地址和端口而已,用户名和密码还没有设置,这和使用命令行参数没什么区别。所以还需要下面的第二行代码:

chrome.webRequest.onAuthRequired.addListener(
    function (details) {
        return {
            authCredentials: {
                username: "username",
                password: "password"
            }
        };
    },
    { urls: ["<all_urls>"] },
    [ 'blocking' ]
);

我们先看看下面这张图,了解下 Chrome 浏览器接受网络请求的整个流程,一个成功的请求会经历一系列的事件(图片来源):

webrequestapi.png

这些事件都是由 chrome.webRequest API 提供,其中的 onAuthRequired 最值得我们注意,它是用于代理身份认证的关键。所有的事件都可以通过 addListener 方法注册一个回调函数作为监听器,当请求需要身份认证时,回调函数返回代理的用户名和密码。除了回调方法,addListener 第二个参数用于指定该代理适用于哪些 url,这里的 <all_urls> 是固定的特殊语法,表示所有的 url,第三个参数字符串 blocking 表示请求将被阻塞,回调函数将以同步的方式执行。这个 API 也可以参考 Chrome 的 官方文档,这里是 中文翻译

综上,我们就可以写一个简单的代理插件了,甚至将插件做成动态生成的,然后 Selenium 动态的加载生成的插件。完整的源码在 这里

三、Selenium 如何过滤非必要请求?

Selenium 配合代理,你的爬虫几乎已经无所不能了。上面说过,Selenium 爬虫虽然好用,但有个最大的特点是慢,有时候太慢了也不是办法。由于每次打开一个页面 Selenium 都要等待页面加载完成,包括页面上的图片资源,JS 和 CSS 文件的加载,而且更头疼的是,如果页面上有一些墙外资源,比如来自 Google 或 Facebook 等站点的链接,如果不使用境外代理,浏览器要一直等到这些资源连接超时才算页面加载完成,而这些资源对我们的爬虫没有任何用处。

我们能不能让 Selenium 过滤掉那些我们不需要的请求呢?

Yi Zeng 在他的一篇博客 Exclude Selenium WebDriver traffic from Google Analytics 上总结了很多种方法来过滤 Google Analytics 的请求,虽然他的博客是专门针对 Google Analytics 的请求,但其中有很多思路还是很值得我们借鉴的。其中有下面的几种解决方案:

  • 通过修改 hosts 文件,将 google.com、facebook.com 等重定向到本地,这种方法需要修改系统文件,不方便程序的部署,而且不能动态的添加要过滤的请求;
  • 禁用浏览器的 JavaScript 功能,譬如 Chrome 支持参数 --disable-javascript 来禁用 JavaScript,但这种方法有很大的局限性,图片和 CSS 资源还是没有过滤掉,而且页面上少了 JavaScript,可能站点的很多功能无法使用了;
  • 使用浏览器插件,Yi Zeng 的博客中只提到了 Google-Analytics-Opt-out-Add-on 插件用于禁用 Google Analytics,实际上我们很容易想到 AdBlock 插件,这个插件用来过滤页面上的一些广告,这和我们想要的效果有些类似。我们可以自己写一个插件,拦截不需要的请求,相信通过上一节的介绍,也可以做出来。
  • 使用代理服务器 BrowserMob Proxy,通过代理服务器来拦截不需要的请求,除了 BrowserMob Proxy,还有很多代理软件也具有拦截请求的功能,譬如 Fiddler 的 AutoResponder 或者 通过 whistle 设置 Rules 都可以拦截或修改请求;

这里虽然方法有很多,但我只推荐最后一种:使用代理服务器 BrowserMob Proxy,BrowserMob Proxy 简称 BMP,可以这么说,BMP 绝对是为 Selenium 为生的,Selenium + BMP 的完美搭配,可以实现很多你绝对想象不出来的功能。

我之所以推荐 BMP,是由于 BMP 的理念非常巧妙,和传统的代理服务器不一样,它并不是一个简单的代理,而是一个 RESTful 的代理服务,通过 BMP 提供的一套 RESTful 接口,你可以创建或移除代理,设置黑名单或白名单,设置过滤器规则等等,可以说它是一个可编程式的代理服务器。BMP 是使用 Java 语言编写的,它前后经历了两个大版本的迭代,其核心也是从最初的 Jetty 演变为 LittleProxy,使得它更小巧和稳定,你可以从 这里下载 BMP 的可执行文件,在 Windows 系统上,我们直接双击执行 bin 目录下的 browsermob-proxy.bat 文件。

BMP 启动后,默认在 8080 端口创建代理服务,此时 BMP 还不是一个代理服务器,需要你先创建一个代理:

curl -X POST http://localhost:8080/proxy

/proxy 接口发送 POST 请求,可以创建一个代理服务器。此时,我们在浏览器访问 http://localhost:8080/proxy 这个地址,可以看到我们已经有了一个代理服务器,端口号为 8081,现在我们就可以使用 127.0.0.1:8081 这个代理了。

接下来我们要把 Google 的请求拦截掉,BMP 提供了一个 /proxy/[port]/blacklist 接口可以使用,如下:

curl -X PUT -d 'regex=.*google.*&status=404' http://localhost:8080/proxy/8081/blacklist

这样所有匹配到 .*google.* 正则的 url,都将直接返回 404 Not Found。

知道了 BMP 怎么用,再接下来,就是编写代码了。当然我们可以自己写代码来调用 BMP 提供的 RESTful 接口,不过俗话说得好,前人栽树,后人乘凉,早就有人将 BMP 的接口封装好给我们直接使用,譬如 browsermob-proxy-py 是 Python 的实现,我们就来试试它。

from selenium import webdriver
from browsermobproxy import Server

server = Server("D:/browsermob-proxy-2.1.4/bin/browsermob-proxy")
server.start()
proxy = server.create_proxy()

proxy.blacklist(".*google.*", 404)
proxy.blacklist(".*yahoo.*", 404)
proxy.blacklist(".*facebook.*", 404)
proxy.blacklist(".*twitter.*", 404)

chrome_options = webdriver.ChromeOptions()
chrome_options.add_argument("--proxy-server={0}".format(proxy.proxy))
browser = webdriver.Chrome(
    executable_path="./drivers/chromedriver.exe",
    chrome_options = chrome_options
)
browser.get('http://www.flypeach.com/pc/hk')

server.stop()
browser.quit()

关键代码在前面几句,首先创建代理,再通过 proxy.blacklist() 将 google、yahoo、facebook、twitter 的资源拦截掉。后面的代码和前一节的代理设置完全一样。执行程序,体会一下,现在这个页面的打开速度快了多少?

BMP 不仅可以拦截请求,也可以修改请求,这对爬虫来说可能意义不大,但在自动化测试时,可以通过它伪造测试数据还是很有意义的。它提供了两个接口 /proxy/[port]/filter/request/proxy/[port]/filter/response 用于修改 HTTP 的请求和响应,具体的用法可以参考 官网的文档,此处略过。

proxy.request_interceptor(
    '''
    request.headers().remove('User-Agent');
    request.headers().add('User-Agent', 'My-Custom-User-Agent-String 1.0');
    '''
)
proxy.response_interceptor(
    '''
    if (messageInfo.getOriginalUrl().contains("remote/searchFlights")) {
        contents.setTextContents('Hello World');
    }
    '''
)

四、Selenium 如何爬取 Ajax 请求?

到这里,问题变得越来越有意思了。而且我们发现,用 Selenium 做爬虫,中途确实会遇到各种各样的问题,但随着问题的发现到解决,我们花在 Selenium 上面的时间越来越少了,更多的是在研究其他的东西,如浏览器的特性,浏览器插件的编写,可编程式的代理服务器,以此来辅助 Selenium 做的更好。

还记得前面提到的一个问题吗?如果要爬取的内容在 Ajax 请求的响应中,而在页面上并没有体现,这种情况该如何爬取呢?我们可以直接爬 Ajax 请求吗?事实上,我们很难做到,但不是做不到。

通过上一节对 BMP 的介绍,我们了解到 BMP 可以拦截并修改请求的报文,我们可以进一步猜想,既然它可以修改报文,那肯定也可以拿到报文,只是这个报文我们的程序该如何得到?上一节我们提到了两个接口 /proxy/[port]/filter/request/proxy/[port]/filter/response,它们可以接受一段 JS 代码来修改 HTTP 的请求和响应,其中我们可以通过 contents.getTextContents() 来访问响应的报文,只是这段代码运行在远程服务器上,和我们的代码在两个完全不同的世界里,如何把它传给我们呢?而且,这段 JS 代码的限制非常严格,我们想通过这个地方拿到这个报文几乎是不可能的。

但,路总是有的。

我们回过头来看 BMP 的文档,发现 BMP 提供了两种模式供我们使用:独立模式(Standalone)和 嵌入模式(Embedded Mode)。独立模式就是像上面那样,BMP 作为一个独立的应用服务,我们的程序通过 RESTful 接口与其交互。而嵌入模式则不需要下载 BMP 可执行文件,直接通过包的形式引入到我们的程序中来。可惜的是,嵌入模式只支持 Java 语言,但这也聊胜于无,于是我使用 Java 写了个测试程序尝试了一把。

首先引入 browsermob-core 包,

<dependency>
        <groupId>net.lightbody.bmp</groupId>
        <artifactId>browsermob-core</artifactId>
        <version>2.1.5</version>
    </dependency>

然后参考官网文档写下下面的代码(完整代码见 这里),这里就可以看到嵌入模式的好处了,用于 BMP 拦截处理的代码和我们自己的代码处于同一个环境下,而且 Java 语言具有闭包的特性,我们可以很简单的取到 Ajax 请求的响应报文:

BrowserMobProxy proxyServer = new BrowserMobProxyServer();
proxyServer.start(0);

proxyServer.addRequestFilter((request, contents, messageInfo) -> {
    System.out.println("请求开始:" + messageInfo.getOriginalUrl());
    return null;
});

String ajaxContent = null;
proxyServer.addResponseFilter((response, contents, messageInfo) -> {
    System.out.println("请求结束:" + messageInfo.getOriginalUrl());
    if (messageInfo.getOriginalUrl().contains("ajax")) {
        ajaxContent = contents.getTextContents();
    }
});

如果你是个 .Net guy,可以使用 Fiddler 提供的 FiddlerCore,FiddlerCore 就相当于 BMP 的嵌入模式,和这里的方法类似。这里有一篇很好的文章讲解了如何使用 .Net 和 FiddlerCore 拦截请求。

既然在 Java 环境下解决了这个问题,那么 Python 应该也没问题,但是 BMP 的嵌入模式并不支持 Python 怎么办呢?于是我一直在寻找一款基于 Python 的能替代 BMP 的工具,可惜一直不如愿,未能找到满意的。到最后,我几乎要下结论:Python + Selenium 很难实现 Ajax 请求的爬取。

天无绝人之路,直到我遇到了 har。

有一天我静下心来把 BMP 的文档翻来覆去看了好几遍,之前我看文档的习惯都是用时再查,但这次把 BMP 的文档从头到尾看了几遍,也是希望能从中寻找点蛛丝马迹。而事实上,还真被我发现了点什么。因为 Python 只能通过 RESTful 接口与 BMP 交互,那么每一个接口我都不能放过,有一个接口引起了我的注意:/proxy/[port]/har

这个接口虽然之前也扫过几眼,但当时并不知道这个 har 是什么意思,所以都是一掠而过。但那天心血来潮,特意去查了一下 har 的资料,才发现这是一种特殊的 JSON 格式的归档文件。HAR 全称 HTTP Archive Format,通常用于记录浏览器访问网站的所有交互请求,绝大多数浏览器和 Web 代理都支持这种格式的归档文件,用于分析 HTTP 请求,因为广泛的应用,W3C 甚至还提出 HAR 的规范,目前还在草稿阶段。

/proxy/[port]/har 接口用于创建一份新的 har 文件,Selenium 启动浏览器后所有的请求都将被记录到这份 har 文件中,然后通过 GET 请求,可以获取到这份 har 文件的内容(JSON 格式)。har 文件的内容类似于下面这样:

{
    "log": {
        "version" : "1.2",
        "creator" : {},
        "browser" : {},
        "pages": [],
        "entries": [],
        "comment": ""
    }
}

其中 entries 数组包含了所有 HTTP 请求的列表,默认情况下 BMP 创建的 har 文件并不包含请求的响应内容,我们可以通过 captureContent 参数来让 BMP 记录响应内容:

curl -X PUT -d 'captureContent=true' http://localhost:8080/proxy/8081/har

万事俱备,只欠东风。我们开始写代码,首先通过 proxy.new_har() 创建一份 har 文件:

proxy.new_har(options={
    'captureContent': True
})

然后启动浏览器,访问要爬取的页面,等待页面加载结束,这时我们就可以通过 proxy.har 来访问 har 文件中的请求报文了(完整代码在 这里):

for entry in proxy.har['log']['entries']:
    if 'remote/searchFlights' in entry['request']['url']:
        result = json.loads(entry['response']['content']['text'])
        for key, item in result['data']['flightInfo'].items():
            print(key)

总结

这篇博客总结了 Selenium 的一些基础语法,并尝试使用 Python + Selenium 开发浏览器爬虫。本文还分享了我在实际开发过程中遇到的几个常见问题,并提供了一种或多种解决方案,包括代理的使用,拦截浏览器请求,爬取 Ajax 请求等等。实践出真知,通过一系列问题的提出,到研究,到解决,我学习到了非常多的东西。不仅意识到知识广度的重要性,而且更重要的是知识的聚合和熔炼。我一直认为知识的广度比深度更重要,只有你懂的越多,你才有可能接触更多的东西,你的思路才更放得开;深度固然也重要,但往往会让人局限于自己的漩涡之中。但知识的广度不是天马行空,需要不断的总结提炼,融会贯通,形成自己的知识体系,这样才不至于被繁多的知识点所困扰。

另外,我也意识到阅读项目文档的重要性,心平气和的将项目文档从头到尾阅读一遍,遇到不懂的,就去查找资料,而不是只挑自己知道或感兴趣的,这样会得到意想不到的收获。

本文所有源码都在我的 GitHub 上,你可以从 这里 查看完整源码。本人能力有限,文中如有错误,欢迎斧正,望不吝赐教。如有好的想法和问题,也欢迎留言评论。

参考

  1. Selenium发展史
  2. 自动化测试工具Webdriver入门解析
  3. Python爬虫利器五之Selenium的用法
  4. Selenium with Python中文翻译文档
  5. Selenium Downloads
  6. Selenium Chrome 设置带用户名密码的proxy代理
  7. Exclude Selenium WebDriver traffic from Google Analytics
  8. lightbody/browsermob-proxy - GitHub
  9. JavaScript API - Google Chrome 扩展程序开发文档(非官方中文版)
扫描二维码,在手机上阅读!

记一个 white-space: nowrap 的坑

最近在工作中遇到了一个 white-space 和 float 组合时,CSS 样式在 Firefox 浏览器下不兼容的问题,特此记录一下。需求是做一个国家列表,类似于下面这样:

country-list.png

需求很简单,任何一个 CSS 初学者应该都会做,我毫不犹豫的使用 ul 实现如下:

<div class='container'>
    <ul>
        <li>中国</li>
        <li>中国香港</li>
        <li>中国澳门</li>
        <li>中国台湾</li>
        <li>美国</li>
        <li>英国</li>
        <li>日本</li>
        <li>加拿大</li>
        <li>法国</li>
        <li>韩国</li>
        <li>德国</li>
    </ul>
</div>

并写下相应的样式:

.container {
    width: 200px;
    background: #fff;
    border: 1px solid #37c249;
}
.container ul {
    list-style: none;
    padding: 0 0;
    margin: 0 0;
}
.container ul li {
    padding-left: 5px;
    border-bottom: 1px dashed #e7e7e7;
    height: 30px;
    line-height: 30px;
}
.container ul li:hover {
    cursor: pointer;
    background-color: #37c249;
}

写完之后,在不同的浏览器里都测试一遍,没毛病,于是提交代码,收工。

好景不长,第二天测试找过来说,有的国家名太长了,譬如这个:圣多美和普林西比民主共和国,样式超出了列表宽度,掉到了下一行,而且把下一行的文字覆盖了。如下图所示:

country-list-over.png

我一拍脑袋,嗯,都是我的错,确实没考虑周全,我赶紧修复一下。像这种文字长度超出边界的情况,解决方案有很多,最常见的方法莫过于将超出部分隐藏,并在后面加点点点来显示。关于这种解决方案,张鑫旭的这篇博客给出了很多种实现方法(文章比较老,其中他所说的 Firefox 不支持 text-overflow,现在的浏览器基本上都已经支持了)。

于是我加了三行代码,所谓的“三连击”,像这种通用的解决方案,类似于一种设计模式,应该熟练运用在项目里。

.container ul li {
    padding-left: 5px;
    border-bottom: 1px dashed #e7e7e7;
    height: 30px;
    line-height: 30px;
    
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

其中 white-space: nowrap 表示超过文字部分不要换行,然后使用 overflow: hidden 将超出部分隐藏起来,再加上 text-overflow: ellipsis 打造出点点点的效果,如下图所示:

country-list-nowrap.png

看上去效果不错,在不同浏览器中测试一遍,没毛病,于是再次提交代码,收工。

第三天,产品找到我,说这个国家列表除非国家名长点,大多数情况下国家名就两三个字,右边部分有很多空白,看上去有点单调,而且不够国际化,想调整下样式。多年开发经验的我早就对产品需求的善变见怪不怪,于是按捺住内心的情绪,平淡的说,好的,没问题。拿到最新的样式如下所示:

country-list-with-en.png

哦,原来是在右边加个国家英文名,看上去果真高大上不少,内心一边默默的佩服产品脑洞大开的思路,一边偷偷窃喜,就这点改动,能难倒我?于是抄起编辑器,刷刷刷,在每个国家后面加上英文名,代码如下:

<div class='container'>
    <ul>
        <li>中国<span>China</span></li>
        <li>中国香港<span>Hongkong,China</span></li>
        <li>中国澳门<span>Macao,China</span></li>
        <li>中国台湾<span>Taiwan,China</span></li>
        <li>美国<span>United States of America</span></li>
        <li>英国<span>United Kingdom</span></li>
        <li>日本<span>Japan</span></li>
        <li>加拿大<span>Canada</span></li>
        <li>法国<span>France</span></li>
        <li>韩国<span>Korea</span></li>
        <li>德国<span>Germany</span></li>
    </ul>
</div>

在每一个 li 节点中,加了一个行内元素 span,并将该国家对应的英文名放在里面。原先的宽度肯定不够,需要调宽一点,改成了 350px,右边加上 5px 的 padding,然后将 span 元素浮动到右边,样式代码如下:

.container {
    width: 350px;
    background: #fff;
    border: 1px solid #37c249;
}
.container ul {
    list-style: none;
    padding: 0 0;
    margin: 0 0;
}
.container ul li {
    padding: 0 5px;
    border-bottom: 1px dashed #e7e7e7;
    height: 30px;
    line-height: 30px;
    
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}
.container ul li:hover {
    cursor: pointer;
    background-color: #37c249;
}
.container ul li span {
    float: right;
}

在 Chrome 中刷新测试了一下,完美,这个时候正好到了饭点,信心满满的我直接提交了代码,其他浏览器就不看了,肯定没问题的。

下午,我正在为自己高效的工作效率,神乎其技般的码字能力感到得意时,测试找过来了。你一上午都做了啥?说好的国家英文名呢?咋啥都没有?我一听,怎么可能?上午明明做好了啊。跑到测试那一看,Firefox 浏览器,还真的啥都没有!只是把宽度调宽了点。

country-list-with-en-firefox.png

这是怎么回事,难道上午写的三行代码竟然有问题?这让我不禁开始怀疑人生。郁闷的回到工位,打开 Chrome(63.0) 浏览器一看,没问题,打开 IE11 和 Edge,也没问题,最后打开 Firefox(57.0)还真的有问题!一直以来都是在修补 IE 浏览器的各种不兼容,这一次竟然轮到 Firefox 了。

F12 打开开发者工具,查看页面元素,span 节点有,只是不显示而已,那么肯定是被隐藏了。那么为什么被隐藏了?看代码只有 overflow: hidden 这一种可能,将这个样式去掉,国家英文名果然露出来了,只是,所有的国家英文名都掉到下一行:

country-list-with-en-firefox-show.png

但是我明明设置了 white-space: nowrap 不允许换行啊,为什么会换行呢?然后尝试着将 white-space: nowrap 这条样式去掉,出乎意料的竟然显示正常了。

难道被我找到了一个 Firefox 的 Bug?赶紧在 Google 上搜索相关的信息,果然在 stackoverflow 上找到了一个和我的情况完全一样的问题:Firefox float bug? How do I get my float:right on the same line? 但是,并没有人说这是 Firefox 的 Bug,而是提出了三个解决方法。

解决方法一:调换浮动元素和非浮动元素的位置

将浮动的 span 元素放在文本的前面,如下:

<div class='container'>
    <ul>
        <li><span>China</span>中国</li>
        <li><span>Hongkong,China</span>中国香港</li>
        <li><span>Macao,China</span>中国澳门</li>
        <li><span>Taiwan,China</span>中国台湾</li>
        <li><span>United States of America</span>美国</li>
        <li><span>United Kingdom</span>英国</li>
        <li><span>Japan</span>日本</li>
        <li><span>Canada</span>加拿大</li>
        <li><span>France</span>法国</li>
        <li><span>Korea</span>韩国</li>
        <li><span>Germany</span>德国</li>
    </ul>
</div>

解决方法二:white-space: normal

white-space: nowrap 修改为 white-space: normal,或者去掉 white-space 的样式(默认为 normal):

.container ul li {
    padding: 0 5px;
    border-bottom: 1px dashed #e7e7e7;
    height: 30px;
    line-height: 30px;
    
    white-space: normal;
    overflow: hidden;
    text-overflow: ellipsis;
}

解决方法三:将非浮动元素改成浮动元素

将中文名放在一个 span 元素中,英文名放在另一个 span 元素中,然后将中文名向左浮动,英文名向右浮动即可。

<div class='container'>
    <ul>
        <li><span class='l'>中国</span><span class='r'>China</span></li>
        <li><span class='l'>中国香港</span><span class='r'>Hongkong,China</span></li>
        <li><span class='l'>中国澳门</span><span class='r'>Macao,China</span></li>
        <li><span class='l'>中国台湾</span><span class='r'>Taiwan,China</span></li>
        <li><span class='l'>美国</span><span class='r'>United States of America</span></li>
        <li><span class='l'>英国</span><span class='r'>United Kingdom</span></li>
        <li><span class='l'>日本</span><span class='r'>Japan</span></li>
        <li><span class='l'>加拿大</span><span class='r'>Canada</span></li>
        <li><span class='l'>法国</span><span class='r'>France</span></li>
        <li><span class='l'>韩国</span><span class='r'>Korea</span></li>
        <li><span class='l'>德国</span><span class='r'>Germany</span></li>
    </ul>
</div>

样式如下:

.container ul li span.l {
    float: left;
}
.container ul li span.r {
    float: right;
}

虽然问题解决了,但是问题的原因并没有找到。只是隐隐觉得 Firefox 对 white-space: nowrap 的实现和其他内核的浏览器的实现应该不一样。white-space 有 5 种可能的属性,如下图所示(图片来源):

white-space-css.png

可以看出 nowrap 和 normal 唯一的区别是:是否允许换行。将 white-space 换成其他三种值发现,pre 一样有换行问题,而 pre-wrap 和 pre-line 都没有问题。那么为什么允许换行的情况下,浮动元素都正常显示在当前行,而不允许换行的情况,浮动元素却掉到了下一行?

关于这个问题我并没有找到答案,要完全搞清楚这一点,我觉得有必要去仔细翻阅一下 CSS 的规范文档以及了解不同浏览器内核在渲染元素时对 white-space 样式处理上的区别(Gecko、Webkit、Trident),精力有限,我到这里就停止了,并没有继续研究。如果你有兴趣,欢迎深挖下去,一定可以发现更精彩的内容。

我只是有一个猜想:Firefox 在处理 white-space: nowrap 元素时,认为该元素不可换行,那么如何让一个元素里的内容不换行呢?可能是设置了其宽度为无限宽,这样就导致了浮动元素在这一行没有多余的位置,只能下移被挤到另一行了。欢迎讨论。

总结

CSS 对于一个后端开发人员来说,无异于一场噩梦,我所认识的大多数后端开发人员,都不太愿意接触前端技术,特别是 CSS 以及不同浏览器的兼容问题,觉得这是没有多少技术含量的事。我在机缘巧合下,有幸参与到公司的前端开发工作,在工作过程中学到了不少前端的技巧和技术。我对最近几年前端技术的迅速发展感到非常吃惊,可能大多数后端开发都不知道,前端技术日新月异,早已不是当年 jQuery 一把梭的年代了,无论是 Angular、React、Vue 等等前端框架的变迁,还是 Node.js、Webpack 等前后端一体化的趋势,更不用说 ECMAScript6、HTML5、CSS3 这些最新的技术都让我感到不可思议。CSS 对我们来说,只是一种类似于 HTML 的标记语言,但是要真正学好这门语言,并不是仅仅掌握一些 CSS 语法就够了,而是要深入到不同浏览器的内核,探索浏览器渲染元素的原理,这条路漫长而充满挑战,与君共勉。

注:本文情节纯属虚构。

参考

  1. 关于文字内容溢出用点点点(…)省略号表示
  2. white-space - CSS | MDN
  3. Firefox float bug? How do I get my float:right on the same line?
扫描二维码,在手机上阅读!

学习 Java 的调试技术

在软件开发的过程中,可以说调试是一项基本技能。调试的英文单词为 debug ,顾名思义,就是去除 bug 的意思。俗话说的好,编程就是制造 bug 的过程,所以 debug 的重要性毋庸置疑,如果能熟练掌握调试技能,也就可以很快的定位出代码中的 bug。要知道,看的懂代码不一定写的出代码,写的出代码不一定能调试好代码,为了能写出没有 bug 的代码,我们必须得掌握一些基本的调试技巧。

工欲善其事,必先利其器。无论你的开发工具是 IntelliJ IDEA 还是 Eclipse ,调试器都是标配。在遇到有问题的程序时,合理的利用调试器的跟踪和断点技巧,可以很快的定位出问题原因。虽然说合理利用日志也可以方便定位线上问题,但是日志并不是调试工具,不要在开发环境把 System.out.println 当作调试手段,掌握调试器自带的调试技能才是正道。

一、实战 IDEA 调试技巧

如果你是做 Java 开发的,相信你不会没有听过 IntelliJ IDEA ,和大多数 Java 开发者一样,我一开始的时候也是用 Eclipse 来进行开发,但是自从换了 IDEA 之后,就再也离不开它了,彻底变成了 IDEA 的忠实粉丝(不好意思,打一波广告。。)。不得不说,JetBrains 这家来自捷克的软件公司真的是良心企业,所出产品皆是精品,除了 IDEA,还有 WebStorm,PhpStorm,PyCharm 等,风格都是很类似的,一些类似的快捷键包括调试技巧也是通用的。

打开 IDEA 的调试面板,如下图所示,可以大致的将其分成五个部分:

  • 单步跟踪
  • 断点管理
  • 求值表达式
  • 堆栈和线程
  • 变量观察

idea-debugging.png

1.1 单步跟踪

说起调试,估计很多人第一反应就是对程序进行一步一步的跟踪分析,其实 IDEA 提供了很多快捷键来帮助我们跟踪程序,大抵可以列出下面几个技巧:

  • Show Execution Point

调试时往往需要浏览代码,对代码进行分析,有时候在浏览若干个源文件之后就找不到当前执行到哪了,可能很多人会使用 Navigate Back 来返回,虽然也可以返回去,但可能需要点多次返回按钮,相对来说使用这个技巧快速定位到当前调试器正在执行的代码行要更简便。

  • Step Over

这是最基本的单步命令,每一次都是执行一行代码,如果该行代码有方法会直接跳过,可以说真的是一步一个脚印。

  • Step In / Force Step In

Step Over 会跳过方法的执行,可以观察方法的返回值,但如果需要进到方法里面,观察方法的执行细节,则需要使用 Step In 命令了。另外,Step In 命令也会跳过 jdk 自带的系统方法,如果要跟踪系统方法的执行细节,需要使用 Force Step In 命令。关于单步的时候忽略哪些系统方法,可以在 IDEA 的配置项 Settings -> Build, Execution, Deployment -> Debugger -> Stepping 中进行配置,如下图所示。

idea-step-skip.png

  • Step Out

当使用 Step In 命令跟踪到一个方法的内部时,如果发现自己不想继续调这个方法了,可以直接把这个方法执行完,并停在调用该方法的下一行位置,这就是 Step Out 命令。

  • Drop to Frame

这一招可以说是调试器的一大杀器。在单步调试的时候,如果由于粗心导致单步过了头,没有看到关键代码的执行情况,譬如想定位下某个中间变量的值,这个时候如果能回到那行关键代码再执行一遍就好了,Drop to Frame 就提供了我们这个能力,它可以回到方法调用的地方(跟 Step Out 不一样,Step Out 是回到方法调用的下一行),让我们可以再调试一次这个方法,这一次可不要再粗心了。

Drop to frame 的原理其实也非常简单,顾名思义,它将堆栈的最上面一个栈帧删除(也就是当前正在执行的方法),让程序回到上一个栈帧(父方法),可以想见,这只会恢复堆栈中的局部变量,全局变量无法恢复,如果方法中有对全局变量进行操作的地方,是没有办法再来一遍的。

  • Run to Cursor / Force Run to Cursor

这两个命令在需要临时断点时非常有用,譬如已经知道自己想分析哪一行代码了,但又不需要下很多无谓的断点,可以直接使用该命令执行到某行,Force Run to Cursor 甚至可以无视所有断点,直接到达我们想分析的地方。

1.2 断点管理

断点是调试器的基础功能之一,可以让程序暂停在需要的地方,帮助我们进行分析程序的运行过程。在 IDEA 中断点管理如下图所示,合理使用断点技巧可以快速让程序停在我们想停的地方:

idea-breakpoints.png

可以将断点分成两种类型:行断点指的是在特定的某行代码上暂停下来,而全局断点是在某个条件满足时停下来,并不限于停在固定的某一行,譬如当出现异常时暂停程序。

1.2.1 行断点

  • Suspend (All / Thread)
  • Condition

条件断点。这应该也是每个使用调试器的开发者都应该掌握的一个技巧,当遇到遍历比较大的 List 或 Map 对象时,譬如有 1000 个 Person 对象,你不可能每个对象都调一遍,你可能只想在 person.name = 'Zhangsan' 的时候让断点断下来,就可以使用条件断点,如下图所示:

idea-breakpoints-condition.png

  • Log message to console
  • Evaluate and log

当看到上面的 Suspend 这个选项的时候有的人可能会感到奇怪,我下一个断点不就是为了让程序停下来吗?还需要这个选项干什么?是不是有点多余?难道你下个断点却不想让程序停下来?在发现 Evaluate and log 这个技巧之前,我对这一点也感觉很奇怪,直到有一天我突然发现 Suspend Off + Evaluate and log 的配合真的是太有用了。前面有讲过,不要把 System.out.println 当作调试手段,因为你完全可以用这个技巧来打印所有你想打印的信息,而不需要修改你的源代码。

  • Remove once hit

一次性断点。上面介绍的 Run to Cursor 就是一次性断点的例子。

  • Instance filters
  • Class filters
  • Pass count

这几个我用的不是很多,但应该也是非常有用的技巧可以先记下来。在 IDEA 里每个对象都有一个实例ID,Instance filters 就是用于当断点处代码所处的实例和设定ID匹配则断下来。Pass count 则是在断点执行到第几次的时候暂停下来。

1.2.2 全局断点

  • Exception breakpoints
  • Method breakpoints
  • Field watchpoints

个人感觉这几个技巧都不是很常用,感兴趣的同学自己实验一把吧。

1.3 求值表达式

在一堆单步跟踪的按钮旁边,有一个不显眼的按钮,这个按钮就是 “求值表达式”。它在调试的时候很有用,可以查看某个变量的值,也可以计算某个表达式的值,甚至还可以计算自己的一段代码的值,这分别对应下面两种不同的模式:

  • 表达式模式(Expression Mode)
  • 代码片段模式(Code Fragment Mode)

这两个模式类似于 Eclipse 里面的 Expression View 和 Display View。在 Display View 里也可以编写一段代码来执行,确实非常强大,但是要注意的是,这里只能写代码片段,不能自定义方法,如下图:

idea-evaluate-code-fragment.png

1.4 堆栈和线程

这个没什么好说的,一个视图可以查看当前的所有线程,另一个视图可以查看当前的函数堆栈。在线程视图里可以进行 Thread dump,分析每个线程当前正在做什么;堆栈视图里可以切换栈帧,结合右边的变量观察区,可以方便的查看每个函数里的局部变量和参数。

  • 线程视图
  • 堆栈视图

idea-threads.png

1.5 变量观察

变量区和观察区可以合并在一起,也可以分开来显示(如下图所示),我比较喜欢分开来显示,这样局部变量、参数以及静态变量显示在变量区,要观察的表达式显示在观察区。观察区类似于求值表达式中的 Expression mode,你可以添加需要观察的表达式,在调试的时候可以实时的看到表达式的值。变量区的内容相对是固定的,随着左边的栈帧调整,值也会变得不同。在这里还可以修改变量原有的值。

idea-variables.png

二、使用 jdb 命令行调试

相信很多人都听过 gdb,这可以说是调试界的鼻祖,以前在学习 C/C++ 的时候,就是使用它来调试程序的。和 gdb 一样,jdb 也是一个命令行版的调试器,用于调试 Java 程序。而且 jdb 不需要安装下载,它是 JDK 自带的工具(在 JDK 的 bin 目录中,JRE 中没有)。

每研究一项新技术,我总是会看看有没有命令行版本的工具可以替代,在命令行下进行操作给人一种踏实的感觉,每一个指令,每一个参数,都清清楚楚的摆在那里,这相比较于图形界面的工具,可以学习更深层的知识,而不是把技术细节隐藏在图形界面之后,你可以发现命令行下的每一个参数,每一个配置,都是可以学习的点。

2.1 jdb 基本命令

在 jdb 中调试 Java 程序如下图所示,直接使用 jdb Test 命令加载程序即可。

jdb-debugging.png

运行完 jdb Test 命令之后,程序这时并没有运行起来,而是停在那里等待进一步的命令。这个时候我们可以想好在哪里下个断点,譬如在 main() 函数处下个断点,然后再使用 run 命令运行程序:

> stop in Test.main
正在延迟断点Test.main。
将在加载类后设置。
> run
运行Test
设置未捕获的 java.lang.Throwable
设置延迟的未捕获的 java.lang.Throwable
>
VM 已启动:设置延迟的断点:Test.main

可以看出在执行 run 命令之前,程序都还没有开始运行,这个时候的断点叫做“延迟断点”,当程序真正运行起来时,也就是 JVM 启动的时候,才将断点设置上。除了 stop in Class.Method 命令,还可以使用 stop at Class:LineNumber 的方式来设置断点。

main[1] stop at Test:25

在 jdb 中下断点,就没有 IDEA 中那么多名堂了,什么条件断点,什么 Instance filters 都不支持,只能乖乖的一步一步来。在断点处,可以使用 list 命令查看断点附近的代码,或者用 step 命令单步执行,print 或者 dump 打印变量或表达式的值,locals 命令查看当前方法中的所有变量,cont 命令继续执行代码。还有一些其他的命令就不多做介绍了,可以使用 help 查看所有的命令清单,或者参考 jdb 的官方文档

2.2 探索 class 文件结构

在 jdb 中调试 Java 程序时,有可能源代码文件和 class 文件不在一起,这个时候需要指定源码位置:

# jdb -sourcepath path/to/source Test

如果不指定源码位置,在使用 list 命令时会提示找不到源码文件,如果真的没有源码文件,那么在 jdb 里完全就是瞎调了。我们知道 Java 代码在执行的时候,是以字节码的形式运行在 JVM 里的,可以猜测到,class 文件中必然有着和源码相关联的一些信息,类似于 C/C++ 语言的 obj 文件一样,要不然 list 命令怎么可以显示出当前正在执行的代码是哪一行呢?我们可以使用开源的 jclasslib 软件查看 class 文件里的内容,一个标准的 class 文件包含了下面这些信息:

  • 基本信息
  • 常量池
  • 接口
  • 属性
  • 父类
  • 字段
  • 方法

    • Code 属性

      • 行号属性
      • 局部变量表

如下图所示,其中最重要的一个部分就是 Code 属性,Code 属性下面有行号属性 LineNumberTable,这个 LineNumberTable 就是调试器用来关联字节码和源代码的关键。关于 class 文件,可以参考这篇文章《深入理解 Java 虚拟机》的学习笔记

dis-class-file.png

题外话:没有源码时如何调试?

如果没有源码,虽然在 jdb 里也可以用 step 来单步,但是没有办法显示当前正在运行的代码,这简直就是盲调。这个时候只能使用字节码调试工具了,常见的字节码调试器有:Bytecode VisualizerJSwat DebuggerJava ByteCode Debugger (JBCD) 等等,参考这里

三、关于远程调试

通过对 jdb 的学习,我们已经越来越接近 Java 调试器的真相了,但是还差最后一步。让我们先看看 Java 程序在 IDEA 里是如何被调试的,如果你有很强的好奇心,那么在 IDEA 里调试程序的时候可能已经发现了下面的秘密:

idea-debug-java.png

或者在调试 tomcat 的时候,也有类似的一串仿佛魔咒一般的参数:

idea-debug-tomcat.png

这串魔咒般的参数像下面这样,一旦你理解了这串参数,你也就打破了 Java 调试器的魔咒,然后才能认识到 Java 调试器真正的面目:

"C:\Program Files\Java\jdk1.8.0_111\bin\java" -agentlib:jdwp=transport=dt_socket,address=127.0.0.1:20060,suspend=y,server=n Foo
Connected to the target VM, address: '127.0.0.1:20060', transport: 'socket'

这里面有两个关键点:

  1. Java 程序在运行的时候带着 -agentlib 参数,这个参数用于指示 JVM 在启动时额外加载的动态库文件,-agentlib 参数和 -javaagent 不一样,这个库文件应该是由 C/C++ 编写的原生程序(JNI),类似于这里的 jdwp,在 Windows 上对应一个 jdwp.dll 库文件,在 Linux 上对应 jdwp.so 文件。那么这个 jdwp 库文件到底是做什么的呢?它后面的一串参数又是什么意思?
  2. jdwp 的参数里貌似提到了 socket,并有 address=127.0.0.1:20060 这样的 IP 地址和端口号,而且下面的 Connected to the target VM 也似乎表示调试器连接到了这么一个网络地址,那么这个地址到底是什么呢?由于这里是本地调试,所以 IP 地址是 127.0.0.1 ,那么如果是远程调试的话,是不是这里也是支持的呢?

在 IDEA 的 Run/Debug Configuration 配置页面,你也可以添加一个远程调试,界面如下图,可以发现上面那串魔咒参数又出现了:

idea-remote-debugging.png

在真正开始远程调试之前,我们不妨带着这些疑问,来学习 Java 调试器的基本原理。

四、Java 调试原理及 JPDA 简介

在武侠世界里,天下武功可以分为两种:一种讲究招式新奇,出招时能出其不意,善于利用兵器的特性和自身的高妙手法攻敌不备;另一种讲究内功心法,就算是最最普通的招式,结合自身的深厚内力,出招时也能有雷霆之势。其实在技术的世界里,武功也可以分为两种:技巧和原理。上面讲的那么多,无论你是掌握了 IDEA 的所有调试技巧也好,还是记熟了 jdb 的所有命令也好,都只是招式上的变化,万变不离其宗,我们接下来就看看调试器的内功心法。

4.1 JPDA

我们知道,Java 程序都是运行在 JVM 上的,我们要调试 Java 程序,事实上就需要向 JVM 请求当前运行态的状态,并对 JVM 发出一定的指令,或者接受 JVM 的回调。为了实现 Java 程序的调试,JVM 提供了一整套用于调试的工具和接口,这套接口合在一起我们就把它叫做 JPDA(Java Platform Debugger Architecture,Java 平台调试体系)。这个体系为开发人员提供了一整套用于调试 Java 程序的 API,是一套用于开发 Java 调试工具的接口和协议。本质上说,它是我们通向虚拟机,考察虚拟机运行态的一个通道,一套工具。

JPDA 由三个相对独立的层次共同组成,而且规定了它们三者之间的交互方式。这三个层次由低到高分别是 Java 虚拟机工具接口(JVMTI),Java 调试线协议(JDWP)以及 Java 调试接口(JDI),如下图所示(图片来自 IBM developerWorks):

jpda.jpg

这三个模块把调试过程分解成几个很自然的概念:调试者(debugger)和被调试者(debuggee),以及他们中间的通信器。被调试者运行于我们想调试的 Java 虚拟机之上,它可以通过 JVMTI 这个标准接口,监控当前虚拟机的信息;调试者定义了用户可使用的调试接口,通过这些接口,用户可以对被调试虚拟机发送调试命令,同时调试者接受并显示调试结果。在调试者和被调试者之间,调试命令和调试结果,都是通过 JDWP 的通讯协议传输的。所有的命令被封装成 JDWP 命令包,通过传输层发送给被调试者,被调试者接收到 JDWP 命令包后,解析这个命令并转化为 JVMTI 的调用,在被调试者上运行。类似的,JVMTI 的运行结果,被格式化成 JDWP 数据包,发送给调试者并返回给 JDI 调用。而调试器开发人员就是通过 JDI 得到数据,发出指令。详细的内容,可以参考 IBM developerWorks 上的一篇系列文章 《深入 Java 调试体系》

4.2 Connectors & Transport

到这里,我们已经知道了 jdwp 是调试器和被调试程序之间的一种通信协议。不过命令行参数 -agentlib:jdwp=transport=dt_socket,address=127.0.0.1:20060,suspend=y,server=n 中的 jdwp 貌似还不止于此。事实上,这个地方上 jdwp.dll 库文件把 JDI,JDWP,JVMTI 三部分串联成了一个整体,它不仅能调用本地 JVMTI 提供的调试能力,还实现了 JDWP 通信协议来满足 JVMTI 与 JDI 之间的通信。想要完全理解这串参数的意思,我们还需要学习两个概念:Connectors(连接器)和 Transport(传输)。

常见的连接器有下面 5 种,它指的是 JDWP 建立连接的方式:

  • Socket-attaching connector
  • Shared-memory attaching connector
  • Socket-listening connector
  • Shared-memory listening connector
  • Command-line launching connector

其中 attaching connector 和 listening connector 的区别在于到底是调试器作为服务端,还是被调试程序作为服务端。
传输指的是 JDWP 的通信方式,一旦调试器和被调试程序之间建立起了连接,他们之间就需要开始通信,目前有两种通信方式:Socket(套接字) 和 Shared-memory(共享内存,只用在 Windows 平台)。

4.3 实战远程调试

通过上面的学习我们了解到,Java 调试器和被调试程序是以 C/S 架构的形式运行的,首先必须有一端以服务器的形式启动起来,然后另一段以客户端连接上去。如果被调试程序以服务端运行,必须加上下面的命令行参数(关于 jdwp 参数,可以参考 JavaSE 的文档):

# java -agentlib:jdwp=transport=dt_socket,server=y,address=5005 Test
# java -agentlib:jdwp=transport=dt_shmem,server=y,address=javadebug Test

第一句是以 socket 通信方式 来启动程序,第二句是以 共享内存 的方式来启动程序,socket 方式需要指定一个端口号,调试器通过该端口号来连接它,共享内存方式需要指定一个连接名,而不是端口号。在程序运行起来之后,可以使用 jdb 的 -attach 参数将调试器和被调试程序连接起来:

# jdb -attach 5005
# jdb -attach javadebug

在 Windows 平台上,第一条命令会报这样的错:java.io.IOException: shmemBase_attach failed: The system cannot find the file specified,这是由于 jdb -attach 使用系统默认的传输来建立连接,而在 Windows 上默认的传输是 共享内存 。要想在 Windows 上使用 socket 方式来连接,要使用 jdb -connect 命令,只是命令的参数在写法上不太好记忆:

# jdb -connect com.sun.jdi.SocketAttach:hostname=localhost,port=5005
# jdb -connect com.sun.jdi.SharedMemoryAttach:name=javadebug

如果反过来,想让调试器以服务端运行,执行下面的命令:

# jdb -listen javadebug

然后 Java 程序通过下面的参数来连接调试器:

# java -agentlib:jdwp=transport=dt_shmem,address=javadebug, suspend=y Test
# java -agentlib:jdwp=transport=dt_socket,address=127.0.0.1:5005,suspend=y,server=n

最后我们再回过头来看一看 IDEA 打印出来的这串魔咒参数,可以大胆的猜测,IDEA 在调试的时候,首先以服务器形式启动调试器,并在 20060 端口监听,然后 Java 程序以 socket 通信方式连接该端口,并将 JVM 暂停等待调试。

"C:\Program Files\Java\jdk1.8.0_111\bin\java" -agentlib:jdwp=transport=dt_socket,address=127.0.0.1:20060,suspend=y,server=n Foo
Connected to the target VM, address: '127.0.0.1:20060', transport: 'socket'

如果在 IDEA 下进行远程调试,可以参考 IBM developerWorks 上的另一篇与调试相关的主题:使用 Eclipse 远程调试 Java 应用程序

总结

这篇文章首先介绍了 IDEA 的一些常用调试技巧,然后通过使用 jdb 进行 Java 程序的调试,学习了 jdb 的常用命令,最后通过远程调试引出调试器原理的话题,对 JPDA、JVMTI、JDWP、JDI 等概念有了一个初步的认识。从招式到心法,由技巧到原理,逐步揭开了 Java 调试器的神秘面纱。对于开发人员来说,如果只懂招式,只会一些奇淫技巧,那么他只是把工具用得更得心应手而已,很难在技术上得到质的突破;而如果只懂心法,只沉浸于基本原理和理论,那么他只能做一个眼高手低的学院派,空有满腹大道理却无用武之地。我们更应该内外兼修,把招式和心法结合起来,融会贯通,方能成正果。

最后的最后,关于调试的话题不得不补充一句:调试程序是一个费时费力的过程,一旦需要调试来定位问题,说明代码的逻辑性和清晰性有问题,最好的代码是不需要调试的。所以,少一点调试,多一点单元测试,多一点重构,将代码写的更清晰才是最好的编程方式。

番外篇:关于调试器的测不准效应

在量子物理学中,有一个名词叫 测不准原理,又叫 不确定性原理,讲的是粒子的位置与动量不可同时被确定,位置的不确定性越小,则动量的不确定性越大,反之亦然。说白点就是,你如果要很准确的测量粒子的位置,那么就不能准确的测量粒子的动量;如果要很准确的测量粒子的动量,那么粒子的位置就测不准;正是由于测量本身,会导致系统受影响。

把这个现象套在调试器领域里,也有着类似的效果。由于调试器本身的干扰,程序已经不是以前的程序了。所以问题来了,在调试器下运行出来的结果,真的可信吗?下面是我想出来的一个有趣的例子,假设我们在第 4 行下一个断点,程序最后输出结果会是什么呢?

debugger-reflactor.png

参考

  1. IntelliJ IDEA 13 debug调试细节
  2. 你所不知道的Android Studio调试技巧
  3. Eclipse 的 Debug 介绍与技巧
  4. 使用Eclipse调试Java程序的10个技巧
  5. JDB 的简单使用
  6. 《深入理解Java虚拟机》读书笔记4:类文件结构
  7. 使用 Eclipse 平台进行调试
  8. Java .class bytecode debugger
  9. Java调试——回归基础
  10. JVM源码分析之javaagent原理完全解读
  11. 使用 Eclipse 远程调试 Java 应用程序
  12. 深入 Java 调试体系,第 1 部分,JPDA 体系概览
  13. 深入 Java 调试体系,第 2 部分,JVMTI 和 Agent 实现
  14. 深入 Java 调试体系,第 3 部分,JDWP 协议及实现
  15. 深入 Java 调试体系,第 4 部分,Java 调试接口(JDI)
  16. Java Tool Tutorials - Herong's Tutorial Notes
  17. Java调试那点事
  18. 如何编写属于自己的Java / Scala的调试器
  19. jdb fails to connect to running java application over sockets
  20. Connection and Invocation Details
  21. Attach Intellij-IDEA debugger to a running java process
扫描二维码,在手机上阅读!

Java 和 HTTP 的那些事(四) HTTPS 和 证书

说起 HTTP 的那些事,则不得不提 HTTPS ,而说起 HTTPS ,则不得不提数字证书。这篇博客将从 Java 的角度,学习 HTTPS 和数字证书技术,并分享爬虫开发的过程中针对爬取 HTTPS 站点时可能遇到的一些问题。
在前面的几篇博客里,其实已经略微提到过 HTTPS 了,譬如使用 HttpsURLConnection 类发送 HTTPS 请求,在使用代理时 HTTP 和 HTTPS 的一些差异等等。关于 HTTPS 的概念就不废话了,下面直接进入正题。

一、访问 HTTPS 站点

在前面的第一篇博客《模拟 HTTP 请求》里,介绍了两种方法来模拟发送 HTTP 请求,访问 HTTP 站点。一种方式是通过 java.net 自带的 HttpURLConnection,另一种方式是通过 Apache 的 HttpClient,这两种方式各有各的优势。这里也使用这两种方式来访问 HTTPS 站点,从下面的代码可以看到,和前面访问 HTTP 站点几乎完全一样。

1.1 使用 HttpURLConnection

@Test
public void basicHttpsGet() throws Exception {
    
    String url = "https://www.baidu.com";
    URL obj = new URL(url);

    HttpsURLConnection con = (HttpsURLConnection) obj.openConnection();    
    con.setRequestProperty("User-Agent", "Mozilla/5.0 (Windows NT 6.1; WOW64) ...");
    con.setRequestProperty("Accept-Language", "en-US,en;q=0.5");
    con.setRequestMethod("GET");

    String responseBody = readResponseBody(con.getInputStream());
    System.out.println(responseBody);
}

1.2 使用 HttpClient

@Test
public void basicHttpsGet() throws Exception {
    
    String url = "https://www.baidu.com";    
    HttpGet request = new HttpGet(url);
    request.setHeader("User-Agent", "Mozilla/5.0 (Windows NT 6.1; WOW64) ...");
    
    CloseableHttpClient httpclient = HttpClients.createDefault();
    CloseableHttpResponse response = httpclient.execute(request);
    String responseBody = readResponseBody(response);
    System.out.println(responseBody);
}

具体的代码解释参见第一篇博客,这里不再赘述。一般情况下,访问 HTTPS 站点就和访问 HTTP 站点一样简单,无论是 HttpURLConnection 还是 HttpClient ,都将底层的实现细节封装了起来,给我们提供了一致的对外接口,所以我们不用关心 HTTPS 的实现原理。对底层细节的封装,本来是一件好事,也是一种好的设计方式,可以让开发人员使用起来更方便,提高开发效率,但是对于那些不求甚解的人来说,可能带来的困惑比之带来的方便要更多。

1.3 遭遇 PKIX path building failed

使用上面的代码作为爬虫程序爬取成千上万的网页,在大多数情况下,无论是 HTTP 也好,HTTPS 也罢,都可以很好的工作。不过有时候,你可能没那么好的运气,有些站点在墙外,被强大的防火长城拒之门外,这时你可以找一些境外代理,通过《使用代理》这篇博客中介绍的方法来解决;有些站点需要使用身份认证输入用户名密码才能访问,这可以使用上一篇博客《代理认证》中介绍的方法来解决;另外,在访问有些 HTTPS 站点时,你还可能会遇到下面的异常:

javax.net.ssl.SSLHandshakeException:
sun.security.validator.ValidatorException: PKIX path building failed:
sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target

要解决这个异常,这就是我们这篇将要介绍的内容。

二、证书认证的原理

大多数人第一次遇到上面的异常时的反应,估计都是一脸茫然,因为这个异常信息提示比较模糊,对于不懂 HTTPS 的人来说,什么 SSLHandshake ,什么 PKIX path ,完全不知所云。所以我们要先弄懂 HTTPS 的工作原理,才好去解决这个问题。我们知道 HTTPS 其实就是 HTTP + SSL/TLS 的合体,它其实还是 HTTP 协议,只是在外面加了一层,SSL 是一种加密安全协议,引入 SSL 的目的是为了解决 HTTP 协议在不可信网络中使用明文传输数据导致的安全性问题。可以说,整个互联网的通信安全,都是建立在 SSL/TLS 的安全性之上的。

2.1 SSL/TLS 协议及其握手过程

学过计算机网络的同学肯定都还记得 TCP 在建立连接时的三次握手,之所以需要 TCP 三次握手,是因为网络中存在延迟的重复分组,可能会导致服务器重复建立连接造成不必要的开销。SSL/TLS 协议在建立连接时与此类似,也需要客户端和服务器之间进行握手,但是其目的却大相径庭,在 SSL/TLS 握手的过程中,客户端和服务器彼此交换并验证证书,并协商出一个 “对话密钥” ,后续的所有通信都使用这个 “对话密钥” 进行加密,保证通信安全。

网上有很多 SSL/TLS 握手的示意图,其中下面这副非常全面,也非常专业,想深入了解 SSL/TLS 的同学可以研究下。

http://www.cheat-sheets.org/saved-copy/Ssl_handshake_with_two_way_authentication_with_certificates-1.pdf

阮一峰在他的 《SSL/TLS协议运行机制的概述》《图解SSL/TLS协议》 两篇博客中详细介绍了 SSL/TLS 的原理,感兴趣的同学可以去看看。我这里使用 IBM Tivoli Risk Manager 用户手册 里的一张图(因为这张图比较浅显易懂)来大概的说明下我们在平时使用浏览器访问 HTTPS 站点时,中间发生的握手过程。

ssl_handshake.png

整个 SSL/TLS 的握手和通信过程,简单来说,其实可以分成下面三个阶段:

  1. 打招呼

    • 当用户通过浏览器访问 HTTPS 站点时,浏览器会向服务器打个招呼(ClientHello),服务器也会和浏览器打个招呼(ServerHello)。所谓的打招呼,实际上是告诉彼此各自的 SSL/TLS 版本号以及各自支持的加密算法等,让彼此有一个初步了解。
  2. 表明身份、验证身份

    • 第二步是整个过程中最复杂的一步,也是 HTTPS 通信中的关键。为了保证通信的安全,首先要保证我正在通信的人确实就是那个我想与之通信的人,服务器会发送一个证书来表明自己的身份,浏览器根据证书里的信息进行核实(为什么通过证书就可以证明身份呢?怎么通过证书来验证对方的身份呢?这个后面再说)。如果是双向认证的话,浏览器也会向服务器发送客户端证书。
    • 双方的身份都验证没问题之后,浏览器会和服务器协商出一个 “对话密钥” ,要注意这个 “对话密钥” 不能直接发给对方,而是要用一种只有对方才能懂的方式发给他,这样才能保证密钥不被别人截获(或者就算被截获了也看不懂)。
  3. 通信

    • 至此,握手就结束了。双方开始聊天,并通过 “对话密钥” 加密通信的数据。

握手的过程大致如此,我们现在已经了解到 HTTPS 通信需要进行一次握手,所以上面看到的 javax.net.ssl.SSLHandshakeException 这个异常,我们也不难理解,实际上也就是在 SSL/TLS 握手的过程中出现了问题。当然,这其中还有很多很多细节,下面继续。

2.2 HTTPS 中的密码学

HTTPS 协议之所以复杂,是为了保证通信过程中数据的安全性,而要保证通信安全,它在协议中运用了大量的密码学原理,可以说 HTTPS 是集密码学之大成。无论是在 SSL/TLS 握手的过程中,还是在加密通信的过程中,HTTPS 都涉及了大量的密码学概念,譬如,在证书的数字签名中使用了哈希算法和非对称加密算法,在加密通信的过程中使用了对称加密算法,为了防止传输的数据被篡改和重放还使用了 MAC(消息认证码)等。

要想深入了解 HTTPS 的工作原理,下面这些概念还是得好好研究下,网上已经有很多文章介绍这些概念了,我在这里总结一下。

  • 哈希

    • 哈希算法又称散列,它是一种将任意长度的数据转化为固定长度的算法
    • 哈希算法是不可逆的
    • 常见的哈希算法有 MD5 和 SHA1
  • 对称加密

    • 对称加密指的是加密和解密使用相同一个密钥
    • 对称加密的优点是速度快,缺点是密钥管理不方便,必须共享密钥
    • 常见的对称加密算法有 DES、AES、Blowfish 等
  • 非对称加密

    • 非对称加密指的是加密和解密使用不同的密钥,其中一个是公钥,另一个是私钥,公钥是公开的,私钥只有自己知道
    • 使用公钥加密的数据必须使用私钥解密,使用私钥加密的数据必须使用公钥解密
    • 公钥和私钥之间存在着某种联系,但是从公钥不能(或很难)推导出私钥
    • 非对称加密的缺点是速度慢,优点是密钥管理很方便
    • 常见的非对称加密算法有 RSA、ECC 等
  • 数字证书

2.3 关于证书

简单来说,数字证书就好比介绍信上的公章,有了它,就可以证明这份介绍信确实是由某个公司发出的,而证书可以用来证明任何一个东西的身份,只要这个东西能出示一份证明自己身份的证书即可,譬如可以用来验证某个网站的身份,可以验证某个文件是否可信等等。《数字证书及 CA 的扫盲介绍》《数字证书原理》 这篇博客对数字证书进行了很通俗的介绍。

知道了证书是什么之后,我们往往更关心它的原理,在上面介绍 SSL/TLS 握手的时候留了两个问题:为什么通过证书就可以证明身份呢?怎么通过证书来验证对方的身份呢?

这就要用到上面所说的非对称加密了,非对称加密的一个重要特点是:使用公钥加密的数据必须使用私钥才能解密,同样的,使用私钥加密的数据必须使用公钥解密。正是因为这个特点,网站就可以在自己的证书中公开自己的公钥,并使用自己的私钥将自己的身份信息进行加密一起公开出来,这段被私钥加密的信息就是证书的数字签名,浏览器在获取到证书之后,通过证书里的公钥对签名进行解密,如果能成功解密,则说明证书确实是由这个网站发布的,因为只有这个网站知道他自己的私钥(如果他的私钥没有泄露的话)。

在非对称加密算法中,最出众的莫过于 RSA 算法,关于 RSA 算法的数学细节,可以参考阮一峰的《RSA算法原理(一)》《RSA算法原理(二)》这两篇博客,强烈推荐。

当然,如果只是简单的对数字签名进行校验的话,还不能完全保证这个证书确实就是网站所有,黑客完全可以在中间进行劫持,使用自己的私钥对网站身份信息进行加密,并将证书中的公钥替换成自己的公钥,这样浏览器同样可以解密数字签名,签名中身份信息也是完全合法的。这就好比那些地摊上伪造公章的小贩,他们可以伪造出和真正的公章完全一样的出来以假乱真。为了解决这个问题,信息安全的专家们引入了 CA 这个东西,所谓 CA ,全称为 Certificate Authority ,翻译成中文就是证书授权中心,它是专门负责管理和签发证书的第三方机构。因为证书颁发机构关系到所有互联网通信的身份安全,因此一定要是一个非常权威的机构,像 GeoTrust、GlobalSign 等等,这里有一份常见的 CA 清单。如果一个网站需要支持 HTTPS ,它就要一份证书来证明自己的身份,而这个证书必须从 CA 机构申请,大多数情况下申请数字证书的价格都不菲,不过也有一些免费的证书供个人使用,像最近比较火的 Let's Encrypt 。从安全性的角度来说,免费的和收费的证书没有任何区别,都可以为你的网站提供足够高的安全性,唯一的区别在于如果你从权威机构购买了付费的证书,一旦由于证书安全问题导致经济损失,可以获得一笔巨额的赔偿。

如果用户想得到一份属于自己的证书,他应先向 CA 提出申请。在 CA 判明申请者的身份后,便为他分配一个公钥,并且 CA 将该公钥与申请者的身份信息绑在一起,并为之签字后,便形成证书发给申请者。如果一个用户想鉴别另一个证书的真伪,他就用 CA 的公钥对那个证书上的签字进行验证,一旦验证通过,该证书就被认为是有效的。通过这种方式,黑客就不能简单的修改证书中的公钥了,因为现在公钥有了 CA 的数字签名,由 CA 来证明公钥的有效性,不能轻易被篡改,而黑客自己的公钥是很难被 CA 认可的,所以我们无需担心证书被篡改的问题了。

下图显示了证书的申请流程(图片来自刘坤的技术博客):

shuzizhengshu_5.png

CA 证书可以具有层级结构,它建立了自上而下的信任链,下级 CA 信任上级 CA ,下级 CA 由上级 CA 颁发证书并认证。 譬如 Google 的证书链如下图所示:

shuzizhengshu_6.png

可以看出:google.com.hk 的 SSL 证书由 Google Internet Authority G2 这个 CA 来验证,而 Google Internet Authority G2 由 GeoTrust Global CA 来验证,GeoTrust Global CA 由 Equifax Secure Certificate Authority 来验证。这个最顶部的证书,我们称之为根证书(root certificate),那么谁来验证根证书呢?答案是它自己,根证书自己证明自己,换句话来说也就是根证书是不需要证明的。浏览器在验证证书时,从根证书开始,沿着证书链的路径依次向下验证,根证书是整个证书链的安全之本,如果根证书被篡改,整个证书体系的安全将受到威胁。所以不要轻易的相信根证书,当下次你访问某个网站遇到提示说,请安装我们的根证书,它可以让你访问我们网站的体验更流畅通信更安全时,最好留个心眼。在安装之前,不妨看看这几篇博客:《12306的证书问题》《在线买火车票为什么要安装根证书?》

最后总结一下,其实上面说的这些,什么非对称加密,数字签名,CA 机构,根证书等等,其实都是 PKI 的核心概念。PKI(Public Key Infrastructure)中文称作公钥基础设施,它提供公钥加密和数字签名服务的系统或平台,方便管理密钥和证书,从而建立起一个安全的网络环境。而数字证书最常见的格式是 X.509 ,所以这种公钥基础设施又称之为 PKIX 。

至此,我们大致弄懂了上面的异常信息,sun.security.validator.ValidatorException: PKIX path building failed,也就是在沿着证书链的路径验证证书时出现异常,验证失败了。

讲了这么多,全都是些理论的东西,下面开始实践吧,看看怎么解决这个异常。

2.4 关于 Java 里的证书

上面所介绍的是浏览器对证书进行验证的过程,浏览器保存了一个常用的 CA 证书列表,在验证证书链的有效性时,直接使用保存的证书里的公钥进行校验,如果在证书列表中没有找到或者找到了但是校验不通过,那么浏览器会警告用户,由用户决定是否继续。与此类似的,操作系统也一样保存有一份可信的证书列表,譬如在 Windows 系统下,你可以运行 certmgr.msc 打开证书管理器查看,这些证书实际上是存储在 Windows 的注册表中,一般情况下位于:\SOFTWARE\Microsoft\SystemCertificates\ 路径下。那么在 Java 程序中是如何验证证书的呢?

和浏览器和操作系统类似,Java 在 JRE 的安装目录下也保存了一份默认可信的证书列表,这个列表一般是保存在 $JRE/lib/security/cacerts 文件中。要查看这个文件,可以使用类似 KeyStore Explorer 这样的软件,当然也可以使用 JRE 自带的 keytool 工具(后面再介绍),cacerts 文件的默认密码为 changeit (但是我保证,大多数人都不会 change it)。

我们知道,证书有很多种不同的存储格式,譬如 CA 在发布证书时,常常使用 PEM 格式,这种格式的好处是纯文本,内容是 BASE64 编码的,证书中使用 "-----BEGIN CERTIFICATE-----" 和 "-----END CERTIFICATE-----" 来标识。另外还有比较常用的二进制 DER 格式,在 Windows 平台上较常使用的 PKCS#12 格式等等。当然,不同格式的证书之间是可以相互转换的,我们可以使用 openssl 这个命令行工具来转换,参考 SSL Converter ,另外,想了解更多证书格式的,可以参考这里:Various SSL/TLS Certificate File Types/Extensions

在 Java 平台下,证书常常被存储在 KeyStore 文件中,上面说的 cacerts 文件就是一个 KeyStore 文件,KeyStore 不仅可以存储数字证书,还可以存储密钥,存储在 KeyStore 文件中的对象有三种类型:Certificate、PrivateKey 和 SecretKey 。Certificate 就是证书,PrivateKey 是非对称加密中的私钥,SecretKey 用于对称加密,是对称加密中的密钥。KeyStore 文件根据用途,也有很多种不同的格式:JKS、JCEKS、PKCS12、DKS 等等,PixelsTech 上有一系列文章对 KeyStore 有深入的介绍,可以学习下:Different types of keystore in Java

到目前为止,我们所说的 KeyStore 其实只是一种文件格式而已,实际上在 Java 的世界里 KeyStore 文件分成两种:KeyStore 和 TrustStore,这是两个比较容易混淆的概念,不过这两个东西从文件格式来看其实是一样的。KeyStore 保存私钥,用来加解密或者为别人做签名;TrustStore 保存一些可信任的证书,访问 HTTPS 时对被访问者进行认证,以确保它是可信任的。所以准确来说,上面的 cacerts 文件应该叫做 TrustStore 而不是 KeyStore,只是它的文件格式是 KeyStore 文件格式罢了。

除了 KeyStore 和 TrustStore ,Java 里还有两个类 KeyManager 和 TrustManager 与此息息相关。JSSE 的参考手册中有一张示意图,说明了各个类之间的关系:

jsse-class.jpg

可以看出如果要进行 SSL 会话,必须得新建一个 SSLSocket 对象,而 SSLSocket 对象是通过 SSLSocketFactory 来管理的,SSLSocketFactory 对象则依赖于 SSLContext ,SSLContext 对象又依赖于 keyManagerTrustManagerSecureRandom。我们这里最关心的是 TrustManager 对象,另外两个暂且忽略,因为正是 TrustManager 负责证书的校验,对网站进行认证,要想在访问 HTTPS 时通过认证,不报 sun.security.validator.ValidatorException 异常,必须从这里开刀。

三、自定义 TrustManager 绕过证书检查

我们知道了 TrustManager 是专门负责校验证书的,那么最容易想到的方法应该就是改写 TrustManager 类,让它不要对证书做校验,这种方法虽然粗暴,但是却相当有效,而且 Java 中的 TrustManager 也确实可以被重写,下面是示例代码:

@Test
public void basicHttpsGetIgnoreCertificateValidation() throws Exception {
    
    String url = "https://kyfw.12306.cn/otn/";
    
    // Create a trust manager that does not validate certificate chains
    TrustManager[] trustAllCerts = new TrustManager[] {
        new X509TrustManager() {
            public X509Certificate[] getAcceptedIssuers() {
                return null;
            }
            public void checkClientTrusted(X509Certificate[] certs, String authType) {
                // don't check
            }
            public void checkServerTrusted(X509Certificate[] certs, String authType) {
                // don't check
            }
        }
    };
    
    SSLContext ctx = SSLContext.getInstance("TLS");
    ctx.init(null, trustAllCerts, null);
    
    LayeredConnectionSocketFactory sslSocketFactory = new SSLConnectionSocketFactory(ctx);
    
    CloseableHttpClient httpclient = HttpClients.custom()
            .setSSLSocketFactory(sslSocketFactory)
            .build();
    
    HttpGet request = new HttpGet(url);
    request.setHeader("User-Agent", "Mozilla/5.0 (Windows NT 6.1; WOW64) ...");
    
    CloseableHttpResponse response = httpclient.execute(request);
    String responseBody = readResponseBody(response);
    System.out.println(responseBody);
}

我们新建了一个匿名类,继承自 X509TrustManager 接口,这个接口提供了三个方法用于验证证书的有效性:getAcceptedIssuerscheckClientTrustedcheckServerTrusted,我们在验证的函数中直接返回,不做任何校验,这样在访问 HTTPS 站点时,就算是证书不可信,也不会抛出异常,可以继续执行下去。

这种方法虽然简单,但是却有一个最严重的问题,就是不安全。因为不对证书做任何合法性校验,而且这种处理是全局性的,不管青红皂白,所有的证书都不会做验证,所以就算遇到不信任的证书,代码依然会继续与之通信,至于通信的数据安全不安全就不能保证了。所以如果你只是想在测试环境做个实验,那没问题,但是如果你要将代码发布到生产环境,请慎重。

四、使用证书

对于有些证书,我们基本上确定是可以信任的,但是这些证书又不在 Java 的 cacerts 文件中,譬如 12306 网站,或者使用了 Let's Encrypt 证书的一些网站,对于这些网站,我们可以将其添加到信任列表中,而不是使用上面的方法统统都相信,这样程序的安全性仍然可以得到保障。

4.1 使用 keytool 导入证书

简单的做法是将这些网站的证书导入到 cacerts 文件中,这样 Java 程序在校验证书的时候就可以从 cacerts 文件中找到并成功校验这个证书了。上面我们介绍过 JRE 自带的 keytool 这个工具,这个工具小巧而强悍,拥有很多功能。首先我们可以使用它查看 KeyStore 文件,使用下面的命令可以列出 KeyStore 文件中的所有内容(包括证书、私钥等):

$ keytool -list -keystore cacerts

然后通过下面的命令,将证书导入到 cacerts 文件中:

$ keytool -import -alias 12306 -keystore cacerts -file 12306.cer

要想将网站的证书导入 cacerts 文件中,首先要获取网站的证书,譬如上面命令中的 12306.cer 文件,它是使用浏览器的证书导出向导保存的。如下图所示:

export-cert.png

关于 keytool 的更多用法,可以参考 keytool 的官网手册,SSLShopper 上也有一篇文章列出了常用的 keytool 命令

4.2 使用 KeyStore 动态加载证书

使用 keytool 导入证书,这种方法不仅简单,而且保证了代码的安全性,最关键的是代码不用做任何修改。所以我比较推荐这种方法。但是这种方法有一个致命的缺陷,那就是你需要修改 JRE 目录下的文件,如果你的程序只是在自己的电脑上运行,那倒没什么,可如果你的程序要部署在其他人的电脑上或者公司的服务器上,而你没有权限修改 JRE 目录下的文件,这该怎么办?如果你的程序是一个分布式的程序要部署在成百上千台机器上,难道还得修改每台机器的 JRE 文件吗?好在我们还有另一种通过编程的手段来实现的思路,在代码中动态的加载 KeyStore 文件来完成证书的校验,抱着知其然知其所以然的态度,我们在最后也实践下这种方法。通过编写代码可以更深刻的了解 KeyStoreTrustManagerFactorySSLContext 以及 SSLSocketFactory 这几个类之间的关系。

@Test
public void basicHttpsGetUsingSslSocketFactory() throws Exception {

    String keyStoreFile = "D:\\code\\ttt.ks";
    String password = "poiuyt";
    KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType());
    FileInputStream in = new FileInputStream(keyStoreFile);
    ks.load(in, password.toCharArray());
    
    System.out.println(KeyStore.getDefaultType().toString());
    System.out.println(TrustManagerFactory.getDefaultAlgorithm().toString());
    
    TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
    tmf.init(ks);
    SSLContext ctx = SSLContext.getInstance("TLS");
    ctx.init(null, tmf.getTrustManagers(), null);
    
    LayeredConnectionSocketFactory sslSocketFactory = new SSLConnectionSocketFactory(ctx);
    
    String url = "https://ttt.aneasystone.com";
    
    /**
     * Return the page with content:
     *     401 Authorization Required
     */
    
    CloseableHttpClient httpclient = HttpClients.custom()
            .setSSLSocketFactory(sslSocketFactory)
            .build();
    
    HttpGet request = new HttpGet(url);
    request.setHeader("User-Agent", "Mozilla/5.0 (Windows NT 6.1; WOW64) ...");
    
    CloseableHttpResponse response = httpclient.execute(request);
    String responseBody = readResponseBody(response);
    System.out.println(responseBody);
}

上面的代码使用了 HttpClient ,如果是使用 HttpsURLConnection 只需要改动下面两行即可:

HttpsURLConnection con = (HttpsURLConnection) obj.openConnection();
con.setSSLSocketFactory(ctx.getSocketFactory());

最后的最后,我们还可以通过下面的属性来指定 trustStore ,这样也不需要编写像上面那样大量繁琐的代码,另外,参考我前面的博客,这些属性还可以通过 JVM 的参数来设置。

System.setProperty("javax.net.ssl.trustStore", "D:\\code\\ttt.ks");
System.setProperty("javax.net.ssl.trustStorePassword", "poiuyt");

小结

至此,我们的 HTTPS 之旅就要告一段落了。在学习 HTTPS 的过程中,我时时不经意的会被 HTTPS 中的一些技术或技巧感触到,特别是证书的认证过程以及非对称加密算法的原理,真的是积累了人类无穷的智慧,让人不得不感叹数学的美妙。而在学习过程中越是刨根问底,越是一发不可收拾,中间牵扯到的细节太多,太深入反而让人不自觉的迷失了方向。这篇博客断断续续的写了一个多月,慢慢的自己也是从对 HTTPS 一知半解,到现在的初窥门径。写的越多,越发觉自己很多东西不清楚,看得资料越多,越是不敢写,怕写错。这篇博客参考资料众多,质量也参差不齐,不能说对读者会起什么作用,但是确实是在我学习过程中帮我理清了很多思路。在这里对这些博客的原作者表示感谢。同时,如果你发现本篇博客中存在什么问题或错误,欢迎斧正。

共勉。

参考

  1. SSL 如何工作
  2. SSL/TLS 协议简介与实例分析
  3. SSL/TLS原理详解
  4. TLS 握手优化详解
  5. 三种解密 HTTPS 流量的方法介绍
  6. 图解SSL/TLS协议
  7. SSL/TLS协议运行机制的概述
  8. HTTPS 从原理到实战
  9. HTTPS工作原理和TCP握手机制
  10. 扫盲 HTTPS 和 SSL/TLS 协议
  11. HTTPS那些事(一)HTTPS原理
  12. 理解HTTPS协议
  13. SSL/TLS协议安全系列:SSL/TLS概述
  14. Different types of keystore in Java -- Overview
  15. Different types of keystore in Java -- JKS
  16. Java中用HttpsURLConnection访问Https链接的问题
  17. Where is the certificate folder in Windows 7?
  18. 数字证书及 CA 的扫盲介绍
  19. 数字证书原理
  20. 数字证书
  21. Java 使用自签证书访问https站点
  22. 12306的证书问题
  23. 数字签名是什么?
  24. 在线买火车票为什么要安装根证书?
  25. Java加密技术(八)——数字证书
  26. Java加密技术(九)——初探SSL
  27. 常见的数字证书格式
  28. keyStore vs trustStore
  29. Difference between trustStore and keyStore in Java - SSL
  30. Java Secure Socket Extension (JSSE) Reference Guide
  31. Disable Certificate Validation in Java SSL Connections
  32. javax.net.ssl.SSLHandshakeException: sun.security.validator.ValidatorException: PKIX path building failed
  33. How to solve javax.net.ssl.SSLHandshakeException?
  34. SSL Converter
  35. The Most Common Java Keytool Keystore Commands
  36. keytool - Key and Certificate Management Tool
扫描二维码,在手机上阅读!

Java 和 HTTP 的那些事(三) 代理认证

距离上一篇已经有好长一段时间没有写博客了,这段时间适逢年终和年初,工作和生活都变得特别忙碌,无暇抽身对自己学习的东西或者工作中遇到的东西做一些总结,忙忙碌碌中很多东西做过就一笔带过了,也没有记录下来回顾分析,实在是一种遗憾。忙碌过后再回首审视之,发现其实无论是工作还是个人学习的过程中,很多小的知识点都可圈可点,完全可以在博客中记录下来总结一番。我在后面的博客写作中希望能争取做到这一点,随时记录下学习过程中的点点滴滴。好了,感慨不多说,进入今天的主题。

前面一篇博客介绍了在 Java 中使用 HttpURLConnectionHttpClient 通过代理访问 HTTP 站点的方法,但是可以看到代码中使用的代理都是免费公开的代理,不需要用户名密码就能直接访问。由于互联网上公开的代理安全性不能保证,这种代理随便用用即可,如果要慎重起见,我推荐大家还是自己搭建代理服务器。但是有一点要特别注意,如果自己搭建代理服务器的话,一定不要公开,要设置用户名密码,一般情况下,我们使用简单的基本身份认证就可以了。如果你不设置密码的话,没过几天你就会发现你的服务器会卡到爆,登上去使用 netstat 一看,几百上千个连接,服务器带宽全占满了。这是因为互联网上有着大量的代理扫描程序在没日没夜的扫描,你搭建的代理服务器没设密码,或者弱密码,都会被扫出来,而扫出来的后果就是,你的代理服务器被公开到各大免费代理站点,然后所有人都来连你的代理服务器,直到把你的带宽流量耗尽。

一、关于 HTTP 的身份认证

我们这里给代理服务器设置了用户名和密码之后,无论在程序中,还是在浏览器里使用该代理时,都需要进行身份认证了。HTTP 协议最常见的认证方式有两种:基本认证(Basic authentication)和摘要认证(Digest authentication)。HTTP 的认证模型非常简单,就是所谓的质询/响应(challenge/response)框架:当用户向服务器发送一条 HTTP 请求报文时,服务器首先回复一个“认证质询”响应,要求用户提供身份信息,然后用户再一次发送 HTTP 请求报文,这次的请求头中附带上身份信息(用户名密码),如果身份匹配,服务器则正常响应,否则服务器会继续对用户进行质询或者直接拒绝请求。

摘要认证的实现比基本认证要复杂一点,在平时的使用中也并不多见,这里忽略,如果想详细了解它,可以查看维基百科上关于 HTTP 摘要认证 的解释。这里重点介绍下 HTTP 基本认证,因为无论是代理服务器对用户进行认证,还是 Web 服务器对用户进行认证,最常用的手段都是 HTTP 基本认证,它实现简单,容易理解,几乎所有的服务器都能支持它。

一个典型的 HTTP 基本认证,如下图所示,图片摘自《HTTP 权威指南》

http-basic-auth.png

用户第一次向服务器发起请求时,服务器会返回一条 401 Unauthorized 响应,如果用户是使用浏览器访问的话,浏览器会弹出一个密码提示框,提醒用户输入用户名和密码,于是用户重新发起请求,在第二次请求中将在 Authorization 头部添加上身份信息,这个身份信息其实只是简单的对用户输入的用户名密码做了 Base64 编码 处理,服务器对用户的认证成功之后,返回 200 OK 。

二、使用基本认证

2.1 区分 Proxy 认证 和 WWW 认证

这篇博客本来是介绍代理认证的,但是代理认证其实只是 HTTP 身份认证中的一种而已,所以上面大部分内容对于代理认证来说是一样的,包括质询/响应框架以及身份认证的基本流程。不过要在代码里实现这两种认证,细节方面会有所不同,下面是两种认证的一个对比。

  • 根本区别

    • WWW 认证:指的是 Web 服务器对客户端发起的认证
    • Proxy 认证:指的是代理服务器对客户端发起的认证
  • 响应的状态码不同

    • WWW 认证:第一次访问时响应 401 Unauthorized
    • Proxy 认证:第一次访问时响应 407 Unauthorized
  • 认证头部不同

    • WWW 认证:WWW-Authenticate, Authorization, Authentication-Info
    • Proxy 认证:Proxy-Authenticate, Proxy-Authorization, Proxy-Authentication-Info

2.2 手工设置认证头部

通过上面的介绍我们了解到,要实现 HTTP 身份认证,无论是 WWW 认证也好,Proxy 认证也罢,其实只需要在 HTTP 的请求头部添加一个认证的头部(Authorization 或者 Proxy-Authorization)。认证头部的信息就是用户名和密码,将用户名和密码使用冒号分割,然后再对其进行 Base64 编码即可,我们使用 HttpURLConnection 来模拟这个过程,如下:

URL obj = new URL(url);
HttpURLConnection con = (HttpURLConnection) obj.openConnection();

// 设置认证头部
final String userName = "username";
final String password = "password";
String nameAndPass = userName + ":" + password;
String encoding = new String(Base64.encodeBase64(nameAndPass.getBytes()));
con.setRequestProperty("Authorization", "Basic " + encoding);

con.setRequestMethod("GET");
String responseBody = readResponseBody(con.getInputStream());

如果是代理认证的话,设置头部的代码改成下面这样:

con.setRequestProperty("Proxy-Authorization", "Basic " + encoding);

这种方式最为原始,也最为简单直白,几乎没什么好解释的。

但是在我使用这种方式来访问 HTTPS 站点的时候却遇到了问题:第一种情况是访问需要进行基本身份认证的 HTTPS 站点,测试通过;第二种情况是访问 HTTPS 站点,通过一个代理,代理需要进行基本身份认证,测试失败,返回下面的错误:

java.io.IOException: Unable to tunnel through proxy. Proxy returns "HTTP/1.0 407 Proxy Authentication Required"

at sun.net.www.protocol.http.HttpURLConnection.doTunneling(HttpURLConnection.java:2085)
at sun.net...https.AbstractDelegateHttpsURLConnection.connect(AbstractDelegateHttpsURLConnection.java:183)

我的第一直觉是通过代理访问 HTTPS 站点时,代理的认证信息应该没有发出去,果不其然,使用 Wireshark 截获了两次请求的数据包,第二次请求里没有我们加的 Proxy-Authorization 头部。
在 Google 上搜索这个问题,发现早在 2000 年(那可是 16 年前,当时 Java 的版本还是 1.0 呢)就有人在 JDK 的 bug database 里提交了这个问题(JDK-4323990),看这个问题的更新状态应该是在 JDK 1.4 版本里已经修复了,但是为啥我这里还是测试不通过呢!

下面是测试的完整代码,始终不理解为什么通不过,还需要继续研究下 HttpURLConnection 中的实现细节,如果有高人知道,还请多多指教。

@Test
public void basicHttpsGetWithProxyNeedAuthUsingBase64Basic() throws Exception {
    
    String url = "https://www.baidu.com";
    Proxy proxy = new Proxy(Proxy.Type.HTTP, new InetSocketAddress("139.132.22.90", 8213));
    final String proxyUserName = "username";
    final String proxyPassword = "password";
    
    URL obj = new URL(url);
    HttpsURLConnection con = (HttpsURLConnection) obj.openConnection(proxy);
    
    String nameAndPass = proxyUserName + ":" + proxyPassword;
    String encoding = new String(Base64.encodeBase64(nameAndPass.getBytes()));
    con.setRequestProperty("Proxy-Authorization", "Basic " + encoding);
    
    con.setRequestMethod("GET");
    String responseBody = readResponseBody(con.getInputStream());
    System.out.println(responseBody);
}

2.3 实现 Authenticator

自己手工设置认证头部虽然简单,而且在某些情况下可以达到意想不到的效果。但是使用这种方法可能会出现问题(像上面提到的访问 HTTPS 站点时遇到的问题),而且 Proxy 认证和 WWW 认证这两种情形还需要分别处理,不是很方便。除了可以自己手工拼 HTTP 请求头部之外,其实还有另一种更简单的方法,那就是 java.net 提供的 Authenticator 类。Authenticator 类是一个抽象类,必须先定义一个类来继承它,然后重写它的 getPasswordAuthentication() 这个方法,定义新类比较繁琐,我们可以直接使用匿名类,如下:

Authenticator authenticator = new Authenticator() {
    public PasswordAuthentication getPasswordAuthentication() {
        return new PasswordAuthentication(userName, password.toCharArray());
    }
};
Authenticator.setDefault(authenticator);

请求部分代码还是一样,如果使用代理,openConnection() 方法就加个代理参数。这种方式不区分是 Proxy 认证还是 WWW 认证,如果是 Proxy 认证,userName 和 password 就设置成代理的用户名密码,如果是 WWW 认证,则设置成 Web 服务器认证的用户名密码。

要特别注意的一点是,通过这种方式设置认证方式是 JVM 全局的,同一个 JVM 下的所有应用程序都会受影响。

然后抱着实验精神,和 2.2 节一样,我也做了几个测试,看看 Authenticator 类对 HTTPS 的支持情况,发现无论是带认证的 HTTPS 站点,还是通过带认证的代理去访问 HTTPS 站点,都没问题。不过在测试的过程中还是发现了一些有趣的现象,在使用正确的用户名和密码时都可以成功认证,但是在使用错误的用户名和密码时,不同情况下的错误情形略有不同。有些情况可能报 407 错误,有些情况可能报 401 错误,有些情况还可能报 “java.net.ProtocolException: Server redirected too many times (20)”。虽然这可能并没有什么卵用,我还是在这里记录一下吧,算是做个总结。

http-auth-example.png

注:上图是使用 Lucidchart 在线画的,强大的在线版 Visio ,推荐!

2.4 使用 HttpClient 的 CredentialsProvider

Authenticator 类似,HttpClient 也提供了一个类 CredentialsProvider 来实现 HTTP 的身份认证,它的子类 BasicCredentialsProvider 用于基本身份认证。和 Authenticator 不一样的是,这种方法不再是全局的,而是针对指定的 HttpClient 实例有效,可以根据需要来设置。这里不再多述,示例代码如下:

CredentialsProvider credentialsProvider = new BasicCredentialsProvider();
credentialsProvider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials(userName, password));

CloseableHttpClient httpclient = HttpClients.custom()
    .setDefaultCredentialsProvider(credentialsProvider)
    .build();

三、特殊的情况:多重认证

在分析上面的四种情形时,我突发奇想,如果后两种情形中不仅需要对代理进行认证,Web 服务器也需要进行认证,该如何处理呢?或者在用户和 Web 服务器之间需要经过多个代理需要认证,又该如何处理呢?

暂且给自己挖个坑,以后有时间再做研究吧,也欢迎有兴趣的朋友发表意见。^_^

参考

  1. HTTP协议 (二) 基本认证
  2. Http Authentication
  3. Connect through a Proxy
  4. Java Http连接中(HttpURLConnection)中使用代理(Proxy)及其验证(Authentication)
  5. JDK-4323990 : HttpsURLConnection doesn't send Proxy-Authorization on CONNECT
  6. HTTP Spec: Proxy-Authorization and Authorization headers
扫描二维码,在手机上阅读!