👽

Fiber + Redis で URL Shortenerを実装し、仕組みを理解する

に公開

はじめに

今回は、Go言語のFiberフレームワークを使用して、URL Shortenerを実装し、PostmanでAPIテストするところまでをまとめていこうと思います。
このURL Shortenerは、ユーザーが入力したURLを短縮し、その短縮URLを使用して元のURLにリダイレクトできるシステムです。
また、実際のコードを抜粋しながら、各部分の重要なロジックを丁寧に解説していきます。

📌 本記事で解説する内容

  • Fiber を使用した URL Shortener API の実装
  • Redis を使用した短縮URLと元のURLの保存方法
  • レートリミットの設定 で不正アクセスを制御
  • Postman を使った API テストでの動作確認

使用技術

  • Fiber
    • Go言語のフレームワーク
    • Goバージョン:1.24.2
  • Redis
    • URL保存
    • レートリミット
  • Postman
    • APIテストツール
  • Docker
    • Go + Redis の環境構築

本記事では、Dockerでの環境構築は省略しますが、GitHubリポジトリを公開しているので、セットアップする際はそちらをご覧ください。

https://github.com/anton-fuji/minilink

Redisについて

URL Shortener では、短縮されたURLと元のURLを即座に紐付け、短縮URLのアクセス時に、瞬時にリダイレクトできることが重要になります。
そのため、高速なデータアクセスが求められます。
Redis は 「インメモリデータベース」 として、すべてのデータをメモリ(RAM)に保存し、高速な読み書きを実現します。

今回、MongoDB ではなく Redis を選んだ理由は以下の通りです。

  • シンプルなKey-Value構造が最適

    • URL Shortenerでは「短縮URL(Key)」と「元のURL(Value)」を保存するだけのシンプルな構造で十分
    • MongoDB は複雑なドキュメント構造を扱う用途に向いており、今回の用途にはオーバースペック
  • レートリミットの管理が容易

    • IPアドレスごとにリクエスト数をカウントし、レートリミットの管理が容易
    • レートリミットカウントとリセットをシンプルに管理可能

Redisの特徴

  • インメモリデータベース
    • データはすべて RAM 上に保存され、ミリ秒単位での読み書きが可能
  • Key-Value ストア(KVS)
    • Key : Value の組み合わせでデータを保存
  • 高いスケーラビリティ
    • クラスタにも対応し、大量アクセスにも対応可能
  • 多用途
    • キャッシュ、セッション管理、レートリミット、ランキングシステムなど様々な用途に利用可能

URL Shortenerの全体の流れ

1. ユーザーがURLを入力し、短縮をリクエスト

  • クライアント(Postman)からAPIに、POST /api/v1/shorten リクエストを送信
  • 送信データには以下が含まれます
    • URL :短縮したい元のURL
    • CustomShort(任意):カスタムの短縮コード(指定されていないと自動生成)
    • Expiry(任意) :URLの有効期限(デフォルトでは24時間)
  • Redis(DB 0)に以下を保存
// Redisの保存例
DB 0:
Key: abc123
Value: https://example.com

2. URL検証と短縮URLの生成

  • バリデーション
    • 入力されたURLが存在する or 正しい形式かをチェック
  • ドメインチェック
    • 短縮URLが自分のサービスドメインと重複しないかチェック
    • 今回は検証なのでDOMAINという環境変数にlocalhost:3000を入れておきます
  • 短縮コードの生成
    • ユーザーが指定のCustomShortを入力(任意)
    • 何も入力がない場合はUUIDを使用しランダムに生成
  • レスポンス
    • 短縮URLをユーザーに返す

3. ユーザーが短縮URLにアクセス

  • クライアントがGET /:url (短縮URL)にアクセス
  • サーバーはRedis(DB 0)から短縮URLに対応する元のURLを検索し、リダイレクト
  • アクセスカウンター
    • Redis(DB 1)でアクセス回数を記録

Redisのデータ構造をざっくりまとめると

  • Key-Value形式でURLを保存
    • DB 0:短縮URL(ショートコード)と元のURLのペア
    • DB 1:レートリミット(IPアドレスごとのカウント)およびアクセスカウンター

DB 0 (短縮URL - 元のURL)

Key Value
abc123 https://example.com
xyz789 https://testfuji.com
custom1 https://yourdomain.com

DB 1 (IPアドレスごとのカウント)

Key Value
192.168.1.1 15 (残りリクエスト数)
counter 12 (アクセス数)

ソースコード

ここからは重要な箇所を抜粋して、各部分を解説していこうと思います。

✅ URL短縮 APIエンドポイント(POST /api/v1/shorten)

  • ユーザーがURLを短縮するリクエストを送信し、短縮URLを生成して返す
routes/shorten.go
func ShortenURL(c *fiber.Ctx) error {
	// JSON パース
	body := new(request)
	if err := c.BodyParser(body); err != nil {
		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "cannot parse JSON"})
	}
  • JSONパース
    • JSON形式のリクエストボディをパースし、request構造体にマッピング
  • リクエストが無効(JSONパースエラー)の場合は、400 Bad Request エラーを返す
    • 空のリクエスト、破損したJSONがそのまま通過しないように制御

✅ レートリミットの設定

  • ユーザーのIPアドレスごとにリクエスト数を制御(Redis DB 1)
routes/shorten.go
quotaStr := os.Getenv("API_QUOTA")
if quotaStr == "" {
	quotaStr = "15"
}
quota, _ := strconv.Atoi(quotaStr)
resetMin := 30
if v := os.Getenv("RATE_LIMIT_RESET"); v != "" {
	if x, err := strconv.Atoi(v); err == nil {
		resetMin = x
	}
}
resetDur := time.Duration(resetMin) * time.Minute

rlDB := databases.CreateClient(1)
defer rlDB.Close()

// IPごとのレートリミットカウント
countStr, err := rlDB.Get(databases.Ctx, c.IP()).Result()
if err == redis.Nil {
	rlDB.Set(databases.Ctx, c.IP(), quota, resetDur)
} else if n, _ := strconv.Atoi(countStr); n <= 0 {
	ttl, _ := rlDB.TTL(databases.Ctx, c.IP()).Result()
	return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{
		"error":            "Rate Limit Exceeded",
		"rate_limit_reset": ttl / time.Second,
	})
}
  • レートリミットの設定
    • API_QUOTA 環境変数でリクエスト上限を設定
    • 悪意のあるユーザーに過剰なリクエストを送信させないようにするため
  • IPアドレスごとのリクエストをDB 1 で管理
    • 各ユーザー(IPアドレスごと)に対し、リクエスト回数のカウントが可能
  • API_QUOTARATE_LIMIT_RESET で動的にリクエスト数とリセット時間を設定可能

✅ URL検証と短縮URLの生成

  • ユーザーの送信したURLが有効かをチェックし、短縮URLを生成
routes/shorten.go
	// URL検証
	if !govalidator.IsURL(body.URL) {
		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid URL"})
	}
	bodyURL := helpers.EnforceHTTP(body.URL)
	if !helpers.RemoveDomainError(bodyURL) {
		return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Disallowed domain"})
	}

	key := body.CustomShort
	if key == "" {
		key = uuid.New().String()[:6]
	}
  • URLバリデーション
    • govalidator でURL形式をチェック
    • helpers.EnforceHTTP で「http://」を強制
    • helpers.RemoveDomainError で自身のドメイン(localhost:3000)を除外
    • これらはhelpersパッケージに関数を作成し、それを呼んでいます
  • 短縮URLの生成
    • CustomShort が指定されていればそれを使用
    • そうでなければUUIDからランダムな6文字を生成

✅ Redis に短縮URLを保存

  • 短縮URLと元のURLをRedis(DB 0)に保存
routes/shorten.go
// すでに存在しないか確認
dataDB := databases.CreateClient(0)
defer dataDB.Close()
if ex, _ := dataDB.Exists(databases.Ctx, key).Result(); ex > 0 {
	return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "Short key already used"})
}

// 有効期限
hours := body.ExpiryHours
if hours <= 0 {
	hours = 24
}
expDur := time.Duration(hours) * time.Hour
dataDB.Set(databases.Ctx, key, bodyURL, expDur
  • 短縮URLの重複チェック
    • すでに同じ短縮コードが存在する場合はエラーを返す
  • URLを保存
    • 短縮コード(Key)と元のURL(Value)を保存
    • 有効期限を指定(デフォルトは24時間)で保存

✅ URLリダイレクト (GET /:url)

  • 短縮URLにアクセスすると、元のURLにリダイレクトさせる
routes/resolve.go
func ResolveURL(c *fiber.Ctx) error {
	ctx := context.Background()
	url := c.Params("url")
	r := databases.CreateClient(0)
	defer r.Close()

	val, err := r.Get(databases.Ctx, url).Result()
	if err == redis.Nil {
		return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "short not found in database"})
	} else if err != nil {
		return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "cannot connect to DB"})
	}

	// アクセスカウンター
	rInr := databases.CreateClient(1)
	defer rInr.Close()
	_, err = rInr.Incr(ctx, "counter").Result()

	return c.Redirect(val, fiber.StatusMovedPermanently)
}
  • 短縮URLで元のURLを検索
    • 元のURLが見つかればリダイレクト(301
    • 見つからなければ404 Not Foundを返す
  • アクセスカウンター
    • DB 1 でcounterをインクリメントし、アクセス数を記録

PostmanでAPIテスト手順

テストには、私が以前Qiitaに投稿した記事を使用します。

1.URLのみ指定

まずは、URLのみの指定でPOSTリクエストを送っていきます。
Postmanで以下の設定を行い、「Send」をクリックしましょう。

下の画像のJSONが得られ、短縮URLにアクセスできたら成功です。

2.カスタムURLを指定

続いて、CustomShortを設定し、反映されるかチェックしていきましょう。
先ほどのJSONの部分を以下に変更して、リクエストを送ってみます。

{
    "url": "https://qiita.com/fujifuji1414/items/359d754f9ab0ad2ccbb7",
    "short": "fuji14"
}

以下の画像のようにCustomShortが作成せれていれば成功です。

ではurl部分を変更し、CustomShortはそのままでエラーがちゃんと出力するか確認していきます。

"error": "Short key already used"」が出力されたので、テスト成功です。

3.有効期限を指定

JSONの項目にexpiryを追加して、リクエストを送っていきます。
expiryは時間していで、以下の例だと「1時間」を指定していることになります。

{
    "url": "https://qiita.com/fujifuji1414/items/359d754f9ab0ad2ccbb7",
    "expiry": 1
}


"expiry": 3600000000000 (msで返ってくる)が反映されているので成功です。

4.無効なURLを指定

以下のJSONを指定してみます。

{
    "url": "javascript:alert('XSS')"
}

画像の通り、バリデーションできているので成功です。

5.レートリミット超過

最後に、同一IPアドレスから連続でリクエストを送信し、レートリミットが超過した時の挙動をチェックしていきます。

{
    "url": "https://qiita.com/fujifuji1414/items/359d754f9ab0ad2ccbb7"
}

こちらのJSONを16回リクエストを送信し、以下を確認できるかテストしていきます。

  • ステータスコード:429 Too Many Requests
  • エラーメッセージ:"Rate Limit Exceeded"
  • rate_limit_reset フィールドが含まれる

以下の画像の通り、エラーレスポンスが返ってきたので、成功です。

以上で、テストは終了です!

まとめ

今回は、Go 言語の Fiber を使用した URL Shortener の実装方法を解説しました。
Redis を使った高速データアクセス、レートリミットの設定、Postman を使った API テストまでをカバーし、シンプルかつ実用的な短縮URLシステムを構築しました。

最後まで読んでいただき、ありがとうございました!

参考

https://docs.gofiber.io/storage/redis/

Discussion