Introducing 'cache' tag helper

Fixes #1552
This commit is contained in:
Pranav K 2015-01-07 12:43:55 -08:00
parent eb1eca9e1a
commit 80ada8d01b
19 changed files with 1263 additions and 3 deletions

View File

@ -0,0 +1,266 @@
// Copyright (c) Microsoft Open Technologies, Inc. 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.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNet.Http;
using Microsoft.AspNet.Razor.Runtime.TagHelpers;
using Microsoft.Framework.Cache.Memory;
namespace Microsoft.AspNet.Mvc.TagHelpers
{
/// <summary>
/// <see cref="TagHelper"/> implementation targeting &lt;cache&gt; elements.
/// </summary>
public class CacheTagHelper : TagHelper
{
/// <summary>
/// Prefix used by <see cref="CacheTagHelper"/> instances when creating entries in <see cref="MemoryCache"/>.
/// </summary>
public static readonly string CacheKeyPrefix = nameof(CacheTagHelper);
private const string VaryByAttributeName = "vary-by";
private const string VaryByHeaderAttributeName = "vary-by-header";
private const string VaryByQueryAttributeName = "vary-by-query";
private const string VaryByRouteAttributeName = "vary-by-route";
private const string VaryByCookieAttributeName = "vary-by-cookie";
private const string VaryByUserAttributeName = "vary-by-user";
private const string ExpiresOnAttributeName = "expires-on";
private const string ExpiresAfterAttributeName = "expires-after";
private const string ExpiresSlidingAttributeName = "expires-sliding";
private const string CachePriorityAttributeName = "priority";
private const string CacheKeyTokenSeparator = "||";
private static readonly char[] AttributeSeparator = new[] { ',' };
/// <summary>
/// Gets or sets the <see cref="IMemoryCache"/> instance used to cache entries.
/// </summary>
[Activate]
protected internal IMemoryCache MemoryCache { get; set; }
/// <summary>
/// Gets or sets the <see cref="ViewContext"/> for the current executing View.
/// </summary>
[Activate]
protected internal ViewContext ViewContext { get; set; }
/// <summary>
/// Gets or sets a <see cref="string" /> to vary the cached result by.
/// </summary>
[HtmlAttributeName(VaryByAttributeName)]
public string VaryBy { get; set; }
/// <summary>
/// Gets or sets the name of a HTTP request header to vary the cached result by.
/// </summary>
[HtmlAttributeName(VaryByHeaderAttributeName)]
public string VaryByHeader { get; set; }
/// <summary>
/// Gets or sets a comma-delimited set of query parameters to vary the cached result by.
/// </summary>
[HtmlAttributeName(VaryByQueryAttributeName)]
public string VaryByQuery { get; set; }
/// <summary>
/// Gets or sets a comma-delimited set of route data parameters to vary the cached result by.
/// </summary>
[HtmlAttributeName(VaryByRouteAttributeName)]
public string VaryByRoute { get; set; }
/// <summary>
/// Gets or sets a comma-delimited set of cookie names to vary the cached result by.
/// </summary>
[HtmlAttributeName(VaryByCookieAttributeName)]
public string VaryByCookie { get; set; }
/// <summary>
/// Gets or sets a value that determines if the cached result is to be varied by the Identity for the logged in
/// <see cref="HttpContext.User"/>.
/// </summary>
[HtmlAttributeName(VaryByUserAttributeName)]
public bool VaryByUser { get; set; }
/// <summary>
/// Gets or sets the exact <see cref="DateTimeOffset"/> the cache entry should be evicted.
/// </summary>
[HtmlAttributeName(ExpiresOnAttributeName)]
public DateTimeOffset? ExpiresOn { get; set; }
/// <summary>
/// Gets or sets the duration, from the time the cache entry was added, when it should be evicted.
/// </summary>
[HtmlAttributeName(ExpiresAfterAttributeName)]
public TimeSpan? ExpiresAfter { get; set; }
/// <summary>
/// Gets or sets the duration from last access that the cache entry should be evicted.
/// </summary>
[HtmlAttributeName(ExpiresSlidingAttributeName)]
public TimeSpan? ExpiresSliding { get; set; }
/// <summary>
/// Gets or sets the <see cref="CachePreservationPriority"/> policy for the cache entry.
/// </summary>
[HtmlAttributeName(CachePriorityAttributeName)]
public CachePreservationPriority? Priority { get; set; }
/// <inheritdoc />
public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
{
var key = GenerateKey(context);
string result;
if (!MemoryCache.TryGetValue(key, out result))
{
result = await context.GetChildContentAsync();
MemoryCache.Set(key, cacheSetContext =>
{
UpdateCacheContext(cacheSetContext);
return result;
});
}
// Clear the contents of the "cache" element since we don't want to render it.
output.SuppressOutput();
output.Content = result;
}
// Internal for unit testing
internal string GenerateKey(TagHelperContext context)
{
var builder = new StringBuilder(CacheKeyPrefix);
builder.Append(CacheKeyTokenSeparator)
.Append(context.UniqueId);
var request = ViewContext.HttpContext.Request;
if (!string.IsNullOrEmpty(VaryBy))
{
builder.Append(CacheKeyTokenSeparator)
.Append(nameof(VaryBy))
.Append(CacheKeyTokenSeparator)
.Append(VaryBy);
}
AddStringCollectionKey(builder, nameof(VaryByCookie), VaryByCookie, request.Cookies);
AddStringCollectionKey(builder, nameof(VaryByHeader), VaryByHeader, request.Headers);
AddStringCollectionKey(builder, nameof(VaryByQuery), VaryByQuery, request.Query);
AddVaryByRouteKey(builder);
if (VaryByUser)
{
builder.Append(CacheKeyTokenSeparator)
.Append(nameof(VaryByUser))
.Append(CacheKeyTokenSeparator)
.Append(ViewContext.HttpContext.User?.Identity?.Name);
}
// The key is typically too long to be useful, so we use a cryptographic hash
// as the actual key (better randomization and key distribution, so small vary
// values will generate dramatically different keys).
using (var sha = SHA256.Create())
{
var contentBytes = Encoding.UTF8.GetBytes(builder.ToString());
var hashedBytes = sha.ComputeHash(contentBytes);
return Convert.ToBase64String(hashedBytes);
}
}
// Internal for unit testing
internal void UpdateCacheContext(ICacheSetContext cacheSetContext)
{
if (ExpiresOn != null)
{
cacheSetContext.SetAbsoluteExpiration(ExpiresOn.Value);
}
if (ExpiresAfter != null)
{
cacheSetContext.SetAbsoluteExpiration(ExpiresAfter.Value);
}
if (ExpiresSliding != null)
{
cacheSetContext.SetSlidingExpiration(ExpiresSliding.Value);
}
if (Priority != null)
{
cacheSetContext.SetPriority(Priority.Value);
}
}
private static void AddStringCollectionKey(StringBuilder builder,
string keyName,
string value,
IReadableStringCollection sourceCollection)
{
if (!string.IsNullOrEmpty(value))
{
// keyName(param1=value1|param2=value2)
builder.Append(CacheKeyTokenSeparator)
.Append(keyName)
.Append("(");
var tokenFound = false;
foreach (var item in Tokenize(value))
{
tokenFound = true;
builder.Append(item)
.Append(CacheKeyTokenSeparator)
.Append(sourceCollection[item])
.Append(CacheKeyTokenSeparator);
}
if (tokenFound)
{
// Remove the trailing separator
builder.Length -= CacheKeyTokenSeparator.Length;
}
builder.Append(")");
}
}
private void AddVaryByRouteKey(StringBuilder builder)
{
var tokenFound = false;
if (!string.IsNullOrEmpty(VaryByRoute))
{
builder.Append(CacheKeyTokenSeparator)
.Append(nameof(VaryByRoute))
.Append("(");
foreach (var route in Tokenize(VaryByRoute))
{
tokenFound = true;
builder.Append(route)
.Append(CacheKeyTokenSeparator)
.Append(ViewContext.RouteData.Values[route])
.Append(CacheKeyTokenSeparator);
}
if (tokenFound)
{
builder.Length -= CacheKeyTokenSeparator.Length;
}
builder.Append(")");
}
}
private static IEnumerable<string> Tokenize(string value)
{
return value.Split(AttributeSeparator, StringSplitOptions.RemoveEmptyEntries)
.Select(token => token.Trim())
.Where(token => token.Length > 0);
}
}
}

View File

@ -6,7 +6,9 @@
},
"dependencies": {
"Microsoft.AspNet.Mvc.Common": { "version": "6.0.0-*", "type": "build" },
"Microsoft.AspNet.Mvc.Razor": ""
"Microsoft.AspNet.Mvc.Razor": "6.0.0-*",
"Microsoft.Framework.Cache.Memory": "1.0.0-*",
"System.Security.Cryptography.Hashing.Algorithms": "4.0.0-beta-*"
},
"frameworks": {
"aspnet50": {},

View File

@ -13,6 +13,7 @@ using Microsoft.AspNet.Mvc.Razor.OptionDescriptors;
using Microsoft.AspNet.Mvc.Rendering;
using Microsoft.AspNet.Mvc.Routing;
using Microsoft.AspNet.Security;
using Microsoft.Framework.Cache.Memory;
using Microsoft.Framework.ConfigurationModel;
using Microsoft.Framework.DependencyInjection;
using Microsoft.Framework.DependencyInjection.NestedProviders;
@ -138,6 +139,9 @@ namespace Microsoft.AspNet.Mvc
// Only want one ITagHelperActivator so it can cache Type activation information. Types won't conflict.
yield return describe.Singleton<ITagHelperActivator, DefaultTagHelperActivator>();
// Consumed by the Cache tag helper to cache results across the lifetime of the application.
yield return describe.Singleton<IMemoryCache, MemoryCache>();
// DefaultHtmlGenerator is pretty much stateless but depends on Scoped services such as IUrlHelper and
// IActionBindingContextProvider. Therefore it too is scoped.
yield return describe.Transient<IHtmlGenerator, DefaultHtmlGenerator>();

View File

@ -6,7 +6,8 @@
},
"dependencies": {
"Microsoft.AspNet.Mvc.Common": { "version": "6.0.0-*", "type": "build" },
"Microsoft.AspNet.Mvc.Razor": "6.0.0-*"
"Microsoft.AspNet.Mvc.Razor": "6.0.0-*",
"Microsoft.Framework.Cache.Memory": "1.0.0-*"
},
"frameworks": {
"aspnet50": {},

View File

@ -0,0 +1,15 @@
<h2>Category: Laptops</h2>
<h2>Region: North</h2>
<h2>Cached content</h2>
Locations closest to your locale:
NorthWest Store
<div>CorrelationId in View Component: 1</div>
<partial-title>Listing items</partial-title>
Cached Content for Laptops
<div>CorrelationId in Partial: 1</div>
<div>CorrelationId in Splash: 1</div>

View File

@ -0,0 +1,15 @@
<h2>Category: Phones</h2>
<h2>Region: North</h2>
<h2>Cached content</h2>
Locations closest to your locale:
NorthWest Store
<div>CorrelationId in View Component: 1</div>
<partial-title>Listing items</partial-title>
Cached Content for Phones
<div>CorrelationId in Partial: 2</div>
<div>CorrelationId in Splash: 2</div>

View File

@ -0,0 +1,15 @@
<h2>Category: Phones</h2>
<h2>Region: East</h2>
<h2>Cached content</h2>
Locations closest to your locale:
Nationwide Store
<div>CorrelationId in View Component: 3</div>
<partial-title>Listing items</partial-title>
Cached Content for Phones
<div>CorrelationId in Partial: 2</div>
<div>CorrelationId in Splash: 3</div>

View File

@ -19,7 +19,7 @@ namespace Microsoft.AspNet.Mvc.FunctionalTests
{
private readonly IServiceProvider _provider = TestHelper.CreateServices("MvcTagHelpersWebSite");
private readonly Action<IApplicationBuilder> _app = new Startup().Configure;
private static readonly Assembly _resourcesAssembly = typeof(TagHelpersTests).GetTypeInfo().Assembly;
private static readonly Assembly _resourcesAssembly = typeof(MvcTagHelpersTests).GetTypeInfo().Assembly;
[Theory]
[InlineData("Index", null)]
@ -96,5 +96,195 @@ namespace Microsoft.AspNet.Mvc.FunctionalTests
expectedContent = string.Format(expectedContent, forgeryToken);
Assert.Equal(expectedContent.Trim(), responseContent.Trim());
}
[Fact]
public async Task CacheTagHelper_CanCachePortionsOfViewsPartialViewsAndViewComponents()
{
// Arrange
var assertFile =
"compiler/resources/CacheTagHelper_CanCachePortionsOfViewsPartialViewsAndViewComponents.Assert";
var server = TestServer.Create(_provider, _app);
var client = server.CreateClient();
client.BaseAddress = new Uri("http://localhost");
client.DefaultRequestHeaders.Add("Locale", "North");
// Act - 1
// Verify that content gets cached based on vary-by-params
var targetUrl = "/catalog?categoryId=1&correlationid=1";
var response1 = await client.GetStringAsync(targetUrl);
var response2 = await client.GetStringAsync(targetUrl);
// Assert - 1
var expected1 = await _resourcesAssembly.ReadResourceAsStringAsync(assertFile + "1.txt");
Assert.Equal(expected1, response1.Trim());
Assert.Equal(expected1, response2.Trim());
// Act - 2
// Verify content gets changed in partials when one of the vary by parameters is changed
targetUrl = "/catalog?categoryId=3&correlationid=2";
var response3 = await client.GetStringAsync(targetUrl);
var response4 = await client.GetStringAsync(targetUrl);
// Assert - 2
var expected2 = await _resourcesAssembly.ReadResourceAsStringAsync(assertFile + "2.txt");
Assert.Equal(expected2, response3.Trim());
Assert.Equal(expected2, response4.Trim());
// Act - 3
// Verify content gets changed in a View Component when the Vary-by-header parameters is changed
client.DefaultRequestHeaders.Remove("Locale");
client.DefaultRequestHeaders.Add("Locale", "East");
targetUrl = "/catalog?categoryId=3&correlationid=3";
var response5 = await client.GetStringAsync(targetUrl);
var response6 = await client.GetStringAsync(targetUrl);
// Assert - 3
var expected3 = await _resourcesAssembly.ReadResourceAsStringAsync(assertFile + "3.txt");
Assert.Equal(expected3, response5.Trim());
Assert.Equal(expected3, response6.Trim());
}
[Fact]
public async Task CacheTagHelper_ExpiresContent_BasedOnExpiresParameter()
{
// Arrange
var server = TestServer.Create(_provider, _app);
var client = server.CreateClient();
client.BaseAddress = new Uri("http://localhost");
// Act - 1
var response1 = await client.GetStringAsync("/catalog/2");
// Assert - 1
var expected1 = "Cached content for 2";
Assert.Equal(expected1, response1.Trim());
// Act - 2
await Task.Delay(TimeSpan.FromSeconds(1));
var response2 = await client.GetStringAsync("/catalog/3");
// Assert - 2
var expected2 = "Cached content for 3";
Assert.Equal(expected2, response2.Trim());
}
[Fact]
public async Task CacheTagHelper_UsesVaryByCookie_ToVaryContent()
{
// Arrange
var server = TestServer.Create(_provider, _app);
var client = server.CreateClient();
client.BaseAddress = new Uri("http://localhost");
// Act - 1
var response1 = await client.GetStringAsync("/catalog/cart?correlationid=1");
// Assert - 1
var expected1 = "Cart content for 1";
Assert.Equal(expected1, response1.Trim());
// Act - 2
client.DefaultRequestHeaders.Add("Cookie", "CartId=10");
var response2 = await client.GetStringAsync("/catalog/cart?correlationid=2");
// Assert - 2
var expected2 = "Cart content for 2";
Assert.Equal(expected2, response2.Trim());
// Act - 3
// Resend the cookiesless request and cached result from the first response.
client.DefaultRequestHeaders.Remove("Cookie");
var response3 = await client.GetStringAsync("/catalog/cart?correlationid=3");
// Assert - 3
Assert.Equal(expected1, response3.Trim());
}
[Fact]
public async Task CacheTagHelper_VariesByRoute()
{
// Arrange
var server = TestServer.Create(_provider, _app);
var client = server.CreateClient();
client.BaseAddress = new Uri("http://localhost");
// Act - 1
var response1 = await client.GetStringAsync(
"/catalog/north-west/confirm-payment?confirmationId=1");
// Assert - 1
var expected1 = "Welcome Guest. Your confirmation id is 1. (Region north-west)";
Assert.Equal(expected1, response1.Trim());
// Act - 2
var response2 = await client.GetStringAsync(
"/catalog/south-central/confirm-payment?confirmationId=2");
// Assert - 2
var expected2 = "Welcome Guest. Your confirmation id is 2. (Region south-central)";
Assert.Equal(expected2, response2.Trim());
// Act 3
var response3 = await client.GetStringAsync(
"/catalog/north-west/Silver/confirm-payment?confirmationId=4");
var expected3 = "Welcome Silver member. Your confirmation id is 4. (Region north-west)";
Assert.Equal(expected3, response3.Trim());
// Act 4
var response4 = await client.GetStringAsync(
"/catalog/north-west/Gold/confirm-payment?confirmationId=5");
var expected4 = "Welcome Gold member. Your confirmation id is 5. (Region north-west)";
Assert.Equal(expected4, response4.Trim());
// Act - 4
// Resend the responses and expect cached results.
response1 = await client.GetStringAsync(
"/catalog/north-west/confirm-payment?confirmationId=301");
response2 = await client.GetStringAsync(
"/catalog/south-central/confirm-payment?confirmationId=402");
response3 = await client.GetStringAsync(
"/catalog/north-west/Silver/confirm-payment?confirmationId=503");
response4 = await client.GetStringAsync(
"/catalog/north-west/Gold/confirm-payment?confirmationId=608");
// Assert - 4
Assert.Equal(expected1, response1.Trim());
Assert.Equal(expected2, response2.Trim());
Assert.Equal(expected3, response3.Trim());
Assert.Equal(expected4, response4.Trim());
}
[Fact]
public async Task CacheTagHelper_VariesByUserId()
{
// Arrange
var server = TestServer.Create(_provider, _app);
var client = server.CreateClient();
client.BaseAddress = new Uri("http://localhost");
// Act - 1
var response1 = await client.GetStringAsync("/catalog/past-purchases/test1?correlationid=1");
var response2 = await client.GetStringAsync("/catalog/past-purchases/test1?correlationid=2");
// Assert - 1
var expected1 = "Past purchases for user test1 (1)";
Assert.Equal(expected1, response1.Trim());
Assert.Equal(expected1, response2.Trim());
// Act - 2
var response3 = await client.GetStringAsync("/catalog/past-purchases/test2?correlationid=3");
var response4 = await client.GetStringAsync("/catalog/past-purchases/test2?correlationid=4");
// Assert - 2
var expected2 = "Past purchases for user test2 (3)";
Assert.Equal(expected2, response3.Trim());
Assert.Equal(expected2, response4.Trim());
}
}
}

View File

@ -0,0 +1,634 @@
// Copyright (c) Microsoft Open Technologies, Inc. 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.Collections.Generic;
using System.IO;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNet.Mvc.ModelBinding;
using Microsoft.AspNet.Mvc.Rendering;
using Microsoft.AspNet.Http.Core;
using Microsoft.AspNet.Razor.Runtime.TagHelpers;
using Microsoft.AspNet.Routing;
using Microsoft.Framework.Cache.Memory;
using Microsoft.Framework.Cache.Memory.Infrastructure;
using Moq;
using Xunit;
namespace Microsoft.AspNet.Mvc.TagHelpers
{
public class CacheTagHelperTest
{
[Fact]
public void GenerateKey_ReturnsKeyBasedOnTagHelperUniqueId()
{
// Arrange
var id = Guid.NewGuid().ToString();
var tagHelperContext = GetTagHelperContext(id);
var cacheTagHelper = new CacheTagHelper
{
ViewContext = GetViewContext()
};
var expected = GetHashedBytes("CacheTagHelper||" + id);
// Act
var key = cacheTagHelper.GenerateKey(tagHelperContext);
// Assert
Assert.Equal(expected, key);
}
[Theory]
[InlineData("Vary-By-Value")]
[InlineData("Vary with spaces")]
[InlineData(" Vary with more spaces ")]
public void GenerateKey_UsesVaryByPropertyToGenerateKey(string varyBy)
{
// Arrange
var tagHelperContext = GetTagHelperContext();
var cacheTagHelper = new CacheTagHelper
{
ViewContext = GetViewContext(),
VaryBy = varyBy
};
var expected = GetHashedBytes("CacheTagHelper||testid||VaryBy||" + varyBy);
// Act
var key = cacheTagHelper.GenerateKey(tagHelperContext);
// Assert
Assert.Equal(expected, key);
}
[Theory]
[InlineData("Cookie0", "CacheTagHelper||testid||VaryByCookie(Cookie0||Cookie0Value)")]
[InlineData("Cookie0,Cookie1",
"CacheTagHelper||testid||VaryByCookie(Cookie0||Cookie0Value||Cookie1||Cookie1Value)")]
[InlineData("Cookie0, Cookie1",
"CacheTagHelper||testid||VaryByCookie(Cookie0||Cookie0Value||Cookie1||Cookie1Value)")]
[InlineData(" Cookie0, , Cookie1 ",
"CacheTagHelper||testid||VaryByCookie(Cookie0||Cookie0Value||Cookie1||Cookie1Value)")]
[InlineData(",Cookie0,,Cookie1,",
"CacheTagHelper||testid||VaryByCookie(Cookie0||Cookie0Value||Cookie1||Cookie1Value)")]
public void GenerateKey_UsesVaryByCookieName(string varyByCookie, string expected)
{
// Arrange
var tagHelperContext = GetTagHelperContext();
var cacheTagHelper = new CacheTagHelper
{
ViewContext = GetViewContext(),
VaryByCookie = varyByCookie
};
cacheTagHelper.ViewContext.HttpContext.Request.Headers["Cookie"] =
"Cookie0=Cookie0Value;Cookie1=Cookie1Value";
// Act
var key = cacheTagHelper.GenerateKey(tagHelperContext);
// Assert
Assert.Equal(GetHashedBytes(expected), key);
}
[Theory]
[InlineData("Accept-Language", "CacheTagHelper||testid||VaryByHeader(Accept-Language||en-us;charset=utf8)")]
[InlineData("X-CustomHeader,Accept-Encoding, NotAvailable",
"CacheTagHelper||testid||VaryByHeader(X-CustomHeader||Header-Value||Accept-Encoding||utf8||NotAvailable||)")]
[InlineData("X-CustomHeader, , Accept-Encoding, NotAvailable",
"CacheTagHelper||testid||VaryByHeader(X-CustomHeader||Header-Value||Accept-Encoding||utf8||NotAvailable||)")]
public void GenerateKey_UsesVaryByHeader(string varyByHeader, string expected)
{
// Arrange
var tagHelperContext = GetTagHelperContext();
var cacheTagHelper = new CacheTagHelper
{
ViewContext = GetViewContext(),
VaryByHeader = varyByHeader
};
var headers = cacheTagHelper.ViewContext.HttpContext.Request.Headers;
headers["Accept-Language"] = "en-us;charset=utf8";
headers["Accept-Encoding"] = "utf8";
headers["X-CustomHeader"] = "Header-Value";
// Act
var key = cacheTagHelper.GenerateKey(tagHelperContext);
// Assert
Assert.Equal(GetHashedBytes(expected), key);
}
[Theory]
[InlineData("category", "CacheTagHelper||testid||VaryByQuery(category||cats)")]
[InlineData("Category,SortOrder,SortOption",
"CacheTagHelper||testid||VaryByQuery(Category||cats||SortOrder||||SortOption||Adorability)")]
[InlineData("Category, SortOrder, SortOption, ",
"CacheTagHelper||testid||VaryByQuery(Category||cats||SortOrder||||SortOption||Adorability)")]
public void GenerateKey_UsesVaryByQuery(string varyByQuery, string expected)
{
// Arrange
var tagHelperContext = GetTagHelperContext();
var cacheTagHelper = new CacheTagHelper
{
ViewContext = GetViewContext(),
VaryByQuery = varyByQuery
};
cacheTagHelper.ViewContext.HttpContext.Request.QueryString =
new Http.QueryString("?sortoption=Adorability&Category=cats&sortOrder=");
// Act
var key = cacheTagHelper.GenerateKey(tagHelperContext);
// Assert
Assert.Equal(GetHashedBytes(expected), key);
}
[Theory]
[InlineData("id", "CacheTagHelper||testid||VaryByRoute(id||4)")]
[InlineData("Category,,Id,OptionRouteValue",
"CacheTagHelper||testid||VaryByRoute(Category||MyCategory||Id||4||OptionRouteValue||)")]
[InlineData(" Category, , Id, OptionRouteValue, ",
"CacheTagHelper||testid||VaryByRoute(Category||MyCategory||Id||4||OptionRouteValue||)")]
public void GenerateKey_UsesVaryByRoute(string varyByRoute, string expected)
{
// Arrange
var tagHelperContext = GetTagHelperContext();
var cacheTagHelper = new CacheTagHelper
{
ViewContext = GetViewContext(),
VaryByRoute = varyByRoute
};
cacheTagHelper.ViewContext.RouteData.Values["id"] = 4;
cacheTagHelper.ViewContext.RouteData.Values["category"] = "MyCategory";
// Act
var key = cacheTagHelper.GenerateKey(tagHelperContext);
// Assert
Assert.Equal(GetHashedBytes(expected), key);
}
[Fact]
public void GenerateKey_UsesVaryByUser_WhenUserIsNotAuthenticated()
{
// Arrange
var expected = "CacheTagHelper||testid||VaryByUser||";
var tagHelperContext = GetTagHelperContext();
var cacheTagHelper = new CacheTagHelper
{
ViewContext = GetViewContext(),
VaryByUser = true
};
// Act
var key = cacheTagHelper.GenerateKey(tagHelperContext);
// Assert
Assert.Equal(GetHashedBytes(expected), key);
}
[Fact]
public void GenerateKey_UsesVaryByUserAndAuthenticatedUserName()
{
// Arrange
var expected = "CacheTagHelper||testid||VaryByUser||test_name";
var tagHelperContext = GetTagHelperContext();
var cacheTagHelper = new CacheTagHelper
{
ViewContext = GetViewContext(),
VaryByUser = true
};
var identity = new ClaimsIdentity(new[] { new Claim(ClaimsIdentity.DefaultNameClaimType, "test_name") });
cacheTagHelper.ViewContext.HttpContext.User = new ClaimsPrincipal(identity);
// Act
var key = cacheTagHelper.GenerateKey(tagHelperContext);
// Assert
Assert.Equal(GetHashedBytes(expected), key);
}
[Fact]
public void GenerateKey_WithMultipleVaryByOptions_CreatesCombinedKey()
{
// Arrange
var expected = GetHashedBytes("CacheTagHelper||testid||VaryBy||custom-value||" +
"VaryByHeader(content-type||text/html)||VaryByUser||someuser");
var tagHelperContext = GetTagHelperContext();
var cacheTagHelper = new CacheTagHelper
{
ViewContext = GetViewContext(),
VaryByUser = true,
VaryByHeader = "content-type",
VaryBy = "custom-value"
};
cacheTagHelper.ViewContext.HttpContext.Request.Headers["Content-Type"] = "text/html";
var identity = new ClaimsIdentity(new[] { new Claim(ClaimsIdentity.DefaultNameClaimType, "someuser") });
cacheTagHelper.ViewContext.HttpContext.User = new ClaimsPrincipal(identity);
// Act
var key = cacheTagHelper.GenerateKey(tagHelperContext);
// Assert
Assert.Equal(expected, key);
}
[Fact]
public async Task ProcessAsync_ReturnsCachedValue_IfVaryByParamIsUnchanged()
{
// Arrange - 1
var id = "unique-id";
var childContent = "original-child-content";
var cache = new MemoryCache(new MemoryCacheOptions());
var tagHelperContext1 = GetTagHelperContext(id, childContent);
var tagHelperOutput1 = new TagHelperOutput("cache", new Dictionary<string, string>());
var cacheTagHelper1 = new CacheTagHelper
{
VaryByQuery = "key1,key2",
ViewContext = GetViewContext(),
MemoryCache = cache
};
cacheTagHelper1.ViewContext.HttpContext.Request.QueryString = new Http.QueryString(
"?key1=value1&key2=value2");
// Act - 1
await cacheTagHelper1.ProcessAsync(tagHelperContext1, tagHelperOutput1);
// Assert - 1
Assert.Null(tagHelperOutput1.PreContent);
Assert.Null(tagHelperOutput1.PostContent);
Assert.True(tagHelperOutput1.ContentSet);
Assert.Equal(childContent, tagHelperOutput1.Content);
// Arrange - 2
var tagHelperContext2 = GetTagHelperContext(id, "different-content");
var tagHelperOutput2 = new TagHelperOutput("cache", new Dictionary<string, string>());
var cacheTagHelper2 = new CacheTagHelper
{
VaryByQuery = "key1,key2",
ViewContext = GetViewContext(),
MemoryCache = cache
};
cacheTagHelper2.ViewContext.HttpContext.Request.QueryString = new Http.QueryString(
"?key1=value1&key2=value2");
// Act - 2
await cacheTagHelper2.ProcessAsync(tagHelperContext2, tagHelperOutput2);
// Assert - 2
Assert.Null(tagHelperOutput2.PreContent);
Assert.Null(tagHelperOutput2.PostContent);
Assert.True(tagHelperOutput2.ContentSet);
Assert.Equal(childContent, tagHelperOutput2.Content);
}
[Fact]
public async Task ProcessAsync_RecalculatesValueIfCacheKeyChanges()
{
// Arrange - 1
var id = "unique-id";
var childContent1 = "original-child-content";
var cache = new MemoryCache(new MemoryCacheOptions());
var tagHelperContext1 = GetTagHelperContext(id, childContent1);
var tagHelperOutput1 = new TagHelperOutput("cache", new Dictionary<string, string> { { "attr", "value" } })
{
PreContent = "<cache>",
PostContent = "</cache>"
};
var cacheTagHelper1 = new CacheTagHelper
{
VaryByCookie = "cookie1,cookie2",
ViewContext = GetViewContext(),
MemoryCache = cache
};
cacheTagHelper1.ViewContext.HttpContext.Request.Headers["Cookie"] = "cookie1=value1;cookie2=value2";
// Act - 1
await cacheTagHelper1.ProcessAsync(tagHelperContext1, tagHelperOutput1);
// Assert - 1
Assert.Null(tagHelperOutput1.PreContent);
Assert.Null(tagHelperOutput1.PostContent);
Assert.True(tagHelperOutput1.ContentSet);
Assert.Equal(childContent1, tagHelperOutput1.Content);
// Arrange - 2
var childContent2 = "different-content";
var tagHelperContext2 = GetTagHelperContext(id, childContent2);
var tagHelperOutput2 = new TagHelperOutput("cache", new Dictionary<string, string> { { "attr", "value" } })
{
PreContent = "<cache>",
PostContent = "</cache>"
};
var cacheTagHelper2 = new CacheTagHelper
{
VaryByCookie = "cookie1,cookie2",
ViewContext = GetViewContext(),
MemoryCache = cache
};
cacheTagHelper2.ViewContext.HttpContext.Request.Headers["Cookie"] = "cookie1=value1;cookie2=not-value2";
// Act - 2
await cacheTagHelper2.ProcessAsync(tagHelperContext2, tagHelperOutput2);
// Assert - 2
Assert.Null(tagHelperOutput2.PreContent);
Assert.Null(tagHelperOutput2.PostContent);
Assert.True(tagHelperOutput2.ContentSet);
Assert.Equal(childContent2, tagHelperOutput2.Content);
}
[Fact]
public void UpdateCacheContext_SetsAbsoluteExpiration_IfExpiresOnIsSet()
{
// Arrange
var expiresOn = DateTimeOffset.UtcNow.AddMinutes(4);
var cache = new MemoryCache(new MemoryCacheOptions());
var cacheContext = new Mock<ICacheSetContext>();
cacheContext.Setup(c => c.SetAbsoluteExpiration(expiresOn))
.Verifiable();
var cacheTagHelper = new CacheTagHelper
{
MemoryCache = cache,
ExpiresOn = expiresOn
};
// Act
cacheTagHelper.UpdateCacheContext(cacheContext.Object);
// Assert
cacheContext.Verify();
}
[Fact]
public void UpdateCacheContext_SetsAbsoluteExpiration_IfExpiresAfterIsSet()
{
// Arrange
var expiresAfter = TimeSpan.FromSeconds(42);
var cache = new MemoryCache(new MemoryCacheOptions());
var cacheContext = new Mock<ICacheSetContext>();
cacheContext.Setup(c => c.SetAbsoluteExpiration(expiresAfter))
.Verifiable();
var cacheTagHelper = new CacheTagHelper
{
MemoryCache = cache,
ExpiresAfter = expiresAfter
};
// Act
cacheTagHelper.UpdateCacheContext(cacheContext.Object);
// Assert
cacheContext.Verify();
}
[Fact]
public void UpdateCacheContext_SetsSlidingExpiration_IfExpiresSlidingIsSet()
{
// Arrange
var expiresSliding = TimeSpan.FromSeconds(37);
var cache = new MemoryCache(new MemoryCacheOptions());
var cacheContext = new Mock<ICacheSetContext>();
cacheContext.Setup(c => c.SetSlidingExpiration(expiresSliding))
.Verifiable();
var cacheTagHelper = new CacheTagHelper
{
MemoryCache = cache,
ExpiresSliding = expiresSliding
};
// Act
cacheTagHelper.UpdateCacheContext(cacheContext.Object);
// Assert
cacheContext.Verify();
}
[Fact]
public void UpdateCacheContext_SetsCachePreservationPriority()
{
// Arrange
var priority = CachePreservationPriority.High;
var cache = new MemoryCache(new MemoryCacheOptions());
var cacheContext = new Mock<ICacheSetContext>();
cacheContext.Setup(c => c.SetPriority(priority))
.Verifiable();
var cacheTagHelper = new CacheTagHelper
{
MemoryCache = cache,
Priority = priority
};
// Act
cacheTagHelper.UpdateCacheContext(cacheContext.Object);
// Assert
cacheContext.Verify();
}
[Fact]
public async Task ProcessAsync_UsesExpiresAfter_ToExpireCacheEntry()
{
// Arrange - 1
var currentTime = new DateTimeOffset(2010, 1, 1, 0, 0, 0, TimeSpan.Zero);
var id = "unique-id";
var childContent1 = "original-child-content";
var clock = new Mock<ISystemClock>();
clock.SetupGet(p => p.UtcNow)
.Returns(() => currentTime);
var cache = new MemoryCache(new MemoryCacheOptions { Clock = clock.Object });
var tagHelperContext1 = GetTagHelperContext(id, childContent1);
var tagHelperOutput1 = new TagHelperOutput("cache", new Dictionary<string, string> { { "attr", "value" } })
{
PreContent = "<cache>",
PostContent = "</cache>"
};
var cacheTagHelper1 = new CacheTagHelper
{
ViewContext = GetViewContext(),
MemoryCache = cache,
ExpiresAfter = TimeSpan.FromMinutes(10)
};
// Act - 1
await cacheTagHelper1.ProcessAsync(tagHelperContext1, tagHelperOutput1);
// Assert - 1
Assert.Null(tagHelperOutput1.PreContent);
Assert.Null(tagHelperOutput1.PostContent);
Assert.True(tagHelperOutput1.ContentSet);
Assert.Equal(childContent1, tagHelperOutput1.Content);
// Arrange - 2
var childContent2 = "different-content";
var tagHelperContext2 = GetTagHelperContext(id, childContent2);
var tagHelperOutput2 = new TagHelperOutput("cache", new Dictionary<string, string> { { "attr", "value" } })
{
PreContent = "<cache>",
PostContent = "</cache>"
};
var cacheTagHelper2 = new CacheTagHelper
{
ViewContext = GetViewContext(),
MemoryCache = cache,
ExpiresAfter = TimeSpan.FromMinutes(10)
};
currentTime = currentTime.AddMinutes(11);
// Act - 2
await cacheTagHelper2.ProcessAsync(tagHelperContext2, tagHelperOutput2);
// Assert - 2
Assert.Null(tagHelperOutput2.PreContent);
Assert.Null(tagHelperOutput2.PostContent);
Assert.True(tagHelperOutput2.ContentSet);
Assert.Equal(childContent2, tagHelperOutput2.Content);
}
[Fact]
public async Task ProcessAsync_UsesExpiresOn_ToExpireCacheEntry()
{
// Arrange - 1
var currentTime = new DateTimeOffset(2010, 1, 1, 0, 0, 0, TimeSpan.Zero);
var id = "unique-id";
var childContent1 = "original-child-content";
var clock = new Mock<ISystemClock>();
clock.SetupGet(p => p.UtcNow)
.Returns(() => currentTime);
var cache = new MemoryCache(new MemoryCacheOptions { Clock = clock.Object });
var tagHelperContext1 = GetTagHelperContext(id, childContent1);
var tagHelperOutput1 = new TagHelperOutput("cache", new Dictionary<string, string> { { "attr", "value" } })
{
PreContent = "<cache>",
PostContent = "</cache>"
};
var cacheTagHelper1 = new CacheTagHelper
{
ViewContext = GetViewContext(),
MemoryCache = cache,
ExpiresOn = currentTime.AddMinutes(5)
};
// Act - 1
await cacheTagHelper1.ProcessAsync(tagHelperContext1, tagHelperOutput1);
// Assert - 1
Assert.Null(tagHelperOutput1.PreContent);
Assert.Null(tagHelperOutput1.PostContent);
Assert.True(tagHelperOutput1.ContentSet);
Assert.Equal(childContent1, tagHelperOutput1.Content);
// Arrange - 2
currentTime = currentTime.AddMinutes(5).AddSeconds(2);
var childContent2 = "different-content";
var tagHelperContext2 = GetTagHelperContext(id, childContent2);
var tagHelperOutput2 = new TagHelperOutput("cache", new Dictionary<string, string> { { "attr", "value" } })
{
PreContent = "<cache>",
PostContent = "</cache>"
};
var cacheTagHelper2 = new CacheTagHelper
{
ViewContext = GetViewContext(),
MemoryCache = cache,
ExpiresOn = currentTime.AddMinutes(5)
};
// Act - 2
await cacheTagHelper2.ProcessAsync(tagHelperContext2, tagHelperOutput2);
// Assert - 2
Assert.Null(tagHelperOutput2.PreContent);
Assert.Null(tagHelperOutput2.PostContent);
Assert.True(tagHelperOutput2.ContentSet);
Assert.Equal(childContent2, tagHelperOutput2.Content);
}
[Fact]
public async Task ProcessAsync_UsesExpiresSliding_ToExpireCacheEntryWithSlidingExpiration()
{
// Arrange - 1
var currentTime = new DateTimeOffset(2010, 1, 1, 0, 0, 0, TimeSpan.Zero);
var id = "unique-id";
var childContent1 = "original-child-content";
var clock = new Mock<ISystemClock>();
clock.SetupGet(p => p.UtcNow)
.Returns(() => currentTime);
var cache = new MemoryCache(new MemoryCacheOptions { Clock = clock.Object });
var tagHelperContext1 = GetTagHelperContext(id, childContent1);
var tagHelperOutput1 = new TagHelperOutput("cache", new Dictionary<string, string> { { "attr", "value" } })
{
PreContent = "<cache>",
PostContent = "</cache>"
};
var cacheTagHelper1 = new CacheTagHelper
{
ViewContext = GetViewContext(),
MemoryCache = cache,
ExpiresSliding = TimeSpan.FromSeconds(30)
};
// Act - 1
await cacheTagHelper1.ProcessAsync(tagHelperContext1, tagHelperOutput1);
// Assert - 1
Assert.Null(tagHelperOutput1.PreContent);
Assert.Null(tagHelperOutput1.PostContent);
Assert.True(tagHelperOutput1.ContentSet);
Assert.Equal(childContent1, tagHelperOutput1.Content);
// Arrange - 2
currentTime = currentTime.AddSeconds(35);
var childContent2 = "different-content";
var tagHelperContext2 = GetTagHelperContext(id, childContent2);
var tagHelperOutput2 = new TagHelperOutput("cache", new Dictionary<string, string> { { "attr", "value" } })
{
PreContent = "<cache>",
PostContent = "</cache>"
};
var cacheTagHelper2 = new CacheTagHelper
{
ViewContext = GetViewContext(),
MemoryCache = cache,
ExpiresSliding = TimeSpan.FromSeconds(30)
};
// Act - 2
await cacheTagHelper2.ProcessAsync(tagHelperContext2, tagHelperOutput2);
// Assert - 2
Assert.Null(tagHelperOutput2.PreContent);
Assert.Null(tagHelperOutput2.PostContent);
Assert.True(tagHelperOutput2.ContentSet);
Assert.Equal(childContent2, tagHelperOutput2.Content);
}
private static ViewContext GetViewContext()
{
var actionContext = new ActionContext(new DefaultHttpContext(), new RouteData(), new ActionDescriptor());
return new ViewContext(actionContext,
Mock.Of<IView>(),
new ViewDataDictionary(new EmptyModelMetadataProvider()),
TextWriter.Null);
}
private static TagHelperContext GetTagHelperContext(string id = "testid",
string childContent = "some child content")
{
return new TagHelperContext(new Dictionary<string, object>(),
id,
() => Task.FromResult(childContent));
}
private static string GetHashedBytes(string input)
{
using (var sha = SHA256.Create())
{
var contentBytes = Encoding.UTF8.GetBytes(input);
var hashedBytes = sha.ComputeHash(contentBytes);
return Convert.ToBase64String(hashedBytes);
}
}
}
}

View File

@ -0,0 +1,19 @@
// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using Microsoft.AspNet.Mvc;
namespace MvcSample.Web.Components
{
public class SplashViewComponent : ViewComponent
{
public ViewViewComponentResult Invoke()
{
var region = (string)ViewData["Locale"];
var model = region == "North" ? "NorthWest Store":
"Nationwide Store";
return View(model: model);
}
}
}

View File

@ -0,0 +1,65 @@
// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System.Security.Claims;
using Microsoft.AspNet.Mvc;
namespace MvcTagHelpersWebSite.Controllers
{
public class Catalog_CacheTagHelperController : Controller
{
[HttpGet("/catalog")]
public ViewResult Splash(int categoryId, int correlationId, [FromHeader]string locale)
{
var category = categoryId == 1 ? "Laptops" : "Phones";
ViewData["Category"] = category;
ViewData["Locale"] = locale;
ViewData["CorrelationId"] = correlationId;
return View();
}
[HttpGet("/catalog/{id:int}")]
public ViewResult Details(int id)
{
ViewData["ProductId"] = id;
return View();
}
[HttpGet("/catalog/cart")]
public ViewResult ShoppingCart(int correlationId)
{
ViewData["CorrelationId"] = correlationId;
return View();
}
[HttpGet("/catalog/{region}/confirm-payment")]
public ViewResult GuestConfirmPayment(string region, int confirmationId = 0)
{
ViewData["Message"] = "Welcome Guest. Your confirmation id is " + confirmationId;
ViewData["Region"] = region;
return View("ConfirmPayment");
}
[HttpGet("/catalog/{region}/{section}/confirm-payment")]
public ViewResult ConfirmPayment(string region, string section, int confirmationId)
{
var message = "Welcome " + section + " member. Your confirmation id is " + confirmationId;
ViewData["Message"] = message;
ViewData["Region"] = region;
return View();
}
[HttpGet("/catalog/past-purchases/{id}")]
public ViewResult PastPurchases(string id, int correlationId)
{
var identity = new ClaimsIdentity();
identity.AddClaim(new Claim(ClaimsIdentity.DefaultNameClaimType, id));
Context.User = new ClaimsPrincipal(identity);
ViewData["CorrelationId"] = correlationId;
return View();
}
}
}

View File

@ -0,0 +1,3 @@
<cache vary-by-route="region, section">
@ViewBag.Message. (Region @ViewBag.Region)
</cache>

View File

@ -0,0 +1,4 @@
@using Microsoft.Framework.Cache.Memory
<cache expires-after="TimeSpan.FromSeconds(1)" priority="CachePreservationPriority.Low">
Cached content for @ViewBag.ProductId
</cache>

View File

@ -0,0 +1,3 @@
<cache vary-by-user="true">
Past purchases for user @Context.User.Identity.Name (@ViewBag.CorrelationId)
</cache>

View File

@ -0,0 +1,3 @@
<cache vary-by-cookie="CartId">
Cart content for @ViewBag.CorrelationId
</cache>

View File

@ -0,0 +1,8 @@
<h2>Category: @ViewBag.Category</h2>
<h2>Region: @ViewBag.Locale</h2>
<cache vary-by-header="Locale" vary-by-query="categoryid">
<h2>Cached content</h2>
@await Component.InvokeAsync("Splash")
@await Html.PartialAsync("_SplashPartial")
<div>CorrelationId in Splash: @ViewBag.CorrelationId</div>
</cache>

View File

@ -0,0 +1,5 @@
<partial-title>Listing items</partial-title>
<cache vary-by="@ViewBag.Category" data-content="some-content">
Cached Content for @ViewBag.Category
<div>CorrelationId in Partial: @ViewBag.CorrelationId</div>
</cache>

View File

@ -0,0 +1 @@
@addtaghelper "Microsoft.AspNet.Mvc.TagHelpers"

View File

@ -0,0 +1,7 @@
@addtaghelper "Microsoft.AspNet.Mvc.TagHelpers"
@model string
Locations closest to your locale:
<cache vary-by-header="Locale">
@Model
<div>CorrelationId in View Component: @ViewBag.CorrelationId</div>
</cache>