CLOVER🍀

That was when it all began.

Java 11で導入されたHTTPクライアント(JEP 321 HTTP Client)を試す

これは、なにをしたくて書いたもの?

Java 11からHTTPクライアントが導入されましたが、そういえば触ったことがありませんでした。
自分でも1度試してみようかなということで。

JEP 321: HTTP Client

HTTPクライアントは、「JEP 321: HTTP Client」で導入されました。

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を試してみたいと思います。

環境

今回の環境は、こちら。

$ 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!!";
    }
}

それぞれ、以下のメソッドを用意しています。

  • 単にテキストを返す
  • QueryStringを受け取ってJSONを返す
  • POSTでJSONを受け取ってJSONを返す
  • 10秒スリープしてレスポンスを返す

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はわかりにくかったので。