Tier-3ロールアウトの4つ目の移行ソースを本日リリースしました。TinyURL ProまたはBulk APIトークンを貼り付け、ターゲットとなるElidoドメインを選択し、開始をクリックしてください。4〜7分後には、すべてのTinyURLエイリアスがElidoドメイン上に配置され、競合がない限りエイリアスはそのまま維持されます。
本記事はエンジニアリングに関する解説です。TinyURL特有の仕様、今回意図的に設けた制限、そして「無料プランのTinyURL移行」を構築できない理由について説明します。
無料プランの問題#
公開TinyURLにはAPIがなく、これまでも存在しません。アカウントを作成せずに作成する従来のtinyurl.com/<slug>は、fire-and-forget(実行しっぱなし)のリダイレクトです。ユーザーはホームページのフォームから作成してslugを取得しますが、そのslugはアカウントダッシュボードに再表示されることはありません。ユーザーごとのバインディングがないため、ユーザーごとのリストも存在しません。
これは周知の事実ですが、/migrate-from/tinyurlのランディングページで明記しておく価値があります。「migrate from TinyURL」という検索クエリでは、Proと無料プランが区別されないためです。以下の対応を行いました。
- ランディングページのヒーローセクションに「Pro/Bulkのみ」という明確な注意書きを配置。
- 無料プランのユーザーに対して、Bulk createフォーム(/docs/guides/bulk-create)を用いたリスト形式のBulk短縮を案内するFAQエントリーを追加。
- ランチャーにトークン検証ステップを追加。ページネーションの途中でサイレントに401エラーが発生するのを防ぐため、「このトークンはProまたはBulkプランのものではありません」というエラーを即座に返す。
理由は、私たちが提供する他のすべての移行ソースには、「検索するすべてのユーザー」に対応できるハッピーパスが存在するからです。TinyURLはその例外であり、無料プランのユーザーには異なるメンタルモデルが必要であり、何らかの操作を行う前にその期待値を設定すべきだと判断しました。
Pro/Bulk REST APIの仕様#
TinyURL Pro APIはシンプルです。Bearerトークンを使用し、JSONレスポンスを返し、1ページあたり100エイリアスを扱います。ページネーションには1から始まるpageクエリパラメータを使用します。レスポンスにはdata.aliases(リンク配列)とmeta.has_more(継続のシグナル)が含まれます。
const tinyurlPageSize = 100
page := 1
for {
resp, err := w.fetchPage(ctx, opts.Token, page)
if err != nil { /* 失敗とマーク */ return }
if len(resp.Data.Aliases) == 0 { break }
for _, alias := range resp.Data.Aliases { /* インポート */ }
if !resp.Meta.HasMore { break }
page++
}
各エイリアスには、url(長縮先)、alias(カスタムslugまたは自動生成されたショートコード)、description(Elidoリンクタイトルとして保持するTinyURLのオプションフィールド)、およびdomain(TinyURLのBulkプランではブランデッドドメインが許可されている)が含まれます。
用語 — aliasとslug#
TinyURLではこれらを「aliases(エイリアス)」と呼びます。私たちは「slugs(スラッグ)」と呼びます。リダイレクトURLのホスト以降の文字列という点で同じものです。移行では、ターゲットとなるElidoドメインで競合がない限り、エイリアスを1:1で維持します。競合が発生した場合は、標準のサフィックス/スキップ/失敗という競合解決戦略が適用されます。
ランチャー内の用語をソースベンダーに合わせて「slug」から「alias」へ変更することも検討しましたが、一貫性の観点から却下しました。他のすべてのElidoサーフェス(リンク一覧、API、SDK、ダッシュボード)では「slug」が使用されているためです。インポートランチャーにだけ非対称な用語を持ち込むと、インポート後の体験が混乱を招く可能性があります。
ランチャーには、競合解決戦略のラジオボタンの上に「TinyURLではこれらをエイリアスと呼びます」という一行のラベルを配置し、レシピページで「alias」を検索したユーザーが、すべてのテキストを読まなくても適切なコントロールを見つけられるようにしています。
ブランデッドドメインとDNSハンドオーバー#
TinyURL Bulkプランは、独自のホスト名でTinyURLのインフラストラクチャを経由するブランデッドドメインをサポートしています。Elidoへの移行時、slugはクリーンにインポートされ、DNS側はCNAMEを1つ変更するだけで済みます。
興味深いのは「TinyURL Bulkでブランデッドドメインを使用しており、移行後も同じホスト名を維持したい」というケースです。これはShort.ioの移行と同じ方法で処理します。
- 移行が完了。インポートされたリンクはデフォルトで
s.elido.me/<alias>(または既存のElidoカスタムドメイン)に配置されます。 - /docs/guides/custom-domainsの手順に従い、TinyURLのブランデッドドメインをElidoのカスタムドメインとして追加します。
- CNAMEの向き先をElidoに変更します。CaddyのオンデマンドTLS(on-demand TLS)が最初のリクエスト時に証明書を発行します。
domain-managerが許可リストの信頼できるソースとなっているため、許可されていないホスト名は拒否されます。 - そのホスト名に対するTinyURL側のサーフェスが解決を停止し、Elido側が引き継ぎます。
TinyURLのサブスクリプションが終了するまでは両方のサーフェスを並行して稼働させることができ、その後、TinyURL側のホスト名の期限切れを待つだけで切り替えは完了します。緊急性はなく、切り替え当日のリスクもありません。
移行できないもの#
クリック履歴です。TinyURL Pro/Bulkの分析レポートエンドポイントは、エクスポート用に構造化されていません。Bulkプランのダッシュボードではリンクごとのクリック数が表示されますが、移行用のAPI経由では取得できません。切り替え以降のクリック数はElidoの分析に記録されます。
QRのデザインやBulkプランのUTMテンプレートも同様です。他のすべての移行ソースと同じく、slugはインポートされますが、周囲のプレゼンテーションレイヤーはElidoで再構築する必要があります。これらはimported:tinyurlというタグが付けられ、キャンペーン(campaigns)機能を通じて後からまとめてフォローアップ可能です。
無料プランのTinyURLリンクについても同様です。前述の通り、公開TinyURLにはAPIがありません。この緩和策は移行ジョブではなく、Bulk作成フォームの使用となります。
トークンの取り扱い#
Bitly、Rebrandly、Short.ioと同じワンショット(使い捨て)のセマンティクスです。
bgCtx := context.WithoutCancel(r.Context())
go h.tinyurl.Run(bgCtx, job.ID, imports.TinyURLJobOptions{
Token: req.Token,
})
source_token_idはNULLのままです。トークンはワーカー実行中のapi-coreプロセスのメモリ内にのみ存在し、完了後に破棄されます。永続化は行われず、service_tokensテーブルへの記録もなく、ADR-0036によるエンベロープ暗号化も使用しません。これらは、ユーザーがベンダーへの定期的な呼び出しを望むTier-2統合のためのものです。
ジョブ開始時のトークン検証ステップでは、TinyURLの/account/domainsエンドポイントを叩きます。これは低コストな呼び出しで、トークンからアクセス可能なドメインのリストを返します。もし401エラーが返った場合は、「トークンが無効であるか、Pro/Bulkプランではありません」というエラーを即座に返し、ページネーションの途中で2分間待たされた挙句に401エラーと分かりにくいエラーメッセージが表示される事態を防ぎます。
競合解決#
他のすべての移行ベンダーと同一です。サフィックス(接尾辞)ウォーク戦略を採用しており、競合時にはmyalias-2、myalias-3と順次試行します。スキップ(skip)戦略では既存のElidoリンクを維持し、ソースの行をログに記録します。失敗(fail)戦略では最初の競合で中止します。
func (w *TinyURLWorker) resolveSlug(ctx context.Context, domainID int64, desired, strategy string) (string, error) {
if _, err := w.links.GetByDomainSlug(ctx, domainID, desired); err != nil {
if errors.Is(err, pgx.ErrNoRows) { return desired, nil }
return "", fmt.Errorf("slug lookup: %w", err)
}
// bitly.goと同一のサフィックス/スキップ/失敗の分岐
}
ルックアップは行ごとに1回のインデックス付き読み取りを行います。読み取り負荷は増えますが、一貫性のあるサフィックスウォークが可能となり、単にユニーク制約違反を拾うよりもフレンドリーなエラーメッセージを提供できます。
ワーカーのコントラクト#
MaxLinksPerImport=50_000、ImportRunBudget=30*time.Minute、progressEvery=50、errorLogCap=1_000。これらは5つすべての移行ベンダーで共有されています。これらの定数は、ダッシュボードのポーリングUIが前提としているコントラクトです。
2,000エイリアスのTinyURL Proアカウントであれば、APIを20回呼び出し、3〜5分で完了します。20,000エイリアスのBulkアカウントでは200回のラウンドトリップが発生し、15〜20分かかります。50,000エイリアスを超える場合、ワーカーは強制停止し、チャンク分割された移行については[email protected]までメールするよう指示を表示します。チャンク分割による移行(/blog/short-links-as-terraform)のパスは、v1時点ではコンシェルジュ対応のみとなります。
再開可能性とデプロイの問題#
最初の3つの移行と同じトレードオフです。ワーカーはプロセス内で実行されるため、インポート中のデプロイによってゴルーチンが終了します。「停止したジョブのスイープ(stuck-sweep)」cronが、30分間進捗のないrunning状態の行をfailedに切り替えます。再実行はサフィックスおよびスキップの戦略により冪等(idempotent)です。
10,000エイリアスを超えるアカウントの場合、再開可能性があれば大きなメリットになります。その場合、TinyURLのpageカーソルをimport_jobs.source_filterに記録し、最後に完了したページから再開するようにします。他の4つの移行ベンダーも実装後に同様のメリットが得られるように、設計は共有されています。
CSVフォールバック#
有効なAPIトークンを持っていないBulkプランのユーザー向けに、エクスポートされたCSVファイルがある場合は、インボックス経由で個別にCSVジョブを実行します。[email protected]までメールしてください。セルフサービスのCSVアップロードフォームを用意しなかったのは、RESTパスが一般的なケースをカバーしており、CSVパスにはアカウントごとのスキーマ調整が必要であり、脆い汎用パーサーよりも手作業で行う方が適切だからです。
次の予定#
あと1つのベンダーを残すのみです。
- Dub.co —
GET /api/links?projectSlug=…&limit=100。フォルダはタグにフラット化されます。5つのベンダーの中で最もクリーンなAPIです。
Dubの対応でTier-3ロールアウトは完了です。5つの移行ランディングページ、5つのエンジニアリングブログ投稿、1つの共有ワーカーの足場、1つの共有ダッシュボードポーリングUIが揃います。
TinyURLの移行がドキュメント化されていないことを理由に見送っていた方も、これで準備が整いました。ぜひお試しください(/migrate-from/tinyurl)。一般的なアカウントであれば、Pro/Bulkトークンから最後のリンクインポートまで7分以内に完了します。