アイキャッチ

設計・テスト・レビュー

オブジェクト指向、SOLID原則に基づいたHTTPリクエスト通信部分を具体的なコードで説明

利用者登録のPOSTを送った後、応答が来なかったとします。サーバー側では登録が完了しているかもしれません。同じPOSTをそのまま送り直した場合と、サーバーが同じ操作として扱えるキーを付けた場合を比べます。

関連記事オブジェクト指向、SOLID原則に基づいたHTTPリクエスト通信部分のクラス設計例(2) APIごとに変わる電文・ヘッダーと、共通の送受信処理をどのクラスへ置くかを説明した設計編です。 記事を読む

コードの前提

JDK 17の標準APIだけで動くサンプルです。元の記事ではSpringのControllerから呼んでいました。ここでは同じ呼び出し順を小さく再現し、ローカルサーバーが最初のHTTP応答を返さないケースを確認します。POSTではメモリへ保存した後、HTTPヘッダーを送らずに待ちます。

サンプルコード ZIPには4つのJavaファイル、run.sh、READMEが入っています。展開先でJDK 17を使い、sh run.sh を実行してください。

GitHubでコードを見る · ZIPをダウンロード

操作HTTP要求数サーバーの保存件数呼び出し側で確認する結果
読み取り専用のGETを再試行20HTTP 200
キーなしPOSTを無条件に再試行22HTTP 201、登録ID 2
同一キー・同一本文のPOSTを再試行21HTTP 201、最初の登録ID 1
同一キーで本文を変えて送信212回目はHTTP 409
キーなしPOSTで応答が来ず停止11タイムアウト。呼び出し側では保存されたか分からない

コード設計

Controllerは業務操作を呼び出し、UserRequestServiceがAPIの契約を基に再試行するかを決めます。AbstractApiはネットワークの処理を担当し、更新を繰り返してよいかをHTTP送受信だけから判断しません。

HTTP送受信を共通化する当時のクラス設計図
当時の設計図。下のサンプルでは、同じ責務の分け方で再試行の条件を検証します。

Controller

このサンプルのControllerは起動用のクラスです。各ケースでサーバーを作り直し、HTTP結果だけでなく、実際の要求数と保存件数を照合します。キーなしPOSTでタイムアウトしたケースは1回で止め、例外から登録結果を決めません。

public final class SampleController {
    public static void main(String[] args) throws Exception {
        try (var api = new LocalUserApi(true)) {
            var client = new UserRequestService(api.baseUrl());
            check(client.findUsers(), 200, "0");
            checkCounts(api, 2, 0);
            System.out.println("GET retried: requests=2, saved=0");
        }

        try (var api = new LocalUserApi(true)) {
            var client = new UserRequestService(api.baseUrl());
            check(client.registerWithoutKeyAndRetryForComparison("Aki"), 201, "2");
            checkCounts(api, 2, 2);
            System.out.println("POST without key, blindly retried: requests=2, saved=2");
        }

        try (var api = new LocalUserApi(true)) {
            var client = new UserRequestService(api.baseUrl());
            check(client.registerUser("Aki", "registration-7"), 201, "1");
            checkCounts(api, 2, 1);
            System.out.println("POST with same key and body: requests=2, saved=1, id=1");
        }

        try (var api = new LocalUserApi(false)) {
            var client = new UserRequestService(api.baseUrl());
            check(client.registerUser("Aki", "registration-7"), 201, "1");
            check(client.registerUser("Yui", "registration-7"), 409,
                    "same key with different name");
            checkCounts(api, 2, 1);
            System.out.println("POST with same key and changed body: HTTP 409, saved=1");
        }

        try (var api = new LocalUserApi(true)) {
            var client = new UserRequestService(api.baseUrl());
            try {
                client.registerUser("Aki", null);
                throw new AssertionError("expected unknown result after response timeout");
            } catch (AbstractApi.ResponseTimeout expected) {
                checkCounts(api, 1, 1);
                System.out.println("POST without key, stopped: requests=1, saved=1");
            }
        }
    }

    private static void check(AbstractApi.Response response, int status, String body) {
        if (response.status() != status || !response.body().equals(body)) {
            throw new AssertionError("unexpected response: " + response);
        }
    }

    private static void checkCounts(LocalUserApi api, int requests, int saved) {
        if (api.requests() != requests || api.savedUsers() != saved) {
            throw new AssertionError("requests=" + api.requests() + ", saved=" + api.savedUsers());
        }
    }
}

UserRequestService

読み取り専用のGETには再試行1回を許可します。登録POSTは、相手が同じキーと同じ本文を再送として扱う契約を持つときだけ、同じキーを載せて再試行します。registerWithoutKeyAndRetryForComparison は二重登録を起こす比較専用メソッドです。

import java.io.IOException;
import java.net.URL;
import java.util.Map;

final class UserRequestService extends AbstractApi {
    UserRequestService(URL baseUrl) {
        super(baseUrl);
    }

    Response findUsers() throws IOException {
        return send(new Request("GET", "/users", null, Map.of()),
                RetryPolicy.RETRY_ONCE);
    }

    Response registerUser(String name, String key) throws IOException {
        boolean hasKey = key != null && !key.isBlank();
        return send(registration(name, hasKey ? key : null),
                hasKey ? RetryPolicy.RETRY_ONCE : RetryPolicy.STOP);
    }

    Response registerWithoutKeyAndRetryForComparison(String name) throws IOException {
        return send(registration(name, null), RetryPolicy.RETRY_ONCE);
    }

    private Request registration(String name, String key) {
        var headers = key == null
                ? Map.of("Content-Type", "text/plain; charset=UTF-8")
                : Map.of("Content-Type", "text/plain; charset=UTF-8", "Idempotency-Key", key);
        return new Request("POST", "/users", name, headers);
    }
}

受信側では最初のPOSTを保存した直後に応答を止めます。同じキーと同じ名前を受けたら最初のIDを返し、同じキーで名前が違えば409を返します。この例ではキー判定と保存を1つの同期区間に置きました。複数インスタンスで動く実サービスでは、業務操作や利用者を含めたキーの範囲と有効期間を決め、保存結果と重複防止を永続ストレージで扱います。

import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;

final class LocalUserApi implements AutoCloseable {
    private record Registration(String name, int id) {}

    private final AtomicInteger requests = new AtomicInteger();
    private final CountDownLatch releaseFirstRequest = new CountDownLatch(1);
    private final Map<String, Registration> registrationsByKey = new HashMap<>();
    private final ExecutorService executor = Executors.newCachedThreadPool();
    private final HttpServer server;
    private final boolean suppressFirstResponse;
    private int savedUsers;

    LocalUserApi(boolean suppressFirstResponse) throws IOException {
        this.suppressFirstResponse = suppressFirstResponse;
        server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
        server.setExecutor(executor);
        server.createContext("/users", this::handle);
        server.start();
    }

    URL baseUrl() throws IOException {
        return new URL("http://127.0.0.1:" + server.getAddress().getPort());
    }

    int requests() {
        return requests.get();
    }

    synchronized int savedUsers() {
        return savedUsers;
    }

    private void handle(HttpExchange exchange) throws IOException {
        int attempt = requests.incrementAndGet();
        try (exchange) {
            AbstractApi.Response response = switch (exchange.getRequestMethod()) {
                case "GET" -> new AbstractApi.Response(200, Integer.toString(savedUsers()));
                case "POST" -> {
                    String name = new String(exchange.getRequestBody().readAllBytes(),
                            StandardCharsets.UTF_8);
                    yield register(name, exchange.getRequestHeaders().getFirst("Idempotency-Key"));
                }
                default -> new AbstractApi.Response(405, "method not allowed");
            };
            if (suppressFirstResponse && attempt == 1) {
                try {
                    releaseFirstRequest.await();
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
                return;
            }
            byte[] bytes = response.body().getBytes(StandardCharsets.UTF_8);
            exchange.sendResponseHeaders(response.status(), bytes.length);
            exchange.getResponseBody().write(bytes);
        }
    }

    private synchronized AbstractApi.Response register(String name, String key) {
        Registration prior = registrationsByKey.get(key);
        if (prior != null) {
            if (!prior.name().equals(name)) {
                return new AbstractApi.Response(409, "same key with different name");
            }
            return new AbstractApi.Response(201, Integer.toString(prior.id()));
        }
        int id = ++savedUsers;
        if (key != null) {
            registrationsByKey.put(key, new Registration(name, id));
        }
        return new AbstractApi.Response(201, Integer.toString(id));
    }

    @Override
    public void close() {
        releaseFirstRequest.countDown();
        server.stop(0);
        executor.shutdownNow();
    }
}

AbstractApi

1回の送信ごとに新しい接続オブジェクトを作ります。応答の読み取りがタイムアウトしたときだけ、Serviceが選んだ方針に従って最大1回再試行します。409のようにHTTP応答を受け取った場合は、その結果を呼び出し側へ返します。再試行時に本文やキーを作り直さないよう、同じRequestを送ります。

import java.io.IOException;
import java.net.HttpURLConnection;
import java.net.SocketTimeoutException;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.Map;

abstract class AbstractApi {
    private final URL baseUrl;

    AbstractApi(URL baseUrl) {
        this.baseUrl = baseUrl;
    }

    enum RetryPolicy {
        STOP, RETRY_ONCE
    }

    record Request(String method, String path, String body, Map<String, String> headers) {}
    record Response(int status, String body) {}

    static final class ResponseTimeout extends IOException {
        ResponseTimeout(SocketTimeoutException cause) {
            super("Timed out while reading the HTTP response", cause);
        }
    }

    protected Response send(Request request, RetryPolicy policy) throws IOException {
        try {
            return sendOnce(request);
        } catch (ResponseTimeout timeout) {
            if (policy == RetryPolicy.STOP) {
                throw timeout;
            }
            return sendOnce(request);
        }
    }

    private Response sendOnce(Request request) throws IOException {
        var connection = (HttpURLConnection) new URL(baseUrl, request.path()).openConnection();
        try {
            connection.setConnectTimeout(1000);
            connection.setReadTimeout(500);
            connection.setInstanceFollowRedirects(false);
            connection.setRequestMethod(request.method());
            request.headers().forEach(connection::setRequestProperty);

            if (request.body() != null) {
                byte[] bytes = request.body().getBytes(StandardCharsets.UTF_8);
                connection.setDoOutput(true);
                connection.setFixedLengthStreamingMode(bytes.length);
                try (var body = connection.getOutputStream()) {
                    body.write(bytes);
                }
            } else {
                connection.connect();
            }

            try {
                int status = connection.getResponseCode();
                try (var body = status >= 400 ? connection.getErrorStream()
                                               : connection.getInputStream()) {
                    return new Response(status, body == null ? ""
                            : new String(body.readAllBytes(), StandardCharsets.UTF_8));
                }
            } catch (SocketTimeoutException timeout) {
                throw new ResponseTimeout(timeout);
            }
        } finally {
            connection.disconnect();
        }
    }
}
関連記事JavaのHttpUrlConnectionのリクエストタイムアウトとレスポンスタイムアウトの違い 接続待ちと応答待ちの違い、同じ接続オブジェクトを使い回した場合の挙動を、別のローカル例で確認できます。 記事を読む

配布ZIPを展開し、次のコマンドを実行します。

#!/bin/sh
set -eu

cd -- "$(dirname -- "$0")"
mkdir -p build
javac --release 17 -d build AbstractApi.java UserRequestService.java LocalUserApi.java SampleController.java
java -cp build SampleController
sh run.sh
GET retried: requests=2, saved=0
POST without key, blindly retried: requests=2, saved=2
POST with same key and body: requests=2, saved=1, id=1
POST with same key and changed body: HTTP 409, saved=1
POST without key, stopped: requests=1, saved=1

レビューでは「その操作を2回送ったとき、保存件数と応答はどうなるか」「初回の結果を確認できないとき、照会・手動対応・再試行のどれを選ぶか」を確認します。

出典 HTTPの冪等性と再試行、Java SE 17のHttpURLConnection

-設計・テスト・レビュー
-,