Fork me on GitHub

分类 编程语言 下的文章

学习 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
扫描二维码,在手机上阅读!

Java 和 HTTP 的那些事(一) 模拟 HTTP 请求

最新在学习使用 Java 来写网络爬虫,模拟浏览器发送 HTTP 请求,并抓取返回页面中的信息。由于对 Java 刚接触,以前用 .Net 写的一些网络请求相关的工具类都派不上用场,于是对如何使用 Java 模拟 HTTP 请求潜心研究了一番,在此写下这个《Java 和 HTTP 的那些事》系列的博客,并记录一些我中途遇到了明坑和暗坑,供后来人参考。此为第一篇。

一、使用 HttpURLConnection 发送 HTTP 请求

Java 自带的 java.net 这个包中包含了很多与网络请求相关的类,但是对于我们来说,最关心的应该是 HttpURLConnection 这个类了。

1.1 创建 HTTP 连接对象

要得到一个 HttpURLConnection HTTP 连接对象,首先需要一个 URL,代码如下:

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

1.2 添加 HTTP 请求头

得到 HTTP 连接对象之后,我们就可以进行 HTTP 操作了,我们可以添加任意的 HTTP 请求头,然后执行我们需要的 GET 或者 POST 请求。我们像下面这样,添加两个 HTTP 头(User-Agent 和 Accept-Language):

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

对于有些爬虫来说,这个设置是必要的,譬如有很多网站会对请求头中的 Referer 进行检查,以此来防爬或者防盗链。又譬如有些网站还会对 User-Agent 进行检查,根据这个字段来过滤一些非浏览器的请求。如果请求头设置不对的话,很可能是爬不下正确的数据的。

1.3 HTTP GET

HTTP 协议中定义了很多种 HTTP 请求方法:GET、POST、PUT、DELETE、OPTIONS 等等,其中最常用到的就是 GET 和 POST,因为在浏览器中大多都是使用这两种请求方法。

使用 HttpURLConnection 来发送 GET 请求是非常简单的,通过上面的代码创建并初始化好一个 HTTP 连接之后,就可以直接来发送 GET 请求了。

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

可以看到,代码非常简洁,没有任何累赘的代码,甚至没有任何和发送请求相关的代码,请求就是在 getResponseCode() 函数中默默的执行了。其中 readResponseBody() 函数用于读取流并转换为字符串,具体的实现如下:

// 读取输入流中的数据
private String readResponseBody(InputStream inputStream) throws IOException {

    BufferedReader in = new BufferedReader(
            new InputStreamReader(inputStream));
    String inputLine;
    StringBuffer response = new StringBuffer();

    while ((inputLine = in.readLine()) != null) {
        response.append(inputLine);
    }
    in.close();
    
    return response.toString();
}

1.4 HTTP POST

使用 HttpURLConnection 来模拟 POST 请求和 GET 请求基本上是一样的,但是有一点不同,由于 POST 请求一般都会向服务端发送一段数据,所以 HttpURLConnection 提供了一个方法 setDoOutput(true) 来表示有数据要输出给服务端,并可以通过 getOutputStream() 得到输出流,我们将要写的数据通过这个输出流 POST 到服务端。

con.setRequestMethod("POST");
con.setDoOutput(true);
DataOutputStream wr = new DataOutputStream(con.getOutputStream());
wr.writeBytes(parameter);
wr.flush();
wr.close();

POST 完成之后,和 GET 请求一样,我们通过 getInputStream() 函数来读取服务端返回的数据。

二、使用 HttpClient 发送 HTTP 请求

使用 Java 自带的 HttpURLConnection 类完全可以满足我们的一些日常需求,不过对于网络爬虫这种高度依赖于 HTTP 工具类的程序来说,它在有些方面还是显得略为不足(我们之后会讨论到),我们需要一种扩展性定制性更强的类。Apache 的 HttpClient 就是首选。

HttpClient 是 Apache Jakarta Common 下的子项目,用来提供高效的、最新的、功能丰富的支持 HTTP 协议的客户端编程工具包。它相比传统的 HttpURLConnection,增加了易用性和灵活性,它不仅让客户端发送 HTTP 请求变得更容易,而且也方便了开发人员测试接口(基于 HTTP 协议的),即提高了开发的效率,也方便提高代码的健壮性。

好了,关于 HttpClient 介绍的大话空话套话结束,让我们来看一段使用 HttpClient 来模拟 HTTP GET 请求的代码片段:

CloseableHttpClient httpclient = HttpClients.createDefault();
HttpGet request = new HttpGet(url);
request.setHeader("User-Agent", "Mozilla/5.0 (Windows NT 6.1; WOW64) ...");
CloseableHttpResponse response = httpclient.execute(request);

// read response

2.1 HTTP GET 与 HTTP POST

上面的示例代码展示了如何使用 HttpClient 来模拟 HTTP GET 请求,可以看出 HttpClient 对每一种 HTTP 方法都准备了一个类,GET 请求使用 HttpGet 类,POST 请求使用 HttpPost 类。

和上文中介绍的一样,在发送 POST 请求时,需要向服务端写入一段数据,我们这里使用 setEntity() 函数来写入数据:

String parameter = "key=value";
HttpPost request = new HttpPost(url);
request.setEntity(
    new StringEntity(parameter, ContentType.create("application/x-www-form-urlencoded"))
);

Entity 是 HttpClient 中的一个特别的概念,有着各种的 Entity ,都实现自 HttpEntity 接口,输入是一个 Entity,输出也是一个 Entity 。要注意的是,在这里我采用一种取巧的方式,直接使用 StringEntity 来写入 POST 数据,然后将 Content-type 改成 application/x-www-form-urlencoded ,这样就和浏览器里的表单提交请求一致了。但是我们要知道的是,一般情况下,我们可能还会使用 UrlEncodedFormEntity 这个类,只是在写爬虫的时候比较繁琐,使用起来像下面这样:

List<NameValuePair> nvps = new ArrayList <NameValuePair>();
nvps.add(new BasicNameValuePair("key", "value"));
request.setEntity(new UrlEncodedFormEntity(nvps, HTTP.UTF_8));

2.2 读取响应

正如上文所说,HttpClient 的输入是一个 Entity,输出也是一个 Entity 。这和 HttpURLConnection 的流有些不同,但是基本理念是相通的。对于 Entity ,HttpClient 提供给我们一个工具类 EntityUtils,使用它可以很方便的将其转换为字符串。

CloseableHttpResponse response = httpclient.execute(request);
String responseBody = EntityUtils.toString(response.getEntity());

2.3 HttpEntiy 接口

上面说到了 HttpClient 中的 HttpEntity 这个接口,这个接口在使用 HttpClient 的时候相当重要,这里对其略做补充。

大多数的 HTTP 请求和响应都会包含两个部分:头和体,譬如请求头请求体,响应头响应体, Entity 也就是这里的 “体” 部分,这里暂且称之为 “实体” 。一般情况下,请求包含实体的有 POST 和 PUT 方法,而绝大多数的响应都是包含实体的,除了 HEAD 请求的响应,还有 204 No Content、304 Not Modified 和 205 Reset Content 这些不包含实体。

HttpClient 将实体分为三种类型:

  1. 流类型(streamed):实体内容从流中读取的,通常只能读一次
  2. 自包含类型(self-contained):手工创建的,通常可重复读取
  3. 包装类型(wrapping):使用一种实体包装另一种实体

上面的例子中我们直接使用工具方法 EntityUtils.toString() 将一个 HttpEntity 转换为字符串,虽然使用起来非常方便,但是要特别注意的是这其实是不安全的做法,要确保返回内容的长度不能太长,如果太长的话,还是建议使用流的方式来读取:

CloseableHttpResponse response = httpclient.execute(request);
HttpEntity entity = response.getEntity();
if (entity != null) {
    long length = entity.getContentLength();
    if (length != -1 && length < 2048) {
        String responseBody = EntityUtils.toString(entity);
    }
    else {
        InputStream in = entity.getContent();
        // read from the input stream ...
    }
}

三、关于 HTTPS 请求

到这里为止,我们一直忽略了 HTTP 请求和 HTTPS 请求之间的差异,因为大多数情况下,我们确实不需要关心 URL 是 HTTP 的还是 HTTPS 的,上面给出的代码也都能很好的自动处理这两种不同类型的请求。

但是,我们还是应该注意下这两种请求的差异,后面我们在介绍 HTTP 代理时将会特别看到这两者之间的差异。另外还有一点,在调用 URLopenConnection() 方法时,如果 URL 是 HTTP 协议的,返回的是一个 HttpURLConnection 对象,而如果 URL 是 HTTPS 协议的,返回的将是一个 HttpsURLConnection 对象。

参考

  1. How to send HTTP request GET/POST in Java
  2. HttpClient Tutorial
  3. HttpClient Examples
扫描二维码,在手机上阅读!

Windows下搭建PHP本地开发环境

一直以来,我都是使用新浪的SAE作为我本地的PHP开发环境。SAE使用起来非常方便,是个纯绿色的软件包,可以从这里下载,使用之前首先配置sae.conf文件中的DocumentRoot参数为网站根目录(也就是你PHP开发的目录),然后运行init.cmd脚本即可(Win7以上系统可能需要以管理员身份运行)。 我将SAE作为我的本地开发环境,官方的SAE只包含了PHP、Apache和Redis,而我的生产环境是PHP + Nginx + MySQL。这样不仅需要安装MySQL,而且不能和生产环境的Nginx保持一致,导致很多生产上的Nginx重写规则,在本地要再写一份Apache版的,虽然差异不大,但是依然感觉很麻烦。所以就想打造一个自己的PHP开发环境,像SAE一样绿色版,并且支持Apache和Nginx两个服务器。

一、搭建步骤

我们按PHP、Apache和Nginx的顺序来搭建PHP的本地开发环境,MySQL没有包含其中,以后有机会再做。这次的重点是学习Apache和Nginx的配置以及PHP是怎么和这两大Web服务器交互的。

1.1 PHP

首先,我们在这里下载PHP的Windows版本,我们直接下载编译好的Zip包即可。这里要特别注意的是,PHP for Windows有多种不同的版本,VC9和VC11,TS和NTS,x86和x64等。

  • VC9 vs VC11:代表使用哪个VS版本编译的,VC9表示Visual Studio 2008,VC11表示Visual Studio 2012
  • TS vs NTS:TS代表线程安全,也就是说支持多线程,PHP以模块形式加载到Web服务器中需要使用TS;NTS代表只支持单线程,在CLI/CGI/FastCGI等模式下建议使用NTS
  • x86 vs x64:使用32位还是64位,x64目前是实验版本
    综上所述,这里我们选择PHP 5.6 (5.6.9) VC11 x86 Thread Safe

1.2 Apache

Apache官网上并没有提供Windows上的可执行文件供下载,而是只提供了源码和编译安装步骤。我们这里省去编译可能会带来的问题和麻烦,直接使用第三方编译好的可执行文件。我们有很多不同的选择,下面是Apache官方提供的选择列表:

这里我们使用Apache Lounge提供的httpd-2.4.12-win32-VC11.zip,这是因为上面提到的PHP中一些SAPI就是使用Apache Lounge提供的二进制文件来编译的。注意选择和上面PHP一样的VC11和32位版本。

1.3 Nginx

比较流行的Nginx for Windows有两种不同的版本,一种是官方提供的使用native Win32 API的原生版本,另一种是利用Cygwin模拟编译出来的版本。这里我们选择原生版本

二、PHP的两种配置方式

将可执行文件都下下来之后,我们就需要对其进行配置了。首先我们以下图所示结构对刚下载的软件包进行组织:

structure

webserver目录用于存放Web服务器的二进制文件,譬如Apache和Nginx,之后可以添加lighttpd,IIS等;database目录用于存放数据库相关文件,譬如MySQL、Redis和Memcached等;interpreter目录用于存放脚本解析文件,如PHP、Perl、Python等。 PHP一般有两种配置方式:一种方式是以Apache的模块mod_php运行在Apache服务器中,这种方式最传统最著名,历史也最为悠久,在Nginx服务器没流行起来的时代,Apache统治了Web服务器的绝大部分,当时几乎都是以mod_php方式来配置的。这种配置方式虽然简单而且运行效率很高,但是这种配置不够灵活,导致Web服务器臃肿,也导致Apache服务器会占用更多的内存。于是出现了第二种配置方式CGI/FastCGI,CGI由于其效率低下已经基本上被FastCGI替代了。这种配置方式将Web服务器和PHP解析器解耦分离开来,PHP和Web服务器使用两个进程,甚至可以运行在两个不同服务器上,两者之间通过CGI/FastCGI协议进行通信。关于CGI/FastCGI/PHP-FPM的资料网上有很多,可以参考这里这里这里了解下。关于PHP的两种配置方式可以参考这里这里这里。 下面我们就以这两种方式分别来配置我们刚下载的Apache和Nginx。

2.1 mod_php

Apache支持以模块的方式来配置PHP,这个模块就是mod_php5.so,要注意的是mod_php是Linux下的名称,在Windows下的名称是php5apache2_4.dllphp5apache2_4.dll这个文件只包含在TS版的PHP打包中,如果你下载的是NTS版的可能找不到这个文件,因为Apache以模块形式载入PHP需要保证其是线程安全的。Nginx暂时还不支持这种配置方式。 参考PHP官方文档,我们在webserver\Apache-2.4\conf\httpd.conf文件中加入下面的代码来完成配置:

#
# mod_php
#
LoadModule php5_module "full-path-of-php5apache2_4.dll"
PHPIniDir "full-path-of-php.ini-dir"
AddType application/x-httpd-php .php

确保配置文件中的ServerRootDocumentRoot路径正确,然后运行webserver\Apache-2.4\bin\httpd.exe即可。可以在htdocs目录下新建一个PHP文件,使用phpinfo()方法来确保PHP和Apache已经正确运行。

2.2 CGI / FastCGI / PHP-FPM

在Windows环境下通过CGI方式解析PHP脚本一般使用的是PHP自带的php-cgi.exe程序。该程序运行方式如下:

php-cgi.exe -b 127.0.0.1:9000 -c path/to/php.ini

一旦按上面的方式运行php-cgi.exe程序之后就可以通过9000端口使用CGI协议来和它进行通信解析PHP脚本了。然后我们参考这里,在Nginx的配置文件中配上下面的代码(Nginx的配置文件位于webservernginx-1.9.0confnginx.conf):

#
# pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000
#
location ~ \.php$ {
    root           html;
    fastcgi_pass   127.0.0.1:9000;
    fastcgi_index  index.php;
    fastcgi_param  SCRIPT_FILENAME  $document_root$fastcgi_script_name;
    include        fastcgi_params;
}

配置好了以后,运行webserver\nginx-1.9.0\nginx.exe即可。在html目录下新建一个PHP文件,使用phpinfo()方法确保PHP和Nginx正确运行。可以看到mod_php方式配置简单,而且只要运行Web服务器即可,FastCGI方式不仅要运行Web服务器,还要运行FastCGI解析程序。 当然不仅仅是Nginx支持FastCGI这种配置方式,几乎所有其他的Web服务器都支持这种配置方式,譬如:Apache、IIS和lighttpd等。如果你想在Apache上使用这种配置,可以使用mod_fcgid扩展模块,在httpd.conf配置文件中添加如下代码:

#
# fast_cgi
#
LoadModule fcgid_module modules/mod_fcgid.so
FcgidInitialEnv PHPRC        "c:/php" 
FcgidWrapper "c:/php/php-cgi.exe" .php  
AddHandler fcgid-script .php

另外要提出的一点是,这里不管是mod_php方式还是FastCGI方式,我们都使用的是PHP的线程安全(TS)版本。而一般推荐做法是mod_php使用多线程(TS)版本,FastCGI使用单线程(NTS)版本,使用NTS版本不仅可以提高性能,也是为了和PHP的扩展(有很多PHP扩展是非线程安全的)保持兼容。

2.3 总结

为了方便,我写了两个批处理程序来启动Apache和Nginx。完整的版本可以看这里start-apache.bat文件:

@echo off
set APACHEROOT="path/to/webserver/Apache-2.4"
set PHPROOT="path/to/interpreter/php-5.6.9"
echo Starting Apache...
copy "%APACHEROOT:~1,-1%\conf\httpd.conf.bak" "%APACHEROOT:~1,-1%\conf\httpd.conf" /Y
utils\fnr.exe --cl --dir "%APACHEROOT:~1,-1%\conf" --fileMask "httpd.conf" --find "

start-nginx.bat文件:

@echo off
set NGINXROOT="path/to/webserver/nginx-1.9.0"
set PHPROOT="path/to/interpreter/php-5.6.9"
echo Starting PHP FastCGI...
utils\RunHiddenConsole "%PHPROOT:~1,-1%\php-cgi.exe" -b 9000 -c "%PHPROOT:~1,-1%\php.ini-development"
echo Starting Nginx...
utils\RunHiddenConsole "%NGINXROOT:~1,-1%\nginx.exe" -p "%NGINXROOT:~1,-1%"
pause

三、PHP SAPI

上面介绍了PHP的两种不同的配置方式,这两种配置都可以使PHP工作,实现这一点其实要归功于PHP的SAPI。SAPI(Server Application Programming Interface)是服务器应用编程接口的缩写,PHP通过SAPI提供了一组接口供应用和PHP内核之间进行数据交互。PHP提供很多种形式的接口,包括apache、apache2filter、apache2handler、cgi 、cgi-fcgi、cli、cli-server、embed、isapi、litespeed等等。但是常用的只有5种形式,CLI/CGI(单进程)、Multiprocess(多进程,PHP可以编译成Apache下的prefork MPM模式和APXS模块)、Multithreaded(多线程,Apache2的Worker MPM模块)、FastCGIEmbedded(内嵌)。可以使用PHP的php_sapi_name()函数获取当前的SAPI接口类型。参考这里这里了解更多。另外在简明现代魔法上有一个系列的文章对SAPI进行了详细的介绍:

参考

  1. PHP For Windows
  2. Using Apache HTTP Server on Microsoft Windows
  3. Apache Lounge
  4. nginx for Windows
  5. 什么是CGI、FastCGI、PHP-CGI、PHP-FPM、Spawn-FCGI?
  6. 概念了解:CGI,FastCGI,PHP-CGI与PHP-FPM
  7. 搞不清FastCgi与PHP-fpm之间是个什么样的关系
  8. mod_php vs FastCGI vs PHP-FPM
  9. [[好文]mod_php和mod_fastcgi和php-fpm的介绍,对比,和性能数据](http://wenku.baidu.com/view/887de969561252d380eb6e92.html)
  10. mod_php和mod_fastcgi(待整理)
  11. Microsoft Windows 下的 Apache 2.x
  12. php-fcgi on Windows
  13. windows下配置nginx+php环境
  14. Thread Safe PHP with FastCGI on IIS 7 or not?
  15. 【问底】王帅:深入PHP内核(二)——SAPI探究
  16. 第二章 » 第二节 SAPI概述 | TIPI: 深入理解PHP内核
扫描二维码,在手机上阅读!

LINQ中的Distinct

一、从去重说起

去重一直是数据处理中的一个重要操作,保证处理的数据中没有冗余,而在编写代码的时候更是经常需要剔除重复的数据避免重复计算。LINQ中的Distinct方法可以很好的处理基本数据类型的去重操作,如下所示:

// int类型
List<int> ints = new List<int> { 
    4, 5, 2, 1, 4, 6, 3, 2, 1, 3 
};
ints.Distinct().ToList().ForEach(x => Console.WriteLine(x));

// string类型
List<string> strings = new List<string> { 
    "Tom", "John", "Lily", "Tom", "Jess", "John" 
};
strings.Distinct().ToList().ForEach(x => Console.WriteLine(x));

但是在使用Distinct方法处理对象类型的数据时却没有这么好的体验了,假设我们有下面这个Person类,和类似的去重代码:

public class Person
{
    public int Id { get; set; }
    public string Name { get; set; }
    public int Age { get; set; }

    public override string ToString()
    {
        return string.Format("Id: {0}, Name: {1}, Age: {2}", 
            this.Id, this.Name, this.Age);
    }
}

List<Person> personList = new List<Person> 
{
    new Person { Id = 1, Name = "Zhangsan", Age = 23 },
    new Person { Id = 2, Name = "Lisi", Age = 22 },
    new Person { Id = 3, Name = "Wangwu", Age = 25 },
    new Person { Id = 2, Name = "Lisi", Age = 22 },
    new Person { Id = 1, Name = "Zhangsan", Age = 23 }
};
var dist = personList.Distinct().ToList();
dist.ForEach(x => Console.WriteLine(x));

得到的结果如下图,可以看到直接简单的使用Distinct来对对象列表进行去重,得到的结果和没去重的结果是一样的!原因在于Distinct方法默认的实现是根据对象的引用来进行的,而上面new了5个Person对象,是5个不同的引用。

distinct

我们查看Distinct方法的定义,发现该方法还可以带一个IEqualityComparer<TSource>类型的参数:

namespace System.Linq
{
    public static class Enumerable
    {
        // ...
        public static IEnumerable<TSource> Distinct<TSource>(this IEnumerable<TSource> source);
        public static IEnumerable<TSource> Distinct<TSource>(
                this IEnumerable<TSource> source, IEqualityComparer<TSource> comparer);
        // ...
    }
}

下面我们通过实现IEqualityComparer接口来实现对象列表的去重。

二、实现IEqualityComparer

IEqualityComparer接口的定义如下:

namespace System.Collections.Generic
{
    public interface IEqualityComparer<in T>
    {
        bool Equals(T x, T y);
        int GetHashCode(T obj);
    }
}

可以看到通过实现IEqualityComparer接口封装了EqualsGetHashCode这两个函数的实现,而这两个正是用于判断对象是否相等的重要函数。因为这里我们是想通过Id字段来去重,所以我们新建一个PersonIdComparer类:

public class PersonIdComparer : IEqualityComparer<Person>
{
    public bool Equals(Person x, Person y)
    {
        if (x == null)
            return y == null;
        return x.Id == y.Id;
    }

    public int GetHashCode(Person obj)
    {
        if (obj == null)
            return 0;
        return obj.Id.GetHashCode();
    }
}

使用Distinct去重的时候只需要这样就可以了:

var dist1 = personList.Distinct(new PersonIdComparer()).ToList();

看起来很简单的一个去重操作,却要每次都要新建一个类,然后实现它的两个方法。而且在需求变更的同时,这个类还不能很好的适应变化,譬如现在我们需要根据Name字段去重,那么我们又需要新建一个PersonNameComparer类,当我们需要根据Age字段去重的时候,又需要新建一个PersonAgeComparer类,如此反复,非常繁琐,为什么我们不能写一个通用的类直接根据某个字段来去重呢? LoveJenny在《Linq的Distinct太不给力了》这篇博文中使用泛型、反射和表达式树的方法实现了一个PropertyComparer类,这个类可以很好的适应上面的变化。另外这篇文章也有类似的实现。

三、简化IEqualityComparer的实现

LoveJenny的博文给出的解决方法虽然满足了适应变化的能力,但在使用上仍然感觉不是很便捷,为什么我们不能像LINQ中的其他方法如OrderBy(x => x.Id)这样使用lambda表达式来去重呢?鹤冲天在他的两篇博文《c# 扩展方法奇思妙用基础篇八:Distinct 扩展》《何止 Linq 的 Distinct 不给力》中介绍了一种更简单通用的实现IEqualityComparer接口的方法,并通过结合C#的扩展方法使得LINQ中的Distinct使用起来非常便捷。下面直接上代码:

public static class Equality<T>
{
    public static IEqualityComparer<T> CreateComparer<V>(Func<T, V> keySelector)
    {
        return new CommonEqualityComparer<V>(keySelector);
    }
    public static IEqualityComparer<T> CreateComparer<V>(
            Func<T, V> keySelector, IEqualityComparer<V> comparer)
    {
        return new CommonEqualityComparer<V>(keySelector, comparer);
    }

    class CommonEqualityComparer<V> : IEqualityComparer<T>
    {
        private Func<T, V> keySelector;
        private IEqualityComparer<V> comparer;

        public CommonEqualityComparer(
                Func<T, V> keySelector, IEqualityComparer<V> comparer)
        {
            this.keySelector = keySelector;
            this.comparer = comparer;
        }
        public CommonEqualityComparer(Func<T, V> keySelector)
            : this(keySelector, EqualityComparer<V>.Default)
        { }

        public bool Equals(T x, T y)
        {
            return comparer.Equals(keySelector(x), keySelector(y));
        }
        public int GetHashCode(T obj)
        {
            return comparer.GetHashCode(keySelector(obj));
        }
    }
}

public static class DistinctExtensions
{
    public static IEnumerable<T> DistinctBy<T, V>(
            this IEnumerable<T> source, Func<T, V> keySelector)
    {
        return source.Distinct(Equality<T>.CreateComparer(keySelector));
    }
    public static IEnumerable<T> DistinctBy<T, V>(
            this IEnumerable<T> source, Func<T, V> keySelector, IEqualityComparer<V> comparer)
    {
        return source.Distinct(Equality<T>.CreateComparer(keySelector, comparer));
    }
}

原文中的方法名保持了LINQ中的Distinct不变,我这里改成了DistinctBy,这样和OrderByGroupBy相一致,而且在使用的时候personList.DistinctBy(x => x.Id)在语义上感觉更清晰。而且这里的Equality静态类,可以为我们很方便的创建IEqualityComparer接口,直接通过lambda表达式就可以而不需要再另外定义一个类了,譬如:var idComparer = Equality<Person>.CreateComparer(x => x.Id)

四、第三方库中的解决方法

4.1 morelinq

morelinq是一个关于linq的开源项目,添加了一些很实用的扩展方法,像ExcludeMinByMaxByTakeEvery等等,当然还有我们这里讲的DistinctBy,用法和上面的一样:personList.DistinctBy(x => x.Id)就可以了。我们这里主要关心它的实现方法,因为它并没有通过实现IEqualityComparer接口来,而是巧妙的利用了C#中的HashSet类型和yield关键字,实现了去重的目的。我将无关部分剔除,关键代码如下:

static partial class MoreEnumerable
{   
    public static IEnumerable<TSource> DistinctBy<TSource, TKey>(
            this IEnumerable<TSource> source, Func<TSource, TKey> keySelector)
    {
        return source.DistinctBy(keySelector, null);
    }

    public static IEnumerable<TSource> DistinctBy<TSource, TKey>(
            this IEnumerable<TSource> source,
            Func<TSource, TKey> keySelector, IEqualityComparer<TKey> comparer)
    {
        return DistinctByImpl(source, keySelector, comparer);
    }

    private static IEnumerable<TSource> DistinctByImpl<TSource, TKey>(
            IEnumerable<TSource> source,
            Func<TSource, TKey> keySelector, IEqualityComparer<TKey> comparer)
    {
        var knownKeys = new HashSet<TKey>(comparer);
        foreach (var element in source)
        {
            if (knownKeys.Add(keySelector(element)))
            {
                yield return element;
            }
        }
    }
}

具体的实现在DistinctByImpl这个方法里,首先新建一个HashSet,然后通过Add方法将元素添加到HashSet中,如果元素已存在Add方法返回false,则会继续插入下一个元素直到下一个元素插入成功,然后通过yield返回。需要注意的是HashSet接受一个IEqualityComparer类型的参数,如果HashSet的key是个对象而不是简单类型,则需要和我们上面一样定义一个类实现IEqualityComparer接口了。

4.2 Miscellaneous Utility Library

MiscUtil是一个C#的工具包大杂烩,它有各种辅助方法可以方便我们的开发工作。其中有一个ProjectionEqualityComparer类是和上面介绍的鹤冲天的Equality<T>类几乎完全一样,具体实现可以去看下它的源代码。唯一的区别在于它提供了两个ProjectionEqualityComparer类的实现,一个是ProjectionEqualityComparer,另一个是ProjectionEqualityComparer<TSource>。如下:

public static class ProjectionEqualityComparer
{
    public static ProjectionEqualityComparer<TSource, TKey> 
            Create<TSource, TKey>(Func<TSource, TKey> projection)
    {
        return new ProjectionEqualityComparer<TSource, TKey>(projection);
    }
}

public static class ProjectionEqualityComparer<TSource>
{    
    public static ProjectionEqualityComparer<TSource, TKey> 
            Create<TKey>(Func<TSource, TKey> projection)
    {
        return new ProjectionEqualityComparer<TSource, TKey>(projection);
    }
}

这样当我们需要实现IEqualityComparer接口时可以有更多的选择,如下所示。我们注意到CreateComparer<int>可以简写成CreateComparer,所以ProjectionEqualityComparer<Person>.CreateComparer(x => x.Id)这种写法可能要更实用一点。

var c1 = ProjectionEqualityComparer.CreateComparer<Person, int>(x => x.Id);
var c2 = ProjectionEqualityComparer<Person>.CreateComparer<int>(x => x.Id);
var c3 = ProjectionEqualityComparer<Person>.CreateComparer(x => x.Id);

4.3 AnonymousComparer

最后我们再来看下codeplex上的一个项目:AnonymousComparer,它可以通过lambda表达式直接创建匿名类实现IComparer<T>IEqualityComparer<T>接口。所以通过AnonymousComparer不仅简化了IEqualityComparer接口的实现,还简化了IComparer接口,IComparer接口在LINQ中的很多方法如OrderByGroupByContains等中有着广泛的应用。AnonymousComparer实现IEqualityComparer的方式大同小异,但是有一点不同的是,它提供了一种Full IEqualtyComparer<T> overload,这样我们可以在代码里完全通过lambda实现IEqualtyComparer的两个方法:EqualsGetHashCode。而上面所介绍的其他方法中都是将这两个方法封装起来的。

public static IEqualityComparer<T> Create<T, TKey>(Func<T, TKey> compareKeySelector)
{
    if (compareKeySelector == null) throw new ArgumentNullException("compareKeySelector");

    return new EqualityComparer<T>(
        (x, y) =>
        {
            if (object.ReferenceEquals(x, y)) return true;
            if (x == null || y == null) return false;
            return compareKeySelector(x).Equals(compareKeySelector(y));
        },
        obj =>
        {
            if (obj == null) return 0;
            return compareKeySelector(obj).GetHashCode();
        });
}

public static IEqualityComparer<T> Create<T>(Func<T, T, bool> equals, Func<T, int> getHashCode)
{
    if (equals == null) throw new ArgumentNullException("equals");
    if (getHashCode == null) throw new ArgumentNullException("getHashCode");

    return new EqualityComparer<T>(equals, getHashCode);
}

private class EqualityComparer<T> : IEqualityComparer<T>
{
    private readonly Func<T, T, bool> equals;
    private readonly Func<T, int> getHashCode;

    public EqualityComparer(Func<T, T, bool> equals, Func<T, int> getHashCode)
    {
        this.equals = equals;
        this.getHashCode = getHashCode;
    }

    public bool Equals(T x, T y)
    {
        return equals(x, y);
    }

    public int GetHashCode(T obj)
    {
        return getHashCode(obj);
    }
}

还是用Person列表根据Id去重的例子,这个Comparer可以写成这样(写起来可能会没有上面的方便快捷,但是可以实现更强大的定制功能,譬如要根据多列来去重或根据某种算法来去重而不是简单的根据单列去重):

var idComparer = AnonymousComparer.Create<Person>(
    (x, y) => x.Id == y.Id,         // Equals
    obj => obj.Id.GetHashCode()     // GetHashCode
);

五、最简单的解决方法

上面说了这么多,大多是通过实现IEqualityComparer接口的,也有根据HashSetyield实现的。但是其实通过LINQ自带的GroupBy方法也可以实现去重的目的,像下面这样:

var dist = personList.GroupBy(x => x.Id).Select(x => x.First());

尽管在性能上有一定的折扣,在可读性方面也不容易让人理解,但这确实应该算是最简单的做法。这让我想起了SQL中的去重,SQL语言中DISTINCT是根据所有字段来去重的,如果需要根据某一列或几列来去重也会使用GROUP BY,类似于:

SELECT * FROM Person GROUP BY Id

从这一点看上去,LINQ真的和SQL有着惊人的相似。

参考

  1. Distinct() with lambda?
  2. Can I specify my explicit type comparator inline?
  3. Distinct list of objects based on an arbitrary key in LINQ
  4. Linq的Distinct太不给力了
  5. c# 扩展方法奇思妙用基础篇八:Distinct 扩展
  6. 何止 Linq 的 Distinct 不给力
  7. morelinq - Extensions to LINQ to Objects
  8. Miscellaneous Utility Library
  9. AnonymousComparer - lambda compare selector for Linq
  10. 快速创建 IEqualityComparer 和 IComparer 的实例
  11. A Generic IEqualityComparer for Linq Distinct()
扫描二维码,在手机上阅读!

为什么Visual Studio不能在调试时使用lambda表达式

一、引子

相信很多人都有这样的经历,在Visual Studio的Watch窗口中查看一个List类型的变量,如果这个List中的元素太多,有时想使用LINQ的Where方法筛选一下,类似于下图中的代码:

code

假设我们断在图示断点所在位置,这时我们在Watch窗口中查看personList变量是没问题的,但是如果我只想查看满足Age==20personList,则会看到Visual Studio提示错误:Expression cannot contain lambda expressions

watch

这个问题一直困扰我很久,为什么在Visual Studio中不能在Watch窗口中使用lambda表达式呢?今天忍不住Google了一把,才发现这并不是我一个人的需求,网上有大把大把的人表示不理解为什么Visual Studio不提供这一功能,以至于Visual Studio官方的uservoice上积累了将近一万人的投票,建议Visual Studio引入这个功能。并且好消息是,在Visual Studio最新的2015版本中终于加入了这一特性。

二、原因初探

查看在uservoice下面的回复,我意识到为什么这一特性姗姗来迟,原来是因为这确实是一件很复杂的事。在Stackoverflow上也有很多相关的提问,其中有一篇JaredPar在评论中给出了很精彩的回复。我将他的回复原文摘抄下来放在下面:

No you cannot use lambda expressions in the watch / locals / immediate window. As Marc has pointed out this is incredibly complex. I wanted to dive a bit further into the topic though. What most people don't consider with executing an anonymous function in the debugger is that it does not occur in a vaccuum. The very act of definining and running an anonymous function changes the underlying structure of the code base. Changing the code, in general, and in particular from the immediate window, is a very difficult task. Consider the following code.

void Example() {
  var v1 = 42;
  var v2 = 56; 
  Func<int> func1 = () => v1;
  System.Diagnostics.Debugger.Break();
  var v3 = v1 + v2;
}

This particular code creates a single closure to capture the value v1. Closure capture is required whenever an anonymous function uses a variable declared outside it's scope. For all intents and purposes v1 no longer exists in this function. The last line actually looks more like the following

var v3 = closure1.v1 + v2;

If the function Example is run in the debugger it will stop at the Break line. Now imagine if the user typed the following into the watch window

(Func<int>)(() => v2);

In order to properly execute this the debugger (or more appropriatel the EE) would need to create a closure for variable v2. This is difficult but not impossible to do. What really makes this a tough job for the EE though is that last line. How should that line now be executed? For all intents and purposes the anonymous function deleted the v2 variable and replaced it with closure2.v2. So the last line of code really now needs to read

var v3 = closure1.v1 + closure2.v2;

Yet to actually get this effect in code requires the EE to change the last line of code which is actually an ENC action. While this specific example is possible, a good portion of the scenarios are not. What's even worse is executing that lambda expression shouldn't be creating a new closure. It should actually be appending data to the original closure. At this point you run straight on into the limitations ENC. My small example unfortunately only scratches the surface of the problems we run into. I keep saying I'll write a full blog post on this subject and hopefully I'll have time this weekend.

大意是讲由于lambda表达式涉及到了C#的闭包,而由于C#闭包的特性,导致在调试时如果在Watch窗口中输入lambda表达式将会修改原有代码结构,这并不是不可能,但是确实是一件非常困难且巨大的工程。

三、理解C#的lambda表达式和闭包

好奇心驱使我使用.NET Reflector查看了一下生成的exe文件,看到了下面这样的代码:

[CompilerGenerated]
private static bool <Main>b__0(Person x)
{
    return x.Age < 20;
}

[CompilerGenerated]
private static void <Main>b__1(Person x)
{
    Console.WriteLine(x.Name);
}

private static void Main(string[] args)
{
    personList.Where<Person>(Program.<Main>b__0).ToList<Person>().ForEach(Program.<Main>b__1);
    Console.Read();
}

可以看到lambda表达式被转换成了带有[CompilerGenerated]特性的静态方法,这也就意味着如果我们在调试的时候在Watch中每写一个lambda表达式,Visual Studio都需要动态的创建一个新的静态方法出来然后重新编译。这恐怕是最简单的情形,对调试器而言只是插入一个新的方法然后重新编译而已,Visual Studio已经支持运行时修改代码了,所以这种情形实现起来应该是没问题的。但是复杂就复杂在并不是每个lambda表达式都是这样简单,闭包特性的引入使得lambda表达式中的代码不再只是上下文无关的一个静态方法了,这使得问题变得越来越有意思。我们看下面的示例代码,其中用到了C#的闭包特性:

static void TestOne()
{
    int x = 1, y = 2, z = 3;
    Func<int> f1 = () => x;
    Func<int> f2 = () => y + z;
    Func<int> f3 = () => 3;

    x = f2();
    y = f1();
    z = f1() + f2();

    Console.WriteLine(x + y + z);
}

再看反编译的代码:

[CompilerGenerated]
private sealed class <>c__DisplayClass6
{
    // Fields
    public int x;
    public int y;
    public int z;

    // Methods
    public int <TestOne>b__4()
    {
        return this.x;
    }

    public int <TestOne>b__5()
    {
        return this.y + this.z;
    }
}

[CompilerGenerated]
private static int <TestOne>b__6()
{
    return 3;
}

private static void TestOne()
{
    <>c__DisplayClass6 CS$<>8__locals7 = new <>c__DisplayClass6();
    CS$<>8__locals7.x = 1;
    CS$<>8__locals7.y = 2;
    CS$<>8__locals7.z = 3;
    Func<int> f1 = new Func<int>(CS$<>8__locals7.<TestOne>b__4);
    Func<int> f2 = new Func<int>(CS$<>8__locals7.<TestOne>b__5);
    Func<int> f3 = new Func<int>(Program.<TestOne>b__6);
    CS$<>8__locals7.x = f2();
    CS$<>8__locals7.y = f1();
    CS$<>8__locals7.z = f1() + f2();
    Console.WriteLine((int) (CS$<>8__locals7.x + CS$<>8__locals7.y + CS$<>8__locals7.z));
}

从生成的代码可以看到编译器帮我们做了很多事情,lambda表达式只是语法糖而已。简单的lambda表达式被转换成带有[CompilerGenerated]特性的静态方法,使用闭包特性的lambda表达式被转换成带有[CompilerGenerated]特性的封装(sealed)类,并将所有涉及到的局部变量移到该类中作为该类的public字段,表达式本身移到该类中作为该类的public方法。而且下面所有对闭包涉及到的变量的操作都转换成了对类的字段的操作。 所以回到上文中JaredPar给出的解释,当我们在调试器中输入lambda表达式(Func)(() => v2)时可能存在两种不同的解决方法:

  1. 该方法中已经存在一个闭包,则需要将v2变量提到该闭包类中,并添加一个() => v2方法;
  2. 新建一个闭包类,将v2变量移到该类中,并添加一个() => v2方法;

但是这还远远不够,所有涉及到v2变量的操作,都需要调整为类似于CS$<>8__locals7.v2这样的代码,哦,想想就觉得好麻烦。

四、解决方法

先不谈Visual Studio 2015已经引入这个特性吧,我还没尝试过,也不知道是怎么实现的。暂时我还是在用Visual Studio 2010,所以如果想在老版本中提供这样的功能,就得另谋他策。下面是一些已知的解决方法,我仅是记下来而已,也没尝试过,等试过再写篇新博客记录下吧。

  1. 使用动态LINQ 参考链接中列出了一些动态LINQ的实现,其中用的比较多的应该是Scott提供的LINQ动态查询库,这样在调试时可以使用personList.Where("Age = @0", 20)来替代personList.Where(x => x.Age == 20)
  2. 使用一个VS的开源插件:Extended Immediate Window for Visual Studio
  3. 升级成Visual Studio 2015 ;-)

参考

  1. Allow the evaluation of lambda expressions while debugging
  2. VS debugging “quick watch” tool and lambda expressions
  3. 闭包在.NET下的实现,编译器玩的小把戏
  4. C#与闭包
  5. LINQ to SQL实战 动态LINQ的几种方法
  6. Extended Immediate Window for Visual Studio (use Linq, Lambda Expr in Debugging)
扫描二维码,在手机上阅读!

PHP的类自动加载机制

假设我们有一个php文件需要引用5个不同的类文件,可能会写出下面这样的代码:

require_once ("A.php");
require_once ("B.php");
require_once ("C.php");
require_once ("D.php");
require_once ("E.php");

$a = new A();
$b = new B();
$c = new C();
$d = new D();
$e = new E();

随着类的增多,文件之间依赖关系的复杂化,这里的require也会越来越多,程序员在编写代码的时候很容易忘记或者漏掉某个require,而且在每个php文件中都大量充斥着这种代码,看着真心累。 在PHP5中为这个问题提供了一个解决方案,这就是类的自动加载机制(autoload),这个机制可以使得程序在真正使用某个类时才开始加载这个类,所以也可以叫做类的延迟加载(lazy loading)。PHP提供了两种方法来达到这个目的:

  1. __autoload方法
  2. SPL的autoload机制

使用__autoload方法,可以将类名和所需要的类文件建立一个映射关系,一般情况是让类名和文件名保持一致,让PHP自动的去加载这些类。一个典型的__autoload方法如下:

function __autoload($classname) {
    require_once ($classname . ".php");
}
 
$a = new A();
$b = new B();
$c = new C();
$d = new D();
$e = new E();

参考

  1. php _autoload自动加载类与机制分析
  2. PHP自动加载__autoload的工作机制
  3. PHP的类自动加载机制
  4. PHP SPL,被遗落的宝石
扫描二维码,在手机上阅读!

WebRequest.Create报异常:The specified registry key does not exist

在Visual Studio中调试网络程序时经常报下面这样的异常,异常的类型是IOException,内容为The specified registry key does not exist,展开里面的详情更是一头雾水,不知道到底是什么错误,而且这个异常只会报一次,后面的请求都是正常。

io_exception

今天忍不住Google了一把,才明白原来是.Net Framework的一次安全更新(MS12-074)导致的,要避免每次都无厘头的抛这样的异常也很简单,向注册表中添加一条记录LegacyWPADSupport即可。 32位版本:

Registry location: HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\.NETFramework  
DWORD (32-bit) Value name: LegacyWPADSupport
Value data: 0

64位版本:

Registry location: HKEY_LOCAL_MACHINE\SOFTWARE\Wow6432Node\Microsoft\.NETFramework
DWORD (32-bit) Value name: LegacyWPADSupport
Value data: 0

reg

参考:

HttpClient request throws IOException

扫描二维码,在手机上阅读!