2分で読了エンジニアリング

組み込み HttpClient で Java の URL を短縮する方法

依存関係なしで java.net.http を使って URL を短縮し、タイムアウト、Idempotency-Key を使ったリトライ、仮想スレッドでの一括短縮を追加する方法。

Marius Voß
DevRel · edge infra
Java で URL を短縮する方法:HttpClient の POST で遷移先 URL を短縮 API に送り、レスポンスボディからショートリンクを読み取る

Java で URL を短縮する処理は、java.net.http.HttpClient を通した1回の POST です。リクエストを組み立て、API キーを Bearer ヘッダーとして設定し、送信して、レスポンスボディから short_url を読み取ります。このクライアントは Java 11 以降 JDK に含まれているため、最初に動くバージョンには依存関係がまったくありません。

このテーマで検索すると「Spring Boot と JPA で URL 短縮サービスを作る」という Java の例がほとんどですが、それは今回の問題ではなくストレージの問題です。それを求めている場合は、URL 短縮サービスの作り方で設計を説明しています。既存サービスからショートリンクを取得したい場合は、エンドポイントの形と認証を確認できる無料 URL 短縮 API の概要から始めてください。

他のランタイムでも同じ手順を確認できます:PythonJavaScriptGoRuby

最短の方法:共有クライアントで1回リクエストする

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;

public final class Shortener {

    private static final HttpClient CLIENT = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(5))
            .build();

    public static void main(String[] args) throws Exception {
        String body = """
                {"destination_url":"https://example.com/spring-sale?utm_source=newsletter"}""";

        HttpRequest request = HttpRequest
                .newBuilder(URI.create("https://api.elido.app/v1/links"))
                .header("Authorization", "Bearer " + System.getenv("ELIDO_API_KEY"))
                .header("Content-Type", "application/json")
                .timeout(Duration.ofSeconds(10))
                .POST(HttpRequest.BodyPublishers.ofString(body))
                .build();

        HttpResponse<String> response = CLIENT.send(request, HttpResponse.BodyHandlers.ofString());

        if (response.statusCode() >= 400) {
            throw new IllegalStateException("shorten failed: " + response.statusCode());
        }
        System.out.println(response.body()); // {"id":"...","short_url":"https://s.elido.me/ab12cd"}
    }
}

2つのタイムアウトには、それぞれ異なる役割があります。ビルダーの connectTimeout は TCP と TLS のハンドシェイクにかけられる時間を制限し、リクエストの timeout は交換全体を制限します。前者だけを設定すると、接続を受け付けた後に何も返さないサーバーによって、スレッドが無期限に保持されます。

statusCode() >= 400 の確認は防御的プログラミングではなく、契約そのものです。send はソケットが切断されたときに IOException を、期限を過ぎたときに HttpTimeoutException をスローしますが、HTTP エラーではスローしません。失効したキーでも、ボディに JSON エラーを含む通常のレスポンスオブジェクトが返されます。

実際のコードでは Jackson でボディをパースしてください。ハードコードされたフィールドが1つならテキストブロックで問題ありませんが、遷移先 URL に引用符が含まれた瞬間に、それでは不十分になります。

呼び出しごとではなく、1つのクライアントを再利用する

HttpClient は構築後はスレッドセーフかつ不変で、各インスタンスが接続プールとエグゼキューターを所有します。ヘルパーメソッド内で作成すると、呼び出しのたびに TLS ハンドシェイクをやり直し、後でガベージコレクターに気づかれるまでエグゼキューターを残すことになります。アプリケーションごとに static インスタンスを1つ使う、それが唯一のルールです。

Java の HttpClient が Bearer トークンと Idempotency-Key を短縮サービスの links エンドポイントに POST し、API が short_url を含む HTTP 201 を返し、429 と 5xx ではバックオフするリトライ経路

重複を作らずに 429 と 5xx をリトライする

設計上考慮する価値のある失敗が1つあります。POST が到着してリンクは作成されたものの、戻りの途中でレスポンスが失われるケースです。コードはタイムアウトを検知してリトライし、今度は1つの遷移先を指すショートリンクが2つできます。

Idempotency-Key がこれを解決します。また、このキーをランダムな UUID ではなく遷移先から導出すれば、同じバッチを再実行しても重複ではなく無料の再実行になります。

static String shorten(String destination, int attempts) throws Exception {
    String key = HexFormat.of().formatHex(
            MessageDigest.getInstance("SHA-256").digest(destination.getBytes(UTF_8)));

    for (int attempt = 0; attempt < attempts; attempt++) {
        HttpRequest request = HttpRequest
                .newBuilder(URI.create("https://api.elido.app/v1/links"))
                .header("Authorization", "Bearer " + System.getenv("ELIDO_API_KEY"))
                .header("Content-Type", "application/json")
                .header("Idempotency-Key", key)
                .timeout(Duration.ofSeconds(10))
                .POST(HttpRequest.BodyPublishers.ofString(MAPPER.writeValueAsString(
                        Map.of("destination_url", destination))))
                .build();

        HttpResponse<String> response = CLIENT.send(request, HttpResponse.BodyHandlers.ofString());
        int status = response.statusCode();

        if (status < 400) {
            return MAPPER.readTree(response.body()).get("short_url").asText();
        }
        if (status == 429) {
            Thread.sleep(Duration.ofSeconds(response.headers()
                    .firstValue("Retry-After").map(Long::parseLong).orElse(2L)));
        } else if (status >= 500) {
            Thread.sleep(Duration.ofSeconds(1L << attempt)); // 1s, 2s, 4s
        } else {
            throw new IllegalStateException("shorten failed: " + status + " " + response.body());
        }
    }
    throw new IllegalStateException("shorten failed after " + attempts + " attempts");
}

401 や 422 は、決して変わらないものに対して3回の試行を消費せず、最初の通過で終了します。待つ価値があるのは 429 と 5xx だけで、429 の分岐では推測するのではなくサーバー自身の値に従います。これらの理由については、レート制限とべき等性の詳細解説で説明しています。

ライブエンドポイントで実行したい場合は、無料プランでキーを作成し、ELIDO_API_KEY をエクスポートしてください。上のコードは Java 21 で記載どおりコンパイルして実行できます。

一括処理:上限を設けた仮想スレッド

仮想スレッドは Java 21 で正式に導入され、この問題の形を変えました。仮想スレッドでのブロッキング IO はほとんどコストがかからないため、URL ごとに1スレッドを使っても無謀ではありません。ただしレート制限は変わらないので、エグゼキューターよりも Semaphore が重要になります。

static Map<String, String> shortenAll(List<String> urls) throws Exception {
    Map<String, String> out = new ConcurrentHashMap<>();
    Semaphore gate = new Semaphore(8); // 8 requests in flight, whatever urls.size() is

    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
        for (String url : urls) {
            executor.submit(() -> {
                gate.acquire();
                try {
                    out.put(url, shorten(url, 3));
                } catch (Exception e) {
                    out.put(url, "ERROR: " + e.getMessage()); // one bad URL, not a dead batch
                } finally {
                    gate.release();
                }
                return null;
            });
        }
    } // close() waits for every task to finish

    return out;
}

try-with-resources ブロックは実際に仕事をしています。仮想スレッドエグゼキューターを閉じると、送信済みのすべてのタスクが完了するまで待機するため、awaitTermination の手順は必要ありません。元の URL をキーにした ConcurrentHashMap によって、部分的な失敗を見える状態に保ち、実行を再現可能にできます。

CompletableFuture.allOf を使った上限のない sendAsync と、Java での URL 一括短縮に向けたセマフォ付き仮想スレッドを比較し、同時実行数を制限したレート制限リクエストを示す図

Java 11 から 17 では、sendAsync と固定スレッドプールで同じ形を実現できます。どのバージョンでも機能しないのは、上限のないリストに対する CompletableFuture.allOf です。これはすべてを一度に開始し、大量の 429 を集めることになります。

生成クライアントを導入する価値が出るとき

1つのエンドポイントなら、上のコードで統合は完了しており、依存関係を追加しても何も得られません。しかし、ページネーション付きでリンクを列挙し、タグでフィルタリングし、半ダースのレスポンス形式をマッピングするようになると話は変わります。生成モデルとリトライルールをすでに理解しているクライアントが価値を生みます。API と SDK のページに現在の一覧があり、SDK クイックスタートでは同じ呼び出しの型付きバージョンを示しています。

どちらを選んでも習慣は同じです。共有クライアントを1つ使い、すべてのリクエストに期限を設定し、ボディを読む前にステータスコードを確認し、2回実行される可能性があるものには安定したべき等性キーを付けます。リンクをノートパソコンではなくサービスから作成するようになったら、次に何が起きたかを知るにはポーリングよりリンクイベント用 Webhookが適しています。

基幹シリーズを読む

この記事はエンジニアリングクラスターに含まれています。無料 URL 短縮 API ガイドから始め、続けてレート制限とべき等性を読んでください。ライブリファレンスはAPI ドキュメントにあり、開発者向けソリューションではその他の機能を説明しています。

ブログ関連記事

よくある質問

Java で URL を短縮するにはどうすればよいですか?

HttpRequest で POST を組み立て、共有の HttpClient から送信し、レスポンスボディから short_url を読み取ります。java.net.http は Java 11 以降 JDK に含まれているため、動作するバージョンに Maven の依存関係はまったく必要ありません。Authorization ヘッダーを設定し、小さな JSON ボディを送信してから、パースする前に statusCode() を確認してください。

Java で REST API を呼び出すために OkHttp や Apache HttpClient は必要ですか?

この用途では必要ありません。組み込みの java.net.http.HttpClient は JSON POST、タイムアウト、非同期送信に対応しています。JDK のクライアントが公開していないインターセプター、接続メトリクス、HTTP/2 プッシュ処理が必要な場合はサードパーティ製クライアントに価値がありますが、単一の作成呼び出しには必要ありません。

Java の HttpClient は 404 や 500 で例外をスローしますか?

いいえ。トランスポート障害では IOException を、リクエストのタイムアウトが発生した場合は HttpTimeoutException をスローするだけです。4xx や 5xx は通常の HttpResponse として返るため、statusCode() を自分で読み取る必要があります。この確認を省略すると、失効したキーでの呼び出しが成功したように見えるのが一般的な原因です。

リクエストごとに新しい HttpClient を作成すべきですか?

いいえ。各 HttpClient は独自の接続プールとエグゼキューターを持つため、リクエストごとに作成すると接続の再利用を捨て、スレッドを積み上げることになります。アプリケーション用に static インスタンスを1つ作成して共有してください。このクラスはスレッドセーフで、再利用を前提に設計されています。

Java で多くの URL を一度に短縮するにはどうすればよいですか?

Semaphore で同時実行数に上限を設けた仮想スレッドエグゼキューターで呼び出しを実行します。通常は約8件です。仮想スレッドなら URL ごとに1スレッドを使っても現実的なコストになりますが、API のレート制限は変わらないため、バッチを維持する役割を実際に担うのは Semaphore です。

ライブラリなしで JSON ボディを組み立てるにはどうすればよいですか?

単一フィールドなら、値をエスケープしたテキストブロックで問題ありません。ボディにユーザー入力の文字列が含まれる場合や、フィールドが2つを超える場合は、Jackson または JSON-B 実装を使ってください。手作業で組み立てた JSON は、引用符を含む最初の遷移先 URL で壊れ、その失敗はフォーマットのバグではなくサーバーエラーのように見えます。

Elidoを試す

URLを貼り付けて短縮リンクを取得

登録不要。リンクは30日間有効。永久に保存するには登録してください。

Free、登録不要 · 1日あたり2件

Elidoを試す

EUホスティングのURL短縮サービス。カスタムドメイン、詳細な分析、オープンAPI付き。無料プラン - クレジットカード不要。

タグ
how to shorten a url in java
java url shortener api
java httpclient post json
shorten url java
java virtual threads http
bulk shorten urls java

続きを読む