分类 编程语言 下的文章

新技术学习笔记: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
扫描二维码,在手机上阅读!

Java 和 HTTP 的那些事(二) 使用代理

在上一篇博客《模拟 HTTP 请求》中,我们分别介绍了两种方法来进行 HTTP 的模拟请求:HttpURLConnectionHttpClient ,到目前为止这两种方法都工作的很好,基本上可以实现我们需要的 GET/POST 方法的模拟。对于一个爬虫来说,能发送 HTTP 请求,能获取页面数据,能解析网页内容,这相当于已经完成 80% 的工作了。只不过对于剩下的这 20% 的工作,还得花费我们另外 80% 的时间 :-)

在这篇博客里,我们将介绍剩下 20% 的工作中最为重要的一项:如何在 Java 中使用 HTTP 代理,代理也是爬虫技术中的重要一项。你如果要大规模的爬别人网页上的内容,必然会对人家的网站造成影响,如果你太拼了,就会遭人查封。要防止别人查封我们,我们要么将自己的程序分布到大量机器上去,但是对于资金和资源有限的我们来说这是很奢侈的;要么就使用代理技术,从网上捞一批代理,免费的也好收费的也好,或者购买一批廉价的 VPS 来搭建自己的代理服务器。关于如何搭建自己的代理服务器,后面有时间的话我再写一篇关于这个话题的博客。现在有了一大批代理服务器之后,就可以使用我们这篇博客所介绍的技术了。

一、简单的 HTTP 代理

我们先从最简单的开始,网上有很多免费代理,直接上百度搜索 “免费代理” 或者 “HTTP 代理” 就能找到很多(虽然网上能找到大量的免费代理,但它们的安全性已经有很多文章讨论过了,也有专门的文章对此进行调研的,譬如这篇文章,我在这里就不多作说明,如果你的爬虫爬取的信息并没有什么特别的隐私问题,可以忽略之,如果你的爬虫涉及一些例如模拟登录之类的功能,考虑到安全性,我建议你还是不要使用网上公开的免费代理,而是搭建自己的代理服务器比较靠谱)。

1.1 HttpURLConnection 使用代理

HttpURLConnection 的 openConnection() 方法可以传入一个 Proxy 参数,如下:

Proxy proxy = new Proxy(Proxy.Type.HTTP, new InetSocketAddress("127.0.0.1", 9876));
URL obj = new URL(url);
HttpURLConnection con = (HttpURLConnection) obj.openConnection(proxy);

OK 了,就这么简单!

不仅如此,我们注意到 Proxy 构造函数的第一个参数为枚举类型 Proxy.Type.HTTP ,那么很显然,如果将其修改为 Proxy.Type.SOCKS 即可以使用 SOCKS 代理。

1.2 HttpClient 使用代理

由于 HttpClient 非常灵活,使用 HttpClient 来连接代理有很多不同的方法。最简单的方法莫过于下面这样:

HttpHost proxy = new HttpHost("127.0.0.1", 9876, "HTTP");
CloseableHttpClient httpclient = HttpClients.createDefault();
HttpGet request = new HttpGet(url);
CloseableHttpResponse response = httpclient.execute(proxy, request);

和上一篇中使用 HttpClient 发送请求的代码几乎一样,只是 httpclient.execute() 方法多加了一个参数,第一参数为 HttpHost 类型,我们这里设置成我们的代理即可。

这里要注意一点的是,虽然这里的 new HttpHost() 和上面的 new Proxy() 一样,也是可以指定协议类型的,但是遗憾的是 HttpClient 默认是不支持 SOCKS 协议的,如果我们使用下面的代码:

HttpHost proxy = new HttpHost("127.0.0.1", 1080, "SOCKS");

将会直接报协议不支持异常:

org.apache.http.conn.UnsupportedSchemeException: socks protocol is not supported

如果希望 HttpClient 支持 SOCKS 代理,可以参看这里:How to use Socks 5 proxy with Apache HTTP Client 4? 通过 HttpClient 提供的 ConnectionSocketFactory 类来实现。

虽然使用这种方式很简单,只需要加个参数就可以了,但是其实看 HttpClient 的代码注释,如下:

/*
* @param target    the target host for the request.
*                  Implementations may accept <code>null</code>
*                  if they can still determine a route, for example
*                  to a default target or by inspecting the request.
* @param request   the request to execute
*/

可以看到第一个参数 target 并不是代理,它的真实作用是 执行请求的目标主机,这个解释有点模糊,什么叫做 执行请求的目标主机?代理算不算执行请求的目标主机呢?因为按常理来讲,执行请求的目标主机 应该是要请求 URL 对应的站点才对。如果不算的话,为什么这里将 target 设置成代理也能正常工作?这个我也不清楚,还需要进一步研究下 HttpClient 的源码来深入了解下。

除了上面介绍的这种方式(自己写的,不推荐使用)来使用代理之外,HttpClient 官网还提供了几个示例,我将其作为推荐写法记录在此。

第一种写法是使用 RequestConfig 类,如下:

CloseableHttpClient httpclient = HttpClients.createDefault();        
HttpGet request = new HttpGet(url);

request.setConfig(
    RequestConfig.custom()
        .setProxy(new HttpHost("45.32.21.237", 8888, "HTTP"))
        .build()
);
        
CloseableHttpResponse response = httpclient.execute(request);

第二种写法是使用 RoutePlanner 类,如下:

HttpHost proxy = new HttpHost("127.0.0.1", 9876, "HTTP");
DefaultProxyRoutePlanner routePlanner = new DefaultProxyRoutePlanner(proxy); 
CloseableHttpClient httpclient = HttpClients.custom()
        .setRoutePlanner(routePlanner)
        .build();
HttpGet request = new HttpGet(url);
CloseableHttpResponse response = httpclient.execute(request);

二、使用系统代理配置

我们在调试 HTTP 爬虫程序时,常常需要切换代理来测试,有时候直接使用系统自带的代理配置将是一种简单的方法。以前在做 .Net 项目时,程序默认使用 Internet 网络设置中配的代理,遗憾的是,我这里说的系统代理配置指的 JVM 系统,而不是操作系统,我还没找到简单的方法来让 Java 程序直接使用 Windows 系统下的代理配置。

尽管如此,系统代理使用起来还是很简单的。一般有下面两种方式可以设置 JVM 的代理配置:

2.1 System.setProperty

Java 中的 System 类不仅仅是用来给我们 System.out.println() 打印信息的,它其实还有很多静态方法和属性可以用。其中 System.setProperty() 就是比较常用的一个。

可以通过下面的方式来分别设置 HTTP 代理,HTTPS 代理和 SOCKS 代理:

// HTTP 代理,只能代理 HTTP 请求
System.setProperty("http.proxyHost", "127.0.0.1");
System.setProperty("http.proxyPort", "9876");

// HTTPS 代理,只能代理 HTTPS 请求
System.setProperty("https.proxyHost", "127.0.0.1");
System.setProperty("https.proxyPort", "9876");

// SOCKS 代理,支持 HTTP 和 HTTPS 请求
// 注意:如果设置了 SOCKS 代理就不要设 HTTP/HTTPS 代理
System.setProperty("socksProxyHost", "127.0.0.1");
System.setProperty("socksProxyPort", "1080");

这里有三点要说明:

  1. 系统默认先使用 HTTP/HTTPS 代理,如果既设置了 HTTP/HTTPS 代理,又设置了 SOCKS 代理,SOCKS 代理会起不到作用
  2. 由于历史原因,注意 socksProxyHostsocksProxyPort 中间没有小数点
  3. HTTP 和 HTTPS 代理可以合起来缩写,如下:
// 同时支持代理 HTTP/HTTPS 请求
System.setProperty("proxyHost", "127.0.0.1");
System.setProperty("proxyPort", "9876");

2.2 JVM 命令行参数

可以使用 System.setProperty() 方法来设置系统代理,也可以直接将这些参数通过 JVM 的命令行参数来指定。如果你使用的是 Eclipse ,可以按下面的步骤来设置:

  1. 按顺序打开:Window -> Preferences -> Java -> Installed JREs -> Edit
  2. 在 Default VM arguments 中填写参数: -DproxyHost=127.0.0.1 -DproxyPort=9876

jvm-arguments.jpg

2.3 使用系统代理

上面两种方法都可以设置系统,下面要怎么在程序中自动使用系统代理呢?

对于 HttpURLConnection 类来说,程序不用做任何变动,它会默认使用系统代理。但是 HttpClient 默认是不使用系统代理的,如果想让它默认使用系统代理,可以通过 SystemDefaultRoutePlannerProxySelector 来设置。示例代码如下:

SystemDefaultRoutePlanner routePlanner = new SystemDefaultRoutePlanner(ProxySelector.getDefault());
CloseableHttpClient httpclient = HttpClients.custom()
        .setRoutePlanner(routePlanner)
        .build();
HttpGet request = new HttpGet(url);        
CloseableHttpResponse response = httpclient.execute(request);

参考

  1. HttpClient Tutorial
  2. 评测告诉你:那些免费代理悄悄做的龌蹉事儿
  3. How to use Socks 5 proxy with Apache HTTP Client 4?
  4. 使用ProxySelector选择代理服务器
  5. Java Networking and Proxies
扫描二维码,在手机上阅读!