IResponseCache adapter and support for vary by header

This commit is contained in:
John Luo 2016-07-21 19:25:51 -07:00
parent 8e7eb5d99e
commit a40cc88169
15 changed files with 516 additions and 39 deletions

View File

@ -15,7 +15,7 @@ namespace ResponseCachingSample
{
public void ConfigureServices(IServiceCollection services)
{
services.AddMemoryCache();
services.AddDistributedResponseCache();
}
public void Configure(IApplicationBuilder app)
@ -23,11 +23,14 @@ namespace ResponseCachingSample
app.UseResponseCaching();
app.Run(async (context) =>
{
// These settings should be configured by context.Response.Cache.*
context.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue()
{
Public = true,
MaxAge = TimeSpan.FromSeconds(10)
MaxAge = TimeSpan.FromSeconds(10)
};
context.Response.Headers["Vary"] = new string[] { "Accept-Encoding", "Non-Existent" };
await context.Response.WriteAsync("Hello World! " + DateTime.UtcNow);
});
}

View File

@ -0,0 +1,13 @@
// 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
{
public interface IResponseCache
{
object Get(string key);
// TODO: Set expiry policy in the underlying cache?
void Set(string key, object entry);
void Remove(string key);
}
}

View File

@ -3,12 +3,14 @@
using Microsoft.AspNetCore.Http;
namespace Microsoft.AspNetCore.ResponseCaching
namespace Microsoft.AspNetCore.ResponseCaching.Internal
{
internal class ResponseCachingEntry
internal class CachedResponse
{
public int StatusCode { get; set; }
internal int StatusCode { get; set; }
internal IHeaderDictionary Headers { get; set; } = new HeaderDictionary();
internal byte[] Body { get; set; }
}
}

View File

@ -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.
using Microsoft.Extensions.Primitives;
namespace Microsoft.AspNetCore.ResponseCaching.Internal
{
internal class CachedVaryBy
{
internal StringValues Headers { get; set; }
}
}

View File

@ -0,0 +1,157 @@
// 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.IO;
using Microsoft.AspNetCore.Http;
namespace Microsoft.AspNetCore.ResponseCaching.Internal
{
internal static class DefaultResponseCacheSerializer
{
private const int FormatVersion = 1;
public static object Deserialize(byte[] serializedEntry)
{
using (var memory = new MemoryStream(serializedEntry))
{
using (var reader = new BinaryReader(memory))
{
return Read(reader);
}
}
}
public static byte[] Serialize(object entry)
{
using (var memory = new MemoryStream())
{
using (var writer = new BinaryWriter(memory))
{
Write(writer, entry);
writer.Flush();
return memory.ToArray();
}
}
}
// Serialization Format
// Format version (int)
// Type (string)
// Type-dependent data (see CachedResponse and CachedVaryBy)
public static object Read(BinaryReader reader)
{
if (reader == null)
{
throw new ArgumentNullException(nameof(reader));
}
if (reader.ReadInt32() != FormatVersion)
{
return null;
}
var type = reader.ReadString();
if (string.Equals(nameof(CachedResponse), type))
{
var cachedResponse = ReadCachedResponse(reader);
return cachedResponse;
}
else if (string.Equals(nameof(CachedVaryBy), type))
{
var cachedResponse = ReadCachedVaryBy(reader);
return cachedResponse;
}
// Unable to read as CachedResponse or CachedVaryBy
return null;
}
// Serialization Format
// Status code (int)
// Header count (int)
// Header(s)
// Key (string)
// Value (string)
// Body length (int)
// Body (byte[])
private static CachedResponse ReadCachedResponse(BinaryReader reader)
{
var statusCode = reader.ReadInt32();
var headerCount = reader.ReadInt32();
var headers = new HeaderDictionary();
for (var index = 0; index < headerCount; index++)
{
var key = reader.ReadString();
var value = reader.ReadString();
headers[key] = value;
}
var bodyLength = reader.ReadInt32();
var body = reader.ReadBytes(bodyLength);
return new CachedResponse { StatusCode = statusCode, Headers = headers, Body = body };
}
// Serialization Format
// Headers (comma separated string)
private static CachedVaryBy ReadCachedVaryBy(BinaryReader reader)
{
var headers = reader.ReadString().Split(',');
return new CachedVaryBy { Headers = headers };
}
// See serialization format above
public static void Write(BinaryWriter writer, object entry)
{
if (writer == null)
{
throw new ArgumentNullException(nameof(writer));
}
if (entry == null)
{
throw new ArgumentNullException(nameof(entry));
}
writer.Write(FormatVersion);
if (entry is CachedResponse)
{
WriteCachedResponse(writer, entry as CachedResponse);
}
else if (entry is CachedVaryBy)
{
WriteCachedVaryBy(writer, entry as CachedVaryBy);
}
else
{
throw new NotSupportedException($"Unrecognized entry format for {nameof(entry)}.");
}
}
// See serialization format above
private static void WriteCachedResponse(BinaryWriter writer, CachedResponse entry)
{
writer.Write(nameof(CachedResponse));
writer.Write(entry.StatusCode);
writer.Write(entry.Headers.Count);
foreach (var header in entry.Headers)
{
writer.Write(header.Key);
writer.Write(header.Value);
}
writer.Write(entry.Body.Length);
writer.Write(entry.Body);
}
// See serialization format above
private static void WriteCachedVaryBy(BinaryWriter writer, CachedVaryBy entry)
{
writer.Write(nameof(CachedVaryBy));
writer.Write(entry.Headers);
}
}
}

View File

@ -0,0 +1,60 @@
// 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 Microsoft.Extensions.Caching.Distributed;
namespace Microsoft.AspNetCore.ResponseCaching.Internal
{
internal class DistributedResponseCache : IResponseCache
{
private readonly IDistributedCache _cache;
public DistributedResponseCache(IDistributedCache cache)
{
if (cache == null)
{
throw new ArgumentNullException(nameof(cache));
}
_cache = cache;
}
public object Get(string key)
{
try
{
return DefaultResponseCacheSerializer.Deserialize(_cache.Get(key));
}
catch
{
// TODO: Log error
return null;
}
}
public void Remove(string key)
{
try
{
_cache.Remove(key);
}
catch
{
// TODO: Log error
}
}
public void Set(string key, object entry)
{
try
{
_cache.Set(key, DefaultResponseCacheSerializer.Serialize(entry));
}
catch
{
// TODO: Log error
}
}
}
}

View File

@ -0,0 +1,38 @@
// 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 Microsoft.Extensions.Caching.Memory;
namespace Microsoft.AspNetCore.ResponseCaching.Internal
{
internal class MemoryResponseCache : IResponseCache
{
private readonly IMemoryCache _cache;
public MemoryResponseCache(IMemoryCache cache)
{
if (cache == null)
{
throw new ArgumentNullException(nameof(cache));
}
_cache = cache;
}
public object Get(string key)
{
return _cache.Get(key);
}
public void Remove(string key)
{
_cache.Remove(key);
}
public void Set(string key, object entry)
{
_cache.Set(key, entry);
}
}
}

View File

@ -3,9 +3,11 @@
using System;
using System.IO;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.AspNetCore.ResponseCaching.Internal;
using Microsoft.Extensions.Primitives;
namespace Microsoft.AspNetCore.ResponseCaching
{
@ -13,15 +15,25 @@ namespace Microsoft.AspNetCore.ResponseCaching
{
private string _cacheKey;
public ResponseCachingContext(HttpContext httpContext, IMemoryCache cache)
public ResponseCachingContext(HttpContext httpContext, IResponseCache cache)
{
if (cache == null)
{
throw new ArgumentNullException(nameof(cache));
}
if (httpContext == null)
{
throw new ArgumentNullException(nameof(httpContext));
}
HttpContext = httpContext;
Cache = cache;
}
private HttpContext HttpContext { get; }
private IMemoryCache Cache { get; }
private IResponseCache Cache { get; }
private Stream OriginalResponseStream { get; set; }
@ -46,38 +58,81 @@ namespace Microsoft.AspNetCore.ResponseCaching
}
// Only QueryString is treated as case sensitive
// GET;HTTP://MYDOMAIN.COM:80/PATHBASE/PATH?QueryString
// GET;/PATH;VaryBy
private string CreateCacheKey()
{
return CreateCacheKey(varyBy: null);
}
private string CreateCacheKey(CachedVaryBy varyBy)
{
var request = HttpContext.Request;
return request.Method.ToUpperInvariant()
+ ";"
+ request.Scheme.ToUpperInvariant()
+ "://"
+ request.Host.Value.ToUpperInvariant()
+ request.PathBase.Value.ToUpperInvariant()
+ request.Path.Value.ToUpperInvariant()
+ request.QueryString;
var builder = new StringBuilder()
.Append(request.Method.ToUpperInvariant())
.Append(";")
.Append(request.Path.Value.ToUpperInvariant())
.Append(CreateVaryByCacheKey(varyBy));
return builder.ToString();
}
private string CreateVaryByCacheKey(CachedVaryBy varyBy)
{
// TODO: resolve key format and delimiters
if (varyBy == null)
{
return string.Empty;
}
var request = HttpContext.Request;
var builder = new StringBuilder(";");
foreach (var header in varyBy.Headers)
{
var value = request.Headers[header].ToString();
// null vs Empty?
if (string.IsNullOrEmpty(value))
{
value = "null";
}
builder.Append(header)
.Append("=")
.Append(value)
.Append(";");
}
// Parse querystring params
return builder.ToString();
}
internal async Task<bool> TryServeFromCacheAsync()
{
_cacheKey = CreateCacheKey();
ResponseCachingEntry cacheEntry;
if (Cache.TryGetValue(_cacheKey, out cacheEntry))
var cacheEntry = Cache.Get(_cacheKey);
if (cacheEntry is CachedVaryBy)
{
// Request contains VaryBy rules, recompute key and try again
_cacheKey = CreateCacheKey(cacheEntry as CachedVaryBy);
cacheEntry = Cache.Get(_cacheKey);
}
if (cacheEntry is CachedResponse)
{
// TODO: Compare cached request headers
// TODO: Evaluate Vary-By and select the most appropriate response
// TODO: Content negotiation if there are multiple cached response formats?
// TODO: Verify content freshness, or else re-validate the data?
var cachedResponse = cacheEntry as CachedResponse;
var response = HttpContext.Response;
// Copy the cached status code and response headers
response.StatusCode = cacheEntry.StatusCode;
foreach (var pair in cacheEntry.Headers)
response.StatusCode = cachedResponse.StatusCode;
foreach (var pair in cachedResponse.Headers)
{
response.Headers[pair.Key] = pair.Value;
}
@ -86,7 +141,7 @@ namespace Microsoft.AspNetCore.ResponseCaching
response.Headers["Served_From_Cache"] = DateTime.Now.ToString();
// Copy the cached response body
var body = cacheEntry.Body;
var body = cachedResponse.Body;
if (body.Length > 0)
{
await response.Body.WriteAsync(body, 0, body.Length);
@ -120,15 +175,34 @@ namespace Microsoft.AspNetCore.ResponseCaching
{
if (CacheResponse)
{
// Store the buffer to cache
var cacheEntry = new ResponseCachingEntry();
cacheEntry.StatusCode = HttpContext.Response.StatusCode;
var response = HttpContext.Response;
var varyHeaderValue = response.Headers["Vary"];
// Check if any VaryBy rules exist
if (!StringValues.IsNullOrEmpty(varyHeaderValue))
{
var cachedVaryBy = new CachedVaryBy
{
// Only vary by headers for now
Headers = varyHeaderValue
};
Cache.Set(_cacheKey, cachedVaryBy);
_cacheKey = CreateCacheKey(cachedVaryBy);
}
// Store the response to cache
var cachedResponse = new CachedResponse
{
StatusCode = HttpContext.Response.StatusCode,
Body = Buffer.ToArray()
};
foreach (var pair in HttpContext.Response.Headers)
{
cacheEntry.Headers[pair.Key] = pair.Value;
cachedResponse.Headers[pair.Key] = pair.Value;
}
cacheEntry.Body = Buffer.ToArray();
Cache.Set(_cacheKey, cacheEntry); // TODO: Timeouts
Cache.Set(_cacheKey, cachedResponse); // TODO: Timeouts
}
// TODO: TEMP, flush the buffer to the client

View File

@ -1,9 +1,9 @@
// 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.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Caching.Memory;
namespace Microsoft.AspNetCore.ResponseCaching
{
@ -11,10 +11,20 @@ namespace Microsoft.AspNetCore.ResponseCaching
public class ResponseCachingMiddleware
{
private readonly RequestDelegate _next;
private readonly IMemoryCache _cache;
private readonly IResponseCache _cache;
public ResponseCachingMiddleware(RequestDelegate next, IMemoryCache cache)
public ResponseCachingMiddleware(RequestDelegate next, IResponseCache cache)
{
if (cache == null)
{
throw new ArgumentNullException(nameof(cache));
}
if (next == null)
{
throw new ArgumentNullException(nameof(next));
}
_next = next;
_cache = cache;
}

View File

@ -0,0 +1,39 @@
// 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 Microsoft.AspNetCore.ResponseCaching;
using Microsoft.AspNetCore.ResponseCaching.Internal;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace Microsoft.Extensions.DependencyInjection
{
public static class ResponseCachingServiceCollectionExtensions
{
public static IServiceCollection AddMemoryResponseCache(this IServiceCollection services)
{
if (services == null)
{
throw new ArgumentNullException(nameof(services));
}
services.AddMemoryCache();
services.TryAdd(ServiceDescriptor.Singleton<IResponseCache, MemoryResponseCache>());
return services;
}
public static IServiceCollection AddDistributedResponseCache(this IServiceCollection services)
{
if (services == null)
{
throw new ArgumentNullException(nameof(services));
}
services.AddDistributedMemoryCache();
services.TryAdd(ServiceDescriptor.Singleton<IResponseCache, DistributedResponseCache>());
return services;
}
}
}

View File

@ -22,7 +22,7 @@
},
"dependencies": {
"Microsoft.AspNetCore.Http": "1.1.0-*",
"Microsoft.Extensions.Caching.Abstractions": "1.1.0-*"
"Microsoft.Extensions.Caching.Memory": "1.1.0-*"
},
"frameworks": {
"net451": {},

View File

@ -14,5 +14,8 @@
<PropertyGroup>
<SchemaVersion>2.0</SchemaVersion>
</PropertyGroup>
<ItemGroup>
<Service Include="{82a7f48d-3b50-4b1e-b82e-3ada8210c358}" />
</ItemGroup>
<Import Project="$(VSToolsPath)\DotNet\Microsoft.DotNet.targets" Condition="'$(VSToolsPath)' != ''" />
</Project>

View File

@ -1,11 +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.
using System;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Caching.Memory;
using Xunit;
namespace Microsoft.AspNetCore.ResponseCaching
namespace Microsoft.AspNetCore.ResponseCaching.Tests
{
public class ResponseCachingContextTests
{
@ -14,7 +15,7 @@ namespace Microsoft.AspNetCore.ResponseCaching
{
var httpContext = new DefaultHttpContext();
httpContext.Request.Method = "GET";
var context = new ResponseCachingContext(httpContext, new MemoryCache(new MemoryCacheOptions()));
var context = new ResponseCachingContext(httpContext, new TestResponseCache());
Assert.True(context.CheckRequestAllowsCaching());
}
@ -25,9 +26,25 @@ namespace Microsoft.AspNetCore.ResponseCaching
{
var httpContext = new DefaultHttpContext();
httpContext.Request.Method = method;
var context = new ResponseCachingContext(httpContext, new MemoryCache(new MemoryCacheOptions()));
var context = new ResponseCachingContext(httpContext, new TestResponseCache());
Assert.False(context.CheckRequestAllowsCaching());
}
private class TestResponseCache : IResponseCache
{
public object Get(string key)
{
return null;
}
public void Remove(string key)
{
}
public void Set(string key, object entry)
{
}
}
}
}

View File

@ -0,0 +1,50 @@
// 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 Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using System;
using Xunit;
namespace Microsoft.AspNetCore.ResponseCaching.Tests
{
public class ResponseCachingTests
{
[Fact]
public async void ServesCachedContentIfAvailable()
{
var builder = new WebHostBuilder()
.ConfigureServices(services =>
{
services.AddDistributedResponseCache();
})
.Configure(app =>
{
app.UseResponseCaching();
app.Run(async (context) =>
{
context.Response.Headers["Cache-Control"] = "public";
await context.Response.WriteAsync(DateTime.UtcNow.ToString());
});
});
using (var server = new TestServer(builder))
{
var client = server.CreateClient();
var initialResponse = await client.GetAsync("");
var subsequentResponse = await client.GetAsync("");
initialResponse.EnsureSuccessStatusCode();
subsequentResponse.EnsureSuccessStatusCode();
// TODO: Check for the appropriate headers once we actually set them
Assert.False(initialResponse.Headers.Contains("Served_From_Cache"));
Assert.True(subsequentResponse.Headers.Contains("Served_From_Cache"));
Assert.Equal(await initialResponse.Content.ReadAsStringAsync(), await subsequentResponse.Content.ReadAsStringAsync());
}
}
}
}

View File

@ -5,9 +5,8 @@
},
"dependencies": {
"dotnet-test-xunit": "2.2.0-*",
"Microsoft.AspNetCore.Http": "1.1.0-*",
"Microsoft.AspNetCore.ResponseCaching": "0.1.0-*",
"Microsoft.Extensions.Caching.Memory": "1.1.0-*",
"Microsoft.AspNetCore.TestHost": "1.1.0-*",
"xunit": "2.2.0-*"
},
"frameworks": {