Visual Studio 2013 から 2022 への移行では、非推奨の WebRequest をどう置き換えるかが最大のハードルになりがちです。RoboCall のような外部サーバーへ JSON を POST して結果を即時に返す要件でも、答えは明快――「非同期ファースト(Async/Await の貫徹)」です。本記事では、同期ブロックの落とし穴を避けつつ、HttpClient へ安全に移行するための実践コード、判断基準、テスト戦略まで一気通貫で解説します。
移行の背景と課題整理
次の要件を満たしつつ、古い WebRequest 実装を廃止したい、という状況を想定します。
- Visual Studio 2013 → 2022 へのアップグレード。
- RoboCall サーバーへ JSON を POST、応答を待って成功/失敗を呼び出し元へ返す。
HttpClientは非同期 API が中心。呼び出し側をAwaitで待ちたいが、「await は Async メソッド内でのみ使用できる」 というエラーで足踏み。
結論から言えば、呼び出しチェーンをすべて Async/Await 化するのが最善です。途中で“同期化”してしまうと、UI スレッドや ASP.NET の同期コンテキストでデッドロックを誘発し、スケーラビリティも落ちます。どうしても同期的に待つ必要がある箇所はありますが、それは「逃げ道」として最小限にとどめるのが定石です。
結論:Async/Await を末端まで貫く(最善策)
Await を使えるようにするには、呼び出し元のメソッド自体を Async で宣言し、戻り値を Task/Task(Of T)(C# なら Task<T>)にします。イベント ハンドラー以外の Async Sub は避け、必ず Task を返すことで例外伝播と合流点の明確化を図ります。
最小構成の呼び出しチェーン像
- UI層(フォームやページ)…
Asyncイベント ハンドラー - アプリ層(サービス/BLL)…
AsyncメソッドでHttpClientを呼ぶ - インフラ層(HTTP クライアント)… 実通信、タイムアウト、例外処理を集約
VB.NET:RoboCall に JSON を POST する最小実装
' 共有の HttpClient(アプリ全体で長寿命化)
Imports System.Net.Http
Imports System.Text
Imports System.Threading
Public NotInheritable Class HttpClient_General_POSTBLL
Private Sub New()
End Sub
' ※アプリ全体で 1 インスタンスを再利用(ソケット枯渇を避ける)
Private Shared ReadOnly _client As New HttpClient(New HttpClientHandler() With {
.AutomaticDecompression = Net.DecompressionMethods.GZip Or Net.DecompressionMethods.Deflate
}) From {
.Timeout = Timeout.InfiniteTimeSpan ' タイムアウトはリクエスト単位の CTS で管理
}
''' <summary>
''' RoboCall へ JSON を POST して応答文字列を返す
''' </summary>
Public Shared Async Function PostData(
strMARCLId As String,
strLogonId As String,
strUri As String,
json As String,
strAuthString As String,
Optional ct As CancellationToken = Nothing
) As Task(Of String)
Using req As New HttpRequestMessage(HttpMethod.Post, strUri)
' 認証方式は要件に応じて(例:Bearer / Basic)
If Not String.IsNullOrEmpty(strAuthString) Then
req.Headers.Authorization =
New Headers.AuthenticationHeaderValue("Bearer", strAuthString)
End If
req.Headers.Accept.Clear()
req.Headers.Accept.Add(New Headers.MediaTypeWithQualityHeaderValue("application/json"))
' 送信 JSON(既に構築済みの文字列を受け取る前提)
req.Content = New StringContent(json, Encoding.UTF8, "application/json")
' 追跡用ヘッダー(必要に応じて)
req.Headers.Add("X-MARCL-ID", strMARCLId)
req.Headers.Add("X-LOGON-ID", strLogonId)
' ヘッダー到着で返す(大きなレスポンスでも効率良く)
Using res As HttpResponseMessage = Await _client.SendAsync(
req, HttpCompletionOption.ResponseHeadersRead, ct
).ConfigureAwait(False)
Dim body As String = Await res.Content.ReadAsStringAsync().ConfigureAwait(False)
If Not res.IsSuccessStatusCode Then
' 失敗ログを残してから再スロー
Dim msg = $"RoboCall Error {(CInt(res.StatusCode))} ({res.ReasonPhrase}) : {body}"
Throw New HttpRequestException(msg)
End If
Return body
End Using
End Using
End Function
End Class
' 呼び出し側(アプリケーションサービス層)
Public Class RoboService
Public Async Function CallRoboAsync(
strMARCLId As String,
strLogonId As String,
strUri As String,
json As String,
strAuthString As String,
Optional timeoutMs As Integer = 10000
) As Task(Of String)
Using cts As New CancellationTokenSource(timeoutMs)
Return Await HttpClient_General_POSTBLL.PostData(
strMARCLId, strLogonId, strUri, json, strAuthString, cts.Token
)
End Using
End Function
End Class
' UI(イベント ハンドラーから末端まで Async)
Private Async Sub btnSend_Click(sender As Object, e As EventArgs) Handles btnSend.Click
Try
Dim svc As New RoboService()
Dim json As String = txtPayload.Text
Dim result As String = Await svc.CallRoboAsync("M123", "user01", txtUri.Text, json, txtToken.Text)
txtResult.Text = result
Catch ex As OperationCanceledException
MessageBox.Show("タイムアウトしました。")
Catch ex As Exception
MessageBox.Show($"送信に失敗しました: {ex.Message}")
End Try
End Sub
C# 版(同等の構成)
using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
public static class RoboHttp
{
private static readonly HttpClient _client = new HttpClient(
new HttpClientHandler
{
AutomaticDecompression =
System.Net.DecompressionMethods.GZip | System.Net.DecompressionMethods.Deflate
})
{
Timeout = System.Threading.Timeout.InfiniteTimeSpan
};
public static async Task<string> PostJsonAsync(
string marclId, string logonId, string uri, string json, string auth, CancellationToken ct = default)
{
using var req = new HttpRequestMessage(HttpMethod.Post, uri);
if (!string.IsNullOrEmpty(auth))
{
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", auth);
}
req.Headers.Accept.Clear();
req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
req.Headers.Add("X-MARCL-ID", marclId);
req.Headers.Add("X-LOGON-ID", logonId);
req.Content = new StringContent(json, Encoding.UTF8, "application/json");
using var res = await _client.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct)
.ConfigureAwait(false);
var body = await res.Content.ReadAsStringAsync().ConfigureAwait(false);
if (!res.IsSuccessStatusCode)
{
throw new HttpRequestException($"RoboCall Error {(int)res.StatusCode} ({res.ReasonPhrase}) : {body}");
}
return body;
}
}
public sealed class RoboService
{
public async Task CallRoboAsync(
string marclId, string logonId, string uri, string json, string auth, int timeoutMs = 10000)
{
using var cts = new CancellationTokenSource(timeoutMs);
return await RoboHttp.PostJsonAsync(marclId, logonId, uri, json, auth, cts.Token);
}
}
// UI 例(WinForms/WPF)
private async void btnSend_Click(object sender, EventArgs e)
{
try
{
var svc = new RoboService();
var result = await svc.CallRoboAsync("M123", "user01", txtUri.Text, txtPayload.Text, txtToken.Text);
txtResult.Text = result;
}
catch (OperationCanceledException)
{
MessageBox.Show("タイムアウトしました。");
}
catch (Exception ex)
{
MessageBox.Show($"送信に失敗しました: {ex.Message}");
}
}
同期でブロックしたい場合(非推奨の逃げ道)
レガシーな呼び出し元の都合で「今はどうしても同期で待ちたい」こともあります。その場合は デッドロックを避けるため、ConfigureAwait(False) をライブラリ側で徹底し、UI/ASP.NET の同期コンテキストを捕捉しないようにします。そのうえで、UI ではないレイヤーに限って以下のように合流させます。
' どうしても同期で合流したいとき(UI スレッドでは避ける)
Dim body As String = Task.Run(Function()
Return HttpClient_General_POSTBLL.PostData(marcl, logon, uri, json, token)
End Function).GetAwaiter().GetResult()
// 同期合流(UI/ASP.NET 同期コンテキストでは非推奨)
var body = Task.Run(() => RoboHttp.PostJsonAsync(marcl, logon, uri, json, token))
.GetAwaiter().GetResult();
注意:この合流はスレッドを占有し、可用性とスループットを落とします。移行期間の“踏み台”と割り切り、最終的には呼び出し元も Async 化する計画を持ちましょう。
HttpClient の寿命と再利用
Using New HttpClient() を毎回作って破棄すると、ソケットが TIME_WAIT を大量に残し、ポート枯渇やスループット低下の原因になります。長寿命の共有インスタンスか、ASP.NET Core なら IHttpClientFactory を使ってプール・DNS 更新・ハンドラ管理をフレームワークに任せるのが安全です。
シングルトン(フレームワーク共通)
public static class HttpClients
{
public static readonly HttpClient Default = new HttpClient(new SocketsHttpHandler
{
AutomaticDecompression =
System.Net.DecompressionMethods.GZip | System.Net.DecompressionMethods.Deflate,
PooledConnectionIdleTimeout = TimeSpan.FromMinutes(2),
MaxConnectionsPerServer = 50
})
{
Timeout = Timeout.InfiniteTimeSpan
};
}
IHttpClientFactory(ASP.NET Core)
// Program.cs / Startup.cs
builder.Services.AddHttpClient("RoboCall", client =>
{
client.DefaultRequestHeaders.Accept.Add(
new MediaTypeWithQualityHeaderValue("application/json"));
}).ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
{
AutomaticDecompression = System.Net.DecompressionMethods.GZip |
System.Net.DecompressionMethods.Deflate
});
// 使う側
public class RoboClient
{
private readonly HttpClient _client;
public RoboClient(IHttpClientFactory factory)
{
_client = factory.CreateClient("RoboCall");
}
public Task<string> PostJsonAsync(string uri, string json, CancellationToken ct) =>
SendAsync(uri, json, ct);
private async Task<string> SendAsync(string uri, string json, CancellationToken ct)
{
using var req = new HttpRequestMessage(HttpMethod.Post, uri);
req.Content = new StringContent(json, Encoding.UTF8, "application/json");
using var res = await _client.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct)
.ConfigureAwait(false);
var body = await res.Content.ReadAsStringAsync().ConfigureAwait(false);
res.EnsureSuccessStatusCode();
return body;
}
}
タイムアウトとキャンセル設計
HttpClient.Timeout に頼るのではなく、リクエスト単位で CancellationTokenSource を渡すと柔軟に制御できます。外部 API は一時的な遅延が起きるため、呼び出し側の要件(UI のレスポンス、バッチの SLA 等)に合わせてタイムアウトを決定しましょう。
' UI 応答優先:3秒で諦める
Using cts As New CancellationTokenSource(TimeSpan.FromSeconds(3))
Dim result = Await HttpClient_General_POSTBLL.PostData(..., cts.Token)
End Using
// バッチ:10秒待ち・再試行あり
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var result = await RoboHttp.PostJsonAsync(..., cts.Token);
エラー処理と戻り値設計
HTTP ステータスコードで成功/失敗を分岐し、失敗時はレスポンス本文(エラー詳細)をログに残したうえで例外に含めます。外部 API の仕様によっては成功でも本文にエラー情報を返すことがあるため、ドメイン固有の結果型を返すと取り回しが良くなります。
public sealed record RoboResult(bool IsSuccess, string Message, string? RawJson);
public async Task<RoboResult> CallRoboAsync(...)
{
try
{
var json = await RoboHttp.PostJsonAsync(...);
// 例:JSON の "status" を見て解釈(実際の仕様に合わせて実装)
var isOk = json.Contains("\"status\":\"ok\"", StringComparison.OrdinalIgnoreCase);
return new RoboResult(isOk, isOk ? "OK" : "API returned non-ok", json);
}
catch (OperationCanceledException)
{
return new RoboResult(false, "timeout", null);
}
catch (Exception ex)
{
return new RoboResult(false, ex.Message, null);
}
}
WebRequest → HttpClient 対応表(よく使う置き換え)
| WebRequest / WebResponse | HttpClient / HttpRequestMessage | ポイント |
|---|---|---|
WebRequest.Create(url) | new HttpRequestMessage(HttpMethod.Get, url) | メソッドは HttpMethod で明示 |
request.Method = "POST" | new HttpRequestMessage(HttpMethod.Post, url) | POST/PUT/PATCH/DELETE を型安全に指定 |
GetRequestStream() | req.Content = new StringContent(...) | 文字コードと MediaType を必ず指定 |
GetResponse() | await client.SendAsync(...) | 非同期で送受信。ResponseHeadersRead が効率的 |
HttpWebResponse.StatusCode | HttpResponseMessage.StatusCode | IsSuccessStatusCode や EnsureSuccessStatusCode() を活用 |
ServicePointManager.SecurityProtocol | 設定不要(既定の TLS を使用) | .NET 4.7 以降は既定で TLS 1.2 を選択 |
使い捨て new WebClient() | 共有 HttpClient | 長寿命化でソケット再利用・高パフォーマンス |
アンチパターンとベストプラクティス
| アンチパターン | ベストプラクティス | 理由/効果 |
|---|---|---|
.Result / .Wait() で同期化 | 呼び出しチェーンを全面 Async/Await 化 | 同期コンテキストでのデッドロック回避、保守性向上 |
毎回 Using New HttpClient() | 共有インスタンス or IHttpClientFactory | ソケット枯渇回避、DNS 更新・ハンドラ管理を自動化 |
client.Timeout だけに依存 | 各呼び出しで CancellationToken を渡す | 要件ごとにキャンセル制御でき、再試行設計が容易 |
| 例外を握りつぶす | 本文を含む詳細ログ+適切な再スロー | トラブル時の診断容易化、品質保証 |
ライブラリで Async Sub | Task を返す Async Function | 例外伝播と合流点を明確化できる |
段階移行を支えるブリッジ(互換レイヤー)
呼び出し元を一気に Async 化できない場合、薄い同期ブリッジを一時的に用意して差分を吸収します。最終的には廃止する前提で、アプリ層の奥(UI から遠い場所)にのみ置くのが安全です。
Public Class RoboBridge
' レガシーな同期 API(※最小限)
Public Shared Function CallRobo(json As String) As String
Return Task.Run(Function()
Dim svc As New RoboService()
Return svc.CallRoboAsync("M123", "user01", My.Settings.RoboUri, json, My.Settings.Token)
End Function).GetAwaiter().GetResult()
End Function
End Class
再試行(リトライ)とバックオフの素振り
外部 API は一時的なエラーが起きます。まずは タイムアウト+再試行 1~2 回の素朴な仕組みから始め、要件次第で指数バックオフ等へ発展させます。
public static async Task<string> PostWithRetryAsync(
string marcl, string logon, string uri, string json, string token, int timeoutMs = 3000)
{
var delays = new[] { 200, 500 }; // ms
for (int attempt = 0; ; attempt++)
{
using var cts = new CancellationTokenSource(timeoutMs);
try
{
return await RoboHttp.PostJsonAsync(marcl, logon, uri, json, token, cts.Token);
}
catch (OperationCanceledException) when (attempt < delays.Length)
{
await Task.Delay(delays[attempt]).ConfigureAwait(false);
continue;
}
catch (HttpRequestException) when (attempt < delays.Length)
{
await Task.Delay(delays[attempt]).ConfigureAwait(false);
continue;
}
}
}
テスト容易性:HttpMessageHandler を差し替えてモック
HttpClient は HttpMessageHandler の差し替えでテストが容易です。外部サーバーに触らず、期待する JSON を返すハンドラーを注入すれば、RoboCall 連携のユニット テストをオフラインで回せます。
public sealed class FakeHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, HttpResponseMessage> _responder;
public FakeHandler(Func<HttpRequestMessage, HttpResponseMessage> responder)
=> _responder = responder;
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
=> Task.FromResult(_responder(request));
}
// テスト例
var handler = new FakeHandler(req =>
{
var ok = new HttpResponseMessage(System.Net.HttpStatusCode.OK)
{
Content = new StringContent("{\"status\":\"ok\"}", Encoding.UTF8, "application/json")
};
return ok;
});
var client = new HttpClient(handler);
セキュリティとプロトコルの補足
- TLS:現在のランタイムでは既定で TLS 1.2+ が選択されるため、特別な設定は原則不要です。
- 認証ヘッダー:Bearer トークンを使う場合は
Authorization: Bearer <token>を設定。Basic 等は要件に合わせて実装。 - 秘匿情報:トークンや個人情報をログに書かない。必要時はマスク処理を徹底。
- 圧縮:
AutomaticDecompressionを有効化してネットワーク帯域を節約。
移行時に押さえる追加ポイント(再整理)
| 項目 | ベターな書き方 | 理由 |
|---|---|---|
| TLS 設定 | 既定の TLS(特に指定不要) | コード簡素化・安全性の既定値が向上 |
| タイムアウト | CancellationTokenSource をリクエスト単位で付与 | 柔軟・キャンセル容易・再試行と相性良し |
| 例外処理 | IsSuccessStatusCode で分岐し、本文をログしてから Throw | エラー情報の欠落を防ぎ、調査時間を短縮 |
| Dispose | HttpClient は長寿命化し通常 Dispose 不要 | ソケット再利用・パフォーマンス安定 |
「await は Async メソッド内でのみ使用できる」の正体
このエラーは、Await を含むメソッドが Async で宣言されていない(または戻り値が Task/Task(Of T) でない)ために発生します。次の要点を満たせば解決します。
- メソッド シグネチャに
Asyncを付ける。 - 戻り値は
Task/Task(Of T)にする(C# はTask/Task<T>)。 - イベント ハンドラー以外で
Async Subを使わない。
移行の実践ステップ(チェックリスト)
- 対象プロジェクトで
WebRequest/WebClientの使用箇所を機械的に洗い出す。 - 呼び出しチェーン(UI → BLL → インフラ)を列挙し、末端まで Async 化の順序を決める。
- まずは 1 経路を
HttpClient+CancellationTokenで置換し、ログとエラー設計を固める。 - レガシー呼び出し元のための同期ブリッジは一時的に限定、性能の要で使用しない。
- 再試行・バックオフ方針とタイムアウト値を要件別(UI/バッチ)に定義。
- テストでは
HttpMessageHandlerを差し替え、外部依存を切り離す。 - 本番相当の負荷でスループット・エラー率・タイムアウト比率を計測し、閾値をチューニング。
RoboCall 向け 完成サンプル(VB.NET:まとめ)
Public Class RoboCallFacade
Private ReadOnly _svc As New RoboService()
' 非同期ファーストの公開 API
Public Async Function SendAsync(uri As String, payloadJson As String, token As String) As Task(Of Boolean)
Using cts As New CancellationTokenSource(TimeSpan.FromSeconds(5))
Dim json As String = Await _svc.CallRoboAsync("M123", "user01", uri, payloadJson, token, 5000)
Return json.Contains("""status"":""ok""")
End Using
End Function
' 暫定:同期ブリッジ(将来的に廃止)
Public Function Send(uri As String, payloadJson As String, token As String) As Boolean
Return Task.Run(Function() SendAsync(uri, payloadJson, token)).GetAwaiter().GetResult()
End Function
End Class
パフォーマンスと可観測性のヒント
- 接続数制限:外部 API へ高頻度にアクセスする場合は
MaxConnectionsPerServerを調整。 - 圧縮とペイロード:常に
application/jsonと UTF-8 を明示。不要フィールドを送らない。 - 集中ロギング:要求 ID(
X-Request-ID等)を付与し、リクエスト/レスポンスの相関を取る。 - フェイルファスト:直近のエラー率が閾値を超えたら一時遮断(Circuit Breaker)を検討。
まとめ:非同期ファーストが唯一の正攻法
RoboCall のように「POST して結果を返す」処理は、非同期を末端まで伝搬することで最も安全・シンプル・高性能に実現できます。同期ブロックは最小限の退避策に留め、HttpClient の長寿命化、CancellationToken によるタイムアウト、失敗時の詳細ログ化という三本柱を標準化しましょう。これらを満たせば、WebRequest からの移行は着実に成功し、将来の .NET 更新にも強いコードベースに生まれ変わります。
付録:よくある Q&A(現場でのつまずき対策)
Q. 同期ブロックは絶対にダメ?
UI スレッドや ASP.NET の同期コンテキストでの .Result/.Wait() はデッドロックの温床です。どうしても必要なら、ライブラリ側は ConfigureAwait(False) を徹底し、UI から遠い箇所でのみ Task.Run(...).GetAwaiter().GetResult() を使います。
Q. Async Sub はダメ?
イベント ハンドラー(ボタンクリックなど)に限って許容。ライブラリ API では Task を返す Async Function に統一しましょう。
Q. HttpClient を Dispose すべき?
長寿命の共有インスタンスであれば、アプリ終了時まで Dispose は不要です。短命な生成・破棄の繰り返しはソケット枯渇を招きます。
Q. タイムアウトはどこに書く?
呼び出しごとに CancellationTokenSource を作り、業務要件に合わせて値を変えられるようにします。固定の client.Timeout は柔軟性に欠けます。
Q. JSON のシリアライザは?
本記事では JSON 文字列を既成前提で受け取りました。実装ではプロジェクト標準のシリアライザ(例:System.Text.Json 等)に統一してください。送受信でプロパティ命名や日付形式が一致しているかを必ずレビューしましょう。
Q. ログには何を残す?
リクエスト URL、メソッド、相関 ID、HTTP ステータス、経過時間、(可能なら)エラー本文。機微情報はマスクし、JSON はサイズ閾値で省略。
移行の要点・再掲
- 非同期ファースト:呼び出しチェーンを
Async/Awaitで統一。 - 共有 HttpClient:1 インスタンスを長寿命運用、または
IHttpClientFactory。 - タイムアウトと再試行:
CancellationTokenを徹底、必要最小のリトライ。 - 失敗時の可観測性:本文を含めて記録し、原因を素早く特定。

コメント