C#でURLを短縮する処理は1回のPOSTです。宛先を短縮サービスのAPIに送信し、APIキーをBearerトークンとして渡して、レスポンスからshort_urlをデシリアライズします。HttpClientを使い、PostAsJsonAsyncがシリアライズを処理するため、クライアントを登録してしまえば呼び出しは1行です。
ここで前提とするエンドポイントの形式、認証モデル、無料プランの制限については、無料URL短縮APIの概要で説明しています。この記事は、Python、JavaScript、Go、Javaも扱うシリーズの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
AddStandardResilienceHandlerはMicrosoft.Extensions.Http.Resilienceに含まれており、ポリシーを自分で書かなくても、一時的な失敗に対するジッター付き再試行、リクエスト全体のタイムアウト、サーキットブレーカーを提供します。古いプロジェクトでは、PollyのAddTransientHttpErrorPolicyが同じ範囲をカバーします。
型付きクライアント自体は小さく保てます。
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を使うより宛先からキーを導出する方がよい理由を説明しています。
実際のエンドポイントで試してみますか。無料プランでキーを作成し、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件