GoでURLを短縮する処理は、JSONボディを使った1回のPOSTです。遷移先をマーシャリングし、net/httpで短縮サービスのlinksエンドポイントに送り、APIキーをBearerトークンとして渡し、レスポンスからshort_urlをデコードします。標準ライブラリだけで全てを処理でき、後から追加する価値がある依存関係は一括処理用のerrgroupだけです。
このテーマの検索結果は「Goで自分の短縮サービスを作る」プロジェクトが大半ですが、それは別の作業です。それらはリンクを保存し、この記事のコードはすでに存在するサービスを呼び出します。保存側が必要なら、URL短縮サービスの作り方で設計上の判断を説明しています。5分以内にショートリンクが必要なら、このまま読み進めてください。
この記事は、Python、JavaScript、PHPも扱うシリーズの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つのゴルーチンが同じホストを叩くとコストになります。
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で応答します。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で検出されます。
リリース前に知っておきたい動作が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件