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

Как сократить URL в Go с помощью net/http и errgroup

Сократите URL в Go с помощью net/http и JSON-тела, затем добавьте дедлайн контекста, повторы с Idempotency-Key и ограниченное массовое сокращение через errgroup.

Marius Voß
DevRel · edge infra
Как сократить URL в Go: POST-запрос net/http отправляет URL назначения в API сокращателя и декодирует короткую ссылку, используя дедлайн контекста и ограниченное распараллеливание через errgroup

Сокращение URL в Go - это один POST-запрос с JSON-телом. Сериализуйте адрес назначения, отправьте его на endpoint links сокращателя через net/http, передайте API-ключ как Bearer-токен и извлеките short_url из ответа. Стандартная библиотека делает всё это, а единственная зависимость, которую стоит добавить позже, - errgroup для массовой обработки.

Поисковая выдача по этой теме заполнена проектами «создайте собственный сокращатель URL на Go», но это другая задача: такие проекты хранят ссылки, а этот пример вызывает уже готовый сервис. Если вам нужна сторона хранения, архитектурные решения разобраны в статье как создать сокращатель URL. Если короткая ссылка нужна в ближайшие пять минут, продолжайте читать.

Это статья о Go в серии, где также рассматриваются Python, JavaScript и PHP. Формат endpoint-а, модель аутентификации и ограничения бесплатного тарифа, используемые здесь, описаны в обзоре бесплатного API сокращателя URL.

Самый быстрый способ: один POST-запрос с net/http

Две структуры, один запрос, один разбор ответа:

package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
	"time"
)

type linkRequest struct {
	DestinationURL string `json:"destination_url"`
}

type linkResponse struct {
	ID       string `json:"id"`
	ShortURL string `json:"short_url"`
}

func main() {
	body, err := json.Marshal(linkRequest{
		DestinationURL: "https://example.com/spring-sale?utm_source=newsletter",
	})
	if err != nil {
		panic(err)
	}

	req, err := http.NewRequest(http.MethodPost, "https://api.elido.app/v1/links", bytes.NewReader(body))
	if err != nil {
		panic(err)
	}
	req.Header.Set("Authorization", "Bearer "+os.Getenv("ELIDO_API_KEY"))
	req.Header.Set("Content-Type", "application/json")

	client := &http.Client{Timeout: 10 * time.Second}
	resp, err := client.Do(req)
	if err != nil {
		panic(err)
	}
	defer resp.Body.Close()

	if resp.StatusCode >= 400 {
		panic("shorten failed: " + resp.Status)
	}

	var link linkResponse
	if err := json.NewDecoder(resp.Body).Decode(&link); err != nil {
		panic(err)
	}

	fmt.Println(link.ShortURL) // https://s.elido.me/ab12cd
}

Запустите go run main.go с экспортированной переменной ELIDO_API_KEY и получите короткую ссылку. Паники допустимы в программе из пятнадцати строк, но в остальных случаях это плохой подход. В следующем разделе мы превратим этот код в функцию, возвращающую ошибки.

Строка client := &http.Client{Timeout: ...} - та, которую стоит сохранить. У http.DefaultClient нет тайм-аута, поэтому http.Post к не отвечающему хосту блокирует горутину, пока соединение само не оборвётся. В плохой сети это может занять минуты.

Задайте дедлайн для каждого запроса и используйте один клиент

Продакшен-вариант: клиент на уровне пакета, контекст в каждом запросе и ошибки вместо паник.

var client = &http.Client{
	Timeout: 10 * time.Second,
	Transport: &http.Transport{
		MaxIdleConns:        64,
		MaxIdleConnsPerHost: 16, // default is 2, too low for a concurrent batch
		IdleConnTimeout:     90 * time.Second,
	},
}

const endpoint = "https://api.elido.app/v1/links"

func shorten(ctx context.Context, destination string) (string, error) {
	ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
	defer cancel()

	body, err := json.Marshal(linkRequest{DestinationURL: destination})
	if err != nil {
		return "", fmt.Errorf("marshal: %w", err)
	}

	req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
	if err != nil {
		return "", fmt.Errorf("new request: %w", err)
	}
	req.Header.Set("Authorization", "Bearer "+os.Getenv("ELIDO_API_KEY"))
	req.Header.Set("Content-Type", "application/json")

	resp, err := client.Do(req)
	if err != nil {
		return "", fmt.Errorf("post %s: %w", endpoint, err)
	}
	defer func() {
		io.Copy(io.Discard, resp.Body) // drain so the connection can be reused
		resp.Body.Close()
	}()

	if resp.StatusCode >= 400 {
		return "", fmt.Errorf("shorten: %s", resp.Status)
	}

	var link linkResponse
	if err := json.NewDecoder(resp.Body).Decode(&link); err != nil {
		return "", fmt.Errorf("decode: %w", err)
	}
	return link.ShortURL, nil
}

Здесь важны три детали. Повторное использование одного http.Client сохраняет пул соединений между вызовами, а создание клиента для каждого запроса уничтожает все keep-alive-соединения. Вычитывание тела перед закрытием действительно позволяет соединению вернуться в пул, и обычно именно из-за этого пакетная задача открывает гораздо больше сокетов, чем должна. А context в запросе означает, что отказавшийся от ожидания вызывающий код, например отменённый CLI или отключившийся пользователь, остановит HTTP-вызов, а не оставит его выполняться.

MaxIdleConnsPerHost заслуживает своего комментария. Значение по умолчанию равно 2: в скрипте это незаметно, но становится дорого, когда восемь горутин обращаются к одному хосту.

POST-запрос Go net/http с дедлайном контекста, Bearer-токеном и Idempotency-Key к endpoint links сокращателя; API возвращает HTTP 201 с short_url, а путь повтора использует backoff для 429 и 5xx с учётом отмены контекста

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

Есть сценарий сбоя, который простой цикл повторов только усугубляет. POST-запрос достигает API, ссылка создаётся, а ответ теряется по дороге обратно. Код видит тайм-аут, повторяет запрос, и теперь две короткие ссылки ведут к одному назначению. Idempotency-Key решает проблему: тот же ключ означает тот же логический запрос, поэтому API возвращает исходную ссылку, а не создаёт новую.

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

var (
	errRateLimited = errors.New("rate limited")
	errServer      = errors.New("server error")
)

func shortenWithRetry(ctx context.Context, destination string, attempts int) (string, error) {
	key := fmt.Sprintf("%x", sha256.Sum256([]byte(destination)))

	var lastErr error
	for attempt := 0; attempt < attempts; attempt++ {
		short, retryAfter, err := postLink(ctx, destination, key)
		if err == nil {
			return short, nil
		}

		var wait time.Duration
		switch {
		case errors.Is(err, errRateLimited):
			wait = retryAfter // from the Retry-After header
		case errors.Is(err, errServer):
			wait = time.Duration(1<<attempt) * time.Second // 1s, 2s, 4s
		default:
			return "", err // 401, 403, 422: retrying will not help
		}

		lastErr = err
		select {
		case <-ctx.Done():
			return "", ctx.Err()
		case <-time.After(wait):
		}
	}
	return "", fmt.Errorf("shorten %q: %w", destination, lastErr)
}

Обратите внимание на select вместо time.Sleep. Сон игнорирует отмену, поэтому задача, которой приказали завершиться, всё равно ждёт четыре секунды для каждого URL. Одновременное ожидание ctx.Done() и time.After делает backoff прерываемым. В этом разница между корректным завершением и SIGKILL. В подробном разборе ограничений частоты и идемпотентности описаны семантика заголовков и причина, по которой слепые повторы превращают один сбой в два.

Хотите проверить это на работающем endpoint-е? Создайте ключ на бесплатном тарифе, экспортируйте ELIDO_API_KEY, и каждый приведённый здесь фрагмент скомпилируется и запустится как есть.

Сокращайте URL массово с errgroup и SetLimit

Наивная конкурентная версия запускает одну горутину на каждый URL. Для 5 000 URL это 5 000 одновременных запросов, и API отвечает на большинство из них кодом 429. errgroup вместе с SetLimit задаёт распараллеливание с верхней границей:

func shortenAll(ctx context.Context, urls []string) (map[string]string, error) {
	g, ctx := errgroup.WithContext(ctx)
	g.SetLimit(8) // at most 8 requests in flight, whatever len(urls) is

	var mu sync.Mutex
	out := make(map[string]string, len(urls))

	for _, u := range urls {
		g.Go(func() error {
			short, err := shortenWithRetry(ctx, u, 3)
			if err != nil {
				return fmt.Errorf("%s: %w", u, err)
			}
			mu.Lock()
			defer mu.Unlock()
			out[u] = short
			return nil
		})
	}

	return out, g.Wait()
}

В Go 1.22 и новее переменная цикла создаётся заново на каждой итерации, поэтому старая строка u := u внутри цикла больше не нужна. Мьютекс по-прежнему обязателен: запись в map из нескольких горутин без него является гонкой данных, и go test -race сообщит об этом.

По одной горутине на URL перегружает API сокращателя запросами с ограничением частоты; errgroup с SetLimit поддерживает ограниченное число одновременных запросов массового сокращения

Перед публикацией учтите ещё одно поведение: errgroup.WithContext отменяет общий контекст сразу после того, как любая горутина возвращает ошибку, поэтому первая серьёзная ошибка останавливает остальные запросы пакета. Это нужно для шага сборки, который должен быть выполнен целиком или не выполнен вообще. Для импортера, который должен сократить всё возможное и сообщить об ошибках в конце, собирайте ошибки в срез, возвращайте nil из каждой горутины и используйте группу только как ограничитель конкурентности.

Типизированный клиент или обычный net/http

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

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

Читайте основную серию

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

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

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

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

Сериализуйте длинный URL в JSON-тело, отправьте его POST-запросом на endpoint links сокращателя через net/http, передайте API-ключ в заголовке Bearer и извлеките short_url из ответа. Стандартная библиотека покрывает всё необходимое, поэтому вся функция занимает около двадцати строк и не требует сторонних зависимостей.

Нужна ли библиотека, чтобы сокращать URL в Go?

Нет. Для одного POST-запроса достаточно net/http и encoding/json, а из внешних пакетов стоит добавить только golang.org/x/sync/errgroup для ограниченной конкурентности в массовых задачах. Сгенерированный SDK окупается, когда вы используете много endpoint-ов и хотите типизированные модели и пагинацию, а не один вызов.

Как установить тайм-аут для HTTP-запроса в Go?

Задайте Timeout в собственном http.Client и создавайте запросы через http.NewRequestWithContext вместе с context.WithTimeout. У http.DefaultClient вообще нет тайм-аута, поэтому зависший сервер навсегда заблокирует горутину. Тайм-аут клиента охватывает весь обмен, а контекст также позволяет вызывающей стороне отменить его раньше.

Как одновременно сократить много URL в Go?

Используйте errgroup с SetLimit, чтобы одновременно выполнялось только фиксированное число запросов, вместо запуска одной горутины на каждый URL с последующим ограничением по частоте. Защитите map с результатами мьютексом и отправляйте стабильный Idempotency-Key для каждого URL, чтобы повторы не создавали дубликаты ссылок.

Почему мой HTTP-клиент Go не переиспользует соединения?

Обычно потому, что тело ответа полностью не прочитано до закрытия, поэтому соединение не может вернуться в пул бездействующих соединений. Сначала вычитайте его через io.Copy(io.Discard, resp.Body), затем вызовите Close. Другая частая причина - значение по умолчанию в transport: два бездействующих соединения на хост, чего мало для конкурентной пакетной задачи.

Почему мой запрос Go к сокращателю URL возвращает 401?

Заголовок Authorization отсутствует, написан с ошибкой или содержит пустую переменную окружения. Выведите os.Getenv перед запросом, чтобы убедиться, что ключ загрузился, и проверьте, что заголовок имеет вид 'Bearer ' с пробелом после него перед ключом. Ответ 403 означает, что ключ действителен, но у него нет нужной области доступа к этому endpoint-у.

Попробуйте Elido

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

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

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

Попробуйте Elido

URL-сокращатель с хостингом в ЕС: собственные домены, глубокая аналитика, открытый API. Бесплатный тариф - без банковской карты.

Теги
how to shorten a url in go
golang url shortener api
shorten url go net/http
go http client post json
bulk shorten urls golang
errgroup setlimit

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