Java 9 中新的HTTP客户端
1. 概述
在本教程中,我们将探讨 Java 11实现 HTTP/2 和 Web Socket 的 HTTP 客户端 API 标准化。 它旨在替换自 Java 早期以来就存在于 JDK 中的遗留HttpUrlConnection 类。
直到最近,Java 只提供了HttpURLConnection API,它是低级的,并不以功能丰富和用户友好而著称。
因此,常用一些广泛使用的第三方库,如Apache HttpClient 、Jetty 和 Spring 的RestTemplate 。
2. 背景
该更改已作为 JEP 321 的一部分实施。
2.1. 作为 JEP 321 一部分的重大变化
- Java 9 中孵化的 HTTP API 现在正式并入 Java SE API。新的HTTP API 可以在java.net.HTTP.*中找到
- 较新版本的 HTTP 协议旨在提高客户端发送请求和接收服务器响应的整体性能。这是通过引入许多变化来实现的,例如流多路复用、标头压缩和推送承诺。
- 从 Java 11 开始,**API 现在是完全异步的(之前的 HTTP/1.1 实现是阻塞的)。**异步调用是使用CompletableFuture实现的。CompletableFuture实现负责在前一个阶段完成后应用每个阶段,因此整个流程是异步的。
- 新的 HTTP 客户端 API 提供了一种执行 HTTP 网络操作的标准方法,支持现代 Web 功能(例如 HTTP/2),而无需添加第三方依赖项。
- 新的 API 为 HTTP 1.1/2 WebSocket 提供本机支持。提供核心功能的核心类和接口包括:
- HttpClient类,java.net.http.HttpClient
- HttpRequest类,java.net.http.HttpRequest
- HttpResponse<T> 接口,java.net.http.HttpResponse
- WebSocket接口,java.net.http.WebSocket
2.2. Java 11 之前的 HTTP 客户端的问题
现有的HttpURLConnection API 及其实现存在许多问题:
- URLConnection API 设计有多种协议,现在不再起作用(FTP、gopher 等)。
- API 早于 HTTP/1.1,过于抽象。
- 它仅在阻塞模式下工作(即,每个请求/响应一个线程)。
- 很难维护。
3. HTTP 客户端 API 概述
与HttpURLConnection不同,HTTP Client 提供同步和异步请求机制。 API 由三个核心类组成:
- HttpRequest表示要通过HttpClient发送的请求。
- HttpClient充当多个请求共有的配置信息的容器。
- HttpResponse表示HttpRequest调用的结果。
我们将在以下部分中更详细地检查它们中的每一个。首先,让我们关注一个请求。
4. HttpRequest
HttpRequest是一个对象,代表我们要发送的请求。可以使用HttpRequest.Builder 创建新实例。
我们可以通过调用*HttpRequest.newBuilder()*来获取它。Builder类提供了一堆我们可以用来配置我们的请求的方法。
我们将介绍最重要的。
注意:在 JDK 16 中,有一个新的HttpRequest.newBuilder(HttpRequest request, BiPredicate<String,String> filter)方法,它创建了一个Builder,其初始状态是从现有的HttpRequest复制的。
此构建器可用于构建HttpRequest,与原始构建器等效,同时允许在构建之前修改请求状态,例如,删除标头:
HttpRequest.newBuilder(request, (name, value) -> !name.equalsIgnoreCase("Foo-Bar"))
4.1. 设置URI
创建请求时,我们要做的第一件事就是提供 URL。
我们可以通过两种方式做到这一点——使用带有URI参数的Builder的构造函数或在Builder实例上调用方法uri(URI):
HttpRequest.newBuilder(new URI("https://postman-echo.com/get"))
HttpRequest.newBuilder()
.uri(new URI("https://postman-echo.com/get"))
我们必须配置以创建基本请求的最后一件事是 HTTP 方法。
4.2. 指定 HTTP 方法
我们可以通过调用Builder中的一种方法来定义我们的请求将使用的 HTTP 方法:
- GET()
- POST(BodyPublisher body)
- PUT(BodyPublisher body)
- DELETE()
稍后我们将详细介绍BodyPublisher。
现在让我们创建一个非常简单的 GET 请求示例:
HttpRequest request = HttpRequest.newBuilder()
.uri(new URI("https://postman-echo.com/get"))
.GET()
.build();
此请求具有HttpClient所需的所有参数。
但是,我们有时需要向我们的请求添加额外的参数。以下是一些重要的:
- HTTP 协议的版本
- 标头
- 超时
4.3. 设置 HTTP 协议版本
API 充分利用 HTTP/2 协议并默认使用它,但我们可以定义我们想要使用的协议版本:
HttpRequest request = HttpRequest.newBuilder()
.uri(new URI("https://postman-echo.com/get"))
.version(HttpClient.Version.HTTP_2)
.GET()
.build();
这里要提到的重要一点是,如果不支持 HTTP/2,客户端将退回到例如 HTTP/1.1。
4.4. 设置标题
如果我们想向我们的请求添加额外的标头,我们可以使用提供的构建器方法。 我们可以通过将所有标头作为键值对传递给headers()方法或对单个键值标头使用header()方法来做到这一点:
HttpRequest request = HttpRequest.newBuilder()
.uri(new URI("https://postman-echo.com/get"))
.headers("key1", "value1", "key2", "value2")
.GET()
.build();
HttpRequest request2 = HttpRequest.newBuilder()
.uri(new URI("https://postman-echo.com/get"))
.header("key1", "value1")
.header("key2", "value2")
.GET()
.build();
我们可以用来自定义请求的最后一个有用的方法是timeout()。
4.5. 设置超时
现在让我们定义我们想要等待响应的时间量。
如果设置的时间到期,将抛出HttpTimeoutException。默认超时设置为无穷大。
可以通过在构建器实例上调用方法timeout()来使用Duration对象设置超时:
HttpRequest request = HttpRequest.newBuilder()
.uri(new URI("https://postman-echo.com/get"))
.timeout(Duration.of(10, SECONDS))
.GET()
.build();
5. 设置请求正文
我们可以使用请求构建器方法向请求添加主体:POST(BodyPublisher body)、PUT(BodyPublisher body)和DELETE()。
新的 API 提供了许多开箱即用的BodyPublisher实现,可简化请求正文的传递:
- StringProcessor – 从使用HttpRequest.BodyPublishers.ofString创建的String中读取正文
- InputStreamProcessor – 从InputStream读取正文,使用HttpRequest.BodyPublishers.ofInputStream创建
- ByteArrayProcessor - 从字节数组中读取正文,使用HttpRequest.BodyPublishers.ofByteArray创建
- FileProcessor – 从给定路径的文件中读取正文,由HttpRequest.BodyPublishers.ofFile创建
如果我们不需要正文,我们可以简单地传入一个HttpRequest.BodyPublishers.noBody():
HttpRequest request = HttpRequest.newBuilder()
.uri(new URI("https://postman-echo.com/post"))
.POST(HttpRequest.BodyPublishers.noBody())
.build();
注意:在 JDK 16 中,有一个新的*HttpRequest.BodyPublishers.concat(BodyPublisher…)*方法可以帮助我们从一系列发布者发布的请求主体的串联中构建请求主体。连接发布者发布的请求正文在逻辑上等同于通过依次连接每个发布者的所有字节来发布的请求正文。
5.1. StringBodyPublisher
使用任何BodyPublishers实现设置请求正文非常简单直观。
例如,如果我们想传递一个简单的String作为正文,我们可以使用StringBodyPublishers。
正如我们已经提到的,可以使用工厂方法ofString()创建这个对象 ——它只接受一个String对象作为参数并从中创建一个主体:
HttpRequest request = HttpRequest.newBuilder()
.uri(new URI("https://postman-echo.com/post"))
.headers("Content-Type", "text/plain;charset=UTF-8")
.POST(HttpRequest.BodyPublishers.ofString("Sample request body"))
.build();
5.2. InputStreamBodyPublisher
为此,必须将InputStream作为Supplier传递(以使其创建变得懒惰),因此它与StringBodyPublishers有点不同。 但是,这也很简单:
byte[] sampleData = "Sample request body".getBytes();
HttpRequest request = HttpRequest.newBuilder()
.uri(new URI("https://postman-echo.com/post"))
.headers("Content-Type", "text/plain;charset=UTF-8")
.POST(HttpRequest.BodyPublishers
.ofInputStream(() -> new ByteArrayInputStream(sampleData)))
.build();
请注意我们在这里如何使用简单的ByteArrayInputStream。当然,这可以是任何InputStream实现。
5.3. ByteArrayProcessor
我们还可以使用ByteArrayProcessor并传递一个字节数组作为参数:
byte[] sampleData = "Sample request body".getBytes();
HttpRequest request = HttpRequest.newBuilder()
.uri(new URI("https://postman-echo.com/post"))
.headers("Content-Type", "text/plain;charset=UTF-8")
.POST(HttpRequest.BodyPublishers.ofByteArray(sampleData))
.build();
5.4. FileProcessor
要使用文件,我们可以使用提供的FileProcessor。 它的工厂方法将文件的路径作为参数,并根据内容创建一个主体:
HttpRequest request = HttpRequest.newBuilder()
.uri(new URI("https://postman-echo.com/post"))
.headers("Content-Type", "text/plain;charset=UTF-8")
.POST(HttpRequest.BodyPublishers.fromFile(
Paths.get("src/test/resources/sample.txt")))
.build();
我们已经介绍了如何创建HttpRequest以及如何在其中设置其他参数。 现在是时候深入了解HttpClient类了,它负责发送请求和接收响应。
6. HttpClient
所有请求都使用HttpClient发送,可以使用*HttpClient.newBuilder()方法或调用HttpClient.newHttpClient()*进行实例化。 它提供了许多有用的自描述方法,我们可以用它来处理我们的请求/响应。
让我们在这里介绍其中的一些。
6.1. 处理响应体
与创建发布者的流利方法类似,也有专门为常见主体类型创建处理程序的方法:
BodyHandlers.ofByteArray
BodyHandlers.ofString
BodyHandlers.ofFile
BodyHandlers.discarding
BodyHandlers.replacing
BodyHandlers.ofLines
BodyHandlers.fromLineSubscriber
注意新的BodyHandlers工厂类的使用。 在 Java 11 之前,我们必须这样做:
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandler.asString());
我们现在可以简化它:
HttpResponse<String> response = client.send(request, BodyHandlers.ofString());
6.2. 设置代理
我们可以通过在Builder实例上调用*proxy()*方法来为连接定义一个代理:
HttpResponse<String> response = HttpClient
.newBuilder()
.proxy(ProxySelector.getDefault())
.build()
.send(request, BodyHandlers.ofString());
在我们的示例中,我们使用了默认系统代理。
6.3. 设置重定向策略
有时我们想要访问的页面已经移动到不同的地址。
在这种情况下,我们将收到 HTTP 状态代码 3xx,通常带有有关新 URI 的信息。如果我们设置了适当的重定向策略,HttpClient可以自动将请求重定向到新的 URI。
我们可以使用Builder上的*followRedirects()*方法来做到这一点:
HttpResponse<String> response = HttpClient.newBuilder()
.followRedirects(HttpClient.Redirect.ALWAYS)
.build()
.send(request, BodyHandlers.ofString());
所有策略都在枚举HttpClient.Redirect中定义和描述。
6.4. 为连接设置Authenticator
Authenticator是一个为连接协商凭据(HTTP 身份验证)的对象。它提供了不同的身份验证方案(例如基本身份验证或摘要身份验证)。在大多数情况下,身份验证需要用户名和密码才能连接到服务器。
我们可以使用PasswordAuthentication类,它只是这些值的持有者:
HttpResponse<String> response = HttpClient.newBuilder()
.authenticator(new Authenticator() {
@Override
protected PasswordAuthentication getPasswordAuthentication() {
return new PasswordAuthentication(
"username",
"password".toCharArray());
}
}).build()
.send(request, BodyHandlers.ofString());
在这里,我们将用户名和密码值作为明文传递。当然,这在生产场景中必须有所不同。
请注意,并非每个请求都应使用相同的用户名和密码。Authenticator类提供了许多getXXX(例如getRequestingSite())方法,可用于找出应该提供哪些值。
现在我们将探索新的HttpClient最有用的特性之一——对服务器的异步调用。
6.5. 发送请求 - 同步与异步
新的HttpClient提供了两种向服务器发送请求的可能性:
- send(…) – 同步(阻塞直到响应到来)
- sendAsync(…) – 异步(不等待响应,非阻塞)
到目前为止,send(…) 方法自然会等待响应:
HttpResponse<String> response = HttpClient.newBuilder()
.build()
.send(request, BodyHandlers.ofString());
此调用返回一个HttpResponse对象,并且我们确信来自应用程序流的下一条指令将仅在响应已经存在时运行。 但是,它有很多缺点,尤其是当我们处理大量数据时。
所以,现在我们可以使用sendAsync(…)方法——它返回CompletableFeature<HttpResponse> ——来异步处理请求:
CompletableFuture<HttpResponse<String>> response = HttpClient.newBuilder()
.build()
.sendAsync(request, HttpResponse.BodyHandlers.ofString());
新的 API 还可以处理多个响应,并流式传输请求和响应主体:
List<URI> targets = Arrays.asList(
new URI("https://postman-echo.com/get?foo1=bar1"),
new URI("https://postman-echo.com/get?foo2=bar2"));
HttpClient client = HttpClient.newHttpClient();
List<CompletableFuture<String>> futures = targets.stream()
.map(target -> client
.sendAsync(
HttpRequest.newBuilder(target).GET().build(),
HttpResponse.BodyHandlers.ofString())
.thenApply(response -> response.body()))
.collect(Collectors.toList());
6.6. 为异步调用设置Executor
我们还可以定义一个Executor来提供异步调用使用的线程。 例如,通过这种方式,我们可以限制用于处理请求的线程数:
ExecutorService executorService = Executors.newFixedThreadPool(2);
CompletableFuture<HttpResponse<String>> response1 = HttpClient.newBuilder()
.executor(executorService)
.build()
.sendAsync(request, HttpResponse.BodyHandlers.ofString());
CompletableFuture<HttpResponse<String>> response2 = HttpClient.newBuilder()
.executor(executorService)
.build()
.sendAsync(request, HttpResponse.BodyHandlers.ofString());
默认情况下,HttpClient使用执行器java.util.concurrent.Executors.newCachedThreadPool()。
6.7. 定义一个CookieHandler
使用新的 API 和构建器,为我们的连接设置CookieHandler很简单。我们可以使用构建器方法cookieHandler(CookieHandler cookieHandler)来定义客户端特定的CookieHandler。
让我们定义根本不允许接受 cookie 的CookieManager(CookieHandler的具体实现,它将 cookie 的存储与接受和拒绝 cookie 的策略分开):
HttpClient.newBuilder()
.cookieHandler(new CookieManager(null, CookiePolicy.ACCEPT_NONE))
.build();
如果我们的CookieManager允许存储 cookie,我们可以通过检查HttpClient中的CookieHandler来访问它们:
((CookieManager) httpClient.cookieHandler().get()).getCookieStore()
现在让我们关注 Http API 的最后一个类—— HttpResponse。
7. HttpResponse对象
HttpResponse类表示来自服务器的响应。它提供了许多有用的方法,但这是最重要的两个:
- statusCode()返回响应的状态代码(类型int )(HttpURLConnection类包含可能的值)。
- body()返回响应的主体(返回类型取决于传递给send()方法的响应BodyHandler参数)。
响应对象还有其他有用的方法,我们将介绍,例如uri()、headers()、trails()和version()。
7.1. 响应对象的URI
响应对象上的uri()方法返回我们从中接收响应的URI。 有时它可能与请求对象中的URI不同,因为可能会发生重定向:
assertThat(request.uri()
.toString(), equalTo("http://stackoverflow.com"));
assertThat(response.uri()
.toString(), equalTo("https://stackoverflow.com/"));
7.2. 响应的标头
我们可以通过在响应对象上调用方法*headers()*从响应中获取标头:
HttpResponse<String> response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
HttpHeaders responseHeaders = response.headers();
它返回HttpHeaders对象,该对象表示 HTTP 标头的只读视图。 它有一些有用的方法可以简化标题值的搜索。
7.3. 响应版本
方法*version()*定义了用于与服务器通信的 HTTP 协议版本。 请记住,即使我们定义要使用 HTTP/2,服务器也可以通过 HTTP/1.1 进行响应。
服务器回答的版本在响应中指定:
HttpRequest request = HttpRequest.newBuilder()
.uri(new URI("https://postman-echo.com/get"))
.version(HttpClient.Version.HTTP_2)
.GET()
.build();
HttpResponse<String> response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
assertThat(response.version(), equalTo(HttpClient.Version.HTTP_1_1));
8. 在 HTTP/2 中处理 Push Promise
新的HttpClient通过PushPromiseHandler接口支持推送承诺。它允许服务器在请求主要资源时将内容“推送”到客户端附加资源,从而节省更多往返时间,从而提高页面渲染性能。
真正让我们忘记资源捆绑的正是 HTTP/2 的多路复用特性。对于每个资源,服务器都会向客户端发送一个特殊请求,称为推送承诺。收到的推送承诺(如果有)由给定的PushPromiseHandler处理。一个空值PushPromiseHandler拒绝任何推送承诺。
HttpClient有一个重载的sendAsync方法,它允许我们处理此类承诺,如下所示。让我们首先创建一个PushPromiseHandler:
private static PushPromiseHandler<String> pushPromiseHandler() {
return (HttpRequest initiatingRequest,
HttpRequest pushPromiseRequest,
Function<HttpResponse.BodyHandler<String>,
CompletableFuture<HttpResponse<String>>> acceptor) -> {
acceptor.apply(BodyHandlers.ofString())
.thenAccept(resp -> {
System.out.println(" Pushed response: " + resp.uri() + ", headers: " + resp.headers());
});
System.out.println("Promise request: " + pushPromiseRequest.uri());
System.out.println("Promise request: " + pushPromiseRequest.headers());
};
}
接下来,让我们使用sendAsync方法来处理这个推送承诺:
httpClient.sendAsync(pageRequest, BodyHandlers.ofString(), pushPromiseHandler())
.thenAccept(pageResponse -> {
System.out.println("Page response status code: " + pageResponse.statusCode());
System.out.println("Page response headers: " + pageResponse.headers());
String responseBody = pageResponse.body();
System.out.println(responseBody);
})
.join();