跳转至

21 HTTP 的高级篇 - HTTPClient(Java)

HttpClient API

HttpClient API 是在 2018 年 9 月发布的 Java 11 中引入的。但是,它早在 Java 9 的前一年就已经可以作为预览功能使用。因为 API 需要一段时间的打磨才能变得成熟和完善。所以,从 Java 11 开始,HttpClient API 是 Java 标准库的一部分。这意味着你不需要再向应用程序添加任何外部依赖关系即可使用此 API。 HttpClient API 替代了在 Java 标准库中存在了很长时间的 HttpURLConnection API。稍后,看我心情要不要说一下为什么需要替换此 API。与 HttpURLConnection 一样,新的 HttpClient API 支持 HTTP 2 和 WebSocket 通信。而且它还支持 HTTP 的早期版本。 HttpClient API 的另一个重要功能是,它提供了同步阻塞和异步非阻塞的方法来执行 HTTP 请求。HttpClient API 的设计目标是在常见情况下易于使用,但是在复杂情况下也具有足够的功能。

HttpClient

HttpClient API 提供了三个重要的类型。所有这些类型都存在于 java.net.http 包中。首先,有 HttpClient 类本身。其中包含两个重要的方法:

  • send->send 方法执行对服务器的同步和阻塞调用。
  • sendAsync->sendAsync 方法执行异步的非阻塞调用。

你不能直接实例化 HttpClient 类。有一个 newBuilder 方法为你提供了一个构建器类。HttpClient API 中的大多数类型都使用了这种构建器模式。

HttpRequest

发送方法的参数之一是 HttpRequest。 HttpRequest 包含你希望获得的所有信息,例如请求所针对的 URI,可能需要与请求一起发送的 HTTP headers,以及指示它是 GET,PUT,POST 还是其他 HTTP 的方法。与 HttpClient 相同,你不能直接构造 HttpRequest。但是可以通过构建器来做(HttpRequest.Builder)。Builder 总是返回不可变的对象。所以,一旦创建,无论是 HttpRequest 还是 HttpClient 都无法更改了。有请求那就必然也会有响应。

HttpResponse

我们来看一下 HttpResponse 类型。除了 URI,headers 和 statusCode,通常 HttpResponse 中最重要的部分是正文。这就是 HTTP 服务器返回的有效负载。了解了这三种类型,你就可以开始使用 API 来执行 HTTP 请求了。我们来看一个最简单的示例。

Hello World 小程序

从创建 HttpClient 实例开始。我们知道你可以使用构建器模式来执行此操作。但是,HttpClient 上还有一个名为 newHttpClient 的静态方法,该方法将返回应用了所有默认设置 HttpClient 实例。我们的这个例子就以使用它开始。然后,我们需要创建一个请求。在这里,我们将构建器模式与 HttpRequest.newBuilder 方法一起使用。我们可以将 URI 传递给 newBuilder 方法,我们使用 csdn 的网址。默认情况下,HttpRequest 构建器将向服务器构造一个 GET 请求。现在,我们只需调用 build 并返回一个 HttpRequest 即可。到目前为止,还没有执行实际的 HttpRequest。我们只创建了 Client 和一个请求。现在我们需要发送一个请求。我们可以调用 client.send 方法并传递刚刚创建的 HttpRequest。 send 方法还有第二个参数,我们传入一个所谓的 BodyHandler(不用理会,只是一个过客)。client.send 方法返回一个 HttpResponse 对象,该对象包装成字符串并提供了有关很多响应的元数据。是不是很简单。代码如下

HttpClient httpClient = HttpClient.newHttpClient();
HttpRequest httpRequest = HttpRequest.newBuilder(URI.create("https://csdn.net")).build();
HttpResponse<String> response = httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString());
复制

为什么 HttpURLConnection 被打入冷宫

我现在心情还不错,来给你们说一下为什么 HttpURLConnection 被打入冷宫。

img

这是一个悲伤的故事,那是一个寒冷的冬天。。。(回归正题)这其中有多个原因为什么需要替换 HttpURLConnection,首先这是一个非常古老的 API。 Java 的第一个版本于 20 多年前 1996 年发布。HttpURLConnection 被添加到 Java 的 JDK 1.1 中,该版本于 1997 年发布。这也刚刚好是 HTTP 1.1 的被设计出来的时间。在对 HTTP 请求和响应以及典型的交互模式进行建模方面,事情并没有现在那么清晰。现在看来,HttpURLConnection 及其相关类中有很多过度抽象(谁告诉我抽象是好事来着,你出来,我保证不打你)。这些抽象使映射 HttpURLConnection 方法中发生的情况和实际 HTTP 发生的情况变得相对困难。

该 API 太旧了(就是说你老,你能咋地),它不包含泛型,枚举和 lambda,因此在现代 Java 中使用时感觉很笨拙(过时)。虽然从 Java 11,HttpURLConnection 已被 HttpClient 取代,我还是希望你能了解一下 HttpURLConnection API。你还是可能会在旧代码中遇到它。只有通过查看旧的 API,你才会欣赏到 HttpClient 给你带来的改进。

try{
    URL url = new URL("https://csdn.net");
    HttpURLConnection connection = (HttpURLConnection) url.openConnection();
    connection.setRequest("GET")
    connection.setRequestProperty("User-Agent", "Java 1.1");
    if(connection.getResponseCode() == 200) {
        System.out.println(readInputStream(connection.getInputStream()));
    } else {
        System.out.println("Something wrong there!");
    }
} catch(IOException ex) {
    System.out.println("Something wrong there!");
}
复制

说明一下,这并不是使用 HttpURLConnection 的最佳实践,因为它不能解决所有使用 API 的用例。我只是举一个例子。以此来指出一些与 API 不带优雅的地方。比如,这个第二行的类型转换,第三行没有 Enum。因为在设计此 API 时还没有枚举这个概念。所以,你可以在此处轻松输入格式错误的字符串。然后可以向连接请求输入流,但这就是原始输入流。因此,我们需要编写一个辅助方法,在本例中为 readInputStream,以获取原始输入流并将其转换为有用的东西。那是相当底层的操作。所以,不应该在新代码中再使用 HttpURLConnection。

但是,如果你还没有使用 Java 11 并且没有访问 HttpClient API 的权限怎么办?你仍然不应该使用 HttpURLConnection。在这种情况下,最好查看用于执行 HTTP 请求的第三方库。包括 Apache HttpComponents 项目,该项目提供 HttpClient API,还有 Square 的 OkHttp,这是 Java 的另一个开源 HttpClient,还有更高级的库,例如 JAX-RS REST Client。该 REST 客户端不仅执行 HTTP 请求,而且应用了 REST 原理,并且可以自动将 JSON 响应映射到 Java 对象。无论如何,现在都不应该使用 HttpURLConnection API。如果你使用的是 Java 11,请使用我们现在正在谈论的 HttpClient API,如果不是,请使用这些第三方组件之一。

HttpClient 的配置

我们不可能在一篇文章中,把一个 API 完全讲透,但是我们还是尽量的把一些关键点讲出来。现在来更深入地研究一下 HttpClient API,包括诸如处理 headers,接受 cookie 以及执行具有请求主体的 HTTP 请求的功能,这些请求与到目前为止所看到的 HTTP GET 请求不同。但是在继续使用这些功能之前,让我们更深入地了解一下 HttpClient 本身的配置选项。我们之前使用了新的 HttpClient 方法,该方法为我们提供了所有默认设置的 HttpClient。作为 HelloWorld 来说,还不错。但是通常你要自己调整一些配置选项。所以,使用构建器 API 创建 HttpClient 时,有几个选项可以影响使用此 HttpClient 完成的请求的行为。这些配置选项中的大多数不能在请求级别覆盖。如果针对不同类型的请求则需要不同的配置,也就是说需要创建多个适当配置的 HttpClient 实例。我们将重点关注与安全性无关的配置。

HTTP Version 版本

第一个配置是 HTTP 版本。使用 HttpClient.newBuilder 创建构建器后,可以使用 version 方法配置将使用的 HTTP 版本。版本枚举本身嵌套在 HttpClient 类的内部,一共有两个选项

  • HttpClient.Version.HTTP_1_1
  • HttpClient.Version.HTTP_2

HTTP 2 是默认选项,如果 HttpClient 配置为 HTTP 2,但是服务器不支持 HTTP 2 的话,它将自动回退到 HTTP 1.1。 HTTP / 2 流量看起来与 HTTP / 1.1 流量完全不同。您也可以根据个人要求配置版本。

Priority 优先级

第二个配置是优先级。由于仅在 HTTP 2 协议中指定了优先级,所以这个配置选项仅影响 HTTP 2 的请求。优先级设置采用 1-256 范围内的整数,包括这种情况下的边界值。较高的数字表示较高的优先级。

Redirection 重定向

另一个设置与重定向策略有关。默认情况下,HttpClient 配置为从不重定向。这意味着,当对要重定向到另一个 URI 的服务器执行请求时,HttpClient 将不会遵循此重定向。你还可以将 HttpClient 配置为在服务器响应重定向状态代码时始终遵循重定向。最后,有一个正常的重定向策略,它与始终重定向相同,只是从安全资源重定向到非安全资源的情况除外。从安全位置重定向到非安全位置通常是安全问题。这就是为什么建议使用常规重定向策略而不是使用 Always 策略的原因。

  • HttpClient.Redirect.NEVER
  • HttpClient.Redirect.ALWAYS
  • HttpClient.Redirect.NORMAL
Connection Timeout 超时

它需要一个 java.time.Duration,并且这是 HttpClient 等待建立与 HTTP 服务器连接的时间。如果未配置 connectTimeout,则默认设置为无限期等待,这肯定不会是你想要的。connectTimeout 与建立与服务器的 TCP 连接有关。如果花费的时间比配置的 connectTimeout 长,则将引发异常。

Custom Executor 自定义执行器

最后,还有一个配置选项,用于设置供 HttpClient 实例使用的自定义执行程序。 HttpClient 使用执行器来执行异步处理。默认情况下,在构建新的 HttpClient 时,它还会为此 HttpClient 实例化一个新的私有线程池。在某些情况下,你可能希望在不同的 HttpClient 之间共享一个执行程序。你可以通过自己创建或获取执行程序并将该执行程序传递给 HttpClient 构建器上的 executor 方法来实现。比如

Executor exec = Executors.newCachedThreadPool();
HttpClient.newBuilder().executor(exec);
复制
综合的例子
HttpClient client = HttpClient.newBuilder()
                .connectTimeout(Duration.ofSeconds(5))
                .followRedirects(HttpClient.Redirect.NORMAL)
                .build()
复制

请求的有效负载

我们一直都在使用简单的 GET 请求作为案例,这些请求不会将负载传输到 HTTP 服务器。我们现在来看一下如何创建包含有效负载的请求。此有效负载可以是纯文本,可以是 JSON,也可以是任何任意二进制有效负载。通常,HTTP POST 的请求主体中包含有效负载。

除了 HttpRequest.Builder 上的 GET 方法外,还有一个 POST 方法。与 GET 方法一样,POST 方法也带有一个参数,即所谓的 BodyPublisher。此 BodyPublisher 负责产生与 POST 请求一起发送的有效负载。从这个意义上讲,BodyPublisher 与我们之前看到的用于处理响应有效负载的 BodyHandler 类似。 它告诉 HttpClient 如何在给定 Java 对象的情况下构造 HTTP 请求的主体。你可以在 BodyPublisher 的类上找到现有的预定义 BodyPublisher。比如,HttpRequest.Builder 上还有 PUT 方法,这将使 BodyPublisher 提供的主体有效负载创建一个 HTTP PUT 请求。

  • POST(BodyPublisher publisher)
  • PUT(BodyPublisher publisher)

除了 POST 和 PUT 之外还有其他 HTTP 方法,例如 PATCH。但是,并非每个 HTTP 方法在 HttpRequest.Builder 上都有其自己的方法。如果要创建除 GET,POST 或 PUT 之外的请求,则必须在 HttpRequest.Builder 上使用 method 方法。

  • method(String method, BodyPublisher publisher)

方法采用两个参数,其中第一个是表示要请求执行的 HTTP 方法的字符串,以及一个 BodyPublisher。

Headers and Cookies

你已经了解了如何使用主体创建 HTTP 请求。客户端向服务器执行 HTTP 请求时,它必须定义要使用的 HTTP 方法(比如 GET)和要获取的资源比如 /index.html。但是,HTTP 请求还有更多的要素。它还包括 headers。

headers 是简单的键/值对,其中可能包含有关请求的其他元数据。一个示例是主机头。比如

  • Host:www.csdn.net

如你看到的,它显示为 Host :xn--www-2w0el71h.csdn.net。主机标头是 HTTP 1.1 和更高版本的必需标头之一,它由 HttpClient 根据创建请求时传递的 URI 由 HttpClient 自动为我们管理。除了这些强制性和自动管理的标头之外,有时你还希望向请求中添加其他标头。例如,您可能想添加一个 accept 标头。

  • Accept: text/html

accept 标头告诉服务器我们想要的首选响应类型,此处表明我们想取回 HTML 文档。诸如 accept 之类的标题是 HTTP 规范的一部分,但它们是可选的。因此,如果你想添加这样的标头来执行请求,则必须为此做一些工作。也可以向 HTTP 请求中添加任意的,未指定的标头。在 API 中的写法就是这样

HttpRequest.newBuilder(URI.create("https://csdn.net"))
            .header("Accept", "text/html")
            .build()
复制

如果你要有多个标题,那当然也可以。只需重复使用 header 方法,直到将所需的所有标头添加到请求中即可。甚至可以使用 headers 方法而不是 header 方法一次性添加多个标题。标头始终需要偶数个参数,因为每个标头名称都必须带有一个值。如果您添加相同的标头,使用相同的标头名称,并多次使用不同的值,那么所有这些值都会出现在标头中,因为 HTTP 定义了标头可以具有多个值。如果你要绝对确定标头没有多个值,则也可以使用 setHeader 方法,该方法也可以使用标头的名称和标头的值,但不必将值添加到标头中替换当前值。

当你构建请求并通过 HttpClient 发送请求时,HttpClient 将负责以正确的方式将标头和值添加到 HTTP 请求。对于 HTTP 1.1 和 HTTP 2,它的执行方式完全不同。 HTTP 1.1 是纯文本协议,并且标头将添加到此纯文本中请求。 HTTP 2 是一个二进制协议,标头将以二进制格式编码,甚至经过专门压缩。不过你不用担心这些,对于你来说,API 是相同的,复杂性全都隐藏在 HttpClient 的实现中。

还有另一种与 HTTP 紧密联系的机制,HTTP 本身是无状态请求响应协议。我们从服务器请求一些东西,服务器提供响应,然后客户端和服务器彼此相忘于江湖。但是,在许多情况下,你希望保持有关服务器和客户端之间交互的某些状态(我就是忘不掉我的前女友,这可咋办)。

img

Cookie 是一种主要用于浏览器的方式。 Cookies 包含服务器定义的状态,但是状态由客户端保留,然后在必要时再发送回服务器。有趣的是,这种机制是基于标头构建的。服务器还可以在响应中包含标头。并且,当服务器包含 Set-Cookie 标头时,客户端应将此标头解释为要保留给下一个请求的状态,这也是该机制在浏览器中的工作方式。当浏览器看到 Set-Cookie 标头,并且在标头的主体中包含一些键-值对时,它将把这些名称/值对存储在与请求域相关联的所谓持久性 cookie 中。然后,每当对同一个域提出新请求时,浏览器将包含一个 Cookie 标头。 Cookie 头的值是先前存储的名称/值对。这样,浏览器和 HTTP 服务器可以在不同的无状态 HTTP 请求之间创建持久状态的错觉。当然,只有当服务器和客户端都知道 Set-Cookie 和 Cookie 标头时,整个设置才有效。你可以尝试使用 HttpClient 自己实现此目的,因为它全都与 Set-Cookie 和 Cookie 标头有关。因为关于 Cookie 的行为方式复杂性要很高,所以你其实并不想自己管理。好消息是 HttpClient 为我们提供了一个用于配置 cookie 的处理。你可以使用 setCookieHandler 来配置 HttpClient 使用它。如果你希望 HttpClient 使用 cookie,那么一种快速的入门方法是使用 CookieManager 类。 CookieManager 是 CookieHandler 的 JDK 内部的具体实现。在此示例中,我们创建一个新的管理器,第一个参数是 Cookie 持久存储在其中的 CookieStore。

img

CookieManager cm = new CookieManager(null, CookiePolicy.ACCEPT_ALL)
HttpClient client = HttpClient.newBuilder().cookieHandler(cm).build()
client.send(HttpRequest.newBuilder(URI.create("https://csdn.net")).build(), HttpResponse.BodyHandlers.discarding())
System.out.println(cm.getCookieStore().getURIs())
System.out.println(cm.getCookieStore().getCookies())
复制

还是那句话,不可能把所有的内容在一小节上全讲解,如果有人感兴趣的话,给我在文下留言,如果留言多的话,我会再整理一篇。