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

HttpClientと.NETを使ってC#でURLを短縮する方法

PostAsJsonAsyncでURLを短縮し、IHttpClientFactory、レジリエンスハンドラー、Idempotency-Key、Parallelを使った上限付き一括短縮を組み込む方法。

Marius Voß
DevRel · edge infra
C#でURLを短縮する方法: 型付きHttpClientが短縮APIに宛先URLをPOSTし、レスポンスからショートリンクをデシリアライズします

C#でURLを短縮する処理は1回のPOSTです。宛先を短縮サービスのAPIに送信し、APIキーをBearerトークンとして渡して、レスポンスからshort_urlをデシリアライズします。HttpClientを使い、PostAsJsonAsyncがシリアライズを処理するため、クライアントを登録してしまえば呼び出しは1行です。

ここで前提とするエンドポイントの形式、認証モデル、無料プランの制限については、無料URL短縮APIの概要で説明しています。この記事は、PythonJavaScriptGoJavaも扱うシリーズのC#編です。

最も速い方法: PostAsJsonAsync

using System.Net.Http.Json;

record LinkRequest(string destination_url);
record LinkResponse(string id, string short_url);

var http = new HttpClient { BaseAddress = new Uri("https://api.elido.app") };
http.DefaultRequestHeaders.Authorization =
    new("Bearer", Environment.GetEnvironmentVariable("ELIDO_API_KEY"));
http.Timeout = TimeSpan.FromSeconds(10);

var response = await http.PostAsJsonAsync("/v1/links",
    new LinkRequest("https://example.com/spring-sale?utm_source=newsletter"));

response.EnsureSuccessStatusCode();

var link = await response.Content.ReadFromJsonAsync<LinkResponse>();
Console.WriteLine(link!.short_url); // https://s.elido.me/ab12cd

レコードのプロパティ名は意図的にAPIのsnake_caseと一致させており、サンプルから依存関係をなくしています。実際のプロジェクトでは[JsonPropertyName("short_url")]を追加してC#の名前をプロパティに付けるか、オプションに一度だけPropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLowerを設定してください。

重要なのはEnsureSuccessStatusCodeの行です。これがないと401でも正常に完了し、ReadFromJsonAsyncはnullを含むレコードを返し、無関係な箇所のNullReferenceExceptionとして3層離れた場所で失敗が表面化します。

newするのではなくクライアントを登録する

上のスニペットはコンソールでの試作には適していますが、サービスでは誤りです。呼び出しごとに作成したHttpClientは毎回新しい接続プールを開き、破棄するとソケットがTIME_WAITに残ります。これをループ内で行うとポートが枯渇します。IHttpClientFactoryはハンドラーをプールし、DNSを正しくローテーションします。

builder.Services.AddHttpClient<ElidoClient>(client =>
    {
        client.BaseAddress = new Uri("https://api.elido.app");
        client.DefaultRequestHeaders.Authorization =
            new("Bearer", builder.Configuration["Elido:ApiKey"]);
        client.Timeout = TimeSpan.FromSeconds(10);
    })
    .AddStandardResilienceHandler();   // retries, timeout, circuit breaker

AddStandardResilienceHandlerMicrosoft.Extensions.Http.Resilienceに含まれており、ポリシーを自分で書かなくても、一時的な失敗に対するジッター付き再試行、リクエスト全体のタイムアウト、サーキットブレーカーを提供します。古いプロジェクトでは、PollyのAddTransientHttpErrorPolicyが同じ範囲をカバーします。

C#の型付きHttpClientがBearerトークンとIdempotency-Keyを付けて宛先URLを短縮サービスのリンクエンドポイントにPOSTし、201レスポンスからshort_urlをデシリアライズする流れ

型付きクライアント自体は小さく保てます。

public sealed class ElidoClient(HttpClient http)
{
    public async Task<string> ShortenAsync(string destination, CancellationToken ct = default)
    {
        var request = new HttpRequestMessage(HttpMethod.Post, "/v1/links")
        {
            Content = JsonContent.Create(new LinkRequest(destination)),
        };

        // stable across retries and across re-runs of the same batch
        request.Headers.Add("Idempotency-Key",
            Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(destination))));

        using var response = await http.SendAsync(request, ct);
        response.EnsureSuccessStatusCode();

        var link = await response.Content.ReadFromJsonAsync<LinkResponse>(ct);
        return link!.short_url;
    }
}

このヘッダーがあるため、レジリエンスハンドラーを安全に有効化できます。これがないと、レスポンスを失った後の再試行で同じ宛先に対する2つ目のリンクが作成されますが、あればAPIが元のリンクを返します。レート制限と冪等性の詳しい解説では、バッチ処理では新しいGUIDを使うより宛先からキーを導出する方がよい理由を説明しています。

コンソールアプリ、ASP.NET Coreサービス、Azure Functionが同じ型付きクライアントを登録し、URL短縮サービスのエンドポイントに同一のPOSTを送信する構成

実際のエンドポイントで試してみますか。無料プランでキーを作成し、Elido:ApiKeyとしてユーザーシークレットに保存すれば、上の登録を変更せずに使えます。

推測せずエラー本文を読む

EnsureSuccessStatusCodeは適切なデフォルトですが、失敗した呼び出しで最も役立つ部分を捨ててしまいます。例外メッセージに含まれるのはステータスコードだけなので、422の場合はリクエストが拒否されたことはわかっても、APIがどのフィールドを問題視したのかはわかりません。

午前3時にデバッグすることになる処理では、まず本文を読んでください。

using var response = await http.SendAsync(request, ct);

if (!response.IsSuccessStatusCode)
{
    var problem = await response.Content.ReadAsStringAsync(ct);
    logger.LogWarning("shorten failed {Status}: {Body}", (int)response.StatusCode, problem);

    throw response.StatusCode switch
    {
        HttpStatusCode.Unauthorized      => new InvalidOperationException("API key rejected"),
        HttpStatusCode.TooManyRequests   => new HttpRequestException("rate limited"),
        _                                => new HttpRequestException($"shorten failed: {problem}"),
    };
}

ここからわかることは2つあります。ログ行がバリデーションエラーのフィールド名を示すため、リクエストを自分で再構成せずに済むこと。そして、再試行する価値のある失敗と、決して成功しない失敗をswitchで分けられることです。401を3回再試行するのは、無駄な3回の呼び出しと遅い失敗を生むだけです。

標準レジリエンスハンドラーは、5xxと429をすでに一時的なエラーとして扱い、4xxはそのままにします。つまり2つの層の動作は一致しています。再試行する価値のあるものは再試行し、残りはコードが説明します。

レート制限を超えない一括処理

3,000個のURLのリストに対してTask.WhenAllを使うと、3,000件のリクエストが開始されます。Parallel.ForEachAsyncは実行中の数に上限を設けられ、手書きのセマフォよりも読みやすくなります。

var results = new ConcurrentDictionary<string, string>();

await Parallel.ForEachAsync(
    urls,
    new ParallelOptions { MaxDegreeOfParallelism = 8 },
    async (url, ct) =>
    {
        try
        {
            results[url] = await client.ShortenAsync(url, ct);
        }
        catch (Exception ex)
        {
            results[url] = $"ERROR: {ex.Message}"; // one bad URL must not sink the batch
        }
    });

8は開始点であり、固定値ではありません。429と、APIが送信するRetry-Afterヘッダーを監視し、増やすのではなく減らす方向で調整してください。元のURLをキーにすれば、途中で終わった実行から、やり直す行を正確に把握できます。

コードを置く場所

CSVを短縮するコンソールツール、注文の発送時にリンクを作成するホステッドサービス、キューのトリガーで動くAzure Function。登録方法は3つでも、型付きクライアントは同じです。変えてはいけないのは、キーの取得元です。開発ではユーザーシークレット、本番では環境変数またはボルト、その間にIOptionsを置き、リポジトリに入るappsettings.jsonにはキーを一切置きません。

サービスからリンクを作成するようになったら、APIが開発者に公開しているその他の機能が興味深い部分になります。クリックやスキャンデータについては、ポーリングよりもリンクイベント用のwebhooksが適しています。クライアント自体を保守したくない場合は、APIとSDKのページに生成済みのものが掲載されています。

中核シリーズを読む

この記事はengineeringクラスターに属します。まず無料URL短縮APIガイドでエンドポイントの形式と認証を確認し、次にレート制限と冪等性で負荷時の動作を確認してください。ライブリファレンスはAPIドキュメントです。

ブログの関連記事

よくある質問

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

HttpClientを使って長いURLを短縮サービスのAPIにPOSTし、PostAsJsonAsyncで本文のシリアライズを任せます。その後、レスポンスを小さなレコードにデシリアライズしてShortUrlを読み取ります。クライアントにAuthorizationヘッダーを設定しておけば、呼び出し自体は1行です。

ループ内でnew HttpClient()を使ってはいけないのはなぜですか?

各インスタンスが独自の接続プールを開き、1つを破棄するとソケットがTIME_WAITに残るため、負荷がかかったループでは利用可能なポートを使い果たす可能性があります。クライアントをIHttpClientFactoryに登録して注入すれば、ハンドラーがプールされ、DNSも正しくローテーションされます。

HttpClientは401または500のレスポンスで例外をスローしますか?

デフォルトではスローしません。タスクは正常に完了し、IsSuccessStatusCodeはfalseになるため、確認しないと失敗した呼び出しが成功したように見えます。EnsureSuccessStatusCodeを呼び出してHttpRequestExceptionに変えるか、本文を読む前に自分でステータスを確認してください。

.NETのHttpClientに再試行を追加するにはどうすればよいですか?

Microsoft.Extensions.Http.Resilienceの標準レジリエンスハンドラーをクライアント登録に追加します。これにより、呼び出しごとのコードなしで、ジッター付きバックオフによる再試行、リクエスト全体のタイムアウト、サーキットブレーカーが追加されます。古いプロジェクトでは、PollyのAddTransientHttpErrorPolicyが同じ役割を果たします。

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

MaxDegreeOfParallelismを約8に設定したParallel.ForEachAsyncを使います。これにより、リストの長さに関係なく、実行中のリクエスト数に上限を設けられます。元のURLをキーにしたConcurrentDictionaryに結果を集めると、一部の失敗を確認でき、バッチを再実行できます。

.NETアプリではAPIキーをどこに置くべきですか?

コミットされない設定に置きます。開発ではユーザーシークレット、本番では環境変数またはキーボルトを使います。IOptionsでバインドしてクライアント登録時に読み取れば、キーをローテーションするときも再ビルドではなく設定変更で済みます。

Elidoを試す

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

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

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

Elidoを試す

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

タグ
how to shorten a url in c#
c# url shortener api
httpclient postasjsonasync
dotnet shorten url
ihttpclientfactory typed client
bulk shorten urls dotnet

続きを読む