HTTPClient Cache

.NETC#HTTPキャッシュRFC2616DelegatingHandlerASP.NET Coreパフォーマンス

GitHub概要

thomasgalliker/HttpClient.Caching

Caching extensions for .NET HttpClient

スター24
ウォッチ1
フォーク7
作成日:2018年11月18日
言語:C#
ライセンス:MIT License

トピックス

cachecachinghttpclientmemorycache

スター履歴

thomasgalliker/HttpClient.Caching Star History
データ取得日時: 2025/10/22 08:06

キャッシュライブラリ

HTTPClient Cache

概要

HTTPClient Cacheは、.NET Framework/.NET CoreのSystem.Net.Http.HttpClientにキャッシュ機能を追加するライブラリ群です。RFC2616 HTTPキャッシング標準に準拠し、HTTPレスポンスの効率的なキャッシングを実現します。DelegatingHandlerパターンやIMemoryCacheとの統合により、既存のHttpClientコードに最小限の変更でキャッシュ機能を追加できます。

詳細

.NET環境でのHTTPキャッシュは、HttpClient.Cachingライブラリ、Marvin.HttpCacheライブラリ、ASP.NET Coreの組み込みキャッシュ機能など、複数のアプローチが存在します。これらはHTTPヘッダー(Cache-Control、ETag、Last-Modifiedなど)を活用し、条件付きGETリクエストやレスポンスキャッシングを自動化します。

主要なライブラリとアプローチ

  • HttpClient.Caching: Thomas Gallikerによる軽量キャッシング拡張ライブラリ
  • Marvin.HttpCache: Kevin Dockxによる包括的なRFC2616実装
  • ASP.NET Core Response Caching: フレームワーク組み込みのサーバーサイドキャッシュ
  • Custom DelegatingHandler: IMemoryCacheと統合したカスタム実装
  • Polly: キャッシュポリシーとの統合によるレジリエンス向上

技術的特徴

  • RFC2616準拠: 標準的なHTTPキャッシング仕様の完全実装
  • 条件付きリクエスト: ETag、Last-Modifiedを活用した効率的な再検証
  • DelegatingHandler統合: HttpClientパイプラインへの透明な統合
  • メモリキャッシュ: IMemoryCacheによる高速なインメモリキャッシング
  • ディスクキャッシュ: 永続化可能なディスクベースキャッシング

メリット・デメリット

メリット

  • 標準準拠: RFC2616による確立されたキャッシング標準の活用
  • 透明性: 既存のHttpClientコードへの影響を最小化
  • 高いパフォーマンス: ネットワーク呼び出しの削減による大幅な速度向上
  • 柔軟性: カスタムキャッシュポリシーとストレージ実装の対応
  • ASP.NET統合: フレームワークとの深い統合によるスケーラブルなソリューション
  • デバッグ支援: HttpClientの既存のログとトレース機能との統合

デメリット

  • 複雑性: HTTPキャッシング仕様の理解とデバッグの困難さ
  • メモリ消費: 大量のレスポンスデータによるメモリ使用量増加
  • 一貫性: 分散環境でのキャッシュ無効化の課題
  • 設定の複雑さ: 適切なキャッシュ戦略の選択と調整の困難性

参考ページ

書き方の例

HttpClient.Cachingライブラリの使用

using System;
using System.Net.Http;
using System.Threading.Tasks;
using HttpClientCaching;

public class HttpClientCachingExample
{
    public async Task BasicCachingExample()
    {
        // InMemoryCacheHandlerの作成
        var cacheHandler = new InMemoryCacheHandler()
        {
            InnerHandler = new HttpClientHandler()
        };

        // HttpClientにキャッシュハンドラーを設定
        using var httpClient = new HttpClient(cacheHandler);
        
        // 最初のリクエスト(キャッシュから取得)
        var response1 = await httpClient.GetAsync("https://api.example.com/data");
        var content1 = await response1.Content.ReadAsStringAsync();
        
        // 二回目のリクエスト(キャッシュから取得、ネットワーク呼び出しなし)
        var response2 = await httpClient.GetAsync("https://api.example.com/data");
        var content2 = await response2.Content.ReadAsStringAsync();
        
        Console.WriteLine($"First request: {content1.Length} characters");
        Console.WriteLine($"Second request: {content2.Length} characters");
        Console.WriteLine($"Same content: {content1 == content2}");
    }
}

カスタムDelegatingHandlerによるキャッシュ実装

using System;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;

public class CachingDelegatingHandler : DelegatingHandler
{
    private readonly IMemoryCache _cache;
    private readonly ILogger<CachingDelegatingHandler> _logger;
    private readonly TimeSpan _defaultCacheDuration;

    public CachingDelegatingHandler(
        IMemoryCache cache, 
        ILogger<CachingDelegatingHandler> logger,
        TimeSpan? defaultCacheDuration = null)
    {
        _cache = cache ?? throw new ArgumentNullException(nameof(cache));
        _logger = logger ?? throw new ArgumentNullException(nameof(logger));
        _defaultCacheDuration = defaultCacheDuration ?? TimeSpan.FromMinutes(5);
    }

    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request, 
        CancellationToken cancellationToken)
    {
        // GETリクエストのみキャッシュ対象
        if (request.Method != HttpMethod.Get)
        {
            return await base.SendAsync(request, cancellationToken);
        }

        var cacheKey = GenerateCacheKey(request);

        // キャッシュから取得を試行
        if (_cache.TryGetValue(cacheKey, out HttpResponseMessage cachedResponse))
        {
            _logger.LogInformation("Cache hit for {RequestUri}", request.RequestUri);
            return CloneResponse(cachedResponse);
        }

        // キャッシュミス - 実際のリクエストを実行
        _logger.LogInformation("Cache miss for {RequestUri}", request.RequestUri);
        var response = await base.SendAsync(request, cancellationToken);

        // 成功したレスポンスをキャッシュ
        if (response.IsSuccessStatusCode)
        {
            var cacheDuration = GetCacheDuration(response);
            var responseToCache = await CloneResponseAsync(response);
            
            _cache.Set(cacheKey, responseToCache, cacheDuration);
            _logger.LogInformation("Cached response for {RequestUri} for {Duration}", 
                request.RequestUri, cacheDuration);
        }

        return response;
    }

    private string GenerateCacheKey(HttpRequestMessage request)
    {
        return $"HttpCache:{request.Method}:{request.RequestUri}";
    }

    private TimeSpan GetCacheDuration(HttpResponseMessage response)
    {
        // Cache-Controlヘッダーから有効期限を取得
        if (response.Headers.CacheControl?.MaxAge.HasValue == true)
        {
            return response.Headers.CacheControl.MaxAge.Value;
        }

        // デフォルトのキャッシュ期間を使用
        return _defaultCacheDuration;
    }

    private async Task<HttpResponseMessage> CloneResponseAsync(HttpResponseMessage original)
    {
        var cloned = new HttpResponseMessage(original.StatusCode)
        {
            Content = new ByteArrayContent(await original.Content.ReadAsByteArrayAsync()),
            ReasonPhrase = original.ReasonPhrase,
            RequestMessage = original.RequestMessage,
            Version = original.Version
        };

        // ヘッダーをコピー
        foreach (var header in original.Headers)
        {
            cloned.Headers.TryAddWithoutValidation(header.Key, header.Value);
        }

        if (original.Content != null)
        {
            foreach (var header in original.Content.Headers)
            {
                cloned.Content.Headers.TryAddWithoutValidation(header.Key, header.Value);
            }
        }

        return cloned;
    }

    private HttpResponseMessage CloneResponse(HttpResponseMessage original)
    {
        // 簡略化されたクローン(実際の実装ではasyncクローンを推奨)
        return original;
    }
}

ASP.NET CoreでのHttpClientFactory統合

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Caching.Memory;

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        // メモリキャッシュの追加
        services.AddMemoryCache();

        // HttpClientFactoryの設定
        services.AddHttpClient("CachedClient")
            .AddHttpMessageHandler<CachingDelegatingHandler>();

        // カスタムキャッシュハンドラーの登録
        services.AddTransient<CachingDelegatingHandler>();

        // 名前付きHttpClientの設定例
        services.AddHttpClient("ApiClient", client =>
        {
            client.BaseAddress = new Uri("https://api.example.com/");
            client.DefaultRequestHeaders.Add("User-Agent", "MyApp/1.0");
        })
        .AddHttpMessageHandler<CachingDelegatingHandler>();
    }
}

// サービスでの使用例
public class ApiService
{
    private readonly HttpClient _httpClient;

    public ApiService(IHttpClientFactory httpClientFactory)
    {
        _httpClient = httpClientFactory.CreateClient("CachedClient");
    }

    public async Task<string> GetDataAsync()
    {
        var response = await _httpClient.GetAsync("data");
        return await response.Content.ReadAsStringAsync();
    }
}

ETagを活用した条件付きリクエスト

public class ETagCachingHandler : DelegatingHandler
{
    private readonly IMemoryCache _cache;

    public ETagCachingHandler(IMemoryCache cache)
    {
        _cache = cache;
    }

    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request, 
        CancellationToken cancellationToken)
    {
        if (request.Method != HttpMethod.Get)
        {
            return await base.SendAsync(request, cancellationToken);
        }

        var cacheKey = request.RequestUri.ToString();
        
        // キャッシュされたETagを確認
        if (_cache.TryGetValue($"{cacheKey}:etag", out string cachedETag))
        {
            // If-None-Matchヘッダーを追加
            request.Headers.IfNoneMatch.ParseAdd(cachedETag);
        }

        var response = await base.SendAsync(request, cancellationToken);

        if (response.StatusCode == System.Net.HttpStatusCode.NotModified)
        {
            // 304 Not Modified - キャッシュからレスポンスを取得
            if (_cache.TryGetValue(cacheKey, out HttpResponseMessage cachedResponse))
            {
                return cachedResponse;
            }
        }
        else if (response.IsSuccessStatusCode)
        {
            // 新しいレスポンスをキャッシュ
            var etag = response.Headers.ETag?.Tag;
            if (!string.IsNullOrEmpty(etag))
            {
                var clonedResponse = await CloneResponseAsync(response);
                _cache.Set(cacheKey, clonedResponse);
                _cache.Set($"{cacheKey}:etag", etag);
            }
        }

        return response;
    }

    private async Task<HttpResponseMessage> CloneResponseAsync(HttpResponseMessage original)
    {
        // レスポンスのクローン実装
        var content = await original.Content.ReadAsByteArrayAsync();
        var cloned = new HttpResponseMessage(original.StatusCode)
        {
            Content = new ByteArrayContent(content)
        };

        foreach (var header in original.Headers)
        {
            cloned.Headers.TryAddWithoutValidation(header.Key, header.Value);
        }

        return cloned;
    }
}

Pollyと統合したレジリエントキャッシング

using Polly;
using Polly.Extensions.Http;
using Microsoft.Extensions.DependencyInjection;

public void ConfigureServicesWithPolly(IServiceCollection services)
{
    services.AddMemoryCache();
    
    // リトライポリシーとキャッシュの組み合わせ
    var retryPolicy = HttpPolicyExtensions
        .HandleTransientHttpError()
        .WaitAndRetryAsync(
            retryCount: 3,
            sleepDurationProvider: retryAttempt => 
                TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)));

    services.AddHttpClient("ResilientCachedClient")
        .AddHttpMessageHandler<CachingDelegatingHandler>()
        .AddPolicyHandler(retryPolicy);
}

// 使用例
public class ResilientApiService
{
    private readonly HttpClient _httpClient;

    public ResilientApiService(IHttpClientFactory factory)
    {
        _httpClient = factory.CreateClient("ResilientCachedClient");
    }

    public async Task<ApiData> GetApiDataAsync()
    {
        var response = await _httpClient.GetAsync("api/data");
        var json = await response.Content.ReadAsStringAsync();
        return JsonSerializer.Deserialize<ApiData>(json);
    }
}

WebRequestのCachePolicy設定

using System.Net;
using System.Net.Cache;

public class WebRequestCacheExample
{
    public void ConfigureWebRequestCache()
    {
        // グローバルなデフォルトキャッシュポリシーを設定
        HttpWebRequest.DefaultCachePolicy = new RequestCachePolicy(RequestCacheLevel.Default);

        // または特定のリクエストに対してポリシーを設定
        var request = (HttpWebRequest)WebRequest.Create("https://api.example.com/data");
        request.CachePolicy = new RequestCachePolicy(RequestCacheLevel.CacheIfAvailable);

        // キャッシュを完全に無効化
        var noCache = new RequestCachePolicy(RequestCacheLevel.BypassCache);
        request.CachePolicy = noCache;
    }

    public async Task<string> GetWithCachePolicy()
    {
        var request = (HttpWebRequest)WebRequest.Create("https://api.example.com/data");
        
        // 期限切れでない限りキャッシュから取得
        request.CachePolicy = new RequestCachePolicy(RequestCacheLevel.CacheOnly);

        try
        {
            using var response = (HttpWebResponse)await request.GetResponseAsync();
            using var reader = new StreamReader(response.GetResponseStream());
            return await reader.ReadToEndAsync();
        }
        catch (WebException ex) when (ex.Status == WebExceptionStatus.CacheEntryNotFound)
        {
            // キャッシュにない場合の処理
            return "No cached data available";
        }
    }
}