7 min czytaniaInżynieria

Jak skrócić URL w Go za pomocą net/http i errgroup

Skróć URL w Go za pomocą net/http i treści JSON, a następnie dodaj termin wykonania kontekstu, ponowienia z Idempotency-Key oraz ograniczone masowe skracanie za pomocą errgroup.

Marius Voß
DevRel · edge infra
Jak skrócić URL w Go: żądanie POST net/http wysyłające docelowy URL do API skracacza i dekodujące krótki link, z terminem wykonania kontekstu i ograniczonym rozgałęzieniem errgroup

Skrócenie URL-a w Go to jedno żądanie POST z treścią JSON. Zserializuj miejsce docelowe, wyślij je do endpointu linków skracacza za pomocą net/http, przekaż klucz API jako token Bearer i zdekoduj short_url z odpowiedzi. Biblioteka standardowa robi to wszystko, a jedyną zależnością wartą dodania później jest errgroup do pracy masowej.

Wyniki wyszukiwania dla tego tematu zdominowały projekty "zbuduj własny skracacz w Go", ale to inne ćwiczenie: tamte projekty przechowują linki, a ten artykuł wywołuje usługę, która już to robi. Jeśli interesuje Cię warstwa przechowywania, artykuł jak zbudować skracacz URL omawia decyzje projektowe. Jeśli chcesz mieć krótki link w ciągu pięciu minut, czytaj dalej.

To artykuł o Go w serii, która obejmuje też Python, JavaScript i PHP. Kształt endpointu, model uwierzytelniania i przyjęte tu limity darmowego planu opisano w przeglądzie bezpłatnego API skracacza URL.

Najszybszy sposób: jedno żądanie POST z net/http

Dwie struktury, jedno żądanie, jedno dekodowanie:

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
}

Uruchom go run main.go po wyeksportowaniu ELIDO_API_KEY, a otrzymasz krótki link. Paniki są dopuszczalne w programie mającym piętnaście wierszy, ale wszędzie indziej są błędem; w następnej sekcji zamienimy to na funkcję zwracającą błędy.

Warto zachować wiersz client := &http.Client{Timeout: ...}. http.DefaultClient nie ma limitu czasu, więc http.Post do nieodpowiadającego hosta blokuje tę goroutine, dopóki połączenie samo nie umrze, co przy kiepskiej sieci może potrwać kilka minut.

Nadaj każdemu żądaniu termin wykonania i współdziel jeden klient

Kształt produkcyjny: klient na poziomie pakietu, kontekst przy każdym żądaniu i błędy zamiast panik.

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
}

Trzy szczegóły zasługują na uwagę. Ponowne używanie jednego http.Client utrzymuje pulę połączeń między wywołaniami; tworzenie klienta dla każdego żądania wyrzuca wszystkie połączenia keep-alive. Opróżnienie treści przed zamknięciem faktycznie pozwala połączeniu wrócić do puli i zwykle właśnie dlatego zadanie masowe otwiera znacznie więcej gniazd, niż powinno. A context przy żądaniu oznacza, że wywołujący, który rezygnuje - anulowana aplikacja CLI albo żądanie użytkownika, który się rozłączył - zatrzymuje wywołanie HTTP zamiast pozostawiać je w toku.

Komentarz przy MaxIdleConnsPerHost jest uzasadniony. Wartość domyślna to 2, co jest niewidoczne w skrypcie i kosztowne, gdy osiem goroutine uderza w tego samego hosta.

Żądanie POST Go net/http z terminem wykonania kontekstu, tokenem Bearer i Idempotency-Key do endpointu linków skracacza, API zwracające HTTP 201 z short_url oraz ścieżka ponowień z wycofywaniem dla 429 i 5xx, respektująca anulowanie kontekstu

Ponawiaj po 429 i 5xx bez tworzenia duplikatów

Istnieje tryb awarii, który zwykła pętla ponowień pogarsza. Żądanie POST dociera do API, link zostaje utworzony, a odpowiedź gubi się po drodze powrotnej. Kod widzi limit czasu, ponawia żądanie i teraz dwa krótkie linki wskazują jedno miejsce docelowe. Idempotency-Key zamyka tę lukę: ten sam klucz oznacza to samo logiczne żądanie, a API zwraca pierwotny link zamiast tworzyć kolejny.

Wyprowadź klucz z miejsca docelowego, a pozostanie stabilny między ponowieniami i kolejnymi uruchomieniami tej samej partii.

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

Zwróć uwagę na select zamiast time.Sleep. Sen ignoruje anulowanie, więc zadanie, któremu kazano się zamknąć, nadal czeka cztery sekundy na każdy URL. Oczekiwanie jednocześnie na ctx.Done() i time.After oznacza, że wycofywanie jest przerywalne, co odróżnia czyste zamknięcie od SIGKILL. Szczegółowy artykuł o limitach szybkości i idempotentności omawia semantykę nagłówków oraz to, dlaczego bezmyślne ponowienia zamieniają jedną awarię w dwie.

Chcesz uruchomić to przeciwko działającemu endpointowi? Utwórz klucz w darmowym planie, wyeksportuj ELIDO_API_KEY, a każdy fragment tutaj skompiluje się i uruchomi dokładnie tak, jak został napisany.

Skracaj masowo za pomocą errgroup i SetLimit

Naiwna wersja współbieżna uruchamia jedną goroutine na każdy URL. Przy 5000 URL-i oznacza to 5000 równoczesnych żądań, a API odpowiada na większość z nich kodem 429. errgroup wraz z SetLimit daje rozgałęzienie z limitem:

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()
}

W Go 1.22 i nowszych zmienna pętli jest przypisywana osobno dla każdej iteracji, więc stary wiersz u := u wewnątrz pętli zniknął. Mutex nadal jest wymagany - mapa zapisywana z kilku goroutine bez niego zawiera wyścig danych, a go test -race to zgłosi.

Jedna goroutine na każdy URL zalewająca API skracacza żądaniami ograniczanymi przez limit szybkości w porównaniu z errgroup używającym SetLimit do utrzymania ograniczonej liczby żądań masowego skracania w toku

Przed wdrożeniem musisz znać jeszcze jedno zachowanie: errgroup.WithContext anuluje współdzielony kontekst natychmiast, gdy którakolwiek goroutine zwróci błąd, więc pierwsza poważna awaria zatrzymuje resztę partii. Tego właśnie chcesz w kroku budowania, który musi być wykonany w całości albo wcale. W przypadku importera, który powinien skrócić wszystko, co się da, i zgłosić awarie na końcu, zbierz błędy w wycinku, zwracaj nil z każdej goroutine i pozostaw grupę wyłącznie jako ogranicznik współbieżności.

Klient typowany czy czysty net/http

Dla jednego endpointu powyższy kod wyczerpuje całą integrację, a zależność niczego nie wnosi. Rachunek zmienia się, gdy zaczynasz wyświetlać listę linków z paginacją, filtrować po tagu, odczytywać sumy kliknięć i obsługiwać pół tuzina kształtów odpowiedzi: wtedy wygenerowane modele i klient, który zna już reguły ponowień, oszczędzają więcej, niż kosztują. Strona API i SDK zawiera aktualną listę, a szybki start SDK pokazuje typowaną wersję tego samego wywołania.

Tak czy inaczej nawyki są identyczne: klucz ze środowiska, termin wykonania przy każdym żądaniu, sprawdzenie kodu stanu przed treścią, stabilny klucz idempotentności dla wszystkiego, co może uruchomić się dwa razy, oraz limit współbieżności. Gdy linki są już tworzone z usługi, a nie z laptopa, webhooki dla zdarzeń linków są lepsze od odpytywania do sprawdzania, co się z nimi stało, a to, co platforma udostępnia deweloperom obejmuje resztę powierzchni.

Przeczytaj serię główną

Ten artykuł należy do klastra engineering. Zacznij od przewodnika po bezpłatnym API skracacza URL, który omawia kształt endpointu i uwierzytelnianie, a następnie przeczytaj artykuł o limitach szybkości i idempotentności, poświęcony zachowaniu przy obciążeniu. Aktualną referencją są dokumenty API, a artykuł Skracacze URL dla deweloperów opisuje, co sprawdzić w API, zanim zaczniesz na nim budować.

Powiązane na blogu

Najczęściej zadawane pytania

Jak skrócić URL w Go?

Zserializuj długi URL do treści JSON, wyślij go metodą POST do endpointu linków skracacza za pomocą net/http, ustaw klucz API jako nagłówek Bearer i zdekoduj short_url z odpowiedzi. Biblioteka standardowa wystarcza do całości, więc cała funkcja ma około dwudziestu wierszy i nie wymaga zależności zewnętrznej.

Czy potrzebuję biblioteki do skracania URL-i w Go?

Nie. net/http i encoding/json wystarczą do pojedynczego żądania POST, a jedynym zewnętrznym pakietem wartym dodania jest golang.org/x/sync/errgroup do ograniczania współbieżności w zadaniach masowych. Wygenerowany SDK opłaca się, gdy używasz wielu endpointów i chcesz mieć typowane modele oraz paginację zamiast jednego wywołania.

Jak ustawić limit czasu żądania HTTP w Go?

Ustaw Timeout we własnym http.Client i twórz żądania za pomocą http.NewRequestWithContext oraz context.WithTimeout. http.DefaultClient w ogóle nie ma limitu czasu, więc zawieszony serwer zablokuje goroutine na zawsze. Limit czasu klienta obejmuje całą wymianę; kontekst pozwala też wywołującemu anulować ją wcześniej.

Jak skrócić wiele URL-i współbieżnie w Go?

Użyj errgroup z SetLimit, aby tylko stała liczba żądań była jednocześnie w toku, zamiast uruchamiać jedną goroutine na każdy URL i doprowadzać do ograniczania przez limit szybkości. Zabezpiecz mapę wyników mutexem i wysyłaj stabilny Idempotency-Key dla każdego URL-a, aby ponowienia nigdy nie tworzyły duplikatów linków.

Dlaczego mój klient HTTP w Go nie ponownie wykorzystuje połączeń?

Zwykle dlatego, że treść odpowiedzi nie jest w pełni odczytywana przed jej zamknięciem, więc połączenie nie może wrócić do puli bezczynnej. Opróżnij ją za pomocą io.Copy(io.Discard, resp.Body) przed Close. Inną częstą przyczyną jest domyślna wartość transportu wynosząca dwa bezczynne połączenia na hosta, co jest małą liczbą dla współbieżnego zadania masowego.

Dlaczego moje żądanie Go do skracacza URL zwraca 401?

Brakuje nagłówka Authorization, jest on zapisany z błędem albo zawiera pustą zmienną środowiskową. Wyświetl os.Getenv przed żądaniem, aby potwierdzić, że klucz został wczytany, i sprawdź, czy nagłówek ma postać 'Bearer ' ze spacją na końcu przed kluczem. Kod 403 oznacza natomiast, że klucz jest prawidłowy, ale nie ma zakresu wymaganego przez ten endpoint.

Wypróbuj Elido

Wklej URL, otrzymaj krótki link

Bez rejestracji. Link działa 30 dni. Zarejestruj się, aby zachować go na zawsze.

Za darmo, bez rejestracji · 2 dziennie

Wypróbuj Elido

Skracarka URL hostowana w UE: własne domeny, głęboka analityka i otwarte API. Darmowy plan - bez karty kredytowej.

Tagi
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

Czytaj dalej