From 6a04fe5fb7838c3ae32c948ae5bef5cc1795dacf Mon Sep 17 00:00:00 2001 From: John Luo Date: Wed, 7 Sep 2016 16:57:49 -0700 Subject: [PATCH] Store body and header separately, preliminary sharding - Add fast id --- .../CacheEntrySerializer.cs | 121 ++++--- .../CachedResponse.cs | 2 + .../CachedResponseBody.cs} | 4 +- .../CacheEntry/CachedVaryRules.cs | 12 + .../Interfaces/IKeyProvider.cs | 6 +- .../Internal/FastGuid.cs | 80 +++++ .../Internal/MemoryResponseCache.cs | 4 +- .../Internal/ResponseCachingState.cs | 2 + .../KeyProvider.cs | 10 +- .../ResponseCachingContext.cs | 101 ++++-- .../ResponseCachingOptions.cs | 5 + .../project.json | 1 + .../CacheEntrySerializerTests.cs | 114 ++++--- .../KeyProviderTests.cs | 59 ++-- .../ResponseCachingContextTests.cs | 320 +++++++++++++++++- .../ResponseCachingTests.cs | 23 ++ 16 files changed, 727 insertions(+), 137 deletions(-) rename src/Microsoft.AspNetCore.ResponseCaching/{Internal => CacheEntry}/CacheEntrySerializer.cs (66%) rename src/Microsoft.AspNetCore.ResponseCaching/{Internal => CacheEntry}/CachedResponse.cs (91%) rename src/Microsoft.AspNetCore.ResponseCaching/{Internal/CachedVaryRules.cs => CacheEntry/CachedResponseBody.cs} (75%) create mode 100644 src/Microsoft.AspNetCore.ResponseCaching/CacheEntry/CachedVaryRules.cs create mode 100644 src/Microsoft.AspNetCore.ResponseCaching/Internal/FastGuid.cs diff --git a/src/Microsoft.AspNetCore.ResponseCaching/Internal/CacheEntrySerializer.cs b/src/Microsoft.AspNetCore.ResponseCaching/CacheEntry/CacheEntrySerializer.cs similarity index 66% rename from src/Microsoft.AspNetCore.ResponseCaching/Internal/CacheEntrySerializer.cs rename to src/Microsoft.AspNetCore.ResponseCaching/CacheEntry/CacheEntrySerializer.cs index 0f4f3f5a37..48d28e319f 100644 --- a/src/Microsoft.AspNetCore.ResponseCaching/Internal/CacheEntrySerializer.cs +++ b/src/Microsoft.AspNetCore.ResponseCaching/CacheEntry/CacheEntrySerializer.cs @@ -13,6 +13,11 @@ namespace Microsoft.AspNetCore.ResponseCaching.Internal public static object Deserialize(byte[] serializedEntry) { + if (serializedEntry == null) + { + return null; + } + using (var memory = new MemoryStream(serializedEntry)) { using (var reader = new BinaryReader(memory)) @@ -37,7 +42,7 @@ namespace Microsoft.AspNetCore.ResponseCaching.Internal // Serialization Format // Format version (int) - // Type (char: 'R' for CachedResponse, 'V' for CachedVaryRules) + // Type (char: 'B' for CachedResponseBody, 'R' for CachedResponse, 'V' for CachedVaryRules) // Type-dependent data (see CachedResponse and CachedVaryRules) public static object Read(BinaryReader reader) { @@ -53,15 +58,17 @@ namespace Microsoft.AspNetCore.ResponseCaching.Internal var type = reader.ReadChar(); - if (type == 'R') + if (type == 'B') { - var cachedResponse = ReadCachedResponse(reader); - return cachedResponse; + return ReadCachedResponseBody(reader); + } + else if (type == 'R') + { + return ReadCachedResponse(reader); } else if (type == 'V') { - var cachedVaryRules = ReadCachedVaryRules(reader); - return cachedVaryRules; + return ReadCachedVaryRules(reader); } // Unable to read as CachedResponse or CachedVaryRules @@ -69,16 +76,30 @@ namespace Microsoft.AspNetCore.ResponseCaching.Internal } // Serialization Format + // Body length (int) + // Body (byte[]) + private static CachedResponseBody ReadCachedResponseBody(BinaryReader reader) + { + var bodyLength = reader.ReadInt32(); + var body = reader.ReadBytes(bodyLength); + + return new CachedResponseBody() { Body = body }; + } + + // Serialization Format + // BodyKeyPrefix (string) // Creation time - UtcTicks (long) // Status code (int) // Header count (int) // Header(s) // Key (string) // Value (string) - // Body length (int) - // Body (byte[]) + // ContainsBody (bool) + // Body length (int) + // Body (byte[]) private static CachedResponse ReadCachedResponse(BinaryReader reader) { + var bodyKeyPrefix = reader.ReadString(); var created = new DateTimeOffset(reader.ReadInt64(), TimeSpan.Zero); var statusCode = reader.ReadInt32(); var headerCount = reader.ReadInt32(); @@ -89,25 +110,28 @@ namespace Microsoft.AspNetCore.ResponseCaching.Internal var value = reader.ReadString(); headers[key] = value; } - var bodyLength = reader.ReadInt32(); - var body = reader.ReadBytes(bodyLength); - return new CachedResponse { Created = created, StatusCode = statusCode, Headers = headers, Body = body }; + var containsBody = reader.ReadBoolean(); + int bodyLength; + byte[] body = null; + if (containsBody) + { + bodyLength = reader.ReadInt32(); + body = reader.ReadBytes(bodyLength); + } + + return new CachedResponse { BodyKeyPrefix = bodyKeyPrefix, Created = created, StatusCode = statusCode, Headers = headers, Body = body }; } // Serialization Format - // ContainsVaryRules (bool) - // If containing vary rules: - // Headers count - // Headers if count > 0 (comma separated string) - // Params count - // Params if count > 0 (comma separated string) + // Guid (long) + // Headers count + // Header(s) (comma separated string) + // Params count + // Param(s) (comma separated string) private static CachedVaryRules ReadCachedVaryRules(BinaryReader reader) { - if (!reader.ReadBoolean()) - { - return new CachedVaryRules(); - } + var varyKeyPrefix = reader.ReadString(); var headerCount = reader.ReadInt32(); var headers = new string[headerCount]; @@ -122,7 +146,7 @@ namespace Microsoft.AspNetCore.ResponseCaching.Internal param[index] = reader.ReadString(); } - return new CachedVaryRules { VaryRules = new VaryRules() { Headers = headers, Params = param } }; + return new CachedVaryRules { VaryKeyPrefix = varyKeyPrefix, VaryRules = new VaryRules() { Headers = headers, Params = param } }; } // See serialization format above @@ -140,7 +164,12 @@ namespace Microsoft.AspNetCore.ResponseCaching.Internal writer.Write(FormatVersion); - if (entry is CachedResponse) + if (entry is CachedResponseBody) + { + writer.Write('B'); + WriteCachedResponseBody(writer, entry as CachedResponseBody); + } + else if (entry is CachedResponse) { writer.Write('R'); WriteCachedResponse(writer, entry as CachedResponse); @@ -156,9 +185,17 @@ namespace Microsoft.AspNetCore.ResponseCaching.Internal } } + // See serialization format above + private static void WriteCachedResponseBody(BinaryWriter writer, CachedResponseBody entry) + { + writer.Write(entry.Body.Length); + writer.Write(entry.Body); + } + // See serialization format above private static void WriteCachedResponse(BinaryWriter writer, CachedResponse entry) { + writer.Write(entry.BodyKeyPrefix); writer.Write(entry.Created.UtcTicks); writer.Write(entry.StatusCode); writer.Write(entry.Headers.Count); @@ -168,31 +205,33 @@ namespace Microsoft.AspNetCore.ResponseCaching.Internal writer.Write(header.Value); } - writer.Write(entry.Body.Length); - writer.Write(entry.Body); - } - - // See serialization format above - private static void WriteCachedVaryRules(BinaryWriter writer, CachedVaryRules varyRules) - { - if (varyRules.VaryRules == null) + if (entry.Body == null) { writer.Write(false); } else { writer.Write(true); - writer.Write(varyRules.VaryRules.Headers.Count); - foreach (var header in varyRules.VaryRules.Headers) - { - writer.Write(header); - } + writer.Write(entry.Body.Length); + writer.Write(entry.Body); + } + } - writer.Write(varyRules.VaryRules.Params.Count); - foreach (var param in varyRules.VaryRules.Params) - { - writer.Write(param); - } + // See serialization format above + private static void WriteCachedVaryRules(BinaryWriter writer, CachedVaryRules varyRules) + { + writer.Write(varyRules.VaryKeyPrefix); + + writer.Write(varyRules.VaryRules.Headers.Count); + foreach (var header in varyRules.VaryRules.Headers) + { + writer.Write(header); + } + + writer.Write(varyRules.VaryRules.Params.Count); + foreach (var param in varyRules.VaryRules.Params) + { + writer.Write(param); } } } diff --git a/src/Microsoft.AspNetCore.ResponseCaching/Internal/CachedResponse.cs b/src/Microsoft.AspNetCore.ResponseCaching/CacheEntry/CachedResponse.cs similarity index 91% rename from src/Microsoft.AspNetCore.ResponseCaching/Internal/CachedResponse.cs rename to src/Microsoft.AspNetCore.ResponseCaching/CacheEntry/CachedResponse.cs index 4743d370b5..d4413bf4d1 100644 --- a/src/Microsoft.AspNetCore.ResponseCaching/Internal/CachedResponse.cs +++ b/src/Microsoft.AspNetCore.ResponseCaching/CacheEntry/CachedResponse.cs @@ -8,6 +8,8 @@ namespace Microsoft.AspNetCore.ResponseCaching.Internal { internal class CachedResponse { + internal string BodyKeyPrefix { get; set; } + internal DateTimeOffset Created { get; set; } internal int StatusCode { get; set; } diff --git a/src/Microsoft.AspNetCore.ResponseCaching/Internal/CachedVaryRules.cs b/src/Microsoft.AspNetCore.ResponseCaching/CacheEntry/CachedResponseBody.cs similarity index 75% rename from src/Microsoft.AspNetCore.ResponseCaching/Internal/CachedVaryRules.cs rename to src/Microsoft.AspNetCore.ResponseCaching/CacheEntry/CachedResponseBody.cs index bd74a08c8c..643fac449c 100644 --- a/src/Microsoft.AspNetCore.ResponseCaching/Internal/CachedVaryRules.cs +++ b/src/Microsoft.AspNetCore.ResponseCaching/CacheEntry/CachedResponseBody.cs @@ -3,8 +3,8 @@ namespace Microsoft.AspNetCore.ResponseCaching.Internal { - internal class CachedVaryRules + internal class CachedResponseBody { - internal VaryRules VaryRules; + internal byte[] Body { get; set; } } } diff --git a/src/Microsoft.AspNetCore.ResponseCaching/CacheEntry/CachedVaryRules.cs b/src/Microsoft.AspNetCore.ResponseCaching/CacheEntry/CachedVaryRules.cs new file mode 100644 index 0000000000..de3355e761 --- /dev/null +++ b/src/Microsoft.AspNetCore.ResponseCaching/CacheEntry/CachedVaryRules.cs @@ -0,0 +1,12 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.ResponseCaching.Internal +{ + internal class CachedVaryRules + { + internal string VaryKeyPrefix { get; set; } + + internal VaryRules VaryRules { get; set; } + } +} diff --git a/src/Microsoft.AspNetCore.ResponseCaching/Interfaces/IKeyProvider.cs b/src/Microsoft.AspNetCore.ResponseCaching/Interfaces/IKeyProvider.cs index 888498f908..972b9917fe 100644 --- a/src/Microsoft.AspNetCore.ResponseCaching/Interfaces/IKeyProvider.cs +++ b/src/Microsoft.AspNetCore.ResponseCaching/Interfaces/IKeyProvider.cs @@ -8,18 +8,18 @@ namespace Microsoft.AspNetCore.ResponseCaching public interface IKeyProvider { /// - /// Create a key using the HTTP request. + /// Create a base key using the HTTP request. /// /// The . /// The created base key. string CreateBaseKey(HttpContext httpContext); /// - /// Create a key using the HTTP context and vary rules. + /// Create a vary key using the HTTP context and vary rules. /// /// The . /// The . - /// The created base key. + /// The created vary key. string CreateVaryKey(HttpContext httpContext, VaryRules varyRules); } } diff --git a/src/Microsoft.AspNetCore.ResponseCaching/Internal/FastGuid.cs b/src/Microsoft.AspNetCore.ResponseCaching/Internal/FastGuid.cs new file mode 100644 index 0000000000..075403f35c --- /dev/null +++ b/src/Microsoft.AspNetCore.ResponseCaching/Internal/FastGuid.cs @@ -0,0 +1,80 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading; + +namespace Microsoft.AspNetCore.ResponseCaching.Internal +{ + internal class FastGuid + { + // Base32 encoding - in ascii sort order for easy text based sorting + private static readonly string _encode32Chars = "0123456789ABCDEFGHIJKLMNOPQRSTUV"; + // Global ID + private static long NextId; + + // Instance components + private string _idString; + internal long IdValue { get; private set; } + + internal string IdString + { + get + { + if (_idString == null) + { + _idString = GenerateGuidString(this); + } + return _idString; + } + } + + // Static constructor to initialize global components + static FastGuid() + { + var guidBytes = Guid.NewGuid().ToByteArray(); + + // Use the first 4 bytes from the Guid to initialize global ID + NextId = + guidBytes[0] << 32 | + guidBytes[1] << 40 | + guidBytes[2] << 48 | + guidBytes[3] << 56; + } + + internal FastGuid(long id) + { + IdValue = id; + } + + internal static FastGuid NewGuid() + { + return new FastGuid(Interlocked.Increment(ref NextId)); + } + + private static unsafe string GenerateGuidString(FastGuid guid) + { + + // stackalloc to allocate array on stack rather than heap + char* charBuffer = stackalloc char[13]; + + // ID + charBuffer[0] = _encode32Chars[(int)(guid.IdValue >> 60) & 31]; + charBuffer[1] = _encode32Chars[(int)(guid.IdValue >> 55) & 31]; + charBuffer[2] = _encode32Chars[(int)(guid.IdValue >> 50) & 31]; + charBuffer[3] = _encode32Chars[(int)(guid.IdValue >> 45) & 31]; + charBuffer[4] = _encode32Chars[(int)(guid.IdValue >> 40) & 31]; + charBuffer[5] = _encode32Chars[(int)(guid.IdValue >> 35) & 31]; + charBuffer[6] = _encode32Chars[(int)(guid.IdValue >> 30) & 31]; + charBuffer[7] = _encode32Chars[(int)(guid.IdValue >> 25) & 31]; + charBuffer[8] = _encode32Chars[(int)(guid.IdValue >> 20) & 31]; + charBuffer[9] = _encode32Chars[(int)(guid.IdValue >> 15) & 31]; + charBuffer[10] = _encode32Chars[(int)(guid.IdValue >> 10) & 31]; + charBuffer[11] = _encode32Chars[(int)(guid.IdValue >> 5) & 31]; + charBuffer[12] = _encode32Chars[(int)guid.IdValue & 31]; + + // string ctor overload that takes char* + return new string(charBuffer, 0, 13); + } + } +} diff --git a/src/Microsoft.AspNetCore.ResponseCaching/Internal/MemoryResponseCache.cs b/src/Microsoft.AspNetCore.ResponseCaching/Internal/MemoryResponseCache.cs index 117d112db9..d5296c93e2 100644 --- a/src/Microsoft.AspNetCore.ResponseCaching/Internal/MemoryResponseCache.cs +++ b/src/Microsoft.AspNetCore.ResponseCaching/Internal/MemoryResponseCache.cs @@ -33,8 +33,8 @@ namespace Microsoft.AspNetCore.ResponseCaching.Internal public void Set(string key, object entry, TimeSpan validFor) { _cache.Set( - key, - entry, + key, + entry, new MemoryCacheEntryOptions() { AbsoluteExpirationRelativeToNow = validFor diff --git a/src/Microsoft.AspNetCore.ResponseCaching/Internal/ResponseCachingState.cs b/src/Microsoft.AspNetCore.ResponseCaching/Internal/ResponseCachingState.cs index 3ff862a502..6ea80b95b0 100644 --- a/src/Microsoft.AspNetCore.ResponseCaching/Internal/ResponseCachingState.cs +++ b/src/Microsoft.AspNetCore.ResponseCaching/Internal/ResponseCachingState.cs @@ -38,6 +38,8 @@ namespace Microsoft.AspNetCore.ResponseCaching.Internal internal CachedResponse CachedResponse { get; set; } + internal CachedVaryRules CachedVaryRules { get; set; } + public RequestHeaders RequestHeaders { get diff --git a/src/Microsoft.AspNetCore.ResponseCaching/KeyProvider.cs b/src/Microsoft.AspNetCore.ResponseCaching/KeyProvider.cs index 6eac525b33..027d2e3b77 100644 --- a/src/Microsoft.AspNetCore.ResponseCaching/KeyProvider.cs +++ b/src/Microsoft.AspNetCore.ResponseCaching/KeyProvider.cs @@ -6,7 +6,6 @@ using System.Linq; using System.Text; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.ResponseCaching.Internal; using Microsoft.Extensions.ObjectPool; using Microsoft.Extensions.Options; using Microsoft.Extensions.Primitives; @@ -70,10 +69,9 @@ namespace Microsoft.AspNetCore.ResponseCaching { throw new ArgumentNullException(nameof(httpContext)); } - if (varyRules == null) + if (varyRules == null || (StringValues.IsNullOrEmpty(varyRules.Headers) && StringValues.IsNullOrEmpty(varyRules.Params))) { - // TODO: replace this with a GUID - return httpContext.GetResponseCachingState()?.BaseKey ?? CreateBaseKey(httpContext); + return httpContext.GetResponseCachingState().CachedVaryRules.VaryKeyPrefix; } var request = httpContext.Request; @@ -81,8 +79,8 @@ namespace Microsoft.AspNetCore.ResponseCaching try { - // TODO: replace this with a GUID - builder.Append(httpContext.GetResponseCachingState()?.BaseKey ?? CreateBaseKey(httpContext)); + // Prepend with the Guid of the CachedVaryRules + builder.Append(httpContext.GetResponseCachingState().CachedVaryRules.VaryKeyPrefix); // Vary by headers if (varyRules?.Headers.Count > 0) diff --git a/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingContext.cs b/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingContext.cs index cd34857e84..4b9fe410ba 100644 --- a/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingContext.cs +++ b/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingContext.cs @@ -68,17 +68,18 @@ namespace Microsoft.AspNetCore.ResponseCaching if (cacheEntry is CachedVaryRules) { // Request contains vary rules, recompute key and try again + State.CachedVaryRules = cacheEntry as CachedVaryRules; var varyKey = _keyProvider.CreateVaryKey(_httpContext, ((CachedVaryRules)cacheEntry).VaryRules); cacheEntry = _cache.Get(varyKey); } if (cacheEntry is CachedResponse) { - var cachedResponse = cacheEntry as CachedResponse; - var cachedResponseHeaders = new ResponseHeaders(cachedResponse.Headers); + State.CachedResponse = cacheEntry as CachedResponse; + var cachedResponseHeaders = new ResponseHeaders(State.CachedResponse.Headers); State.ResponseTime = _options.SystemClock.UtcNow; - var cachedEntryAge = State.ResponseTime - cachedResponse.Created; + var cachedEntryAge = State.ResponseTime - State.CachedResponse.Created; State.CachedEntryAge = cachedEntryAge > TimeSpan.Zero ? cachedEntryAge : TimeSpan.Zero; if (_cacheabilityValidator.CachedEntryIsFresh(_httpContext, cachedResponseHeaders)) @@ -94,16 +95,22 @@ namespace Microsoft.AspNetCore.ResponseCaching { var response = _httpContext.Response; // Copy the cached status code and response headers - response.StatusCode = cachedResponse.StatusCode; - foreach (var header in cachedResponse.Headers) + response.StatusCode = State.CachedResponse.StatusCode; + foreach (var header in State.CachedResponse.Headers) { response.Headers.Add(header); } response.Headers[HeaderNames.Age] = State.CachedEntryAge.TotalSeconds.ToString("F0", CultureInfo.InvariantCulture); + var body = State.CachedResponse.Body ?? + ((CachedResponseBody)_cache.Get(State.CachedResponse.BodyKeyPrefix))?.Body; - var body = cachedResponse.Body; + // If the body is not found, something went wrong. + if (body == null) + { + return false; + } // Copy the cached response body if (body.Length > 0) @@ -181,19 +188,31 @@ namespace Microsoft.AspNetCore.ResponseCaching // Check if any vary rules exist if (!StringValues.IsNullOrEmpty(varyHeaderValue) || !StringValues.IsNullOrEmpty(varyParamsValue)) { - var cachedVaryRules = new CachedVaryRules - { - VaryRules = new VaryRules() - { - // TODO: Vary Encoding - Headers = varyHeaderValue, - Params = varyParamsValue - } - }; + // Normalize order and casing of vary by rules + var normalizedVaryHeaderValue = GetNormalizedStringValues(varyHeaderValue); + var normalizedVaryParamsValue = GetNormalizedStringValues(varyParamsValue); - // TODO: Overwrite? - _cache.Set(State.BaseKey, cachedVaryRules, State.CachedResponseValidFor); - State.VaryKey = _keyProvider.CreateVaryKey(_httpContext, cachedVaryRules.VaryRules); + // Update vary rules if they are different + if (State.CachedVaryRules == null || + !StringValues.Equals(State.CachedVaryRules.VaryRules.Params, normalizedVaryParamsValue) || + !StringValues.Equals(State.CachedVaryRules.VaryRules.Headers, normalizedVaryHeaderValue)) + { + var cachedVaryRules = new CachedVaryRules + { + VaryKeyPrefix = FastGuid.NewGuid().IdString, + VaryRules = new VaryRules() + { + // TODO: Vary Encoding + Headers = normalizedVaryHeaderValue, + Params = normalizedVaryParamsValue + } + }; + + State.CachedVaryRules = cachedVaryRules; + _cache.Set(State.BaseKey, cachedVaryRules, State.CachedResponseValidFor); + } + + State.VaryKey = _keyProvider.CreateVaryKey(_httpContext, State.CachedVaryRules.VaryRules); } // Ensure date header is set @@ -202,9 +221,10 @@ namespace Microsoft.AspNetCore.ResponseCaching State.ResponseHeaders.Date = State.ResponseTime; } - // Store the response to cache + // Store the response on the state State.CachedResponse = new CachedResponse { + BodyKeyPrefix = FastGuid.NewGuid().IdString, Created = State.ResponseHeaders.Date.Value, StatusCode = _httpContext.Response.StatusCode }; @@ -227,9 +247,24 @@ namespace Microsoft.AspNetCore.ResponseCaching { if (State.ShouldCacheResponse && ResponseCacheStream.BufferingEnabled) { - State.CachedResponse.Body = ResponseCacheStream.BufferedStream.ToArray(); + if (ResponseCacheStream.BufferedStream.Length >= _options.MinimumSplitBodySize) + { + // Store response and response body separately + _cache.Set(State.VaryKey ?? State.BaseKey, State.CachedResponse, State.CachedResponseValidFor); - _cache.Set(State.VaryKey ?? State.BaseKey, State.CachedResponse, State.CachedResponseValidFor); + var cachedResponseBody = new CachedResponseBody() + { + Body = ResponseCacheStream.BufferedStream.ToArray() + }; + + _cache.Set(State.CachedResponse.BodyKeyPrefix, cachedResponseBody, State.CachedResponseValidFor); + } + else + { + // Store response and response body together + State.CachedResponse.Body = ResponseCacheStream.BufferedStream.ToArray(); + _cache.Set(State.VaryKey ?? State.BaseKey, State.CachedResponse, State.CachedResponseValidFor); + } } } @@ -275,5 +310,29 @@ namespace Microsoft.AspNetCore.ResponseCaching // TODO: Move this temporary interface with endpoint to HttpAbstractions _httpContext.RemoveResponseCachingFeature(); } + + // Normalize order and casing + internal static StringValues GetNormalizedStringValues(StringValues stringVales) + { + if (stringVales.Count == 1) + { + return new StringValues(stringVales.ToString().ToUpperInvariant()); + } + else + { + var originalArray = stringVales.ToArray(); + var newArray = new string[originalArray.Length]; + + for (int i = 0; i < originalArray.Length; i++) + { + newArray[i] = originalArray[i].ToUpperInvariant(); + } + + // Since the casing has already been normalized, use Ordinal comparison + Array.Sort(newArray, StringComparer.Ordinal); + + return new StringValues(newArray); + } + } } } diff --git a/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingOptions.cs b/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingOptions.cs index fefe65c4e2..7630237ec5 100644 --- a/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingOptions.cs +++ b/src/Microsoft.AspNetCore.ResponseCaching/ResponseCachingOptions.cs @@ -18,6 +18,11 @@ namespace Microsoft.AspNetCore.Builder /// public bool CaseSensitivePaths { get; set; } = false; + /// + /// The smallest size in bytes for which the headers and body of the response will be stored separately. The default is set to 70 KB. + /// + public long MinimumSplitBodySize { get; set; } = 70 * 1024; + /// /// For testing purposes only. /// diff --git a/src/Microsoft.AspNetCore.ResponseCaching/project.json b/src/Microsoft.AspNetCore.ResponseCaching/project.json index 70fa887b15..d2521465b9 100644 --- a/src/Microsoft.AspNetCore.ResponseCaching/project.json +++ b/src/Microsoft.AspNetCore.ResponseCaching/project.json @@ -2,6 +2,7 @@ "version": "0.1.0-*", "buildOptions": { "warningsAsErrors": true, + "allowUnsafe": true, "keyFile": "../../tools/Key.snk", "nowarn": [ "CS1591" diff --git a/test/Microsoft.AspNetCore.ResponseCaching.Tests/CacheEntrySerializerTests.cs b/test/Microsoft.AspNetCore.ResponseCaching.Tests/CacheEntrySerializerTests.cs index e85dabe9f6..bd2ad03bb1 100644 --- a/test/Microsoft.AspNetCore.ResponseCaching.Tests/CacheEntrySerializerTests.cs +++ b/test/Microsoft.AspNetCore.ResponseCaching.Tests/CacheEntrySerializerTests.cs @@ -25,78 +25,109 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests } [Fact] - public void RoundTrip_CachedResponses_Succeeds() + public void Deserialize_NullObject_ReturnsNull() + { + Assert.Null(CacheEntrySerializer.Deserialize(null)); + } + + [Fact] + public void RoundTrip_CachedResponseBody_Succeeds() + { + var cachedResponseBody = new CachedResponseBody() + { + Body = Encoding.ASCII.GetBytes("Hello world"), + }; + + AssertCachedResponseBodyEqual(cachedResponseBody, (CachedResponseBody)CacheEntrySerializer.Deserialize(CacheEntrySerializer.Serialize(cachedResponseBody))); + } + + [Fact] + public void RoundTrip_CachedResponseWithoutBody_Succeeds() { var headers = new HeaderDictionary(); headers["keyA"] = "valueA"; headers["keyB"] = "valueB"; - var cachedEntry = new CachedResponse() + var cachedResponse = new CachedResponse() { + BodyKeyPrefix = FastGuid.NewGuid().IdString, + Created = DateTimeOffset.UtcNow, + StatusCode = StatusCodes.Status200OK, + Headers = headers + }; + + AssertCachedResponseEqual(cachedResponse, (CachedResponse)CacheEntrySerializer.Deserialize(CacheEntrySerializer.Serialize(cachedResponse))); + } + + [Fact] + public void RoundTrip_CachedResponseWithBody_Succeeds() + { + var headers = new HeaderDictionary(); + headers["keyA"] = "valueA"; + headers["keyB"] = "valueB"; + var cachedResponse = new CachedResponse() + { + BodyKeyPrefix = FastGuid.NewGuid().IdString, Created = DateTimeOffset.UtcNow, StatusCode = StatusCodes.Status200OK, Body = Encoding.ASCII.GetBytes("Hello world"), Headers = headers }; - AssertCachedResponsesEqual(cachedEntry, (CachedResponse)CacheEntrySerializer.Deserialize(CacheEntrySerializer.Serialize(cachedEntry))); + AssertCachedResponseEqual(cachedResponse, (CachedResponse)CacheEntrySerializer.Deserialize(CacheEntrySerializer.Serialize(cachedResponse))); } [Fact] - public void RoundTrip_Empty_CachedVaryRules_Succeeds() + public void RoundTrip_CachedVaryRule_EmptyRules_Succeeds() { - var cachedVaryRules = new CachedVaryRules(); - - AssertCachedVaryRulesEqual(cachedVaryRules, (CachedVaryRules)CacheEntrySerializer.Deserialize(CacheEntrySerializer.Serialize(cachedVaryRules))); - } - - [Fact] - public void RoundTrip_CachedVaryRules_EmptyRules_Succeeds() - { - var cachedVaryRules = new CachedVaryRules() + var cachedVaryRule = new CachedVaryRules() { + VaryKeyPrefix = FastGuid.NewGuid().IdString, VaryRules = new VaryRules() }; - AssertCachedVaryRulesEqual(cachedVaryRules, (CachedVaryRules)CacheEntrySerializer.Deserialize(CacheEntrySerializer.Serialize(cachedVaryRules))); + AssertCachedVaryRuleEqual(cachedVaryRule, (CachedVaryRules)CacheEntrySerializer.Deserialize(CacheEntrySerializer.Serialize(cachedVaryRule))); } [Fact] - public void RoundTrip_HeadersOnly_CachedVaryRules_Succeeds() + public void RoundTrip_CachedVaryRule_HeadersOnly_Succeeds() { var headers = new[] { "headerA", "headerB" }; - var cachedVaryRules = new CachedVaryRules() + var cachedVaryRule = new CachedVaryRules() { + VaryKeyPrefix = FastGuid.NewGuid().IdString, VaryRules = new VaryRules() { Headers = headers } }; - AssertCachedVaryRulesEqual(cachedVaryRules, (CachedVaryRules)CacheEntrySerializer.Deserialize(CacheEntrySerializer.Serialize(cachedVaryRules))); + AssertCachedVaryRuleEqual(cachedVaryRule, (CachedVaryRules)CacheEntrySerializer.Deserialize(CacheEntrySerializer.Serialize(cachedVaryRule))); } [Fact] - public void RoundTrip_ParamsOnly_CachedVaryRules_Succeeds() + public void RoundTrip_CachedVaryRule_ParamsOnly_Succeeds() { var param = new[] { "paramA", "paramB" }; - var cachedVaryRules = new CachedVaryRules() + var cachedVaryRule = new CachedVaryRules() { + VaryKeyPrefix = FastGuid.NewGuid().IdString, VaryRules = new VaryRules() { Params = param } }; - AssertCachedVaryRulesEqual(cachedVaryRules, (CachedVaryRules)CacheEntrySerializer.Deserialize(CacheEntrySerializer.Serialize(cachedVaryRules))); + AssertCachedVaryRuleEqual(cachedVaryRule, (CachedVaryRules)CacheEntrySerializer.Deserialize(CacheEntrySerializer.Serialize(cachedVaryRule))); } [Fact] - public void RoundTrip_HeadersAndParams_CachedVaryRules_Succeeds() + public void RoundTrip_CachedVaryRule_HeadersAndParams_Succeeds() { var headers = new[] { "headerA", "headerB" }; var param = new[] { "paramA", "paramB" }; - var cachedVaryRules = new CachedVaryRules() + var cachedVaryRule = new CachedVaryRules() { + VaryKeyPrefix = FastGuid.NewGuid().IdString, VaryRules = new VaryRules() { Headers = headers, @@ -104,30 +135,37 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests } }; - AssertCachedVaryRulesEqual(cachedVaryRules, (CachedVaryRules)CacheEntrySerializer.Deserialize(CacheEntrySerializer.Serialize(cachedVaryRules))); + AssertCachedVaryRuleEqual(cachedVaryRule, (CachedVaryRules)CacheEntrySerializer.Deserialize(CacheEntrySerializer.Serialize(cachedVaryRule))); } [Fact] public void Deserialize_InvalidEntries_ReturnsNull() { var headers = new[] { "headerA", "headerB" }; - var cachedVaryRules = new CachedVaryRules() + var cachedVaryRule = new CachedVaryRules() { + VaryKeyPrefix = FastGuid.NewGuid().IdString, VaryRules = new VaryRules() { Headers = headers } }; - var serializedEntry = CacheEntrySerializer.Serialize(cachedVaryRules); + var serializedEntry = CacheEntrySerializer.Serialize(cachedVaryRule); Array.Reverse(serializedEntry); Assert.Null(CacheEntrySerializer.Deserialize(serializedEntry)); } - private static void AssertCachedResponsesEqual(CachedResponse expected, CachedResponse actual) + private static void AssertCachedResponseBodyEqual(CachedResponseBody expected, CachedResponseBody actual) + { + Assert.True(expected.Body.SequenceEqual(actual.Body)); + } + + private static void AssertCachedResponseEqual(CachedResponse expected, CachedResponse actual) { Assert.NotNull(actual); Assert.NotNull(expected); + Assert.Equal(expected.BodyKeyPrefix, actual.BodyKeyPrefix); Assert.Equal(expected.Created, actual.Created); Assert.Equal(expected.StatusCode, actual.StatusCode); Assert.Equal(expected.Headers.Count, actual.Headers.Count); @@ -135,23 +173,23 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests { Assert.Equal(expectedHeader.Value, actual.Headers[expectedHeader.Key]); } - Assert.True(expected.Body.SequenceEqual(actual.Body)); - } - - private static void AssertCachedVaryRulesEqual(CachedVaryRules expected, CachedVaryRules actual) - { - Assert.NotNull(actual); - Assert.NotNull(expected); - if (expected.VaryRules == null) + if (expected.Body == null) { - Assert.Null(actual.VaryRules); + Assert.Null(actual.Body); } else { - Assert.NotNull(actual.VaryRules); - Assert.Equal(expected.VaryRules.Headers, actual.VaryRules.Headers); - Assert.Equal(expected.VaryRules.Params, actual.VaryRules.Params); + Assert.True(expected.Body.SequenceEqual(actual.Body)); } } + + private static void AssertCachedVaryRuleEqual(CachedVaryRules expected, CachedVaryRules actual) + { + Assert.NotNull(actual); + Assert.NotNull(expected); + Assert.Equal(expected.VaryKeyPrefix, actual.VaryKeyPrefix); + Assert.Equal(expected.VaryRules.Headers, actual.VaryRules.Headers); + Assert.Equal(expected.VaryRules.Params, actual.VaryRules.Params); + } } } diff --git a/test/Microsoft.AspNetCore.ResponseCaching.Tests/KeyProviderTests.cs b/test/Microsoft.AspNetCore.ResponseCaching.Tests/KeyProviderTests.cs index 8f96278fc3..17922f5afa 100644 --- a/test/Microsoft.AspNetCore.ResponseCaching.Tests/KeyProviderTests.cs +++ b/test/Microsoft.AspNetCore.ResponseCaching.Tests/KeyProviderTests.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.ResponseCaching.Internal; using Microsoft.Extensions.ObjectPool; using Microsoft.Extensions.Options; using Xunit; @@ -12,11 +13,15 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests public class DefaultKeyProviderTests { private static readonly char KeyDelimiter = '\x1e'; + private static readonly CachedVaryRules TestVaryRules = new CachedVaryRules() + { + VaryKeyPrefix = FastGuid.NewGuid().IdString + }; [Fact] public void DefaultKeyProvider_CreateBaseKey_IncludesOnlyNormalizedMethodAndPath() { - var httpContext = new DefaultHttpContext(); + var httpContext = CreateDefaultContext(); httpContext.Request.Method = "head"; httpContext.Request.Path = "/path/subpath"; httpContext.Request.Scheme = "https"; @@ -31,7 +36,7 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests [Fact] public void DefaultKeyProvider_CreateBaseKey_CaseInsensitivePath_NormalizesPath() { - var httpContext = new DefaultHttpContext(); + var httpContext = CreateDefaultContext(); httpContext.Request.Method = "GET"; httpContext.Request.Path = "/Path"; var keyProvider = CreateTestKeyProvider(new ResponseCachingOptions() @@ -45,7 +50,7 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests [Fact] public void DefaultKeyProvider_CreateBaseKey_CaseSensitivePath_PreservesPathCase() { - var httpContext = new DefaultHttpContext(); + var httpContext = CreateDefaultContext(); httpContext.Request.Method = "GET"; httpContext.Request.Path = "/Path"; var keyProvider = CreateTestKeyProvider(new ResponseCachingOptions() @@ -56,17 +61,25 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests Assert.Equal($"GET{KeyDelimiter}/Path", keyProvider.CreateBaseKey(httpContext)); } + [Fact] + public void DefaultKeyProvider_CreateVaryKey_ReturnsCachedVaryGuid_IfVaryRulesIsNullOrEmpty() + { + var httpContext = CreateDefaultContext(); + var keyProvider = CreateTestKeyProvider(); + + Assert.Equal($"{TestVaryRules.VaryKeyPrefix}", keyProvider.CreateVaryKey(httpContext, null)); + Assert.Equal($"{TestVaryRules.VaryKeyPrefix}", keyProvider.CreateVaryKey(httpContext, new VaryRules())); + } + [Fact] public void DefaultKeyProvider_CreateVaryKey_IncludesListedHeadersOnly() { - var httpContext = new DefaultHttpContext(); - httpContext.Request.Method = "GET"; - httpContext.Request.Path = "/"; + var httpContext = CreateDefaultContext(); httpContext.Request.Headers["HeaderA"] = "ValueA"; httpContext.Request.Headers["HeaderB"] = "ValueB"; var keyProvider = CreateTestKeyProvider(); - Assert.Equal($"GET{KeyDelimiter}/{KeyDelimiter}H{KeyDelimiter}HeaderA=ValueA{KeyDelimiter}HeaderC=null", + Assert.Equal($"{TestVaryRules.VaryKeyPrefix}{KeyDelimiter}H{KeyDelimiter}HeaderA=ValueA{KeyDelimiter}HeaderC=null", keyProvider.CreateVaryKey(httpContext, new VaryRules() { Headers = new string[] { "HeaderA", "HeaderC" } @@ -76,13 +89,11 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests [Fact] public void DefaultKeyProvider_CreateVaryKey_IncludesListedParamsOnly() { - var httpContext = new DefaultHttpContext(); - httpContext.Request.Method = "GET"; - httpContext.Request.Path = "/"; + var httpContext = CreateDefaultContext(); httpContext.Request.QueryString = new QueryString("?ParamA=ValueA&ParamB=ValueB"); var keyProvider = CreateTestKeyProvider(); - Assert.Equal($"GET{KeyDelimiter}/{KeyDelimiter}Q{KeyDelimiter}ParamA=ValueA{KeyDelimiter}ParamC=null", + Assert.Equal($"{TestVaryRules.VaryKeyPrefix}{KeyDelimiter}Q{KeyDelimiter}ParamA=ValueA{KeyDelimiter}ParamC=null", keyProvider.CreateVaryKey(httpContext, new VaryRules() { Params = new string[] { "ParamA", "ParamC" } @@ -92,13 +103,11 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests [Fact] public void DefaultKeyProvider_CreateVaryKey_IncludesParams_ParamNameCaseInsensitive_UseParamCasing() { - var httpContext = new DefaultHttpContext(); - httpContext.Request.Method = "GET"; - httpContext.Request.Path = "/"; + var httpContext = CreateDefaultContext(); httpContext.Request.QueryString = new QueryString("?parama=ValueA¶mB=ValueB"); var keyProvider = CreateTestKeyProvider(); - Assert.Equal($"GET{KeyDelimiter}/{KeyDelimiter}Q{KeyDelimiter}ParamA=ValueA{KeyDelimiter}ParamC=null", + Assert.Equal($"{TestVaryRules.VaryKeyPrefix}{KeyDelimiter}Q{KeyDelimiter}ParamA=ValueA{KeyDelimiter}ParamC=null", keyProvider.CreateVaryKey(httpContext, new VaryRules() { Params = new string[] { "ParamA", "ParamC" } @@ -108,15 +117,13 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests [Fact] public void DefaultKeyProvider_CreateVaryKey_IncludesAllQueryParamsGivenAsterisk() { - var httpContext = new DefaultHttpContext(); - httpContext.Request.Method = "GET"; - httpContext.Request.Path = "/"; + var httpContext = CreateDefaultContext(); httpContext.Request.QueryString = new QueryString("?ParamA=ValueA&ParamB=ValueB"); var keyProvider = CreateTestKeyProvider(); // To support case insensitivity, all param keys are converted to upper case. // Explicit params uses the casing specified in the setting. - Assert.Equal($"GET{KeyDelimiter}/{KeyDelimiter}Q{KeyDelimiter}PARAMA=ValueA{KeyDelimiter}PARAMB=ValueB", + Assert.Equal($"{TestVaryRules.VaryKeyPrefix}{KeyDelimiter}Q{KeyDelimiter}PARAMA=ValueA{KeyDelimiter}PARAMB=ValueB", keyProvider.CreateVaryKey(httpContext, new VaryRules() { Params = new string[] { "*" } @@ -126,15 +133,13 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests [Fact] public void DefaultKeyProvider_CreateVaryKey_IncludesListedHeadersAndParams() { - var httpContext = new DefaultHttpContext(); - httpContext.Request.Method = "GET"; - httpContext.Request.Path = "/"; + var httpContext = CreateDefaultContext(); httpContext.Request.Headers["HeaderA"] = "ValueA"; httpContext.Request.Headers["HeaderB"] = "ValueB"; httpContext.Request.QueryString = new QueryString("?ParamA=ValueA&ParamB=ValueB"); var keyProvider = CreateTestKeyProvider(); - Assert.Equal($"GET{KeyDelimiter}/{KeyDelimiter}H{KeyDelimiter}HeaderA=ValueA{KeyDelimiter}HeaderC=null{KeyDelimiter}Q{KeyDelimiter}ParamA=ValueA{KeyDelimiter}ParamC=null", + Assert.Equal($"{TestVaryRules.VaryKeyPrefix}{KeyDelimiter}H{KeyDelimiter}HeaderA=ValueA{KeyDelimiter}HeaderC=null{KeyDelimiter}Q{KeyDelimiter}ParamA=ValueA{KeyDelimiter}ParamC=null", keyProvider.CreateVaryKey(httpContext, new VaryRules() { Headers = new string[] { "HeaderA", "HeaderC" }, @@ -142,6 +147,14 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests })); } + private static HttpContext CreateDefaultContext() + { + var context = new DefaultHttpContext(); + context.AddResponseCachingState(); + context.GetResponseCachingState().CachedVaryRules = TestVaryRules; + return context; + } + private static IKeyProvider CreateTestKeyProvider() { return CreateTestKeyProvider(new ResponseCachingOptions()); diff --git a/test/Microsoft.AspNetCore.ResponseCaching.Tests/ResponseCachingContextTests.cs b/test/Microsoft.AspNetCore.ResponseCaching.Tests/ResponseCachingContextTests.cs index c4caca269b..ec11698d0d 100644 --- a/test/Microsoft.AspNetCore.ResponseCaching.Tests/ResponseCachingContextTests.cs +++ b/test/Microsoft.AspNetCore.ResponseCaching.Tests/ResponseCachingContextTests.cs @@ -14,6 +14,7 @@ using Microsoft.Extensions.ObjectPool; using Microsoft.Extensions.Options; using Microsoft.Net.Http.Headers; using Xunit; +using Microsoft.Extensions.Primitives; namespace Microsoft.AspNetCore.ResponseCaching.Tests { @@ -154,10 +155,312 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests Assert.False(context.ConditionalRequestSatisfied(cachedHeaders)); } + [Fact] + public void FinalizeCachingHeaders_DoNotUpdateShouldCacheResponse_IfResponseIsNotCacheable() + { + var httpContext = new DefaultHttpContext(); + var context = CreateTestContext(httpContext); + var state = httpContext.GetResponseCachingState(); + + Assert.False(state.ShouldCacheResponse); + + context.ShimResponseStream(); + context.FinalizeCachingHeaders(); + + Assert.False(state.ShouldCacheResponse); + } + + [Fact] + public void FinalizeCachingHeaders_UpdateShouldCacheResponse_IfResponseIsCacheable() + { + var httpContext = new DefaultHttpContext(); + httpContext.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue() + { + Public = true + }; + var context = CreateTestContext(httpContext); + var state = httpContext.GetResponseCachingState(); + + Assert.False(state.ShouldCacheResponse); + + context.FinalizeCachingHeaders(); + + Assert.True(state.ShouldCacheResponse); + } + + [Fact] + public void FinalizeCachingHeaders_DefaultResponseValidity_Is10Seconds() + { + var httpContext = new DefaultHttpContext(); + httpContext.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue() + { + Public = true + }; + var context = CreateTestContext(httpContext); + + context.FinalizeCachingHeaders(); + + Assert.Equal(TimeSpan.FromSeconds(10), httpContext.GetResponseCachingState().CachedResponseValidFor); + } + + [Fact] + public void FinalizeCachingHeaders_ResponseValidity_UseExpiryIfAvailable() + { + var httpContext = new DefaultHttpContext(); + httpContext.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue() + { + Public = true + }; + var context = CreateTestContext(httpContext); + + var state = httpContext.GetResponseCachingState(); + var utcNow = DateTimeOffset.MinValue; + state.ResponseTime = utcNow; + state.ResponseHeaders.Expires = utcNow + TimeSpan.FromSeconds(11); + + context.FinalizeCachingHeaders(); + + Assert.Equal(TimeSpan.FromSeconds(11), state.CachedResponseValidFor); + } + + [Fact] + public void FinalizeCachingHeaders_ResponseValidity_UseMaxAgeIfAvailable() + { + var httpContext = new DefaultHttpContext(); + httpContext.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue() + { + Public = true, + MaxAge = TimeSpan.FromSeconds(12) + }; + var context = CreateTestContext(httpContext); + + var state = httpContext.GetResponseCachingState(); + state.ResponseTime = DateTimeOffset.UtcNow; + state.ResponseHeaders.Expires = state.ResponseTime + TimeSpan.FromSeconds(11); + + context.FinalizeCachingHeaders(); + + Assert.Equal(TimeSpan.FromSeconds(12), state.CachedResponseValidFor); + } + + [Fact] + public void FinalizeCachingHeaders_ResponseValidity_UseSharedMaxAgeIfAvailable() + { + var httpContext = new DefaultHttpContext(); + httpContext.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue() + { + Public = true, + MaxAge = TimeSpan.FromSeconds(12), + SharedMaxAge = TimeSpan.FromSeconds(13) + }; + var context = CreateTestContext(httpContext); + + var state = httpContext.GetResponseCachingState(); + state.ResponseTime = DateTimeOffset.UtcNow; + state.ResponseHeaders.Expires = state.ResponseTime + TimeSpan.FromSeconds(11); + + context.FinalizeCachingHeaders(); + + Assert.Equal(TimeSpan.FromSeconds(13), state.CachedResponseValidFor); + } + + [Fact] + public void FinalizeCachingHeaders_UpdateCachedVaryRules_IfNotEquivalentToPrevious() + { + var httpContext = new DefaultHttpContext(); + var cache = new TestResponseCache(); + var context = CreateTestContext(httpContext, cache, new ResponseCachingOptions()); + var state = httpContext.GetResponseCachingState(); + + httpContext.Response.Headers[HeaderNames.Vary] = new StringValues(new[] { "headerA", "HEADERB", "HEADERc" }); + httpContext.AddResponseCachingFeature(); + httpContext.GetResponseCachingFeature().VaryParams = new StringValues(new[] { "paramB", "PARAMAA" }); + httpContext.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue() + { + Public = true, + }; + var cachedVaryRules = new CachedVaryRules() + { + VaryRules = new VaryRules() + { + Headers = new StringValues(new[] { "HeaderA", "HeaderB" }), + Params = new StringValues(new[] { "ParamA", "ParamB" }) + } + }; + state.CachedVaryRules = cachedVaryRules; + + context.FinalizeCachingHeaders(); + + Assert.Equal(1, cache.StoredItems); + Assert.NotSame(cachedVaryRules, state.CachedVaryRules); + } + + [Fact] + public void FinalizeCachingHeaders_DoNotUpdateCachedVaryRules_IfEquivalentToPrevious() + { + var httpContext = new DefaultHttpContext(); + var cache = new TestResponseCache(); + var context = CreateTestContext(httpContext, cache, new ResponseCachingOptions()); + var state = httpContext.GetResponseCachingState(); + + httpContext.Response.Headers[HeaderNames.Vary] = new StringValues(new[] { "headerA", "HEADERB" }); + httpContext.AddResponseCachingFeature(); + httpContext.GetResponseCachingFeature().VaryParams = new StringValues(new[] { "paramB", "PARAMA" }); + httpContext.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue() + { + Public = true, + }; + var cachedVaryRules = new CachedVaryRules() + { + VaryKeyPrefix = FastGuid.NewGuid().IdString, + VaryRules = new VaryRules() + { + Headers = new StringValues(new[] { "HEADERA", "HEADERB" }), + Params = new StringValues(new[] { "PARAMA", "PARAMB" }) + } + }; + state.CachedVaryRules = cachedVaryRules; + + context.FinalizeCachingHeaders(); + + Assert.Equal(0, cache.StoredItems); + Assert.Same(cachedVaryRules, state.CachedVaryRules); + } + + [Fact] + public void FinalizeCachingHeaders_DoNotAddDate_IfSpecified() + { + var httpContext = new DefaultHttpContext(); + httpContext.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue() + { + Public = true + }; + var context = CreateTestContext(httpContext); + var state = httpContext.GetResponseCachingState(); + var utcNow = DateTimeOffset.MinValue; + state.ResponseTime = utcNow; + + Assert.Null(state.ResponseHeaders.Date); + + context.FinalizeCachingHeaders(); + + Assert.Equal(utcNow, state.ResponseHeaders.Date); + } + + [Fact] + public void FinalizeCachingHeaders_AddsDate_IfNoneSpecified() + { + var httpContext = new DefaultHttpContext(); + httpContext.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue() + { + Public = true + }; + var context = CreateTestContext(httpContext); + var state = httpContext.GetResponseCachingState(); + var utcNow = DateTimeOffset.MinValue; + state.ResponseHeaders.Date = utcNow; + state.ResponseTime = utcNow + TimeSpan.FromSeconds(10); + + Assert.Equal(utcNow, state.ResponseHeaders.Date); + + context.FinalizeCachingHeaders(); + + Assert.Equal(utcNow, state.ResponseHeaders.Date); + } + + [Fact] + public void FinalizeCachingHeaders_StoresCachedResponse_InState() + { + var httpContext = new DefaultHttpContext(); + httpContext.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue() + { + Public = true + }; + var context = CreateTestContext(httpContext); + var state = httpContext.GetResponseCachingState(); + + Assert.Null(state.CachedResponse); + + context.FinalizeCachingHeaders(); + + Assert.NotNull(state.CachedResponse); + } + + [Fact] + public async Task FinalizeCachingBody_StoreResponseBodySeparately_IfLargerThanLimit() + { + var httpContext = new DefaultHttpContext(); + var cache = new TestResponseCache(); + var context = CreateTestContext(httpContext, cache, new ResponseCachingOptions()); + + context.ShimResponseStream(); + await httpContext.Response.WriteAsync(new string('0', 70 * 1024)); + + var state = httpContext.GetResponseCachingState(); + state.ShouldCacheResponse = true; + state.CachedResponse = new CachedResponse() + { + BodyKeyPrefix = FastGuid.NewGuid().IdString + }; + state.BaseKey = "BaseKey"; + state.CachedResponseValidFor = TimeSpan.FromSeconds(10); + + context.FinalizeCachingBody(); + + Assert.Equal(2, cache.StoredItems); + } + + [Fact] + public async Task FinalizeCachingBody_StoreResponseBodyInCachedResponse_IfSmallerThanLimit() + { + var httpContext = new DefaultHttpContext(); + var cache = new TestResponseCache(); + var context = CreateTestContext(httpContext, cache, new ResponseCachingOptions()); + + context.ShimResponseStream(); + await httpContext.Response.WriteAsync(new string('0', 70 * 1024 - 1)); + + var state = httpContext.GetResponseCachingState(); + state.ShouldCacheResponse = true; + state.CachedResponse = new CachedResponse() + { + BodyKeyPrefix = FastGuid.NewGuid().IdString + }; + state.BaseKey = "BaseKey"; + state.CachedResponseValidFor = TimeSpan.FromSeconds(10); + + context.FinalizeCachingBody(); + + Assert.Equal(1, cache.StoredItems); + } + + [Fact] + public void NormalizeStringValues_NormalizesCasingToUpper() + { + var uppercaseStrings = new StringValues(new[] { "STRINGA", "STRINGB" }); + var lowercaseStrings = new StringValues(new[] { "stringA", "stringB" }); + + var normalizedStrings = ResponseCachingContext.GetNormalizedStringValues(lowercaseStrings); + + Assert.Equal(uppercaseStrings, normalizedStrings); + } + + [Fact] + public void NormalizeStringValues_NormalizesOrder() + { + var orderedStrings = new StringValues(new[] { "STRINGA", "STRINGB" }); + var reverseOrderStrings = new StringValues(new[] { "STRINGB", "STRINGA" }); + + var normalizedStrings = ResponseCachingContext.GetNormalizedStringValues(reverseOrderStrings); + + Assert.Equal(orderedStrings, normalizedStrings); + } + private static ResponseCachingContext CreateTestContext(HttpContext httpContext) { return CreateTestContext( httpContext, + new TestResponseCache(), new ResponseCachingOptions(), new CacheabilityValidator()); } @@ -166,6 +469,7 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests { return CreateTestContext( httpContext, + new TestResponseCache(), options, new CacheabilityValidator()); } @@ -174,12 +478,23 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests { return CreateTestContext( httpContext, + new TestResponseCache(), new ResponseCachingOptions(), cacheabilityValidator); } + private static ResponseCachingContext CreateTestContext(HttpContext httpContext, IResponseCache responseCache, ResponseCachingOptions options) + { + return CreateTestContext( + httpContext, + responseCache, + options, + new CacheabilityValidator()); + } + private static ResponseCachingContext CreateTestContext( HttpContext httpContext, + IResponseCache responseCache, ResponseCachingOptions options, ICacheabilityValidator cacheabilityValidator) { @@ -187,7 +502,7 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests return new ResponseCachingContext( httpContext, - new TestResponseCache(), + responseCache, options, cacheabilityValidator, new KeyProvider(new DefaultObjectPoolProvider(), Options.Create(options))); @@ -195,6 +510,8 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests private class TestResponseCache : IResponseCache { + public int StoredItems { get; private set; } + public object Get(string key) { return null; @@ -206,6 +523,7 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests public void Set(string key, object entry, TimeSpan validFor) { + StoredItems++; } } diff --git a/test/Microsoft.AspNetCore.ResponseCaching.Tests/ResponseCachingTests.cs b/test/Microsoft.AspNetCore.ResponseCaching.Tests/ResponseCachingTests.cs index c28c31e8c1..d37d76e30e 100644 --- a/test/Microsoft.AspNetCore.ResponseCaching.Tests/ResponseCachingTests.cs +++ b/test/Microsoft.AspNetCore.ResponseCaching.Tests/ResponseCachingTests.cs @@ -497,6 +497,29 @@ namespace Microsoft.AspNetCore.ResponseCaching.Tests } } + [Fact] + public async void ServesCachedContent_WithoutReplacingCachedVaryBy_OnCacheMiss() + { + var builder = CreateBuilderWithResponseCaching(async (context) => + { + context.Response.Headers[HeaderNames.Vary] = HeaderNames.From; + await DefaultRequestDelegate(context); + }); + + using (var server = new TestServer(builder)) + { + var client = server.CreateClient(); + client.DefaultRequestHeaders.From = "user@example.com"; + var initialResponse = await client.GetAsync(""); + client.DefaultRequestHeaders.From = "user2@example.com"; + var otherResponse = await client.GetAsync(""); + client.DefaultRequestHeaders.From = "user@example.com"; + var subsequentResponse = await client.GetAsync(""); + + await AssertResponseCachedAsync(initialResponse, subsequentResponse); + } + } + private static async Task AssertResponseCachedAsync(HttpResponseMessage initialResponse, HttpResponseMessage subsequentResponse) { initialResponse.EnsureSuccessStatusCode();