Сокращение 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: в скрипте это незаметно, но становится дорого, когда восемь горутин обращаются к одному хосту.
Повторяйте запросы при 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 сообщит об этом.
Перед публикацией учтите ещё одно поведение: errgroup.WithContext отменяет общий контекст сразу после того, как любая горутина возвращает ошибку, поэтому первая серьёзная ошибка останавливает остальные запросы пакета. Это нужно для шага сборки, который должен быть выполнен целиком или не выполнен вообще. Для импортера, который должен сократить всё возможное и сообщить об ошибках в конце, собирайте ошибки в срез, возвращайте nil из каждой горутины и используйте группу только как ограничитель конкурентности.
Типизированный клиент или обычный net/http
Для одного endpoint-а приведённый выше код и есть вся интеграция, и зависимость ничего не даёт. Расклад меняется, когда вы начинаете получать список ссылок с пагинацией, фильтровать их по тегу, читать число кликов и обрабатывать полдюжины форматов ответа. Тогда сгенерированные модели и клиент, уже знающий правила повторов, экономят больше, чем стоят. На странице API и SDK есть актуальный список, а в кратком руководстве по SDK показана типизированная версия этого вызова.
В любом случае правила одинаковы: ключ из окружения, дедлайн для каждого запроса, проверка кода статуса до чтения тела, стабильный ключ идемпотентности для всего, что может выполняться дважды, и ограничение конкурентности. Когда ссылки создаются сервисом, а не с ноутбука, вебхуки для событий ссылок лучше опроса для понимания того, что с ними произошло, а в статье что платформа предоставляет разработчикам описаны остальные возможности.
Читайте основную серию
Эта статья относится к кластеру engineering. Начните с руководства по бесплатному API сокращателя URL, чтобы разобраться с форматом endpoint-а и аутентификацией, а затем прочитайте материал об ограничениях частоты и идемпотентности, посвящённый поведению под нагрузкой. Актуальный справочник находится в документации API, а в статье сокращатели URL для разработчиков разобрано, что проверить в API до начала разработки на его основе.
Другие статьи в блоге
- Как сократить URL в JavaScript с fetch и Node
- Как сократить URL в Python с библиотекой requests
- Как сократить URL в PHP с curl, Guzzle или WordPress
- Ограничения частоты и идемпотентность API сокращателя URL
- Как сократить URL в Ruby с Net::HTTP и Faraday
- Как сократить URL в Java со встроенным HttpClient
Частые вопросы
Как сократить 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 в день