UPSIDER Tech Blog

Cloudflare Workers + Honoで作るシンプルなURL短縮サービス

支払い.com事業本部でモバイルアプリエンジニアをしているTominagaです。

なぜCloudflare Workersを選定したのか

技術選定の背景

私たちのチームではCloudflareとGoogle Cloudを中心に技術スタックを構築しています。URL短縮サービスの実装にあたり、以下の理由からCloudflare Workersを選定しました。

既存の技術スタックとの親和性:

  • すでにCloudflareをCDNやDNS管理で活用しており、チーム内に運用知見が蓄積されていた
  • 追加のインフラ導入なしで既存のCloudflare環境内で完結できる

Honoフレームワークへの技術的関心:

  • Honoは軽量で高速なWebフレームワークで、Cloudflare Workersを含む様々なJSランタイムをサポート
  • Express.jsライクな書き方でエッジコンピューティングを実装できる
  • 軽量かつ高速で、Workers の制約(1MBのコードサイズ制限など)にも適している
  • TypeScriptファーストな設計で、型安全な開発が可能

Cloudflare Workersの技術的メリット:

  • 🚀 世界275都市でレスポンス50ms以下 - エッジコンピューティングの威力
  • 💰 月間10万リクエストまで無料 - スモールスタートに最適
  • 🛡️ DDoS攻撃対策込み - Cloudflareの保護機能を自動で利用
  • コールドスタート0ms - サーバーレス環境での常時高速レスポンス
  • 📊 KVストアで簡単にデータ永続化 - DBサーバー不要

実際に社内ツールとして運用してみた結果、選定は正解だったと感じています。特にHonoの開発体験の良さと、Cloudflareのグローバルなインフラを活用できる点が大きなメリットとなりました。

Honoを採用した具体的な理由

Honoは本プロジェクトで以下の点で価値を発揮しました:

優れた開発体験:

  • Zodとの統合により、リクエストバリデーションが簡潔に記述できる(zValidatorミドルウェア)
  • Express.jsライクなAPIで学習コストが低く、チームメンバーがすぐに開発に参加できた
  • TypeScriptの型推論が優秀で、環境変数やコンテキストの型が自動的に解決される

パフォーマンス面の恩恵:

  • 軽量設計(約45KB)でWorkers の1MB制限に余裕を持って収まる
  • ルーティングエンジンが高速で、複数エンドポイントでも低レイテンシを維持
  • 不要な機能がないため、バンドルサイズを最小限に抑えられる

実装の柔軟性:

  • ミドルウェアの組み合わせでCORS、認証、ロギングなどを簡単に追加できた
  • カスタムコンテキストを定義して、KVへのアクセスを型安全に実装できた

パフォーマンス特性

📊 理論値と実測値:

  • P50レスポンスタイム: 20-30ms
  • P99レスポンスタイム: 50ms以下
  • 同時接続数: 無制限(Cloudflareが自動スケール)
  • 月間コスト: $0(10万リクエスト以下)

他アーキテクチャとの比較

アーキテクチャ レスポンス時間 月額費用(10万req) グローバル配信 運用負荷
Workers + KV 20-30ms $0 ✅ 275都市
AWS Lambda + DynamoDB 100-300ms $5-10 ❌ リージョン単位
Cloud Run Functions + Firestore 100-200ms $5-8 ❌ リージョン単位
Cloud Run + Spanner 50-100ms $20+ ✅ マルチリージョン
EC2 + Redis 10-50ms $30+ ❌ 単一リージョン
Vercel Edge 50ms $20 ✅ 23都市

API機能とエンドポイント一覧

本サービスは以下の機能を提供します:

エンドポイント概要

エンドポイント メソッド 説明
/:key GET 短縮URLのリダイレクト
/api/shorten POST URL短縮キーの作成
/api/shorten GET URL一覧の取得(ページネーション対応)
/api/shorten/:key GET 特定URLの詳細取得
/api/shorten/:key DELETE URL短縮キーの削除

技術スタック

主要技術

  • Cloudflare Workers: エッジで動作するサーバーレス実行環境
  • Hono: 高速で軽量な Web フレームワーク (v4.6.15)
  • TypeScript: 型安全な開発環境
  • Cloudflare KV: キーバリューストア(URL マッピングとログ保存用)

開発ツール

  • Wrangler: Cloudflare Workers の CLI ツール
  • Zod: スキーマバリデーション
  • Jest + Miniflare: ローカルテスト環境
  • ESLint + Prettier: コード品質管理

アーキテクチャ

システム構成

データモデル

URL_SHORTENER (メインストア)

{
  key: string,              // 短縮キー (例: "98w2aeok")
  value: {
    url: string,            // リダイレクト先 URL
    expireAt?: string,      // 有効期限 (ISO8601)
    createdBy: string,      // 作成者メールアドレス
    createdAt: string       // 作成日時 (ISO8601)
  }
}

URL_SHORTENER_LOGS (ログストア)

{
  key: string,              // 短縮キーと同じ
  value: {
    pv: number,             // ページビュー数
    lastAccessedAt: Date    // 最終アクセス日時
  }
}

社内での活用事例

採用要件と選定理由

URL短縮サービスの導入にあたり、以下の要件を設定しました:

必須要件:

  • 社内で使用している技術スタックとの親和性が高いこと
  • 低レイテンシ(50ms以下)でグローバルにサービスを提供できること
  • 月間10万リクエスト程度の規模で運用コストが低いこと
  • 有効期限設定とアクセスログ機能を備えること

パフォーマンス要件:

  • P99レスポンスタイムが50ms以下
  • 同時アクセスに対するスケーラビリティ
  • コールドスタートによる遅延がないこと

運用要件:

  • サーバー管理が不要であること
  • デプロイが簡単で、CI/CDに組み込みやすいこと
  • チーム内に既存の運用知見があること

Cloudflare Workers + Honoの組み合わせは、これらすべての要件を満たし、特にパフォーマンス面では期待を上回る結果となりました。既存のCloudflare環境内で完結できるため、新たなインフラ学習コストも発生しませんでした。

実際の用途

  • Slack連携: 長いドキュメントURLを自動短縮してチャンネルに投稿
  • QRコード連携: 社内イベントでの資料配布
  • 期限付きリンク: 一時的な共有用途(採用面接資料など)
  • アクセス解析: どの資料がよく参照されているかの可視化

運用実績

  • 平均短縮率: 85%(100文字以上のURLを15文字に)
  • 月間生成URL数: 500〜1,000件
  • アクティブな短縮URL数: 約3,000件
  • 最も使われる機能: 有効期限設定(全体の60%)

主要機能

1. URL 短縮キー生成

export const generateKey = (charCount = 8): string => {
  const str = Math.random().toString(36).substring(2).slice(-charCount)
  return str.length < charCount ? str + 'a'.repeat(charCount - str.length) : str
}

特徴:

  • Base36 (0-9, a-z) でランダムな 8 文字のキーを生成
  • 衝突時は自動的に桁数を増やして再生成
  • パディング処理で文字数を保証

2. リダイレクト処理

エンドポイント: GET /:key

app.get('*', async (c) => {
  const url = new URL(c.req.url)
  const key = url.pathname.slice(1)
  const json = await c.env.URL_SHORTENER.get(key)

  if (json == null) {
    return c.redirect(c.env.NOT_FOUND_REDIRECT_URL, 302)
  }

  const { expireAt, url: redirectUrl } = JSON.parse(json ?? '') as {
    expireAt?: string
    url: string
  }

  // 有効期限チェック
  if (!redirectUrl || (expireAt && new Date(expireAt) < new Date())) {
    return c.redirect(c.env.NOT_FOUND_REDIRECT_URL, 302)
  }

  // アクセスログ更新
  const log = await getLog({ kv: c.env.URL_SHORTENER_LOGS, key })
  await updateLog({
    kv: c.env.URL_SHORTENER_LOGS,
    key,
    log: {
      pv: log.pv + 1,
      lastAccessedAt: new Date(),
    },
  })

  return c.redirect(redirectUrl, 302)
})

処理フロー:

  1. URL から短縮キーを抽出
  2. KV から対応する URL を取得
  3. 存在チェック・有効期限チェック
  4. アクセスログを更新(PV カウント)
  5. 302 リダイレクトを返却

3. URL 登録 API

エンドポイント: POST /api/shorten

curl -X POST -H "Content-Type: application/json" \\
  -d '{"url":"<https://example.com>", "expireAt": "2025-05-04T21:35:20.333Z", "email": "example@upsider.com"}' \\
  <https://your-worker.workers.dev/api/shorten>

リクエストスキーマ (Zod):

z.object({
  url: z.string().url(),                    // 必須: 有効な URL
  expireAt: z.optional(z.string().datetime()), // 任意: ISO8601 形式
  email: z.string().email(),                // 必須: 作成者メール
})

レスポンス:

{
  "key": "98w2aeok"
}

4. URL 一覧取得 API

エンドポイント: GET /api/shorten?limit=100&cursor=xxx&keyword=yyy

app.get(
  '/api/shorten',
  zValidator(
    'query',
    z.object({
      limit: z.string().pipe(z.coerce.number().int().min(0)),
      cursor: z.optional(z.string()),
      keyword: z.optional(z.string()),
    })
  ),
  async (c) => {
    const { limit, cursor, keyword } = c.req.valid('query')
    const keys = await c.env.URL_SHORTENER.list({
      cursor,
      limit,
      prefix: keyword, // 前方一致検索
    })

    // 詳細情報とログを取得
    const logs = await Promise.all(
      keys.keys.map(async (key) => {
        const json = await c.env.URL_SHORTENER.get(key.name)
        const detail = JSON.parse(json ?? '')
        return {
          key: key.name,
          ...detail,
        }
      })
    )

    // createdAt の降順でソート
    logs.sort((a, b) => {
      return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
    })

    return c.json({
      hasNext: !keys.list_complete,
      cursor: keys.list_complete ? undefined : keys.cursor,
      items: logs,
    })
  }
)

レスポンス例:

{
  "hasNext": false,
  "cursor": undefined,
  "items": [
    {
      "key": "98w2aeok",
      "url": "<https://example.com>",
      "expireAt": "2025-05-04T21:35:20.333Z",
      "createdBy": "example@upsider.com",
      "createdAt": "2025-01-21T01:29:19.900Z",
      "pv": 42,
      "lastAccessedAt": "2025-01-21T01:30:28.224Z"
    }
  ]
}

5. URL 詳細取得 API

エンドポイント: GET /api/shorten/:key

app.get(
  '/api/shorten/:key',
  zValidator('param', z.object({ key: z.string() })),
  async (c) => {
    const key = c.req.valid('param').key
    const json = await c.env.URL_SHORTENER.get(key)
    if (json == null) {
      return c.json({ key })
    }
    const detail = JSON.parse(json ?? '')
    const log = await getLog({
      kv: c.env.URL_SHORTENER_LOGS,
      key: key,
    })

    return c.json({ key, ...detail, ...log })
  }
)

6. URL 削除 API

エンドポイント: DELETE /api/shorten/:key

curl -X DELETE <https://your-worker.workers.dev/api/shorten/98w2aeok>

すぐに使える実装パターン

バッチでの一括登録

// 複数URLを一度に登録する実装例
async function bulkShorten(urls: string[], email: string) {
  const results = await Promise.all(
    urls.map(async (url) => {
      const key = generateKey()
      await c.env.URL_SHORTENER.put(key, JSON.stringify({
        url,
        createdBy: email,
        createdAt: new Date().toISOString()
      }))
      return { original: url, short: `${BASE_URL}/${key}` }
    })
  )
  return results
}

Slack Webhook連携

// 短縮URL作成時にSlackに通知
async function notifySlack(key: string, url: string, createdBy: string) {
  await fetch(SLACK_WEBHOOK_URL, {
    method: 'POST',
    body: JSON.stringify({
      text: `新しい短縮URLが作成されました`,
      blocks: [{
        type: 'section',
        text: {
          type: 'mrkdwn',
          text: `*作成者:* ${createdBy}\\n*短縮URL:* ${BASE_URL}/${key}\\n*元URL:* ${url}`
        }
      }]
    })
  })
}

実装でハマったポイントと解決策

1. KVの結果整合性問題

Cloudflare KVは結果整合性のため、書き込み直後の読み取りで古いデータが返ることがあります。

問題のシナリオ:

// NG: 書き込み直後に読み取ると、まだ反映されていない可能性
await c.env.URL_SHORTENER.put(key, value)
const result = await c.env.URL_SHORTENER.get(key) // null or 古い値

解決策:

// OK: 書き込み後のレスポンスには書き込んだ値を使う
const data = { url, expireAt, createdBy, createdAt }
await c.env.URL_SHORTENER.put(key, JSON.stringify(data))
return c.json({ key, ...data }) // KVから読まずに返す

2. 開発環境でのKVエミュレーション

Miniflareバージョンによる挙動の違いに注意が必要です。

解決策:

# Wrangler 3.x以降の--localフラグで安定動作
npm run dev -- --local

3. 型安全性の確保

KVから取得したJSONの型が保証されない問題。

解決策: Zodでランタイムバリデーション

const kvDataSchema = z.object({
  url: z.string().url(),
  expireAt: z.string().datetime().optional(),
  createdBy: z.string().email(),
  createdAt: z.string().datetime()
})

const data = kvDataSchema.parse(JSON.parse(kvValue))

4. KVNamespaceの型が効かない問題

Cloudflare WorkersでKVNamespaceを使用する際、TypeScriptの型推論が効かず、エディタで赤線が表示されたり、ビルドエラーになることがあります。

よくある症状:

// ❌ エラー: Cannot find name 'KVNamespace'
type Bindings = {
  URL_SHORTENER: KVNamespace
}

// ❌ エラー: Property 'URL_SHORTENER' does not exist on type 'Env'
const value = await c.env.URL_SHORTENER.get(key)

原因と解決策:

解決策1: @cloudflare/workers-typesの正しい設定

// 1. パッケージをインストール
npm install -D @cloudflare/workers-types

// 2. tsconfig.jsonに追加
{
  "compilerOptions": {
    "types": ["@cloudflare/workers-types"]
  }
}

// 3. 型定義ファイル(types.d.ts)を作成
/// <reference types="@cloudflare/workers-types" />

declare module "*.html" {
  const content: string
  export default content
}

解決策2: Honoの型定義を正しく設定

// src/types.ts - 型定義を別ファイルに分離
export type Bindings = {
  URL_SHORTENER: KVNamespace
  URL_SHORTENER_LOGS: KVNamespace
  NOT_FOUND_REDIRECT_URL: string
  ROOT_REDIRECT_URL: string
  ALLOWED_ORIGINS: string
}

// src/index.ts - メインファイル
import { Hono } from 'hono'
import type { Bindings } from './types'

const app = new Hono<{ Bindings: Bindings }>()

// これで c.env の型が正しく推論される
app.get('/:key', async (c) => {
  // ✅ 型が効く
  const value = await c.env.URL_SHORTENER.get(c.req.param('key'))
})

解決策3: wrangler.tomlとの型同期

// worker-configuration.d.ts
interface Env {
  // wrangler.tomlのbinding名と完全一致させる
  URL_SHORTENER: KVNamespace
  URL_SHORTENER_LOGS: KVNamespace

  // 環境変数も型定義
  NOT_FOUND_REDIRECT_URL: string
  ROOT_REDIRECT_URL: string
  ALLOWED_ORIGINS: string
}

解決策4: VSCode設定の調整

// .vscode/settings.json
{
  "typescript.tsdk": "node_modules/typescript/lib",
  "typescript.enablePromptUseWorkspaceTsdk": true,
  "typescript.preferences.includePackageJsonAutoImports": "on"
}

5. ローカル開発時のKVNamespace型エラー

Miniflareでローカル開発する際、型定義が異なることがあります。

問題のコード:

// ローカルでは動くが、デプロイすると型エラー
const kv = c.env.URL_SHORTENER as unknown as KVNamespace

推奨される解決策:

// 環境に依存しない型安全な実装
import type { KVNamespace } from '@cloudflare/workers-types'

// ジェネリック型で抽象化
type KVStore = {
  get: KVNamespace['get']
  put: KVNamespace['put']
  delete: KVNamespace['delete']
  list: KVNamespace['list']
}

// ヘルパー関数で型を保証
async function getFromKV(
  kv: KVNamespace,
  key: string
): Promise<string | null> {
  try {
    return await kv.get(key)
  } catch (error) {
    console.error('KV get error:', error)
    return null
  }
}

// 使用例
app.get('/:key', async (c) => {
  const value = await getFromKV(c.env.URL_SHORTENER, c.req.param('key'))
  if (!value) {
    return c.notFound()
  }
  // ...
})

6. テスト環境でのKVNamespaceモック

// test/helpers/kv-mock.ts
export class KVNamespaceMock implements KVNamespace {
  private store = new Map<string, string>()

  async get(key: string): Promise<string | null> {
    return this.store.get(key) || null
  }

  async put(key: string, value: string): Promise<void> {
    this.store.set(key, value)
  }

  async delete(key: string): Promise<void> {
    this.store.delete(key)
  }

  async list(options?: KVNamespaceListOptions): Promise<KVNamespaceListResult> {
    const keys = Array.from(this.store.keys())
    return {
      keys: keys.map(name => ({ name, metadata: null })),
      list_complete: true,
      cursor: ''
    }
  }

  // 他の必要なメソッドも実装
  getWithMetadata(): Promise<KVNamespaceGetWithMetadataResult> {
    throw new Error('Not implemented')
  }
}

// test/index.test.ts
import { KVNamespaceMock } from './helpers/kv-mock'

describe('URL Shortener', () => {
  let env: Bindings

  beforeEach(() => {
    env = {
      URL_SHORTENER: new KVNamespaceMock() as unknown as KVNamespace,
      URL_SHORTENER_LOGS: new KVNamespaceMock() as unknown as KVNamespace,
      // ...
    }
  })

  test('should store and retrieve URL', async () => {
    await env.URL_SHORTENER.put('test', JSON.stringify({ url: '<https://example.com>' }))
    const result = await env.URL_SHORTENER.get('test')
    expect(result).toBeTruthy()
  })
})

トラブルシューティングチェックリスト

KVNamespaceの型エラーが発生した場合のチェックリスト:

  1. @cloudflare/workers-types が最新バージョンか確認
  2. tsconfig.json に types 設定があるか確認
  3. wrangler.toml のbinding名と型定義が一致しているか確認
  4. ✅ Honoのバージョンが4.0以上か確認(型サポートが改善)
  5. ✅ VSCodeを再起動して型キャッシュをクリア
  6. node_modulespackage-lock.json を削除して再インストール

環境構築

1. 前提条件

  • Node.js 18 以上
  • npm または yarn
  • Cloudflare アカウント
  • Wrangler CLI

2. インストール

# 依存パッケージインストール
npm install

3. 環境変数設定

.dev.vars ファイルを作成:

ROOT_REDIRECT_URL="<http://localhost:1234/ROOT>"
NOT_FOUND_REDIRECT_URL="<http://localhost:1234/NOT_FOUND>"
ALLOWED_ORIGINS="<http://localhost:3001>,<http://localhost:3002>"

4. KV Namespace 作成

# 本番環境用
wrangler kv:namespace create "URL_SHORTENER" --env production
wrangler kv:namespace create "URL_SHORTENER_LOGS" --env production

# ステージング環境用
wrangler kv:namespace create "URL_SHORTENER" --env staging
wrangler kv:namespace create "URL_SHORTENER_LOGS" --env staging

生成された ID を wrangler.toml に設定:

kv_namespaces = [
    { binding = "URL_SHORTENER", id = "<YOUR_KV_ID>" },
    { binding = "URL_SHORTENER_LOGS", id = "<YOUR_LOG_KV_ID>" }
]

5. ローカル開発

# 開発サーバー起動
npm run dev

# <http://localhost:8787> でアクセス可能

6. デプロイ

# ステージング環境
npm run deploy:stg

# 本番環境
npm run deploy:prod

参考: 実際に動作する最小構成

// package.json の dependencies
{
  "dependencies": {
    "hono": "^4.6.15"
  },
  "devDependencies": {
    "@cloudflare/workers-types": "^4.20241004.0",
    "wrangler": "^3.80.0",
    "typescript": "^5.5.4"
  }
}

// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "lib": ["ES2022"],
    "types": ["@cloudflare/workers-types"],
    "moduleResolution": "bundler",
    "strict": true,
    "skipLibCheck": true
  }
}

// src/index.ts
import { Hono } from 'hono'

type Bindings = {
  URL_SHORTENER: KVNamespace
}

const app = new Hono<{ Bindings: Bindings }>()

app.get('/test', async (c) => {
  // 型が正しく推論される
  const value = await c.env.URL_SHORTENER.get('test')
  return c.json({ value })
})

export default app

本番運用のセキュリティ対策

1. 内部利用限定の実装

// IPアドレス制限の実装例
const allowedIPs = ['192.168.1.0/24', '10.0.0.0/8']

app.use('*', async (c, next) => {
  const ip = c.req.header('CF-Connecting-IP')
  if (!isAllowedIP(ip, allowedIPs)) {
    return c.text('Forbidden', 403)
  }
  await next()
})

2. レート制限の実装

// KVを使った簡易レート制限
async function checkRateLimit(email: string): Promise<boolean> {
  const key = `ratelimit:${email}:${new Date().toISOString().slice(0, 10)}`
  const count = await c.env.URL_SHORTENER.get(key)

  if (parseInt(count || '0') > 100) {
    return false // 1日100件まで
  }

  await c.env.URL_SHORTENER.put(key, String(parseInt(count || '0') + 1), {
    expirationTtl: 86400 // 24時間後に自動削除
  })
  return true
}

3. 不正URL対策

// 社内ドメインのみ許可
const allowedDomains = ['example.com', 'internal.example.com']

function validateUrl(url: string): boolean {
  const parsed = new URL(url)
  return allowedDomains.some(domain =>
    parsed.hostname.endsWith(domain)
  )
}

CORS 設定

API エンドポイント (/api/*) には CORS ミドルウェアを適用:

const corsMiddleware = async (
  context: Context<{ Bindings: Bindings }, '*', {}>,
  next: () => Promise<void>
) => {
  const arrowedOrigins: string[] | string =
    context.env.ALLOWED_ORIGINS.includes(',')
      ? context.env.ALLOWED_ORIGINS.split(',')
      : context.env.ALLOWED_ORIGINS
  const corsMiddleware = cors({
    origin: arrowedOrigins,
    allowHeaders: [],
    allowMethods: ['POST', 'GET', 'DELETE'],
    exposeHeaders: ['Content-Length'],
    maxAge: 600,
    credentials: false,
  })
  return await corsMiddleware(context, next)
}

app.use('/api/*', corsMiddleware)

環境別設定:

  • Production: 特定のオリジンのみ許可
  • Staging: ALLOWED_ORIGINS="*" でワイルドカード許可

よくあるトラブルと対処法

Q: KVの書き込み上限に達した

A: KVは1秒あたり1,000書き込みが上限。バッチ処理には遅延を入れる:

for (const batch of chunks(items, 100)) {
  await Promise.all(batch.map(item => processItem(item)))
  await new Promise(resolve => setTimeout(resolve, 1000))
}

Q: 短縮キーが重複してエラーになる

A: 重複チェックとリトライ処理を実装:

const MAX_RETRIES = 5
for (let i = 0; i < MAX_RETRIES; i++) {
  const key = generateKey(8 + i) // 衝突したら文字数を増やす
  const existing = await c.env.URL_SHORTENER.get(key)
  if (!existing) return key
}
throw new Error('Failed to generate unique key')

Q: ログデータが大きくなりすぎる

A: 定期的な集計と古いログの削除:

// 30日以上前のログを削除するCron Trigger
export async function scheduled(event: ScheduledEvent, env: Env) {
  const thirtyDaysAgo = new Date()
  thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30)

  const keys = await env.URL_SHORTENER_LOGS.list()
  for (const key of keys.keys) {
    const log = await env.URL_SHORTENER_LOGS.get(key.name)
    const { lastAccessedAt } = JSON.parse(log)
    if (new Date(lastAccessedAt) < thirtyDaysAgo) {
      await env.URL_SHORTENER_LOGS.delete(key.name)
    }
  }
}

テスト

Jest + Miniflare でのローカルテスト

// jest.config.js
module.exports = {
  testEnvironment: 'miniflare',
  testMatch: [
    '**/test/**/*.+(ts|tsx|js)',
    '**/src/**/(*.)+(spec|test).+(ts|tsx|js)',
  ],
  transform: {
    '^.+\\\\.(ts|tsx)$': 'esbuild-jest',
  },
}
npm run test

Miniflare により、ローカル環境で Workers と KV の動作を完全に再現可能。

監視とログ

Workers Logs

wrangler.toml で有効化:

[observability]
enabled = true

アクセスログ

  • PV カウント
  • 最終アクセス日時
  • KV に永続化されたログデータ

Cloudflare ダッシュボードでリアルタイム監視が可能。

実装のポイント

1. キー衝突回避

let keyLength = 8
let key = generateKey(keyLength)

while (await c.env.URL_SHORTENER.get(key)) {
  keyLength++  // 衝突したら桁数を増やす
  key = generateKey(keyLength)
}

2. エラーハンドリング

export const getLog = async ({ kv, key }): Promise<LogType> => {
  try {
    const json = await kv.get(key)
    const log = JSON.parse(json ?? '') as LogType
    return log
  } catch (e) {
    // 初回アクセス時はデフォルト値を返す
    return {
      pv: 0,
      lastAccessedAt: new Date(),
    }
  }
}

3. 型安全性

type Bindings = {
  URL_SHORTENER: KVNamespace
  URL_SHORTENER_LOGS: KVNamespace
  NOT_FOUND_REDIRECT_URL: string
  ROOT_REDIRECT_URL: string
  ALLOWED_ORIGINS: string
}

const app = new Hono<{ Bindings: Bindings }>()

Hono の型システムを活用し、環境変数への型安全なアクセスを実現。

今後の拡張案

1. カスタムエイリアス機能

ユーザーが任意のキーを指定できる機能:

POST /api/shorten
{
  "url": "<https://example.com>",
  "customKey": "my-link"  // オプション
}

2. QR コード生成

短縮 URL の QR コード画像を自動生成:

GET /api/shorten/:key/qrcode

3. アクセス解析

  • 国別アクセス統計
  • リファラー情報
  • デバイス種別

Cloudflare Analytics Engine との連携で実現可能。

4. レート制限

DoS 攻撃対策として、IP ベースのレート制限を追加:

import { rateLimiter } from 'hono-rate-limiter'

app.use('/api/*', rateLimiter({
  windowMs: 15 * 60 * 1000,
  max: 100
}))

5. バルク登録 API

複数 URL の一括登録機能:

POST /api/shorten/bulk
{
  "urls": [
    { "url": "<https://example1.com>", "email": "user@example.com" },
    { "url": "<https://example2.com>", "email": "user@example.com" }
  ]
}

まとめ:導入判断のポイント

✅ Cloudflare Workers + KVが適している場合

  • 社内ツールやスモールスタートのサービス
  • グローバルに低遅延でサービスを提供したい
  • サーバー管理のコストを削減したい
  • 月間100万リクエスト以下の規模
  • すでにCloudflareを利用している組織

⚠️ 他の選択肢を検討すべき場合

  • 複雑なリレーショナルデータを扱う必要がある
  • 1秒1,000件以上の書き込みが常時発生する
  • 25MBを超える大きなデータを保存する必要がある
  • WebSocketなどのリアルタイム通信が必須

実装にかかる工数目安

  • 基本機能の実装: 1-2日
  • アクセスログ機能: 0.5日
  • 管理画面の作成: 2-3日
  • 本番環境構築: 0.5日
  • 合計: 約1週間で本番運用可能

参考リンク

We Are Hiring !!

UPSIDERでは現在積極採用をしています。 ぜひお気軽にご応募ください。

herp.careers

herp.careers

UPSIDER Engineering Deckはこちら📣

speakerdeck.com