From 6a105c6f8fe9ac0f01c6e1c69159ab52d1593829 Mon Sep 17 00:00:00 2001 From: JKorf Date: Thu, 13 Jun 2024 16:29:02 +0200 Subject: [PATCH] Added support for caching GET requests --- CryptoExchange.Net/Caching/MemoryCache.cs | 54 ++++++++++++++++ CryptoExchange.Net/Clients/RestApiClient.cs | 62 ++++++++++++++++++- .../RestApiClientLoggingExtensions.cs | 33 ++++++++++ CryptoExchange.Net/Objects/CallResult.cs | 16 +++++ CryptoExchange.Net/Objects/Enums.cs | 15 +++++ .../Objects/Options/RestExchangeOptions.cs | 14 ++++- .../Objects/RequestDefinition.cs | 6 ++ .../Objects/RequestDefinitionCache.cs | 5 +- 8 files changed, 202 insertions(+), 3 deletions(-) create mode 100644 CryptoExchange.Net/Caching/MemoryCache.cs diff --git a/CryptoExchange.Net/Caching/MemoryCache.cs b/CryptoExchange.Net/Caching/MemoryCache.cs new file mode 100644 index 0000000..35f93c2 --- /dev/null +++ b/CryptoExchange.Net/Caching/MemoryCache.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Concurrent; + +namespace CryptoExchange.Net.Caching +{ + internal class MemoryCache + { + private readonly ConcurrentDictionary _cache = new ConcurrentDictionary(); + + /// + /// Add a new cache entry. Will override an existing entry if it already exists + /// + /// The key identifier + /// Cache value + public void Add(string key, object value) + { + var cacheItem = new CacheItem(DateTime.UtcNow, value); + _cache.AddOrUpdate(key, cacheItem, (key, val1) => cacheItem); + } + + /// + /// Get a cached value + /// + /// The key identifier + /// The max age of the cached entry + /// Cached value if it was in cache + public object? Get(string key, TimeSpan maxAge) + { + _cache.TryGetValue(key, out CacheItem value); + if (value == null) + return null; + + if (DateTime.UtcNow - value.CacheTime > maxAge) + { + _cache.TryRemove(key, out _); + return null; + } + + return value.Value; + } + + private class CacheItem + { + public DateTime CacheTime { get; } + public object Value { get; } + + public CacheItem(DateTime cacheTime, object value) + { + CacheTime = cacheTime; + Value = value; + } + } + } +} diff --git a/CryptoExchange.Net/Clients/RestApiClient.cs b/CryptoExchange.Net/Clients/RestApiClient.cs index 857879d..0264348 100644 --- a/CryptoExchange.Net/Clients/RestApiClient.cs +++ b/CryptoExchange.Net/Clients/RestApiClient.cs @@ -8,6 +8,7 @@ using System.Net; using System.Net.Http; using System.Threading; using System.Threading.Tasks; +using CryptoExchange.Net.Caching; using CryptoExchange.Net.Converters.JsonNet; using CryptoExchange.Net.Interfaces; using CryptoExchange.Net.Logging.Extensions; @@ -17,6 +18,7 @@ using CryptoExchange.Net.RateLimiting; using CryptoExchange.Net.RateLimiting.Interfaces; using CryptoExchange.Net.Requests; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; namespace CryptoExchange.Net.Clients { @@ -85,6 +87,10 @@ namespace CryptoExchange.Net.Clients /// public new RestApiOptions ApiOptions => (RestApiOptions)base.ApiOptions; + /// + /// Memory cache + /// + private static MemoryCache _cache = new MemoryCache(); /// /// ctor @@ -190,6 +196,21 @@ namespace CryptoExchange.Net.Clients Dictionary? additionalHeaders = null, int? weight = null) where T : class { + var key = baseAddress + definition + uriParameters?.ToFormData(); + if (ShouldCache(definition)) + { + _logger.CheckingCache(key); + var cachedValue = _cache.Get(key, ClientOptions.CachingMaxAge); + if (cachedValue != null) + { + _logger.CacheHit(key); + var original = (WebCallResult)cachedValue; + return original.Cached(); + } + + _logger.CacheNotHit(key); + } + int currentTry = 0; while (true) { @@ -215,6 +236,12 @@ namespace CryptoExchange.Net.Clients if (await ShouldRetryRequestAsync(definition.RateLimitGate, result, currentTry).ConfigureAwait(false)) continue; + if (result.Success && + ShouldCache(definition)) + { + _cache.Add(key, result); + } + return result; } } @@ -445,6 +472,7 @@ namespace CryptoExchange.Net.Clients /// Credits used for the request /// Additional headers to send with the request /// The ratelimit gate to use + /// Whether caching should be prevented for this request /// [return: NotNull] protected virtual async Task> SendRequestAsync( @@ -458,9 +486,25 @@ namespace CryptoExchange.Net.Clients ArrayParametersSerialization? arraySerialization = null, int requestWeight = 1, Dictionary? additionalHeaders = null, - IRateLimitGate? gate = null + IRateLimitGate? gate = null, + bool preventCaching = false ) where T : class { + var key = uri.ToString() + method + signed + parameters?.ToFormData(); + if (ShouldCache(method) && !preventCaching) + { + _logger.CheckingCache(key); + var cachedValue = _cache.Get(key, ClientOptions.CachingMaxAge); + if (cachedValue != null) + { + _logger.CacheHit(key); + var original = (WebCallResult)cachedValue; + return original.Cached(); + } + + _logger.CacheNotHit(key); + } + int currentTry = 0; while (true) { @@ -478,6 +522,13 @@ namespace CryptoExchange.Net.Clients if (await ShouldRetryRequestAsync(gate, result, currentTry).ConfigureAwait(false)) continue; + if (result.Success && + ShouldCache(method) && + !preventCaching) + { + _cache.Add(key, result); + } + return result; } } @@ -949,5 +1000,14 @@ namespace CryptoExchange.Net.Clients return new WebCallResult(null, null, null, null, null, null, null, null, null, null, true, null); } + + private bool ShouldCache(RequestDefinition definition) + => ClientOptions.CachingEnabled + && definition.Method == HttpMethod.Get + && !definition.PreventCaching; + + private bool ShouldCache(HttpMethod method) + => ClientOptions.CachingEnabled + && method == HttpMethod.Get; } } diff --git a/CryptoExchange.Net/Logging/Extensions/RestApiClientLoggingExtensions.cs b/CryptoExchange.Net/Logging/Extensions/RestApiClientLoggingExtensions.cs index 883b301..5142615 100644 --- a/CryptoExchange.Net/Logging/Extensions/RestApiClientLoggingExtensions.cs +++ b/CryptoExchange.Net/Logging/Extensions/RestApiClientLoggingExtensions.cs @@ -17,6 +17,9 @@ namespace CryptoExchange.Net.Logging.Extensions private static readonly Action _restApiRateLimitRetry; private static readonly Action _restApiRateLimitPauseUntil; private static readonly Action _restApiSendRequest; + private static readonly Action _restApiCheckingCache; + private static readonly Action _restApiCacheHit; + private static readonly Action _restApiCacheNotHit; static RestApiClientLoggingExtensions() @@ -65,6 +68,21 @@ namespace CryptoExchange.Net.Logging.Extensions LogLevel.Debug, new EventId(4008, "RestApiSendRequest"), "[Req {RequestId}] Sending {Definition} request with body {Body}, query parameters {Query} and headers {Headers}"); + + _restApiCheckingCache = LoggerMessage.Define( + LogLevel.Trace, + new EventId(4009, "RestApiCheckingCache"), + "Checking cache for key {Key}"); + + _restApiCacheHit = LoggerMessage.Define( + LogLevel.Trace, + new EventId(4010, "RestApiCacheHit"), + "Cache hit for key {Key}"); + + _restApiCacheNotHit = LoggerMessage.Define( + LogLevel.Trace, + new EventId(4011, "RestApiCacheNotHit"), + "Cache not hit for key {Key}"); } public static void RestApiErrorReceived(this ILogger logger, int? requestId, HttpStatusCode? responseStatusCode, long responseTime, string? error) @@ -111,5 +129,20 @@ namespace CryptoExchange.Net.Logging.Extensions { _restApiSendRequest(logger, requestId, definition, body, query, headers, null); } + + public static void CheckingCache(this ILogger logger, string key) + { + _restApiCheckingCache(logger, key, null); + } + + public static void CacheHit(this ILogger logger, string key) + { + _restApiCacheHit(logger, key, null); + } + + public static void CacheNotHit(this ILogger logger, string key) + { + _restApiCacheNotHit(logger, key, null); + } } } diff --git a/CryptoExchange.Net/Objects/CallResult.cs b/CryptoExchange.Net/Objects/CallResult.cs index be9b783..17e53ba 100644 --- a/CryptoExchange.Net/Objects/CallResult.cs +++ b/CryptoExchange.Net/Objects/CallResult.cs @@ -331,6 +331,11 @@ namespace CryptoExchange.Net.Objects /// public TimeSpan? ResponseTime { get; set; } + /// + /// The data source of this result + /// + public ResultDataSource DataSource { get; set; } = ResultDataSource.Server; + /// /// Create a new result /// @@ -417,6 +422,17 @@ namespace CryptoExchange.Net.Objects return new WebCallResult(ResponseStatusCode, ResponseHeaders, ResponseTime, ResponseLength, OriginalData, RequestId, RequestUrl, RequestBody, RequestMethod, RequestHeaders, default, error); } + /// + /// Return a copy of this result with data source set to cache + /// + /// + internal WebCallResult Cached() + { + var result = new WebCallResult(ResponseStatusCode, ResponseHeaders, ResponseTime, ResponseLength, OriginalData, RequestId, RequestUrl, RequestBody, RequestMethod, RequestHeaders, default, Error); + result.DataSource = ResultDataSource.Cache; + return result; + } + /// public override string ToString() { diff --git a/CryptoExchange.Net/Objects/Enums.cs b/CryptoExchange.Net/Objects/Enums.cs index bc27771..2af12e0 100644 --- a/CryptoExchange.Net/Objects/Enums.cs +++ b/CryptoExchange.Net/Objects/Enums.cs @@ -188,4 +188,19 @@ /// ExponentialBackoff } + + /// + /// The data source of the result + /// + public enum ResultDataSource + { + /// + /// From server + /// + Server, + /// + /// From cache + /// + Cache + } } diff --git a/CryptoExchange.Net/Objects/Options/RestExchangeOptions.cs b/CryptoExchange.Net/Objects/Options/RestExchangeOptions.cs index 9368b70..80a5e96 100644 --- a/CryptoExchange.Net/Objects/Options/RestExchangeOptions.cs +++ b/CryptoExchange.Net/Objects/Options/RestExchangeOptions.cs @@ -18,6 +18,16 @@ namespace CryptoExchange.Net.Objects.Options /// public TimeSpan TimestampRecalculationInterval { get; set; } = TimeSpan.FromHours(1); + /// + /// Whether caching is enabled. Caching will only be applied to GET http requests. The lifetime of cached results can be determined by the `CachingMaxAge` option + /// + public bool CachingEnabled { get; set; } = false; + + /// + /// The max age of a cached entry, only used when the `CachingEnabled` options is set to true. When a cached entry is older than the max age it will be discarded and a new server request will be done + /// + public TimeSpan CachingMaxAge { get; set; } = TimeSpan.FromSeconds(5); + /// /// Create a copy of this options /// @@ -34,7 +44,9 @@ namespace CryptoExchange.Net.Objects.Options Proxy = Proxy, RequestTimeout = RequestTimeout, RateLimiterEnabled = RateLimiterEnabled, - RateLimitingBehaviour = RateLimitingBehaviour + RateLimitingBehaviour = RateLimitingBehaviour, + CachingEnabled = CachingEnabled, + CachingMaxAge = CachingMaxAge, }; } } diff --git a/CryptoExchange.Net/Objects/RequestDefinition.cs b/CryptoExchange.Net/Objects/RequestDefinition.cs index 7b6d441..04549fb 100644 --- a/CryptoExchange.Net/Objects/RequestDefinition.cs +++ b/CryptoExchange.Net/Objects/RequestDefinition.cs @@ -61,6 +61,12 @@ namespace CryptoExchange.Net.Objects /// public TimeSpan? EndpointLimitPeriod { get; set; } + + /// + /// Whether this request should never be cached + /// + public bool PreventCaching { get; set; } + /// /// ctor /// diff --git a/CryptoExchange.Net/Objects/RequestDefinitionCache.cs b/CryptoExchange.Net/Objects/RequestDefinitionCache.cs index 67ac2f1..9e3ab47 100644 --- a/CryptoExchange.Net/Objects/RequestDefinitionCache.cs +++ b/CryptoExchange.Net/Objects/RequestDefinitionCache.cs @@ -48,6 +48,7 @@ namespace CryptoExchange.Net.Objects /// Request body format /// Parameter position /// Array serialization type + /// Prevent request caching /// public RequestDefinition GetOrCreate( HttpMethod method, @@ -59,7 +60,8 @@ namespace CryptoExchange.Net.Objects TimeSpan? endpointLimitPeriod = null, RequestBodyFormat? requestBodyFormat = null, HttpMethodParameterPosition? parameterPosition = null, - ArrayParametersSerialization? arraySerialization = null) + ArrayParametersSerialization? arraySerialization = null, + bool? preventCaching = null) { if (!_definitions.TryGetValue(method + path, out var def)) @@ -74,6 +76,7 @@ namespace CryptoExchange.Net.Objects ArraySerialization = arraySerialization, RequestBodyFormat = requestBodyFormat, ParameterPosition = parameterPosition, + PreventCaching = preventCaching ?? false }; _definitions.TryAdd(method + path, def); }