Elido
1分で読了エンジニアリング

Short.io移行のリリース:ドメインごとのページネーション(1ページあたり150件)

Elido向けのワンクリックShort.ioインポート機能をいかに構築したか。ドメインごとのページネーションモデル、非公開リンク無効化ルール、そして5つの移行ソースの中で最も高速な移行を実現した経緯について解説します。

Marius Voß
DevRel · edge infra
パイプライン図:左側のShort.io REST APIからElidoインポートワーカーを経由してリンクテーブルへ流れる様子と、ワーカーが保持する数値保証(上限5万件、30分の予算、1ページあたり150件、トークンはメモリ内のみ)を示すサイドパネル。

Tier-3ロールアウトの3つ目の移行ソースを本日リリースしました。Short.io APIキーを貼り付け、Short.ioのソースドメイン(例: example.short.gy)を選択し、Elidoのターゲットドメインを選択して「開始」をクリックするだけです。3〜6分後には、すべてのリンクがスラッグを保持したままElidoドメインに移行されます。

本記事はエンジニアリングの記録です。Short.ioに特有の点、REST APIにおける驚き、そしてなぜアカウント単位のバッチ処理ではなくドメイン単位のジョブを公開することにしたのかについて解説します。

アカウント単位ではなく、ドメイン単位で#

Short.ioのデータモデルには、ランチャーのUX全体を決定づけた一つの特徴があります。リンクはドメインごとに整理されており、/links エンドポイントもドメインごとにページネーションされます。「このアカウント内の全ドメインのリンクをすべて取得する」といったAPIコールは存在しません。

いくつかの設計を検討しました:

  • A. サーバーサイドですべてのドメインを反復し、ユーザーに1つのジョブとして提示する。クリック回数の観点からは高速ですが、進捗状況やドメインごとの競合解決戦略の選択を提示するのが困難です。
  • B. ソースドメインごとに1つのElidoジョブを作成する。クリック回数は増えます(ユーザーはNドメインに対してN個のジョブを実行する必要がある)が、各ジョブのコントラクトは明確です:1つのソースドメイン → 1つのターゲットドメイン → 1つの競合解決戦略。
  • C. すべてのドメインをリストアップし、ユーザーに複数選択させ、サーバーサイドでN個のジョブをキューに入れる。

今回はBを実装し、Cは今後のロールアウト計画での反復用として残しました。ランチャーはソースドメインのホスト名をテキストフィールドで入力させます(ドロップダウンではありません。Short.ioの /domains リストは呼び出しコストは低いもののラウンドトリップが増え、ユーザーは自分のドメインのホスト名を常に把握しているためです)。ドメインごとに1ジョブとし、ダッシュボードから順番にキューに入れます。

ページサイズの利点#

Short.ioはデフォルトで1回の呼び出しにつき150リンクのページネーションを行います。これは、我々が対応している5つの移行ソースの中で最も寛大な仕様です。比較:

  • Bitly: 1ページあたり100件
  • Rebrandly: 1ページあたり25件
  • TinyURL: 1ページあたり100件 (Pro/Bulk)
  • Dub.co: 1ページあたり100件
  • Short.io: 1ページあたり150件

5,000リンクのShort.ioドメインでは34回のラウンドトリップで済みますが、5,000リンクのRebrandlyアカウントでは200回かかります。ワーカーの処理時間の大部分はHTTPレスポンスの待機に費やされるため、これは重要な要素です。Short.ioは、我々がサポートする移行ソースの中で、実測値ベースで最も高速です。

const shortioPageSize = 150

page := 1
for {
    resp, err := w.fetchPage(ctx, opts.Token, opts.DomainID, page)
    if err != nil { /* mark failed */ return }
    if len(resp.Links) == 0 { break }
    for _, link := range resp.Links { /* import */ }
    if !resp.HasMore { break }
    page++
}

HasMoreはShort.ioが明示的に返すブール値であり、カーソルのパースや最後のIDの追跡は不要です。彼らのAPIは、我々がサポートする5つのベンダーの中で最も優れた設計の一つです。

プライベートリンクの取り扱い#

Short.ioにはリンクごとに「プライベート(非公開)」フラグがあります。プライベートリンクは、エッジでスラッグが解決されないよう、is_active=falseを設定したElidoリンクとしてインポートします。ユーザーはインポート結果をスポットチェックした後、ダッシュボードから選択的に有効化できます。

その理由は、Short.ioでプライベート設定されていたリンクは、本来公開したくないという意図があるためです。is_active=trueでインポートすると、意図的に制限されていたURLが公開されてしまいます。is_active=falseでインポートすれば、スラッグを予約しつつユーザーが判断するまで到達不可能にできるため、より安全です。

isActive := !link.Private
linkID, err := w.links.InsertImported(ctx, sqldb.InsertImportedLinkParams{
    WorkspaceID:    job.WorkspaceID,
    DomainID:       job.TargetDomainID,
    Slug:           slug,
    DestinationURL: link.OriginalURL,
    Title:          truncate(link.Title, 250),
    Tags:           append(link.Tags, "imported:shortio"),
    IsActive:       isActive,
    CreatedByUserID: createdByUserID,
})

これは、Bitly(同等のフラグなし)やRebrandly(同等のフラグなし)との小さな相違点です。インポート後のレシピでこの挙動について触れておくことで、一部のリンクがすぐに解決されない理由をユーザーに理解してもらうことが重要です。

移行対象外の項目#

Short.ioのA/Bスプリットテストの設定はクリーンなエクスポートができません。これらはアプリ内ビルダーであり、REST API経由では決定論的なJSON形式で提供されないためです。インポート後にElidoのスマートリンクルールとして再構築してください。構文はより表現力が高いですが、メンタルモデルは同じです。

クリック履歴の移行は、どの移行ソースでも共通の制限事項です。Short.ioのクリックごとのデータはアナリティクスエクスポートにありますが、これはTeamプラン限定(2026年5月22日時点)であり、イベント単位ではなく集計されたカウンタとして提供されます。切り替え以降の新しいクリックは、Elidoのアナリティクスに記録されます。

QRデザインやリンクごとのUTMプリセットも、BitlyやRebrandlyと同様です。imported:shortioタグを付与しているため、Elidoのキャンペーン経由でまとめて後処理が可能です。

ドメインの引き渡し#

Short.ioの興味深いユースケースとして、「Short.ioでブランドドメインを運用しており、URLを変更せずにElidoへ移行したい」というものがあります。移行機能はリンク側をクリーンに処理します。DNS側はCNAMEを1つ変更するだけです。

引き渡しの手順は/migrate-from/shortioランディングページに記載しています。Short.ioのサブスクリプションが終了するまでは両方のサービスを並行して解決させ、その後DNSをElidoに向けるのが推奨です。インポートが完了したその日にShort.ioを停止しなければならないという緊急性はありません。

Elidoのカスタムドメインは、domain-managerを許可リストソースとしたCaddyのオンデマンドTLSを使用しています。そのため、切り替えはCNAMEの変更とドメイン検証のAPI呼び出しで完了します。ユーザー側での証明書に関する面倒な作業は一切ありません。

競合解決とワーカーのコントラクト#

BitlyやRebrandlyと同一です。衝突時にはサフィックス(mylink-2, mylink-3…)を付加、スキップは既存のElidoリンクを維持してソース行をログに記録、失敗は最初の競合で中止します。ルックアップは行ごとに1回のインデックス付き読み取りを行います。

ワーカーのコントラクト(MaxLinksPerImport=50_000, ImportRunBudget=30*time.Minute, progressEvery=50, errorLogCap=1_000)は、5つすべてのベンダーで共有されています。これらの定数は設定可能なパラメータではなく、処理の大部分を規定するものです。これらはダッシュボードのポーリングUIが前提としているコントラクトです。

トークンの取り扱い#

bgCtx := context.WithoutCancel(r.Context())
go h.shortio.Run(bgCtx, job.ID, imports.ShortioJobOptions{
    Token:    req.Token,
    DomainID: req.DomainID,
})

source_token_idはNULLのままです。BitlyやRebrandlyと同じ「使い切り」のセマンティクスです。ユーザーがトークンを一度貼り付けるとワーカーが実行され、完了時にトークンはメモリから破棄されます。永続化の利点(定期的な利用)が移行には適用されないため、保存はしません。

context.WithoutCancelにより、ワーカーを起動したHTTPリクエストが終了した後もワーカーを生存させ続けます。これは本ロールアウトにおける他のすべての移行ベンダーと同じパターンです。

CSVエクスポートパスとの比較#

Short.ioはTeamプランでCSVエクスポートを提供しています。RESTを採用した理由は以下の通りです:

  • RESTはShort.ioのタグ構造を保持しますが、CSVはカンマ区切りの文字列に平坦化されるため、パース後の分割処理が必要です。
  • RESTはprivateフラグを公開していますが、CSVには一貫して含まれていません。
  • RESTは決定論的な進捗(リンク取得済み数 / 残りリンク数)を提供します。CSVは1回限りのファイルアップロードであり、実行中の進捗通知はありません。
  • RESTはプランに依存しません。すべてのShort.ioプランで/linksが利用可能です。CSVエクスポートはTeamプラン限定です。

CSVパスは、APIトークンが取り消されているものの最後のエクスポート時のCSVファイルを保持している、Short.ioのレガシーアカウントユーザー向けの手段として残しています。

再開可能性とデプロイの問題#

最初の2つの移行と同様のトレードオフです。ワーカーはインプロセスで動作するため、インポート中のデプロイによってゴルーチンが終了してしまいます。import_jobs.last_progress_atフィールドと5分間隔のスタック検知cronにより、過去30分間進捗がないrunning状態の行はfailedに切り替わります。再実行は、サフィックス付与またはスキップ設定の下でべき等です。

複数のShort.ioドメインで10,000リンクを超えるアカウントの場合、ドメイン単位のジョブ設計が役立ちます。各ドメインは30分の予算で独立して制限されるため、3つ目のドメインの移行中にデプロイが発生しても、最初の2つの作業は失われません。

今後の予定#

あと2つのベンダーが待機しています:

  • Dub.coGET /api/links?projectSlug=…&limit=100。フォルダはタグに平坦化されます。5つの中で最もクリーンなAPIです。
  • TinyURL — 1ページあたり100件のPro/Bulk REST API。無料版のTinyURLにはAPIが存在せず、今後も予定はないため、こちらは手動のパスとなります。

DubとTinyURLが完了すれば、Tier-3のロールアウトは終了です。5つの移行ランディングページ(/migrate-from/bitly, /migrate-from/rebrandly, /migrate-from/shortio, /migrate-from/dub, /migrate-from/tinyurl)と5つのエンジニアリングブログ投稿により、Bitlyの代替サービスを探しているユーザーのあらゆるクエリをカバーします。

移行手順が不明確であったためにShort.ioとの比較を見送っていた場合でも、これで手順が明確になりました。ぜひ試してみてください。標準的なアカウントであれば、APIキーとドメインの入力から最後のリンクのインポート完了まで6分以内です。

ブログの関連記事#

Elidoを試す

EUホスティングのURL短縮サービス。カスタムドメイン、詳細な分析、オープンAPI付き。無料プラン — クレジットカード不要。

タグ
short.io migration
url shortener
go worker
data migration
engineering
tier 3 integrations

続きを読む