7 Min. LesezeitEngineering

Eine URL in Go mit net/http und errgroup kürzen

Kürzen Sie eine URL in Go mit net/http und einem JSON-Body, fügen Sie dann eine Context-Deadline, Retries mit einem Idempotency-Key und begrenztes Bulk-Kürzen über errgroup hinzu.

Marius Voß
DevRel · edge infra
So kürzen Sie eine URL in Go: Ein net/http-POST sendet eine Ziel-URL an eine URL-Shortener-API und decodiert den Kurzlink, mit einem Context-Deadline und einer begrenzten errgroup-Verteilung

Eine URL in Go zu kürzen ist ein POST mit einem JSON-Body. Serialisieren Sie die Zieladresse, senden Sie sie mit net/http an den Links-Endpunkt des URL-Shorteners, übergeben Sie Ihren API-Key als Bearer-Token und decodieren Sie short_url aus der Antwort. Die Standardbibliothek erledigt all das; die einzige Abhängigkeit, deren Ergänzung sich später lohnt, ist errgroup für Bulk-Aufgaben.

Die Suchergebnisse dazu werden von Projekten dominiert, die "einen eigenen URL-Shortener in Go bauen". Das ist eine andere Aufgabe: Diese Projekte speichern Links, während dieser Beitrag einen Dienst aufruft, der das bereits erledigt. Wenn Sie die Speicherseite suchen, behandelt So bauen Sie einen URL-Shortener die Designentscheidungen. Wenn Sie in den nächsten fünf Minuten einen Kurzlink brauchen, lesen Sie weiter.

Dies ist der Go-Beitrag einer Serie, die auch Python, JavaScript und PHP behandelt. Die hier vorausgesetzte Struktur des Endpunkts, das Auth-Modell und die Limits des kostenlosen Tarifs sind in der Übersicht zur kostenlosen URL-Shortener-API dokumentiert.

Der schnellste Weg: ein POST mit net/http

Zwei Structs, eine Anfrage, eine Decodierung:

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
}

Mit exportiertem ELIDO_API_KEY führen Sie go run main.go aus und haben einen Kurzlink. Die Panics sind in einem Programm mit fünfzehn Zeilen in Ordnung und überall sonst falsch; im nächsten Abschnitt wird daraus eine Funktion, die Fehler zurückgibt.

Die Zeile client := &http.Client{Timeout: ...} sollten Sie beibehalten. http.DefaultClient hat kein Timeout. Daher blockiert http.Post zu einem nicht antwortenden Host die Goroutine, bis die Verbindung von selbst abbricht, was in einem schlechten Netzwerk Minuten dauern kann.

Geben Sie jeder Anfrage eine Deadline und teilen Sie einen Client

Produktionsreife Form: ein Client auf Paketebene, ein Context für jede Anfrage und Fehler statt Panics.

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
}

Drei Details verdienen ihren Platz. Wenn Sie einen http.Client wiederverwenden, bleibt der Verbindungspool über mehrere Aufrufe hinweg aktiv; wenn Sie pro Anfrage einen Client erstellen, verwerfen Sie jedes Keep-Alive. Erst das Leeren des Bodys vor dem Schließen ermöglicht tatsächlich, dass die Verbindung in den Pool zurückkehrt, und das ist meist der Grund, warum ein Batch-Job deutlich mehr Sockets öffnet als nötig. Und context an der Anfrage bedeutet, dass ein Aufrufer, der aufgibt - eine abgebrochene CLI oder eine Anfrage, deren Nutzer die Verbindung getrennt hat -, den HTTP-Aufruf beendet, statt ihn weiterlaufen zu lassen.

MaxIdleConnsPerHost verdient seinen Kommentar. Der Standardwert ist 2, was in einem Skript unsichtbar und teuer ist, sobald acht Goroutines denselben Host ansprechen.

Ein Go-net/http-POST mit Context-Deadline, Bearer-Token und Idempotency-Key an den Links-Endpunkt des URL-Shorteners; die API gibt HTTP 201 mit short_url zurück, und ein Retry-Pfad wartet bei 429 und 5xx unter Beachtung der Context-Abbruchsignale

Retries bei 429 und 5xx, ohne Duplikate zu erzeugen

Es gibt einen Fehlerfall, den eine einfache Retry-Schleife verschlimmert. Der POST erreicht die API, der Link wird erstellt und die Antwort geht auf dem Rückweg verloren. Ihr Code sieht einen Timeout, versucht es erneut und nun zeigen zwei Kurzlinks auf dasselbe Ziel. Ein Idempotency-Key schließt diese Lücke: gleicher Key, gleiche logische Anfrage, und die API gibt den ursprünglichen Link zurück, statt einen weiteren zu erzeugen.

Leiten Sie den Key aus der Zieladresse ab. Dann bleibt er über Retries und erneute Ausführungen desselben Batches hinweg stabil.

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

Beachten Sie das select statt time.Sleep. Schlafen ignoriert den Abbruch, daher wartet ein zum Herunterfahren aufgeforderter Job weiterhin vier Sekunden pro URL. Wenn Sie gleichzeitig auf ctx.Done() und time.After warten, lässt sich das Backoff unterbrechen. Das ist der Unterschied zwischen einem sauberen Herunterfahren und einem SIGKILL. Der Deep-Dive zu Rate Limits und Idempotenz behandelt die Semantik der Header und erklärt, warum blinde Retries aus einem Ausfall zwei machen.

Möchten Sie dies gegen einen Live-Endpunkt ausführen? Erstellen Sie einen Key im kostenlosen Tarif, exportieren Sie ELIDO_API_KEY, und jedes Snippet hier lässt sich genau wie angegeben kompilieren und ausführen.

URLs mit errgroup und SetLimit im Bulk kürzen

Die naive nebenläufige Variante startet eine Goroutine pro URL. Bei 5.000 URLs sind das 5.000 gleichzeitige Anfragen, und die API beantwortet die meisten davon mit 429. errgroup plus SetLimit gibt Ihnen eine Verteilung mit Obergrenze:

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

Ab Go 1.22 ist die Schleifenvariable pro Iteration gültig, daher entfällt die alte Zeile u := u innerhalb der Schleife. Der Mutex ist weiterhin erforderlich: Eine Map, in die mehrere Goroutines ohne Mutex schreiben, enthält eine Data Race, und go test -race wird das melden.

Eine Goroutine pro URL überflutet die URL-Shortener-API mit rate-limitierten Anfragen; dem gegenüber steht eine errgroup mit SetLimit, die eine begrenzte Zahl gleichzeitiger Bulk-Anfragen sicherstellt

Ein Verhalten sollten Sie vor dem Ausrollen kennen: errgroup.WithContext bricht den gemeinsamen Context ab, sobald eine Goroutine einen Fehler zurückgibt. Dadurch stoppt der erste schwerwiegende Fehler den Rest des Batches. Das ist für einen Build-Schritt richtig, der ganz oder gar nicht ausgeführt werden muss. Bei einem Importer, der alles kürzen und die Fehler am Ende melden soll, sammeln Sie die Fehler in einem Slice, geben aus jeder Goroutine nil zurück und verwenden Sie die Group ausschließlich als Begrenzer der Nebenläufigkeit.

Typisierter Client oder rohes net/http

Für einen Endpunkt ist der obige Code die gesamte Integration, und eine Abhängigkeit bringt keinen Vorteil. Das ändert sich, sobald Sie Links mit Paginierung auflisten, nach Tags filtern, Klicksummen lesen und ein halbes Dutzend Antwortformen verarbeiten: Dann sparen generierte Modelle und ein Client, der die Retry-Regeln bereits kennt, mehr Aufwand, als sie kosten. Die Seite API und SDKs enthält die aktuelle Liste, und der SDK-Schnellstart zeigt die typisierte Version desselben Aufrufs.

So oder so sind die Gewohnheiten identisch: Key aus der Umgebung, eine Deadline für jede Anfrage, der Statuscode vor dem Body geprüft, ein stabiler Idempotency-Key für alles, was zweimal laufen kann, und eine Obergrenze für die Nebenläufigkeit. Sobald Links von einem Dienst statt von einem Laptop aus erstellt werden, sind Webhooks für Link-Events besser als Polling, um herauszufinden, was mit ihnen passiert ist, und was die Plattform Entwicklern bietet deckt den Rest der Oberfläche ab.

Die Cornerstone-Serie lesen

Dieser Beitrag gehört zum Engineering-Cluster. Beginnen Sie mit dem Leitfaden zur kostenlosen URL-Shortener-API für Endpunktstruktur und Authentifizierung und lesen Sie anschließend den Beitrag zu Rate Limits und Idempotenz für das Verhalten unter Last. Die aktuelle Referenz sind die API-Dokumente, und URL-Shortener für Entwickler zeigt, was Sie vor der Verwendung einer API prüfen sollten.

Verwandte Beiträge im Blog

Häufig gestellte Fragen

Wie kürze ich eine URL in Go?

Serialisieren Sie die lange URL in einen JSON-Body, senden Sie sie per POST mit net/http an den Links-Endpunkt eines URL-Shorteners, setzen Sie Ihren API-Key als Bearer-Header und decodieren Sie short_url aus der Antwort. Die Standardbibliothek deckt alles ab, daher umfasst die gesamte Funktion ohne Abhängigkeit eines Drittanbieters etwa zwanzig Zeilen.

Brauche ich eine Bibliothek, um URLs in Go zu kürzen?

Nein. net/http und encoding/json reichen für einen einzelnen POST aus. Das einzige externe Paket, dessen Ergänzung sich lohnt, ist golang.org/x/sync/errgroup für begrenzte Nebenläufigkeit in Bulk-Jobs. Ein generiertes SDK zahlt sich aus, wenn Sie viele Endpunkte verwenden und typisierte Modelle sowie Paginierung statt nur eines Aufrufs benötigen.

Wie setze ich ein Timeout für eine Go-HTTP-Anfrage?

Setzen Sie Timeout auf Ihrem eigenen http.Client und erstellen Sie Anfragen mit http.NewRequestWithContext plus context.WithTimeout. http.DefaultClient hat überhaupt kein Timeout, daher blockiert ein hängender Server eine Goroutine für immer. Das Client-Timeout deckt den gesamten Austausch ab; der Context ermöglicht es einem Aufrufer außerdem, frühzeitig abzubrechen.

Wie kürze ich viele URLs gleichzeitig in Go?

Verwenden Sie errgroup mit SetLimit, damit nur eine festgelegte Zahl von Anfragen gleichzeitig läuft, statt eine Goroutine pro URL zu starten und in eine Rate-Begrenzung zu laufen. Schützen Sie die Ergebnismap mit einem Mutex und senden Sie pro URL einen stabilen Idempotency-Key, damit Retries niemals doppelte Links erzeugen.

Warum verwendet mein Go-HTTP-Client Verbindungen nicht wieder?

Meistens wird der Response-Body vor dem Schließen nicht vollständig gelesen, sodass die Verbindung nicht in den Idle-Pool zurückkehren kann. Leeren Sie ihn mit io.Copy(io.Discard, resp.Body), bevor Sie Close aufrufen. Die andere häufige Ursache ist der Transport-Standardwert von zwei inaktiven Verbindungen pro Host, der für einen nebenläufigen Batch-Job niedrig ist.

Warum gibt meine Go-Anfrage an einen URL-Shortener 401 zurück?

Der Authorization-Header fehlt, ist falsch geschrieben oder enthält eine leere Umgebungsvariable. Geben Sie os.Getenv vor der Anfrage aus, um zu bestätigen, dass der Key geladen wurde, und prüfen Sie, dass der Header 'Bearer ' mit dem Leerzeichen vor dem Key lautet. Ein 403 bedeutet dagegen, dass der Key gültig ist, aber der Scope für diesen Endpunkt fehlt.

Elido testen

URL einfügen, kurzer Link in Sekunden

Kein Konto nötig. Link bleibt 30 Tage aktiv. Konto erstellen, um ihn dauerhaft zu behalten.

Kostenlos, keine Anmeldung erforderlich · 2 pro Tag

Elido testen

URL-Shortener mit EU-Hosting: eigene Domains, tiefe Analytik und eine offene API. Kostenloser Tarif - keine Kreditkarte nötig.

Tags
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

Weiterlesen