5 мин чтенияИнженерия

Как сократить URL в Java с помощью встроенного HttpClient

Сократите URL в Java с помощью java.net.http без зависимостей, затем добавьте тайм-аут, повторы с Idempotency-Key и массовое сокращение на виртуальных потоках.

Marius Voß
DevRel · edge infra
Как сократить URL в Java: POST-запрос HttpClient передаёт адрес назначения в API сокращателя и считывает короткую ссылку из тела ответа

Сокращение URL в Java - это один POST через java.net.http.HttpClient. Сформируйте запрос, установите API-ключ в заголовке Bearer, отправьте запрос и считайте short_url из тела ответа. Клиент входит в JDK начиная с Java 11, поэтому первая рабочая версия вообще не требует зависимостей.

Большинство результатов поиска по Java предлагают «создать сокращатель URL на Spring Boot и JPA», но это задача о хранении, а не о нашей. Если вам нужно именно это, как создать сокращатель URL рассказывает о проектировании; если вы хотите получить короткую ссылку из существующего сервиса, начните с обзора бесплатного API сокращателя URL, чтобы разобраться с форматом эндпоинта и авторизацией.

Такое же руководство для других сред выполнения: Python, JavaScript, Go и Ruby.

Самый быстрый способ: один запрос через общий клиент

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"}
    }
}

Два тайм-аута выполняют разные задачи. connectTimeout у сборщика ограничивает время TCP- и TLS-рукопожатия; timeout у запроса ограничивает весь обмен. Если задать только первый, сервер, который принимает соединение, а затем ничего не отвечает, будет удерживать поток бесконечно.

Проверка statusCode() >= 400 - не защитное программирование, а условие контракта. send выбрасывает IOException, когда сокет закрывается, и HttpTimeoutException, когда истекает крайний срок, но никогда не выбрасывает исключение для HTTP-ошибки. Отозванный ключ создаёт совершенно обычный объект ответа с ошибкой JSON в теле.

В рабочем коде разбирайте тело с помощью Jackson. Текстовый блок приемлем для одного жёстко заданного поля, но перестаёт быть приемлемым в тот момент, когда адрес назначения содержит кавычку.

Используйте один клиент, а не новый для каждого вызова

HttpClient потокобезопасен и после создания неизменяем, а каждый экземпляр владеет пулом соединений и исполнителем. Создание клиента внутри вспомогательного метода означает, что каждый вызов заново выполняет TLS-рукопожатие и оставляет исполнителя, которого сборщику мусора придётся обнаружить позже. Общее правило простое: один статический экземпляр на приложение.

POST-запрос Java HttpClient передаёт Bearer-токен и Idempotency-Key эндпоинту коротких ссылок, API возвращает HTTP 201 с short_url, а при ответах 429 и 5xx выполняется повтор с увеличением задержки

Повторяйте запросы при 429 и 5xx, не создавая дубликаты

Есть один сбой, который стоит предусмотреть. POST-запрос доходит, ссылка создаётся, но ответ теряется по дороге обратно. Ваш код видит тайм-аут, повторяет запрос, и теперь две короткие ссылки указывают на один адрес назначения.

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 прекращает выполнение с первой попытки, вместо того чтобы тратить три попытки на то, что не изменится. Ждать стоит только при 429 и 5xx, причем ветка 429 использует число, переданное самим сервером, а не гадает. Все объяснение этой логики приведено в подробном разборе лимитов и идемпотентности.

Хотите запустить это на рабочем эндпоинте? Создайте ключ на бесплатном тарифе, экспортируйте ELIDO_API_KEY, и приведенный выше код скомпилируется и запустится без изменений на Java 21.

Массовое выполнение: виртуальные потоки с ограничением

Виртуальные потоки окончательно появились в Java 21 и изменили подход к этой задаче. Блокирующий ввод-вывод почти ничего не стоит виртуальному потоку, поэтому один поток на URL больше не выглядит безрассудством. Однако лимит запросов не изменился, поэтому 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 не нужны. ConcurrentHashMap с ключом в виде исходного URL делает частичный сбой видимым, а запуск - повторяемым.

Сравнение неограниченного sendAsync с CompletableFuture.allOf и виртуальных потоков с семафором для массового сокращения URL в Java: запросы ограничиваются лимитом при заданном числе одновременных операций

В Java 11-17 та же схема работает с sendAsync и фиксированным пулом потоков. Но ни в одной версии не стоит использовать CompletableFuture.allOf для неограниченного списка: он запускает все запросы сразу и собирает кучу ответов 429.

Когда сгенерированный клиент оправдывает себя

Для одного эндпоинта приведенный выше код - это вся интеграция, и зависимость ничего не даёт. Ситуация меняется, когда вы выводите ссылки постранично, фильтруете их по тегу и сопоставляете полдюжины форматов ответа: сгенерированные модели и клиент, уже знающий правила повторов, окупаются сами. На странице API и SDK приведен актуальный список, а в быстром старте SDK показана типизированная версия этого же вызова.

В любом случае правила те же: один общий клиент, крайний срок для каждого запроса, чтение кода статуса до чтения тела и стабильный ключ идемпотентности для всего, что может запуститься дважды. После создания ссылок сервисом, а не с ноутбука, вебхуки для событий ссылок удобнее опроса, когда нужно узнать, что произошло дальше.

Изучите основную серию

Эта статья входит в кластер engineering. Начните с руководства по бесплатному API сокращателя URL, затем прочитайте о лимитах и идемпотентности. Актуальная справочная информация находится в документации API, а решения для разработчиков охватывают остальные возможности.

Другие статьи в блоге

Частые вопросы

Как сократить URL в Java?

Сформируйте POST-запрос с помощью HttpRequest, отправьте его через общий HttpClient и считайте short_url из тела ответа. java.net.http входит в JDK начиная с Java 11, поэтому рабочая версия вообще не требует зависимости Maven: установите заголовок Authorization, отправьте небольшой JSON и перед разбором проверьте statusCode().

Нужны ли OkHttp или Apache HttpClient для вызова REST API в Java?

Для этого - нет. Встроенный java.net.http.HttpClient поддерживает POST с JSON, тайм-ауты и асинхронную отправку. Сторонний клиент оправдан, когда нужны интерсепторы, метрики соединений или обработка HTTP/2 push, которые клиент JDK не предоставляет, но для одного вызова create они не нужны.

Выбрасывает ли HttpClient Java исключение при ответе 404 или 500?

Нет. Он выбрасывает IOException только при сбое транспорта и HttpTimeoutException, когда истекает тайм-аут запроса. Ответ 4xx или 5xx приходит как обычный HttpResponse, поэтому statusCode() нужно прочитать самостоятельно. Пропуск этой проверки чаще всего приводит к тому, что отозванный ключ выглядит как успешный вызов.

Нужно ли создавать новый HttpClient для каждого запроса?

Нет. Каждый HttpClient имеет собственный пул соединений и исполнитель, поэтому создание нового экземпляра для каждого запроса лишает вас повторного использования соединений и может накопить потоки, которые позднее придётся убирать сборщику мусора. Создайте один статический экземпляр для приложения и используйте его совместно: класс документирован как потокобезопасный и рассчитан на повторное использование.

Как сократить сразу много URL в Java?

Запустите вызовы в исполнителе виртуальных потоков, ограничив Semaphore число запросов в работе, обычно примерно восемью. Виртуальные потоки делают схему «один поток на URL» достаточно дешёвой, но лимит API не изменился, поэтому именно семафор не даёт пакету остановиться.

Как собрать тело JSON без библиотеки?

Для одного поля подойдет текстовый блок с экранированным значением. Как только в теле появляются строки от пользователя или больше двух полей, используйте Jackson или реализацию JSON-B. JSON, собранный вручную, ломается на первом адресе назначения с кавычкой, и эта ошибка выглядит как ошибка сервера, а не как проблема форматирования.

Попробуйте Elido

Вставьте URL - получите короткую ссылку

Без регистрации. Ссылка живёт 30 дней. Зарегистрируйтесь, чтобы оставить её навсегда.

Бесплатно, без регистрации · 2 в день

Попробуйте Elido

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

Читать дальше