これは、なにをしたくて書いたもの?
Java 11からHTTPクライアントが導入されましたが、そういえば触ったことがありませんでした。
自分でも1度試してみようかなということで。
JEP 321: HTTP Client
HTTPクライアントは、「JEP 321: HTTP Client」で導入されました。
Java 9でincubating APIとして導入され、その後Java 11で正式版になったわけです。
Java 9時点でのJEPは、こちらの「JEP 110: HTTP/2 Client (Incubator)」でした。
JEP 110: HTTP/2 Client (Incubator)
このHTTPクライアントが作られた背景として、既存のHttpURLConnection
があります。
URLConnection
は複数のプロトコルが扱えることを想定して設計されたが、ほぼすべて廃止されている- HTTP 1.1よりも古く、抽象的すぎる
- ブロッキングのみのサポート
- このAPIを維持するのが大変
そして、これらのJEPでは以下を目指しています。
- 単純なブロッキングでのユースケースでは扱いやすく
- 非同期(
CompletableFuture
)のサポート - WebSocket、HTTP/2への対応
- Lambdaフレンドリー
- HTTPS/TLSのサポート
- 既存の
HttpURLConnection
、Apache HttpClient、Netty、Jettyと同等のパフォーマンス要件
使い方は、HttpClient
のJavadocを見るのが1番わかりやすい気がしますね。
// 同期の例 HttpClient client = HttpClient.newBuilder() .version(Version.HTTP_1_1) .followRedirects(Redirect.NORMAL) .connectTimeout(Duration.ofSeconds(20)) .proxy(ProxySelector.of(new InetSocketAddress("proxy.example.com", 80))) .authenticator(Authenticator.getDefault()) .build(); HttpResponse<String> response = client.send(request, BodyHandlers.ofString()); System.out.println(response.statusCode()); System.out.println(response.body()); // 非同期の例 HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("https://foo.com/")) .timeout(Duration.ofMinutes(2)) .header("Content-Type", "application/json") .POST(BodyPublishers.ofFile(Paths.get("file.json"))) .build(); client.sendAsync(request, BodyHandlers.ofString()) .thenApply(HttpResponse::body) .thenAccept(System.out::println);
HttpClient (Java SE 17 & JDK 17)
他に見た方が良さそうなクラスは、このあたりですね。
HttpClient
に関するもの- リクエストに関するもの
- レスポンスに関するもの
今回は、このHttpClient
を試してみたいと思います。
環境
今回の環境は、こちら。
$ java --version openjdk 17.0.8.1 2023-08-24 OpenJDK Runtime Environment (build 17.0.8.1+1-Ubuntu-0ubuntu122.04) OpenJDK 64-Bit Server VM (build 17.0.8.1+1-Ubuntu-0ubuntu122.04, mixed mode, sharing) $ mvn --version Apache Maven 3.9.4 (dfbb324ad4a7c8fb0bf182e6d91b0ae20e3d2dd9) Maven home: $HOME/.sdkman/candidates/maven/current Java version: 17.0.8.1, vendor: Private Build, runtime: /usr/lib/jvm/java-17-openjdk-amd64 Default locale: ja_JP, platform encoding: UTF-8 OS name: "linux", version: "5.15.0-82-generic", arch: "amd64", family: "unix"
準備
Maven依存関係等はこちら。
<properties> <maven.compiler.source>17</maven.compiler.source> <maven.compiler.target>17</maven.compiler.target> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding> </properties> <dependencies> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter</artifactId> <version>5.10.0</version> <scope>test</scope> </dependency> <dependency> <groupId>org.assertj</groupId> <artifactId>assertj-core</artifactId> <version>3.24.2</version> <scope>test</scope> </dependency> <dependency> <groupId>org.jboss.resteasy</groupId> <artifactId>resteasy-core</artifactId> <version>6.2.5.Final</version> <scope>test</scope> </dependency> <dependency> <groupId>org.jboss.resteasy</groupId> <artifactId>resteasy-undertow-cdi</artifactId> <version>6.2.5.Final</version> <scope>test</scope> </dependency> <dependency> <groupId>org.jboss.resteasy</groupId> <artifactId>resteasy-jackson2-provider</artifactId> <version>6.2.5.Final</version> <scope>test</scope> </dependency> </dependencies> <build> <plugins> <plugin> <artifactId>maven-surefire-plugin</artifactId> <version>3.1.2</version> </plugin> </plugins> </build>
依存関係は、すべてテストコードに関するものです。
HTTPサーバーを書く
最初に、テスト対象のHTTPサーバーを用意しましょう。
こちらにはRESTEasyとSeBootstrap
を使うことにしました。
JAX-RSリソースクラス。
src/test/java/org/littlewings/httpclient/server/TestResource.java
package org.littlewings.httpclient.server; import jakarta.ws.rs.*; import jakarta.ws.rs.core.MediaType; import javax.print.attribute.standard.Media; import java.util.Map; import java.util.concurrent.TimeUnit; @Path("api") public class TestResource { @GET @Produces(MediaType.TEXT_PLAIN) public String hello() { return "Hello World!!"; } @GET @Path("plus") @Produces(MediaType.APPLICATION_JSON) public Map<String, Integer> plus(@QueryParam("a") int a, @QueryParam("b") int b) { return Map.of("result", a + b); } @POST @Path("echo") @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON) public Map<String, String> echo(Map<String, String> message) { return Map.of("result", String.format("★★★%s★★★", message.get("message"))); } @GET @Path("timeout") @Produces(MediaType.TEXT_PLAIN) public String timeout() throws InterruptedException { TimeUnit.SECONDS.sleep(10L); return "timeout!!"; } }
それぞれ、以下のメソッドを用意しています。
Application
クラス。
src/test/java/org/littlewings/httpclient/server/JaxrsActivator.java
package org.littlewings.httpclient.server; import jakarta.ws.rs.ApplicationPath; import jakarta.ws.rs.core.Application; import java.util.Set; @ApplicationPath("") public class JaxrsActivator extends Application { @Override public Set<Class<?>> getClasses() { return Set.of(TestResource.class); } }
テストコードの雛形
次に、テストコードの雛形を用意します。テストクラスの最初に作成したJAX-RS関連のクラスを使ったサーバーを起動し、テストが
すべて終了したら停止するようにします。
src/test/java/org/littlewings/httpclient/HttpClientTest.java
package org.littlewings.httpclient; import jakarta.ws.rs.SeBootstrap; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.littlewings.httpclient.server.JaxrsActivator; import java.io.IOException; import java.net.URI; import java.net.http.*; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; class HttpClientTest { static SeBootstrap.Instance instance; @BeforeAll static void setUp() throws ExecutionException, InterruptedException { SeBootstrap.Configuration configuration = SeBootstrap .Configuration .builder() .host("localhost") .port(8080) .build(); instance = SeBootstrap .start(new JaxrsActivator(), configuration) .toCompletableFuture() .get(); } @AfterAll static void tearDown() throws ExecutionException, InterruptedException { instance .stop() .toCompletableFuture() .get(); } // ここにテストを書く!! }
HTTPクライアントを使う
それでは、HttpClient
を使っていきます。
同期版
まずはシンプルなアクセスから。
@Test void simplySync() throws IOException, InterruptedException { HttpClient httpClient = HttpClient.newBuilder().build(); HttpRequest request = HttpRequest .newBuilder() .uri(URI.create("http://localhost:8080/api")) .build(); HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); assertThat(response.statusCode()).isEqualTo(200); assertThat(response.headers().firstValue("Content-Type").get()) .isEqualTo("text/plain;charset=UTF-8"); assertThat(response.body()).isEqualTo("Hello World!!"); }
HTTPボディをどのような型で受け取るのかはBodyHandler
で指定するようですが、実際にはBodyHandlers
から選ぶことになるようです。
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
次に、QueryStringをつけて、レスポンスはJSONで受け取ってみます。
@Test void withQueryStringSync() throws IOException, InterruptedException { HttpClient httpClient = HttpClient.newBuilder().build(); HttpRequest request = HttpRequest .newBuilder() .uri(URI.create("http://localhost:8080/api/plus?a=5&b=3")) .build(); HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); assertThat(response.statusCode()).isEqualTo(200); assertThat(response.headers().firstValue("Content-Type").get()) .isEqualTo("application/json"); assertThat(response.body()).isEqualTo(""" {"result":8}\ """); }
QueryStringはふつうにURL(URI)に含めることになりそうです。
POSTの例。
@Test void postSync() throws IOException, InterruptedException { HttpClient httpClient = HttpClient.newBuilder().build(); HttpRequest request = HttpRequest .newBuilder() .uri(URI.create("http://localhost:8080/api/echo")) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(""" {"message": "Hello HttpClient!!"}\ """, StandardCharsets.UTF_8)) .build(); HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); assertThat(response.statusCode()).isEqualTo(200); assertThat(response.headers().firstValue("Content-Type").get()) .isEqualTo("application/json"); assertThat(response.body()).isEqualTo(""" {"result":"★★★Hello HttpClient!!★★★"}\ """); }
POSTするHTTPボディは、BodyPublisher
で指定します。実際には、BodyPublishers
から選ぶことになるようです。
HttpRequest request = HttpRequest .newBuilder() .uri(URI.create("http://localhost:8080/api/echo")) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(""" {"message": "Hello HttpClient!!"}\ """, StandardCharsets.UTF_8)) .build();
タイムアウト。まずはいわゆるRead Timeoutから。
@Test void readTimeout() { HttpClient httpClient = HttpClient .newBuilder() .connectTimeout(Duration.ofSeconds(3L)) .build(); HttpRequest request = HttpRequest .newBuilder() .uri(URI.create("http://localhost:8080/api/timeout")) .timeout(Duration.ofSeconds(5L)) .build(); long startTime = System.currentTimeMillis(); assertThatThrownBy(() -> httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)) ) .hasMessage("request timed out") .isInstanceOf(IOException.class) .isExactlyInstanceOf(HttpTimeoutException.class); long elapsedTime = System.currentTimeMillis() - startTime; assertThat(elapsedTime).isGreaterThanOrEqualTo(5000L).isLessThan(6000L); }
タイムアウト自体は、HttpClient
とHttpRequest
に指定できます。
HttpClient httpClient = HttpClient .newBuilder() .connectTimeout(Duration.ofSeconds(3L)) .build(); HttpRequest request = HttpRequest .newBuilder() .uri(URI.create("http://localhost:8080/api/timeout")) .timeout(Duration.ofSeconds(5L)) .build();
前者が接続タイムアウト(Connect Timeout)、後者が読み取りタイムアウト(Read Timeout)のようです。
今回はRead Timeoutを試しています。
assertThatThrownBy(() -> httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)) ) .hasMessage("request timed out") .isInstanceOf(IOException.class) .isExactlyInstanceOf(HttpTimeoutException.class);
HttpTimeoutException
がスローされるようです。
次は接続タイムアウト。
@Test void connectTimeout() { HttpClient httpClient = HttpClient .newBuilder() .connectTimeout(Duration.ofSeconds(3L)) .build(); HttpRequest request = HttpRequest .newBuilder() .uri(URI.create("http://192.168.0.254:8080")) .timeout(Duration.ofSeconds(5L)) .build(); long startTime = System.currentTimeMillis(); assertThatThrownBy(() -> httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)) ) .hasMessage("HTTP connect timed out") .isInstanceOf(IOException.class) .isExactlyInstanceOf(HttpConnectTimeoutException.class); long elapsedTime = System.currentTimeMillis() - startTime; assertThat(elapsedTime).isGreaterThanOrEqualTo(3000L).isLessThan(4000L); }
こちらはHttpConnectTimeoutException
がスローされるようです。
どちらもIOException
のサブクラスなのですが、HttpConnectTimeoutException
はHttpTimeoutException
のサブクラスでもあります。
非同期版
ここまでの非同期版を載せておきます。
@Test void simplyAsync() throws ExecutionException, InterruptedException { HttpClient httpClient = HttpClient.newBuilder().build(); HttpRequest request = HttpRequest .newBuilder() .uri(URI.create("http://localhost:8080/api")) .build(); CompletableFuture<HttpResponse<String>> responseFuture = httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); responseFuture .thenAccept(response -> { assertThat(response.statusCode()).isEqualTo(200); assertThat(response.headers().firstValue("Content-Type").get()) .isEqualTo("text/plain;charset=UTF-8"); assertThat(response.body()).isEqualTo("Hello World!!"); }) .get(); } @Test void withQueryStringAsync() throws InterruptedException, ExecutionException { HttpClient httpClient = HttpClient.newBuilder().build(); HttpRequest request = HttpRequest .newBuilder() .uri(URI.create("http://localhost:8080/api/plus?a=5&b=3")) .build(); CompletableFuture<HttpResponse<String>> responseFuture = httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); responseFuture .thenAccept(response -> { assertThat(response.statusCode()).isEqualTo(200); assertThat(response.headers().firstValue("Content-Type").get()) .isEqualTo("application/json"); assertThat(response.body()).isEqualTo(""" {"result":8}\ """); }) .get(); } @Test void postAsync() throws ExecutionException, InterruptedException { HttpClient httpClient = HttpClient.newBuilder().build(); HttpRequest request = HttpRequest .newBuilder() .uri(URI.create("http://localhost:8080/api/echo")) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(""" {"message": "Hello HttpClient!!"}\ """, StandardCharsets.UTF_8)) .build(); CompletableFuture<HttpResponse<String>> responseFuture = httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); responseFuture .thenAccept(response -> { assertThat(response.statusCode()).isEqualTo(200); assertThat(response.headers().firstValue("Content-Type").get()) .isEqualTo("application/json"); assertThat(response.body()).isEqualTo(""" {"result":"★★★Hello HttpClient!!★★★"}\ """); }) .get(); } @Test void readTimeoutAsync() { HttpClient httpClient = HttpClient .newBuilder() .connectTimeout(Duration.ofSeconds(3L)) .build(); HttpRequest request = HttpRequest .newBuilder() .uri(URI.create("http://localhost:8080/api/timeout")) .timeout(Duration.ofSeconds(5L)) .build(); long startTime = System.currentTimeMillis(); assertThatThrownBy(() -> { CompletableFuture<HttpResponse<String>> responseFuture = httpClient .sendAsync(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); responseFuture.get(); } ) .hasMessage("java.net.http.HttpTimeoutException: request timed out") .isInstanceOf(ExecutionException.class) .hasRootCauseInstanceOf(HttpTimeoutException.class); long elapsedTime = System.currentTimeMillis() - startTime; assertThat(elapsedTime).isGreaterThanOrEqualTo(5000L).isLessThan(6000L); }
接続タイムアウトの方は、省略しました。
同期版との差は、HttpClient#send
をHttpClient#sendAsync
に変更するところですね。
CompletableFuture<HttpResponse<String>> responseFuture = httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
戻り値はCompletableFuture
になります。
プロキシ
プロキシについては、HttpClientBuilder#proxy
で設定します。
明示的に指定しなかった場合は、http[s].proxyHost
やhttp[s].proxyPort
といったシステムプロパティも見てくれるようです。
おわりに
Java 11で導入されたHTTPクライアントを試してみました。
Javaにはライブラリーからフレームワークに付属するものまで、様々なHTTPクライアントがあり、すでに使っているものからこちらに
乗り換えるほどのものか?というとそうでもない気がしますが。
特にライブラリーなどを追加せずに、HTTPクライアントが使えるのは良いですね。確かにHttpURLConnection
はわかりにくかったので。