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-тілом. Серіалізуйте призначення, надішліть його до ендпойнта посилань сервісу скорочення за допомогою net/http, передайте ваш API-ключ як Bearer-токен і декодуйте short_url з відповіді. Стандартна бібліотека робить усе це, а єдина залежність, яку варто додати згодом, - errgroup для масової роботи.

Результати пошуку на цю тему переповнені проєктами «створіть власний сервіс скорочення на Go», але це інша вправа: ті проєкти зберігають посилання, а цей приклад викликає сервіс, який уже це робить. Якщо вам потрібна частина зі зберіганням, у статті як створити сервіс скорочення URL розглянуто проєктні рішення. Якщо вам потрібне коротке посилання протягом наступних п'яти хвилин, читайте далі.

Це матеріал про Go у серії, яка також охоплює Python, JavaScript та PHP. Форму ендпойнта, модель автентифікації та обмеження безкоштовного плану, припущені тут, задокументовано в огляді безкоштовного 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 до ендпойнта посилань сервісу скорочення; API повертає HTTP 201 із short_url, а шлях повторної спроби застосовує затримку для 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 означає, що затримку можна перервати. Саме це відрізняє чисте завершення від SIGKILL. У детальному розборі обмежень швидкості та ідемпотентності описано семантику заголовків і пояснено, чому сліпі повторні спроби перетворюють один збій на два.

Хочете виконати це проти робочого ендпойнта? Створіть ключ на безкоштовному плані, експортуйте ELIDO_API_KEY, і кожен наведений фрагмент скомпілюється та запуститься як є.

Масове скорочення за допомогою 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 всередині циклу зник. М'ютекс усе ще потрібен: мапа, у яку одночасно записують кілька горутин без нього, має стан перегонів, і go test -race це покаже.

Одна горутина для кожного URL перевантажує API сервісу скорочення запитами з обмеженням швидкості; поруч errgroup із SetLimit підтримує обмежену кількість одночасних запитів масового скорочення

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

Типізований клієнт чи звичайний net/http

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

У будь-якому разі звички однакові: ключ зі змінної середовища, дедлайн у кожному запиті, перевірка коду стану перед тілом, стабільний ключ ідемпотентності для всього, що може виконатися двічі, та верхня межа паралельності. Коли посилання створюються вже сервісом, а не ноутбуком, вебхуки для подій посилань кращі за опитування, якщо потрібно дізнатися, що з ними сталося, а можливості платформи для розробників охоплює решту поверхні.

Ознайомтеся з основною серією

Цей матеріал входить до інженерного кластера. Почніть із посібника з безкоштовного API сервісу скорочення URL, щоб розібратися з формою ендпойнта й автентифікацією, а потім прочитайте матеріал про обмеження швидкості та ідемпотентність, присвячений поведінці під навантаженням. Актуальний довідник - це документація API, а в матеріалі сервіси скорочення URL для розробників описано, що перевірити в API, перш ніж будувати на ньому.

Пов'язані статті в блозі

Поширені запитання

Як скоротити URL у Go?

Серіалізуйте довгий URL у JSON-тіло, надішліть його методом POST до ендпойнта посилань сервісу скорочення за допомогою net/http, передайте ваш API-ключ у заголовку Bearer і декодуйте short_url з відповіді. Стандартна бібліотека охоплює все необхідне, тому вся функція займає близько двадцяти рядків і не потребує сторонніх залежностей.

Чи потрібна бібліотека, щоб скорочувати URL у Go?

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

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

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

Як паралельно скоротити багато URL у Go?

Використовуйте errgroup із SetLimit, щоб у польоті перебувала лише фіксована кількість запитів, замість запуску окремої горутини для кожного URL і потрапляння під обмеження швидкості. Захистіть мапу результатів м'ютексом і надсилайте стабільний Idempotency-Key для кожного URL, щоб повторні спроби ніколи не створювали дублікати посилань.

Чому мій HTTP-клієнт Go не використовує повторно з'єднання?

Зазвичай тому, що тіло відповіді повністю не прочитане перед закриттям, тому з'єднання не може повернутися до пулу простою. Вичитайте його за допомогою io.Copy(io.Discard, resp.Body) перед Close. Інша поширена причина - стандартне значення транспортного рівня: два простоюючі з'єднання на хост, що мало для паралельного пакетного завдання.

Чому мій Go-запит до сервісу скорочення URL повертає 401?

Заголовок Authorization відсутній, містить помилку в назві або порожню змінну середовища. Виведіть os.Getenv перед запитом, щоб переконатися, що ключ завантажено, і перевірте, що в заголовку є 'Bearer ' із пробілом після нього перед ключем. Відповідь 403 натомість означає, що ключ дійсний, але не має потрібної області доступу для цього ендпойнта.

Спробуйте 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

Читати далі