2分で読了エンジニアリング

Goでnet/httpとerrgroupを使ってURLを短縮する方法

net/httpとJSONボディでGoのURLを短縮し、contextの期限、Idempotency-Key付きのリトライ、errgroupによる上限付き一括短縮を追加します。

Marius Voß
DevRel · edge infra
GoでURLを短縮する方法:net/httpのPOSTで遷移先URLを短縮サービスのAPIに送り、contextの期限と上限付きのerrgroupファンアウトを使ってショートリンクをデコードする図

GoでURLを短縮する処理は、JSONボディを使った1回のPOSTです。遷移先をマーシャリングし、net/httpで短縮サービスのlinksエンドポイントに送り、APIキーをBearerトークンとして渡し、レスポンスからshort_urlをデコードします。標準ライブラリだけで全てを処理でき、後から追加する価値がある依存関係は一括処理用のerrgroupだけです。

このテーマの検索結果は「Goで自分の短縮サービスを作る」プロジェクトが大半ですが、それは別の作業です。それらはリンクを保存し、この記事のコードはすでに存在するサービスを呼び出します。保存側が必要なら、URL短縮サービスの作り方で設計上の判断を説明しています。5分以内にショートリンクが必要なら、このまま読み進めてください。

この記事は、PythonJavaScriptPHPも扱うシリーズのGo編です。ここで前提にしているエンドポイントの形式、認証モデル、無料プランの上限は、無料URL短縮サービスAPIの概要に記載されています。

最速の方法:net/httpで1回のPOST

2つの構造体、1つのリクエスト、1回のデコードです。

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
}

ELIDO_API_KEYをエクスポートした状態でgo run main.goを実行すれば、ショートリンクが得られます。15行程度のプログラムならpanicでも問題ありませんが、それ以外では適切ではありません。次のセクションで、これをエラーを返す関数に変えます。

残しておきたいのはclient := &http.Client{Timeout: ...}の行です。http.DefaultClientにはタイムアウトがないため、応答しないホストに対するhttp.Postは、接続が自然に切れるまでゴルーチンをブロックします。ネットワークの状態が悪いと、数分かかることもあります。

すべてのリクエストに期限を設定し、1つのクライアントを共有する

本番向けの形は、パッケージレベルのクライアント、すべてのリクエストに付けるcontext、panicの代わりに返すエラーです。

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
}

3つの詳細に意味があります。1つのhttp.Clientを再利用すると呼び出し間で接続プールを維持できます。リクエストごとにクライアントを作成すると、すべてのKeep-Aliveを破棄してしまいます。ボディを閉じる前に読み切ることで、接続を実際にプールへ戻せます。バッチジョブが必要以上に多くのソケットへ接続する主な理由はこれです。そしてリクエストにcontextを付けると、キャンセルされたCLIやユーザーが切断したリクエストのように、呼び出し側が諦めたときにHTTP呼び出しを実行したままにせず停止できます。

MaxIdleConnsPerHostのコメントには理由があります。デフォルトは2で、スクリプトでは気になりませんが、8つのゴルーチンが同じホストを叩くとコストになります。

Goのnet/http POSTがcontextの期限、Bearerトークン、Idempotency-Keyを短縮サービスのlinksエンドポイントへ送り、APIがshort_urlを含むHTTP 201を返し、contextのキャンセルを尊重しながら429と5xxでバックオフするリトライ経路を示す図

429と5xxをリトライし、重複を作らない

単純なリトライループによって悪化する失敗パターンがあります。POSTがAPIに届いてリンクは作成されたものの、戻りの途中でレスポンスが失われる場合です。コードはタイムアウトと判断してリトライし、同じ遷移先を指すショートリンクが2つできてしまいます。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)
}

time.Sleepではなくselectを使っている点に注目してください。Sleepはキャンセルを無視するため、停止を指示されたジョブがURLごとに4秒間その場で待ち続けます。ctx.Done()time.Afterを同時に待つとバックオフを中断でき、きれいなシャットダウンとSIGKILLの違いになります。レート制限とべき等性の詳しい解説では、ヘッダーの意味と、無条件のリトライが1つの障害を2つに変える理由を説明しています。

ライブのエンドポイントに対して実行してみませんか?無料プランでキーを作成し、ELIDO_API_KEYをエクスポートしてください。ここにあるすべてのスニペットは、そのままコンパイルして実行できます。

errgroupとSetLimitで一括短縮する

単純な並行版はURLごとに1つのゴルーチンを起動します。5,000個のURLなら同時に5,000件のリクエストが発生し、APIはその大半に429で応答します。errgroupSetLimitを組み合わせると、上限付きのファンアウトになります。

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ごとに1つのゴルーチンを起動してレート制限されたリクエストで短縮サービスAPIをあふれさせる方法と、SetLimit付きのerrgroupで一括短縮リクエストの実行数に上限を設ける方法の比較図

リリース前に知っておきたい動作が1つあります。errgroup.WithContextは、いずれかのゴルーチンがエラーを返すと共有contextをすぐにキャンセルするため、最初の重大な失敗でバッチの残りが停止します。全て成功するか全て失敗する必要があるビルドステップには、これが適しています。できる限り全てを短縮し、最後に失敗を報告するインポーターでは、エラーをスライスに集め、各ゴルーチンからnilを返し、グループを並行数の制限だけに使います。

型付きクライアントと生のnet/http

1つのエンドポイントなら、上のコードで統合全体が完結するため、依存関係を追加しても得るものはありません。ページネーション付きでリンクを一覧表示したり、タグで絞り込んだり、クリック合計を読んだり、半ダースほどのレスポンス形式を扱ったりし始めると事情が変わります。その段階では、リトライルールをすでに理解している生成済みモデルとクライアントが、コスト以上の価値をもたらします。APIとSDKページに現在の一覧があり、SDKクイックスタートではこの呼び出しの型付き版を紹介しています。

どちらを選んでも習慣は同じです。環境変数からキーを読み、すべてのリクエストに期限を設定し、ボディの前にステータスコードを確認し、2回実行される可能性のある処理には安定したべき等性キーを付け、並行数に上限を設けます。ノートパソコンではなくサービスからリンクを作成するようになったら、何が起きたかを知るにはポーリングよりリンクイベント用Webhookが適しており、残りの機能についてはプラットフォームが開発者に公開しているもので確認できます。

コーナーストーンシリーズを読む

この記事はエンジニアリングクラスターに属します。まず無料URL短縮サービスAPIガイドでエンドポイントの形式と認証を確認し、次にレート制限とべき等性の記事で負荷時の動作を確認してください。最新のリファレンスはAPIドキュメントにあり、開発者向けURL短縮サービスでは、APIを基盤にする前に確認すべき点を説明しています。

ブログの関連記事

よくある質問

GoでURLを短縮するにはどうすればよいですか?

長いURLをJSONボディにマーシャリングし、net/httpで短縮サービスのlinksエンドポイントにPOSTし、APIキーをBearerヘッダーとして設定して、レスポンスからshort_urlをデコードします。標準ライブラリだけで全てを処理できるため、サードパーティ依存なしで関数全体を約20行に収められます。

GoでURLを短縮するためにライブラリは必要ですか?

いいえ。1回のPOSTにはnet/httpとencoding/jsonで十分です。一括ジョブで並行数を制限する場合に追加する価値がある外部パッケージは、golang.org/x/sync/errgroupだけです。1回の呼び出しではなく多くのエンドポイントを使い、型付きモデルやページネーションが必要になったら、生成済みSDKが役立ちます。

GoのHTTPリクエストにタイムアウトを設定するにはどうすればよいですか?

独自のhttp.ClientにTimeoutを設定し、context.WithTimeoutとhttp.NewRequestWithContextでリクエストを作成します。http.DefaultClientにはタイムアウトが全くないため、応答しないサーバーによってゴルーチンが永久にブロックされる可能性があります。クライアントのタイムアウトは通信全体を対象にし、contextを使えば呼び出し側が早期にキャンセルすることもできます。

Goで多数のURLを並行して短縮するにはどうすればよいですか?

URLごとにゴルーチンを起動してレート制限を受けるのではなく、SetLimit付きのerrgroupを使って、実行中のリクエスト数を固定します。結果のマップはミューテックスで保護し、URLごとに安定したIdempotency-Keyを送ることで、リトライによる重複リンクの作成を防ぎます。

GoのHTTPクライアントが接続を再利用しないのはなぜですか?

通常は、レスポンスボディを閉じる前に最後まで読み取っていないため、接続をアイドルプールに戻せないことが原因です。Closeの前にio.Copy(io.Discard, resp.Body)で読み切ってください。もう1つのよくある原因は、ホストごとのアイドル接続数が2つというトランスポートのデフォルト値です。並行バッチジョブには少なすぎます。

GoからURL短縮サービスへ送ったリクエストが401を返すのはなぜですか?

Authorizationヘッダーがない、スペルを間違えている、または空の環境変数を保持しています。リクエストの前にos.Getenvを出力してキーが読み込まれていることを確認し、ヘッダーがキーの前に末尾のスペースを含む'Bearer 'になっていることを確認してください。403の場合はキー自体は有効ですが、そのエンドポイントのスコープがありません。

Elidoを試す

URLを貼り付けて短縮リンクを取得

登録不要。リンクは30日間有効。永久に保存するには登録してください。

Free、登録不要 · 1日あたり2件

Elidoを試す

EUホスティングの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

続きを読む