2分で読了エンジニアリング

Net::HTTP と Faraday を使って Ruby で URL を短縮する方法

gem なしで Net::HTTP を使って Ruby の URL を短縮し、タイムアウト、リトライ、べき等性を追加する方法と、Rails アプリで処理全体を Faraday に任せる方法を解説します。

Marius Voß
DevRel · edge infra
Ruby で URL を短縮する方法:Net::HTTP の POST で遷移先 URL を短縮 API に送り、JSON レスポンスからショートリンクを読み取る

Ruby で URL を短縮する処理は、Net::HTTP を使った1回の POST です。長いリンクを短縮サービスの API に送り、API キーを Bearer トークンとして渡し、返ってきた JSON から short_url をパースします。gem は必要ありません。自分で管理していないマシン上でスクリプトを実行する場合には、この点が重要です。

以下で使うエンドポイントの形と認証モデルは、無料 URL 短縮 API の概要に記載されています。この記事は、ダッシュボードからの操作を扱うURL を短縮する一般的な手順の Ruby 版です。

同じシリーズの他のランタイム:PythonJavaScriptPHPGo。フィールド名は Elido のものですが、形は多くの最新の URL 短縮ツールに応用できます。

最短の方法:Net::HTTP.post

require は2つ、呼び出しは1つです:

require "json"
require "net/http"

response = Net::HTTP.post(
  URI("https://api.elido.app/v1/links"),
  { destination_url: "https://example.com/spring-sale?utm_source=newsletter" }.to_json,
  "Authorization" => "Bearer #{ENV.fetch('ELIDO_API_KEY')}",
  "Content-Type" => "application/json",
)

raise "shorten failed: #{response.code}" unless response.is_a?(Net::HTTPSuccess)

puts JSON.parse(response.body)["short_url"] # => https://s.elido.me/ab12cd

ENV[] ではなく ENV.fetch を使うのは意図的です。キーがない場合、その場で失敗するため、Bearer を送信して分かりにくい 401 が返る事態を防げます。

unless response.is_a?(Net::HTTPSuccess) の行は、多くの人が省略する部分です。Net::HTTP はソケット切断やタイムアウトでは例外をスローしますが、それ以外ではスローしません。そのため、JSON.parseshort_url のないハッシュを返すまで、エラーボディ付きの 401 は成功とまったく同じように届きます。

タイムアウト、リトライ、べき等性キーを追加する

Net::HTTP.post には修正できない欠点があります。タイムアウト引数を受け付けないことです。放置して実行する処理では、タイムアウトを設定できる Net::HTTP.start に切り替えてください。

その際、単純なリトライによって悪化する障害にも対処します。POST が API に到達した後、戻りのレスポンスが失われると、コードはタイムアウトを検知してリトライし、同じ遷移先に対する2つ目のリンクを作成します。安定した Idempotency-Key でこの隙間を埋めます。遷移先をハッシュすれば、API は新しいリンクを発行せず元のリンクを返します。

require "digest"

def shorten(destination, attempts: 3)
  uri = URI("https://api.elido.app/v1/links")
  key = Digest::SHA256.hexdigest(destination) # stable across retries and re-runs

  attempts.times do |attempt|
    response = Net::HTTP.start(uri.host, uri.port, use_ssl: true,
                               open_timeout: 5, read_timeout: 10) do |http|
      request = Net::HTTP::Post.new(uri)
      request["Authorization"]   = "Bearer #{ENV.fetch('ELIDO_API_KEY')}"
      request["Content-Type"]    = "application/json"
      request["Idempotency-Key"] = key
      request.body = { destination_url: destination }.to_json
      http.request(request)
    end

    case response
    when Net::HTTPSuccess         then return JSON.parse(response.body)["short_url"]
    when Net::HTTPTooManyRequests then sleep(response["Retry-After"].to_i.clamp(1, 60))
    when Net::HTTPServerError     then sleep(2**attempt) # 1s, 2s, 4s
    else raise "shorten failed: #{response.code} #{response.body}"
    end
  end

  raise "shorten failed after #{attempts} attempts"
end

レスポンスのクラスで分岐する case は、ステータスコードを並べるより読みやすく、ポリシーも明確にします。429 ではサーバーが指定した時間だけ待ち、5xx では指数バックオフを行い、それ以外の 401 や 422 ではリトライしても直らないため最初の試行で例外をスローします。レート制限とべき等性の詳細解説では、ヘッダーのセマンティクスを詳しく説明しています。

Ruby の Net::HTTP が Bearer トークン付きで遷移先 URL と Idempotency-Key を短縮サービスの links エンドポイントに POST し、API が short_url を含む HTTP 201 を返し、429 と 5xx のレスポンスでスリープするリトライループ

このまま実行しますか?無料プランでキーを作成し、ELIDO_API_KEY としてエクスポートすれば、このページのすべてのスニペットを変更せずに実行できます。

既存のアプリ向けの Faraday 版

Rails アプリでは、手書きのループは適した形ではありません。Faradayなら、認証ヘッダー、双方向の JSON、記述するのではなく宣言するリトライポリシーを備えた1つの接続オブジェクトを使えます:

# Gemfile: gem "faraday"  and  gem "faraday-retry"

ELIDO = Faraday.new(url: "https://api.elido.app") do |f|
  f.request :json
  f.request :retry, max: 3, interval: 0.5, backoff_factor: 2,
                    retry_statuses: [429, 500, 502, 503, 504]
  f.response :json
  f.response :raise_error
  f.headers["Authorization"] = "Bearer #{ENV.fetch('ELIDO_API_KEY')}"
  f.options.timeout = 10
end

def shorten(destination)
  ELIDO.post("/v1/links",
             { destination_url: destination },
             { "Idempotency-Key" => Digest::SHA256.hexdigest(destination) })
       .body["short_url"]
end

貼り付ける前に知っておくべきことが2つあります。Faraday 2 ではリトライミドルウェアが独自の faraday-retry gem に移ったため、Gemfile に別の行が必要です。また、raise_error は Net::HTTP の動作を反転させます。4xx と 5xx が Faraday::ClientErrorFaraday::ServerError をスローするため、nil を保存するのではなくリトライすべきジョブで望ましい動作になります。

Ruby の Net::HTTP と Faraday による URL 短縮 API 呼び出しの比較。依存関係のないスクリプトと、Rails アプリでリトライミドルウェアを保持する接続オブジェクトを対比している

レート制限に触れずにリストを短縮する

Ruby はスレッドが IO を待っている間、グローバルロックを解放します。そのため、CRuby でもここではスレッドが実際に効果を発揮します。ただし、URL ごとに1つのスレッドを作ってはいけません。2,000行の CSV は2,000個のソケットと大量の429を生みます。Queue と固定プールを組み合わせれば、リストの長さにかかわらず同時実行数を一定に保てます。

def shorten_all(urls, concurrency: 8)
  queue   = Queue.new
  results = {}
  mutex   = Mutex.new

  urls.each { |u| queue << u }
  concurrency.times { queue << :done }

  workers = concurrency.times.map do
    Thread.new do
      while (url = queue.pop) != :done
        value = begin
          shorten(url)
        rescue => e
          "ERROR: #{e.message}" # one bad row must not sink the batch
        end
        mutex.synchronize { results[url] = value }
      end
    end
  end

  workers.each(&:join)
  results
end

元の URL をキーにして結果を保存すると、部分的な失敗が見える状態になり、バッチを再実行できます。Mutex は省略できません。8つのスレッドから通常の Hash に書き込むとデータ競合になり、本当に重要な実行時に問題が発生します。

Rails では shorten を Active Job でラップし、バックオフをキューアダプターに任せてください。コントローラーアクション内でリトライすると、すべての sleep の間 Puma のスレッドを保持します。レート制限されたインポートによって、Web プロセスが気づかないうちに枯渇する可能性があります。これを公開フローに組み込む場合は、リンクイベント用 Webhookの記事で、ポーリングなしにクリックデータを取得する方法を説明しています。

どれを選ぶべきか

rake タスクや一度限りのスクリプトなら、4行で済む Net::HTTP.post です。定期実行するものなら、タイムアウトとべき等性キーを備えた Net::HTTP.start 版です。すでに Faraday を使っている Rails アプリなら、リトライを1回宣言してどこでも再利用できる接続オブジェクトです。

選択そのものより、下にある3つの習慣のほうが重要です。環境変数からキーを読み、明示的なタイムアウトを設定し、ボディをパースする前にレスポンスのクラスを確認します。自分でこれらを管理したくない場合は、API と SDKのページに生成クライアントの一覧があります。開発者向けソリューションでは、コードからリンクを作成するようになった後に API が公開するその他の機能を説明しています。

基幹シリーズを読む

この記事はエンジニアリングクラスターに含まれています。まず無料 URL 短縮 API ガイドでエンドポイントの形と認証を確認し、次にレート制限とべき等性で負荷の下でも適切に動作させる方法を読んでください。ライブリファレンスはAPI ドキュメントにあり、開発者向け URL 短縮ツールでは API を基盤にする前に確認すべき点を説明しています。

ブログ関連記事

よくある質問

Ruby で URL を短縮するにはどうすればよいですか?

Net::HTTP で長い URL を短縮サービスの API に POST し、API キーを Bearer ヘッダーとして送り、JSON ボディに遷移先を入れます。その後、パースしたレスポンスから short_url を読み取ります。Net::HTTP.post は標準ライブラリの1行呼び出しなので、最初のバージョンでは何もインストールする必要がありません。

Ruby で URL を短縮するために gem は必要ですか?

いいえ。Net::HTTP と json ライブラリは Ruby に同梱されており、呼び出し全体を処理できます。再利用可能な接続、リトライミドルウェア、自動 JSON エンコードが必要な場合は Faraday が役立ちます。これはスタンドアロンスクリプトよりも、通常は Rails アプリ内で当てはまります。

Net::HTTP にタイムアウトを設定するにはどうすればよいですか?

open_timeout と read_timeout を指定できる Net::HTTP.start を使います。どちらも設定できない Net::HTTP.post のショートカットは使いません。設定しないと、停止した接続がデフォルトの60秒間ワーカーを占有することがあり、バックグラウンドジョブでは1つのジョブスロットを失うことになります。

Net::HTTP は 401 で例外をスローしないのはなぜですか?

401 はトランスポート障害ではなく、有効な HTTP レスポンスだからです。Net::HTTP が例外をスローするのはソケットやタイムアウトのエラーだけなので、is_a?(Net::HTTPSuccess) でレスポンスのクラスを確認するか、response.code を自分で読み取る必要があります。Faraday の raise_error ミドルウェアならこれを処理してくれます。

Ruby で多くの URL を一度に短縮するにはどうすればよいですか?

URL を Queue に追加し、通常は約8個の小さなスレッドプールで処理します。Ruby は IO 待ちの間グローバルロックを解放するため、スレッドでネットワーク待ちを実際に重ねられます。また、固定プールによって、URL ごとに無制限のスレッドを作るループが API のレート制限を一気に超えるのを防げます。

Rails アプリではリトライループをどこに置くべきですか?

コントローラーアクションではなく、バックグラウンドジョブに置きます。数秒スリープするリトライは、その間ずっと Puma のスレッドを保持するため、レート制限されたバッチが Web プロセスを枯渇させる可能性があります。キューアダプターを備えた Active Job なら、バックオフを処理し、リトライ履歴も自動的に残せます。

Elidoを試す

URLを貼り付けて短縮リンクを取得

登録不要。リンクは30日間有効。永久に保存するには登録してください。

Free、登録不要 · 1日あたり2件

Elidoを試す

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

タグ
how to shorten a url in ruby
ruby url shortener
shorten url ruby net http
url shortener api ruby
faraday post json
bulk shorten urls ruby

続きを読む