URL 短縮サービスはバックエンドの "Hello World"
URL 短縮サービスはシステム設計面接、チュートリアル、課題プロジェクトに登場する定番ですが、実際にデプロイして使えるものが出来上がる数少ない Hello World でもあります。HTTP ルーティング、一意性制約のあるデータベース、ドメイン固有のエンコーディング(スラグ)、書き込み頻度の高い副作用(クリック追跡)、管理エンドポイントの認証、レート制限 — バックエンドの要素がほぼ揃って、約 400 行のコードに収まります。
チュートリアルがよく間違えるのは依存を増やしすぎることです。マイグレーションフレームワーク、クエリビルダ、ORM、URL パーサークレート、base62 クレート、レート制限クレート、Redis — URL 短縮サービスにはどれも要りません。ルーター、組み込みデータベース、数十行のロジックがあれば十分です。
📦 GitHub: https://github.com/sen-ltd/url-shortener-rs
スタック
axum = "0.7"
tokio = { version = "1", features = ["full"] }
tower = { version = "0.5", features = ["util"] }
tower-http = { version = "0.5", features = ["trace"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rusqlite = { version = "0.32", features = ["bundled"] }
chrono = { version = "0.4", features = ["std", "clock", "serde"] }
anyhow = "1"
9 クレートで、ほとんどが axum のトランジティブなインフラです。注目すべき 2 つの選択があります。
-
rusqliteのbundledフィーチャ。 SQLite をソースからコンパイルしてバイナリに静的リンクします。ホストにlibsqlite3が不要になり、リリースコンテナにはAlpine ランタイムと自前のバイナリだけが必要です。最終イメージは 11 MB。 -
URL パーサーも base62 クレートも使わない。 短縮サービスが実際に必要とするルール — 「
http://かhttps://で始まる」「2048 バイト未満」 — は 8 行に収まります。base62 エンコーダ/デコーダは 30 行です。
30 行の base62 エンコーダ
Base62 はすべての URL 短縮サービスが使うコンパクトな URL 安全エンコーディングで、アルファベットは 0-9A-Za-z の 62 文字です。100 億のカウンタでも 6 文字に収まり、URL パーサーでエスケープ不要な文字だけで構成されています。
const ALPHABET: &[u8; 62] =
b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
pub fn encode(mut n: u64) -> String {
if n == 0 {
return "0".to_string();
}
let mut buf = [0u8; 11];
let mut i = buf.len();
while n > 0 {
i -= 1;
buf[i] = ALPHABET[(n % 62) as usize];
n /= 62;
}
std::str::from_utf8(&buf[i..]).unwrap().to_string()
}
pub fn decode(s: &str) -> Option<u64> {
if s.is_empty() {
return None;
}
let mut n: u64 = 0;
for &b in s.as_bytes() {
let v = match b {
b'0'..=b'9' => (b - b'0') as u64,
b'A'..=b'Z' => (b - b'A') as u64 + 10,
b'a'..=b'z' => (b - b'a') as u64 + 36,
_ => return None,
};
n = n.checked_mul(62)?.checked_add(v)?;
}
Some(n)
}
間違いやすいポイント:
-
0は"0"にエンコードされる。while n > 0ループだけだと空文字列になり、自分のスラグをデコードできなくなります。 -
デコーダの
checked_mulとchecked_add。 これがないとu64::MAXを超える入力がサイレントにラップアラウンドしてゴミ値を返します。 -
MSB ファーストで 11 バイトバッファの後ろから埋める。
Vecに push して reverse するよりも速く、「11 文字を超えない」不変条件が自明です。
スラグ生成ロジック
/shorten ハンドラの設計上の問いはスラグ生成です。ランダム 6 文字を INSERT して衝突したらリトライ — という素朴なアプローチも動きますが、遅い上に情報が少ないです。採用した決定的アプローチ:
- プレースホルダの一意文字列で行を INSERT
- SQLite の
rowidを読み戻す -
rowid + 1000を base62 エンコード(最初のスラグが"0"ではなく"G8"になるように) - 実スラグで行を UPDATE
カスタムスラグの場合は検証後に INSERT し、UNIQUE 制約違反を 409 Conflict に変換します。
match conn.execute(
"INSERT INTO links (slug, long_url, created_at) VALUES (?1, ?2, ?3)",
params![slug, long_url, created_at],
) {
Ok(_) => Ok(Some(row)),
Err(rusqlite::Error::SqliteFailure(e, _))
if e.code == rusqlite::ErrorCode::ConstraintViolation =>
{
Ok(None) // ルートハンドラで 409 Conflict に
}
Err(e) => Err(e.into()),
}
「存在するか? → INSERT」の 2 ステップではなく、データベースにアトミックな一意性チェックをやらせてエラーコードを解釈する。1 ラウンドトリップでレースフリーです。
レート制限 — 正直な限界
レート制限のセクションは異例なほど正直に書きたいです。こう書かれたレート制限は本番に出すべきではなく、その理由を知ってほしいからです。
pub struct RateLimiter {
capacity: f64,
refill_per_sec: f64,
buckets: Mutex<HashMap<String, Bucket>>,
}
正しいトークンバケットですが、3 つの弱点があります。
- プロセス内。 ロードバランサーの後ろに 2 レプリカがあると、各クライアントの実効レートが暗黙に倍になります。
-
メモリ無制限。 新しい IP ごとに
HashMapエントリが増えます。gc()メソッドはあるが呼ばれていません。 -
キーは TCP ピア IP。 リバースプロキシの後ろではすべて
127.0.0.1に見え、1 人の迷惑クライアントが全員をブロックし得ます。
3 つとも残したのは、気づいた読者がすでに重要な教訓を学んだからです。レート制限を含める目的は「トークンバケットはこう見える」であり、「50 行で本番対応のリミッター」ではありません。
テスト
34 テスト: base62 ラウンドトリップ、レート制限、URL バリデーション、スラグバリデーションの 16 ユニットテスト、インメモリ SQLite で完全なルーターを構築し tower::ServiceExt::oneshot で駆動する 18 統合テスト。ソケットなし、ライブ HTTP なし、スイート全体が約 60 ms で完走します。
トレードオフ
- SQLite はシングルライター。 短縮サービスのワークロード(リードヘビー、バースト書き込み)には十分。~1000 writes/sec で本格 DB を検討。
-
クリック追跡に集約レイヤなし。 リダイレクトごとに同期
UPDATE+SELECT。低レイテンシが必要ならインメモリカウントの定期フラッシュに。 - HTML フォームに CSRF 保護なし。 same-origin かつ API は JSON のみなのでデモとしては問題ないが、本番では追加すべき。
30 秒で試す
docker build -t url-shortener-rs .
docker run --rm -d -p 8000:8000 -e ADMIN_TOKEN=changeme url-shortener-rs
curl -sS -X POST http://localhost:8000/shorten \
-H 'Content-Type: application/json' \
-d '{"url":"https://sen.ltd"}'
curl -sI http://localhost:8000/G9 | grep -i location
curl -sS http://localhost:8000/G9/info
curl -sS -X DELETE http://localhost:8000/G9 \
-H 'Authorization: Bearer changeme'
ソース全体は GitHub で公開しています。バイナリ 4 MB、Alpine コンテナ 11 MB、テスト・Dockerfile・HTML ページ含めて約 1100 行です。Rust でサーバーサイドを学ぶなら、echo サーバーや「ブログ API」の例よりも、この形の URL 短縮サービスのほうが良い最初のプロジェクトだと思います。
