diff --git a/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/DistributedCacheTagHelperFormatter.cs b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/DistributedCacheTagHelperFormatter.cs
new file mode 100644
index 0000000000..ba9a0fb416
--- /dev/null
+++ b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/DistributedCacheTagHelperFormatter.cs
@@ -0,0 +1,47 @@
+// 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.Text;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Mvc.Rendering;
+
+namespace Microsoft.AspNetCore.Mvc.TagHelpers.Cache
+{
+ ///
+ /// Implements by serializing the content
+ /// in UTF8.
+ ///
+ public class DistributedCacheTagHelperFormatter : IDistributedCacheTagHelperFormatter
+ {
+
+ ///
+ public Task SerializeAsync(DistributedCacheTagHelperFormattingContext context)
+ {
+ if (context == null)
+ {
+ throw new ArgumentNullException(nameof(context));
+ }
+
+ if (context.Html == null)
+ {
+ throw new ArgumentNullException(nameof(context.Html));
+ }
+
+ var serialized = Encoding.UTF8.GetBytes(context.Html.ToString());
+ return Task.FromResult(serialized);
+ }
+
+ ///
+ public Task DeserializeAsync(byte[] value)
+ {
+ if (value == null)
+ {
+ throw new ArgumentNullException(nameof(value));
+ }
+
+ var content = Encoding.UTF8.GetString(value);
+ return Task.FromResult(new HtmlString(content));
+ }
+ }
+}
diff --git a/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/DistributedCacheTagHelperFormattingContext.cs b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/DistributedCacheTagHelperFormattingContext.cs
new file mode 100644
index 0000000000..19e8f908e6
--- /dev/null
+++ b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/DistributedCacheTagHelperFormattingContext.cs
@@ -0,0 +1,18 @@
+// 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.Mvc.Rendering;
+
+namespace Microsoft.AspNetCore.Mvc.TagHelpers.Cache
+{
+ ///
+ /// Represents an object containing the information to serialize with .
+ ///
+ public class DistributedCacheTagHelperFormattingContext
+ {
+ ///
+ /// Gets the instance.
+ ///
+ public HtmlString Html { get; set; }
+ }
+}
diff --git a/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/DistributedCacheTagHelperService.cs b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/DistributedCacheTagHelperService.cs
new file mode 100644
index 0000000000..91b420c248
--- /dev/null
+++ b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/DistributedCacheTagHelperService.cs
@@ -0,0 +1,116 @@
+// 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.Collections.Concurrent;
+using System.IO;
+using System.Text;
+using System.Text.Encodings.Web;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Html;
+using Microsoft.AspNetCore.Mvc.Rendering;
+using Microsoft.AspNetCore.Razor.TagHelpers;
+using Microsoft.Extensions.Caching.Distributed;
+
+namespace Microsoft.AspNetCore.Mvc.TagHelpers.Cache
+{
+ ///
+ /// Implements and ensure
+ /// multiple concurrent requests are gated.
+ ///
+ public class DistributedCacheTagHelperService : IDistributedCacheTagHelperService
+ {
+ private readonly IDistributedCacheTagHelperStorage _storage;
+ private readonly IDistributedCacheTagHelperFormatter _formatter;
+ private readonly HtmlEncoder _htmlEncoder;
+ private readonly ConcurrentDictionary> _workers;
+
+ public DistributedCacheTagHelperService(
+ IDistributedCacheTagHelperStorage storage,
+ IDistributedCacheTagHelperFormatter formatter,
+ HtmlEncoder HtmlEncoder
+ )
+ {
+ _formatter = formatter;
+ _storage = storage;
+ _htmlEncoder = HtmlEncoder;
+
+ _workers = new ConcurrentDictionary>();
+ }
+
+ ///
+ public async Task ProcessContentAsync(TagHelperOutput output, string key, DistributedCacheEntryOptions options)
+ {
+ IHtmlContent content = null;
+
+ while (content == null)
+ {
+ Task result = null;
+
+ // Is there any request already processing the value?
+ if (!_workers.TryGetValue(key, out result))
+ {
+ var tcs = new TaskCompletionSource();
+
+ _workers.TryAdd(key, tcs.Task);
+
+ try
+ {
+ var value = await _storage.GetAsync(key);
+
+ if (value == null)
+ {
+ var processedContent = await output.GetChildContentAsync();
+
+ var stringBuilder = new StringBuilder();
+ using (var writer = new StringWriter(stringBuilder))
+ {
+ processedContent.WriteTo(writer, _htmlEncoder);
+ }
+
+ var formattingContext = new DistributedCacheTagHelperFormattingContext
+ {
+ Html = new HtmlString(stringBuilder.ToString())
+ };
+
+ value = await _formatter.SerializeAsync(formattingContext);
+
+ await _storage.SetAsync(key, value, options);
+
+ content = formattingContext.Html;
+ }
+ else
+ {
+ content = await _formatter.DeserializeAsync(value);
+
+ // If the deserialization fails, it can return null, for instance when the
+ // value is not in the expected format.
+ if (content == null)
+ {
+ content = await output.GetChildContentAsync();
+ }
+ }
+
+ tcs.TrySetResult(content);
+ }
+ catch
+ {
+ tcs.TrySetResult(null);
+ throw;
+ }
+ finally
+ {
+ // Remove the worker task from the in-memory cache
+ Task worker;
+ _workers.TryRemove(key, out worker);
+ }
+ }
+ else
+ {
+ content = await result;
+ }
+ }
+
+ return content;
+ }
+ }
+}
diff --git a/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/DistributedCacheTagHelperStorage.cs b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/DistributedCacheTagHelperStorage.cs
new file mode 100644
index 0000000000..2409f9d5ea
--- /dev/null
+++ b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/DistributedCacheTagHelperStorage.cs
@@ -0,0 +1,54 @@
+// 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.Extensions.Caching.Distributed;
+
+namespace Microsoft.AspNetCore.Mvc.TagHelpers.Cache
+{
+ ///
+ /// Implements by storing the content
+ /// in using as the store.
+ ///
+ public class DistributedCacheTagHelperStorage : IDistributedCacheTagHelperStorage
+ {
+ private readonly IDistributedCache _distributedCache;
+
+ ///
+ /// Creates a new .
+ ///
+ /// The to use.
+ public DistributedCacheTagHelperStorage(IDistributedCache distributedCache)
+ {
+ _distributedCache = distributedCache;
+ }
+
+ ///
+ public Task GetAsync(string key)
+ {
+ if (key == null)
+ {
+ throw new ArgumentNullException(nameof(key));
+ }
+
+ return _distributedCache.GetAsync(key);
+ }
+
+ ///
+ public Task SetAsync(string key, byte[] value, DistributedCacheEntryOptions options)
+ {
+ if (key == null)
+ {
+ throw new ArgumentNullException(nameof(key));
+ }
+
+ if (value == null)
+ {
+ throw new ArgumentNullException(nameof(value));
+ }
+
+ return _distributedCache.SetAsync(key, value, options);
+ }
+ }
+}
diff --git a/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/IDistributedCacheTagHelperFormatter.cs b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/IDistributedCacheTagHelperFormatter.cs
new file mode 100644
index 0000000000..b1cce8ea88
--- /dev/null
+++ b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/IDistributedCacheTagHelperFormatter.cs
@@ -0,0 +1,29 @@
+// 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.Threading.Tasks;
+using Microsoft.AspNetCore.Mvc.Rendering;
+
+namespace Microsoft.AspNetCore.Mvc.TagHelpers.Cache
+{
+ ///
+ /// An implementation of this interface provides a service to
+ /// serialize html fragments for being store by
+ ///
+ public interface IDistributedCacheTagHelperFormatter
+ {
+ ///
+ /// Serializes some html content.
+ ///
+ /// The to serialize.
+ /// The serialized result.
+ Task SerializeAsync(DistributedCacheTagHelperFormattingContext context);
+
+ ///
+ /// Deserialize some html content.
+ ///
+ /// The value to deserialize.
+ /// The deserialized content, null otherwise.
+ Task DeserializeAsync(byte[] value);
+ }
+}
diff --git a/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/IDistributedCacheTagHelperService.cs b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/IDistributedCacheTagHelperService.cs
new file mode 100644
index 0000000000..5843dbd38c
--- /dev/null
+++ b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/IDistributedCacheTagHelperService.cs
@@ -0,0 +1,26 @@
+// 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.Threading.Tasks;
+using Microsoft.AspNetCore.Html;
+using Microsoft.AspNetCore.Razor.TagHelpers;
+using Microsoft.Extensions.Caching.Distributed;
+
+namespace Microsoft.AspNetCore.Mvc.TagHelpers.Cache
+{
+ ///
+ /// An implementation of this interface provides a service to process
+ /// the content or fetches it from cache for distributed cache tag helpers.
+ ///
+ public interface IDistributedCacheTagHelperService
+ {
+ ///
+ /// Processes the html content of a distributed cache tag helper.
+ ///
+ /// The .
+ /// The key in the storage.
+ /// The .
+ /// A cached or new content for the cache tag helper.
+ Task ProcessContentAsync(TagHelperOutput output, string key, DistributedCacheEntryOptions options);
+ }
+}
diff --git a/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/IDistributedCacheTagHelperStorage.cs b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/IDistributedCacheTagHelperStorage.cs
new file mode 100644
index 0000000000..2d29c436d7
--- /dev/null
+++ b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/IDistributedCacheTagHelperStorage.cs
@@ -0,0 +1,31 @@
+// 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.Threading.Tasks;
+using Microsoft.Extensions.Caching.Distributed;
+
+namespace Microsoft.AspNetCore.Mvc.TagHelpers.Cache
+{
+ ///
+ /// An implementation of this interface provides a service to
+ /// cache distributed html fragments from the <distributed-cache>
+ /// tag helper.
+ ///
+ public interface IDistributedCacheTagHelperStorage
+ {
+ ///
+ /// Gets the content from the cache and deserializes it.
+ ///
+ /// The unique key to use in the cache.
+ /// The stored value if it exists, null otherwise.
+ Task GetAsync(string key);
+
+ ///
+ /// Sets the content in the cache and serialized it.
+ ///
+ /// The unique key to use in the cache.
+ /// The value to cache.
+ /// The cache entry options.
+ Task SetAsync(string key, byte[] value, DistributedCacheEntryOptions options);
+ }
+}
diff --git a/src/Microsoft.AspNetCore.Mvc.TagHelpers/CacheTagHelper.cs b/src/Microsoft.AspNetCore.Mvc.TagHelpers/CacheTagHelper.cs
index f6f455f12b..8d629b4bce 100644
--- a/src/Microsoft.AspNetCore.Mvc.TagHelpers/CacheTagHelper.cs
+++ b/src/Microsoft.AspNetCore.Mvc.TagHelpers/CacheTagHelper.cs
@@ -2,15 +2,12 @@
// 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.Cryptography;
using System.Text;
using System.Text.Encodings.Web;
+using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Html;
-using Microsoft.AspNetCore.Mvc.Rendering;
-using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.Razor.TagHelpers;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Primitives;
@@ -20,44 +17,22 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
///
/// implementation targeting <cache> elements.
///
- public class CacheTagHelper : TagHelper
+ public class CacheTagHelper : CacheTagHelperBase
{
///
/// Prefix used by instances when creating entries in .
///
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 const string EnabledAttributeName = "enabled";
- private static readonly char[] AttributeSeparator = new[] { ',' };
///
/// Creates a new .
///
/// The .
/// The to use.
- public CacheTagHelper(IMemoryCache memoryCache, HtmlEncoder htmlEncoder)
+ public CacheTagHelper(IMemoryCache memoryCache, HtmlEncoder htmlEncoder) : base(htmlEncoder)
{
MemoryCache = memoryCache;
- HtmlEncoder = htmlEncoder;
- }
-
- ///
- public override int Order
- {
- get
- {
- return -1000;
- }
}
///
@@ -65,85 +40,12 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
///
protected IMemoryCache MemoryCache { get; }
- ///
- /// Gets the which encodes the content to be cached.
- ///
- protected HtmlEncoder HtmlEncoder { get; }
-
- ///
- /// Gets or sets the for the current executing View.
- ///
- [HtmlAttributeNotBound]
- [ViewContext]
- public ViewContext ViewContext { get; set; }
-
- ///
- /// Gets or sets a to vary the cached result by.
- ///
- [HtmlAttributeName(VaryByAttributeName)]
- public string VaryBy { get; set; }
-
- ///
- /// Gets or sets the name of a HTTP request header to vary the cached result by.
- ///
- [HtmlAttributeName(VaryByHeaderAttributeName)]
- public string VaryByHeader { get; set; }
-
- ///
- /// Gets or sets a comma-delimited set of query parameters to vary the cached result by.
- ///
- [HtmlAttributeName(VaryByQueryAttributeName)]
- public string VaryByQuery { get; set; }
-
- ///
- /// Gets or sets a comma-delimited set of route data parameters to vary the cached result by.
- ///
- [HtmlAttributeName(VaryByRouteAttributeName)]
- public string VaryByRoute { get; set; }
-
- ///
- /// Gets or sets a comma-delimited set of cookie names to vary the cached result by.
- ///
- [HtmlAttributeName(VaryByCookieAttributeName)]
- public string VaryByCookie { get; set; }
-
- ///
- /// Gets or sets a value that determines if the cached result is to be varied by the Identity for the logged in
- /// .
- ///
- [HtmlAttributeName(VaryByUserAttributeName)]
- public bool VaryByUser { get; set; }
-
- ///
- /// Gets or sets the exact the cache entry should be evicted.
- ///
- [HtmlAttributeName(ExpiresOnAttributeName)]
- public DateTimeOffset? ExpiresOn { get; set; }
-
- ///
- /// Gets or sets the duration, from the time the cache entry was added, when it should be evicted.
- ///
- [HtmlAttributeName(ExpiresAfterAttributeName)]
- public TimeSpan? ExpiresAfter { get; set; }
-
- ///
- /// Gets or sets the duration from last access that the cache entry should be evicted.
- ///
- [HtmlAttributeName(ExpiresSlidingAttributeName)]
- public TimeSpan? ExpiresSliding { get; set; }
-
///
/// Gets or sets the policy for the cache entry.
///
[HtmlAttributeName(CachePriorityAttributeName)]
public CacheItemPriority? Priority { get; set; }
- ///
- /// Gets or sets the value which determines if the tag helper is enabled or not.
- ///
- [HtmlAttributeName(EnabledAttributeName)]
- public bool Enabled { get; set; } = true;
-
///
public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
{
@@ -156,87 +58,84 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
{
throw new ArgumentNullException(nameof(output));
}
+
+ IHtmlContent content = null;
- IHtmlContent result = null;
if (Enabled)
{
var key = GenerateKey(context);
- if (!MemoryCache.TryGetValue(key, out result))
+ MemoryCacheEntryOptions options;
+
+ while (content == null)
{
- // Create an entry link scope and flow it so that any tokens related to the cache entries
- // created within this scope get copied to this scope.
- using (var link = MemoryCache.CreateLinkingScope())
+ Task result = null;
+
+ if (!MemoryCache.TryGetValue(key, out result))
{
- var content = await output.GetChildContentAsync();
+ var tokenSource = new CancellationTokenSource();
- var stringBuilder = new StringBuilder();
- using (var writer = new StringWriter(stringBuilder))
+ // Create an entry link scope and flow it so that any tokens related to the cache entries
+ // created within this scope get copied to this scope.
+
+ options = GetMemoryCacheEntryOptions();
+ options.AddExpirationToken(new CancellationChangeToken(tokenSource.Token));
+
+ var tcs = new TaskCompletionSource();
+
+ MemoryCache.Set(key, tcs.Task, options);
+
+ try
{
- content.WriteTo(writer, HtmlEncoder);
+ using (var link = MemoryCache.CreateLinkingScope())
+ {
+ result = ProcessContentAsync(output);
+ content = await result;
+ options.AddEntryLink(link);
+ }
+
+ // The entry is set instead of assigning a value to the
+ // task so that the expiration options are are not impacted
+ // by the time it took to compute it.
+
+ MemoryCache.Set(key, result, options);
}
+ catch
+ {
+ // Remove the worker task from the cache in case it can't complete.
+ tokenSource.Cancel();
+ throw;
+ }
+ finally
+ {
+ // If an exception occurs, ensure the other awaiters
+ // render the output by themselves.
+ tcs.SetResult(null);
+ }
+ }
+ else
+ {
+ // There is either some value already cached (as a Task)
+ // or a worker processing the output. In the case of a worker,
+ // the result will be null, and the request will try to acquire
+ // the result from memory another time.
- result = new StringBuilderHtmlContent(stringBuilder);
- MemoryCache.Set(key, result, GetMemoryCacheEntryOptions(link));
+ content = await result;
}
}
}
+ else
+ {
+ content = await output.GetChildContentAsync();
+ }
// Clear the contents of the "cache" element since we don't want to render it.
output.SuppressOutput();
- if (Enabled)
- {
- output.Content.SetContent(result);
- }
- else
- {
- result = await output.GetChildContentAsync();
- output.Content.SetContent(result);
- }
+
+ output.Content.SetContent(content);
}
// 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, (c, key) => c[key]);
- AddStringCollectionKey(builder, nameof(VaryByHeader), VaryByHeader, request.Headers, (c, key) => c[key]);
- AddStringCollectionKey(builder, nameof(VaryByQuery), VaryByQuery, request.Query, (c, key) => c[key]);
- 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 MemoryCacheEntryOptions GetMemoryCacheEntryOptions(IEntryLink entryLink)
+ internal MemoryCacheEntryOptions GetMemoryCacheEntryOptions()
{
var options = new MemoryCacheEntryOptions();
if (ExpiresOn != null)
@@ -259,134 +158,30 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
options.SetPriority(Priority.Value);
}
- options.AddEntryLink(entryLink);
return options;
}
- private static void AddStringCollectionKey(
- StringBuilder builder,
- string keyName,
- string value,
- IDictionary sourceCollection)
+ protected override string GetUniqueId(TagHelperContext context)
{
- if (!string.IsNullOrEmpty(value))
- {
- // keyName(param1=value1|param2=value2)
- builder.Append(CacheKeyTokenSeparator)
- .Append(keyName)
- .Append("(");
-
- var values = Tokenize(value);
-
- // Perf: Avoid allocating enumerator
- for (var i = 0; i < values.Count; i++)
- {
- var item = values[i];
- builder.Append(item)
- .Append(CacheKeyTokenSeparator)
- .Append(sourceCollection[item])
- .Append(CacheKeyTokenSeparator);
- }
-
- if (values.Count > 0)
- {
- // Remove the trailing separator
- builder.Length -= CacheKeyTokenSeparator.Length;
- }
-
- builder.Append(")");
- }
+ return context.UniqueId;
}
- private static void AddStringCollectionKey(
- StringBuilder builder,
- string keyName,
- string value,
- TSourceCollection sourceCollection,
- Func accessor)
+ protected override string GetKeyPrefix(TagHelperContext context)
{
- if (!string.IsNullOrEmpty(value))
- {
- // keyName(param1=value1|param2=value2)
- builder.Append(CacheKeyTokenSeparator)
- .Append(keyName)
- .Append("(");
-
- var values = Tokenize(value);
-
- // Perf: Avoid allocating enumerator
- for (var i = 0; i < values.Count; i++)
- {
- var item = values[i];
-
- builder.Append(item)
- .Append(CacheKeyTokenSeparator)
- .Append(accessor(sourceCollection, item))
- .Append(CacheKeyTokenSeparator);
- }
-
- if (values.Count > 0)
- {
- // Remove the trailing separator
- builder.Length -= CacheKeyTokenSeparator.Length;
- }
-
- builder.Append(")");
- }
+ return CacheKeyPrefix;
}
- private void AddVaryByRouteKey(StringBuilder builder)
+ private async Task ProcessContentAsync(TagHelperOutput output)
{
- var tokenFound = false;
+ var content = await output.GetChildContentAsync();
- if (!string.IsNullOrEmpty(VaryByRoute))
+ var stringBuilder = new StringBuilder();
+ using (var writer = new StringWriter(stringBuilder))
{
- builder.Append(CacheKeyTokenSeparator)
- .Append(nameof(VaryByRoute))
- .Append("(");
-
- var varyByRoutes = Tokenize(VaryByRoute);
- for (var i = 0; i < varyByRoutes.Count; i++)
- {
- var route = varyByRoutes[i];
- tokenFound = true;
-
- builder.Append(route)
- .Append(CacheKeyTokenSeparator)
- .Append(ViewContext.RouteData.Values[route])
- .Append(CacheKeyTokenSeparator);
- }
-
- if (tokenFound)
- {
- builder.Length -= CacheKeyTokenSeparator.Length;
- }
-
- builder.Append(")");
- }
- }
-
- private static IList Tokenize(string value)
- {
- var values = value.Split(AttributeSeparator, StringSplitOptions.RemoveEmptyEntries);
- if (values.Length == 0)
- {
- return values;
+ content.WriteTo(writer, HtmlEncoder);
}
- var trimmedValues = new List();
-
- for (var i = 0; i < values.Length; i++)
- {
- var trimmedValue = values[i].Trim();
-
- if (trimmedValue.Length > 0)
- {
- trimmedValues.Add(trimmedValue);
- }
- }
-
- return trimmedValues;
+ return new StringBuilderHtmlContent(stringBuilder);
}
private class StringBuilderHtmlContent : IHtmlContent
diff --git a/src/Microsoft.AspNetCore.Mvc.TagHelpers/CacheTagHelperBase.cs b/src/Microsoft.AspNetCore.Mvc.TagHelpers/CacheTagHelperBase.cs
new file mode 100644
index 0000000000..929f257e56
--- /dev/null
+++ b/src/Microsoft.AspNetCore.Mvc.TagHelpers/CacheTagHelperBase.cs
@@ -0,0 +1,312 @@
+// 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.Collections.Generic;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Encodings.Web;
+using Microsoft.AspNetCore.Mvc.Rendering;
+using Microsoft.AspNetCore.Mvc.ViewFeatures;
+using Microsoft.AspNetCore.Razor.TagHelpers;
+using Microsoft.Extensions.Primitives;
+
+namespace Microsoft.AspNetCore.Mvc.TagHelpers
+{
+ ///
+ /// base implementation for caching elements.
+ ///
+ public abstract class CacheTagHelperBase : TagHelper
+ {
+ 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 CacheKeyTokenSeparator = "||";
+ private const string EnabledAttributeName = "enabled";
+ private static readonly char[] AttributeSeparator = new[] { ',' };
+
+ ///
+ /// Creates a new .
+ ///
+ /// The to use.
+ public CacheTagHelperBase(HtmlEncoder htmlEncoder)
+ {
+ HtmlEncoder = htmlEncoder;
+ }
+
+ ///
+ public override int Order
+ {
+ get
+ {
+ return -1000;
+ }
+ }
+
+ ///
+ /// Gets the which encodes the content to be cached.
+ ///
+ protected HtmlEncoder HtmlEncoder { get; }
+
+ ///
+ /// Gets or sets the for the current executing View.
+ ///
+ [HtmlAttributeNotBound]
+ [ViewContext]
+ public ViewContext ViewContext { get; set; }
+
+ ///
+ /// Gets or sets a to vary the cached result by.
+ ///
+ [HtmlAttributeName(VaryByAttributeName)]
+ public string VaryBy { get; set; }
+
+ ///
+ /// Gets or sets the name of a HTTP request header to vary the cached result by.
+ ///
+ [HtmlAttributeName(VaryByHeaderAttributeName)]
+ public string VaryByHeader { get; set; }
+
+ ///
+ /// Gets or sets a comma-delimited set of query parameters to vary the cached result by.
+ ///
+ [HtmlAttributeName(VaryByQueryAttributeName)]
+ public string VaryByQuery { get; set; }
+
+ ///
+ /// Gets or sets a comma-delimited set of route data parameters to vary the cached result by.
+ ///
+ [HtmlAttributeName(VaryByRouteAttributeName)]
+ public string VaryByRoute { get; set; }
+
+ ///
+ /// Gets or sets a comma-delimited set of cookie names to vary the cached result by.
+ ///
+ [HtmlAttributeName(VaryByCookieAttributeName)]
+ public string VaryByCookie { get; set; }
+
+ ///
+ /// Gets or sets a value that determines if the cached result is to be varied by the Identity for the logged in
+ /// .
+ ///
+ [HtmlAttributeName(VaryByUserAttributeName)]
+ public bool VaryByUser { get; set; }
+
+ ///
+ /// Gets or sets the exact the cache entry should be evicted.
+ ///
+ [HtmlAttributeName(ExpiresOnAttributeName)]
+ public DateTimeOffset? ExpiresOn { get; set; }
+
+ ///
+ /// Gets or sets the duration, from the time the cache entry was added, when it should be evicted.
+ ///
+ [HtmlAttributeName(ExpiresAfterAttributeName)]
+ public TimeSpan? ExpiresAfter { get; set; }
+
+ ///
+ /// Gets or sets the duration from last access that the cache entry should be evicted.
+ ///
+ [HtmlAttributeName(ExpiresSlidingAttributeName)]
+ public TimeSpan? ExpiresSliding { get; set; }
+
+ ///
+ /// Gets or sets the value which determines if the tag helper is enabled or not.
+ ///
+ [HtmlAttributeName(EnabledAttributeName)]
+ public bool Enabled { get; set; } = true;
+
+ // Internal for unit testing
+ protected internal string GenerateKey(TagHelperContext context)
+ {
+ var builder = new StringBuilder(GetKeyPrefix(context));
+ builder
+ .Append(CacheKeyTokenSeparator)
+ .Append(GetUniqueId(context));
+
+ 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, (c, key) => c[key]);
+ AddStringCollectionKey(builder, nameof(VaryByHeader), VaryByHeader, request.Headers, (c, key) => c[key]);
+ AddStringCollectionKey(builder, nameof(VaryByQuery), VaryByQuery, request.Query, (c, key) => c[key]);
+ 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);
+ }
+ }
+
+ protected abstract string GetUniqueId(TagHelperContext context);
+
+ protected abstract string GetKeyPrefix(TagHelperContext context);
+
+ protected static void AddStringCollectionKey(
+ StringBuilder builder,
+ string keyName,
+ string value,
+ IDictionary sourceCollection)
+ {
+ if (string.IsNullOrEmpty(value))
+ {
+ return;
+ }
+
+ // keyName(param1=value1|param2=value2)
+ builder
+ .Append(CacheKeyTokenSeparator)
+ .Append(keyName)
+ .Append("(");
+
+ var values = Tokenize(value);
+
+ // Perf: Avoid allocating enumerator
+ for (var i = 0; i < values.Count; i++)
+ {
+ var item = values[i];
+ builder
+ .Append(item)
+ .Append(CacheKeyTokenSeparator)
+ .Append(sourceCollection[item])
+ .Append(CacheKeyTokenSeparator);
+ }
+
+ if (values.Count > 0)
+ {
+ // Remove the trailing separator
+ builder.Length -= CacheKeyTokenSeparator.Length;
+ }
+
+ builder.Append(")");
+ }
+
+ protected static void AddStringCollectionKey(
+ StringBuilder builder,
+ string keyName,
+ string value,
+ TSourceCollection sourceCollection,
+ Func accessor)
+ {
+ if (string.IsNullOrEmpty(value))
+ {
+ return;
+ }
+
+ // keyName(param1=value1|param2=value2)
+ builder
+ .Append(CacheKeyTokenSeparator)
+ .Append(keyName)
+ .Append("(");
+
+ var values = Tokenize(value);
+
+ // Perf: Avoid allocating enumerator
+ for (var i = 0; i < values.Count; i++)
+ {
+ var item = values[i];
+
+ builder
+ .Append(item)
+ .Append(CacheKeyTokenSeparator)
+ .Append(accessor(sourceCollection, item))
+ .Append(CacheKeyTokenSeparator);
+ }
+
+ if (values.Count > 0)
+ {
+ // Remove the trailing separator
+ builder.Length -= CacheKeyTokenSeparator.Length;
+ }
+
+ builder.Append(")");
+ }
+
+ protected static IList Tokenize(string value)
+ {
+ var values = value.Split(AttributeSeparator, StringSplitOptions.RemoveEmptyEntries);
+ if (values.Length == 0)
+ {
+ return values;
+ }
+
+ var trimmedValues = new List();
+
+ for (var i = 0; i < values.Length; i++)
+ {
+ var trimmedValue = values[i].Trim();
+
+ if (trimmedValue.Length > 0)
+ {
+ trimmedValues.Add(trimmedValue);
+ }
+ }
+
+ return trimmedValues;
+ }
+
+ protected void AddVaryByRouteKey(StringBuilder builder)
+ {
+ var tokenFound = false;
+
+ if (string.IsNullOrEmpty(VaryByRoute))
+ {
+ return;
+ }
+
+ builder
+ .Append(CacheKeyTokenSeparator)
+ .Append(nameof(VaryByRoute))
+ .Append("(");
+
+ var varyByRoutes = Tokenize(VaryByRoute);
+ for (var i = 0; i < varyByRoutes.Count; i++)
+ {
+ var route = varyByRoutes[i];
+ tokenFound = true;
+
+ builder
+ .Append(route)
+ .Append(CacheKeyTokenSeparator)
+ .Append(ViewContext.RouteData.Values[route])
+ .Append(CacheKeyTokenSeparator);
+ }
+
+ if (tokenFound)
+ {
+ builder.Length -= CacheKeyTokenSeparator.Length;
+ }
+
+ builder.Append(")");
+ }
+
+ }
+}
\ No newline at end of file
diff --git a/src/Microsoft.AspNetCore.Mvc.TagHelpers/DependencyInjection/TagHelperExtensions.cs b/src/Microsoft.AspNetCore.Mvc.TagHelpers/DependencyInjection/TagHelperExtensions.cs
new file mode 100644
index 0000000000..4895085d0f
--- /dev/null
+++ b/src/Microsoft.AspNetCore.Mvc.TagHelpers/DependencyInjection/TagHelperExtensions.cs
@@ -0,0 +1,40 @@
+// 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.Mvc.TagHelpers.Cache;
+using Microsoft.Extensions.Caching.Distributed;
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+
+namespace Microsoft.Extensions.DependencyInjection
+{
+ ///
+ /// Extension methods for configuring Razor cache tag helpers.
+ ///
+ public static class TagHelperServicesExtensions
+ {
+ ///
+ /// Adds MVC cache tag helper services to the application.
+ ///
+ /// The .
+ /// The .
+ public static IMvcCoreBuilder AddCacheTagHelper(this IMvcCoreBuilder builder)
+ {
+ if (builder == null)
+ {
+ throw new ArgumentNullException(nameof(builder));
+ }
+
+ builder.Services.TryAddTransient();
+ builder.Services.TryAddTransient();
+ builder.Services.TryAddSingleton();
+
+ // Required default services for cache tag helpers
+ builder.Services.TryAddSingleton();
+ builder.Services.TryAddSingleton();
+
+ return builder;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Microsoft.AspNetCore.Mvc.TagHelpers/DistributedCacheTagHelper.cs b/src/Microsoft.AspNetCore.Mvc.TagHelpers/DistributedCacheTagHelper.cs
new file mode 100644
index 0000000000..94cb218b99
--- /dev/null
+++ b/src/Microsoft.AspNetCore.Mvc.TagHelpers/DistributedCacheTagHelper.cs
@@ -0,0 +1,123 @@
+// 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.Text.Encodings.Web;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Html;
+using Microsoft.AspNetCore.Mvc.TagHelpers.Cache;
+using Microsoft.AspNetCore.Razor.TagHelpers;
+using Microsoft.Extensions.Caching.Distributed;
+using Microsoft.Extensions.Caching.Memory;
+
+namespace Microsoft.AspNetCore.Mvc.TagHelpers
+{
+ ///
+ /// implementation targeting <distributed-cache> elements.
+ ///
+ [HtmlTargetElement("distributed-cache", Attributes = NameAttributeName)]
+ public class DistributedCacheTagHelper : CacheTagHelperBase
+ {
+ private readonly IDistributedCacheTagHelperService _distributedCacheService;
+
+ ///
+ /// Prefix used by instances when creating entries in .
+ ///
+ public static readonly string CacheKeyPrefix = nameof(DistributedCacheTagHelper);
+
+ private const string NameAttributeName = "name";
+
+ ///
+ /// Creates a new .
+ ///
+ /// The .
+ /// The .
+ public DistributedCacheTagHelper(
+ IDistributedCacheTagHelperService distributedCacheService,
+ HtmlEncoder htmlEncoder)
+ : base(htmlEncoder)
+ {
+ _distributedCacheService = distributedCacheService;
+ }
+
+ ///
+ /// Gets the instance used to cache workers.
+ ///
+ protected IMemoryCache MemoryCache { get; }
+
+ ///
+ /// Gets or sets a unique name to discriminate cached entries.
+ ///
+ [HtmlAttributeName(NameAttributeName)]
+ public string Name { get; set; }
+
+ ///
+ public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
+ {
+ if (context == null)
+ {
+ throw new ArgumentNullException(nameof(context));
+ }
+
+ if (output == null)
+ {
+ throw new ArgumentNullException(nameof(output));
+ }
+
+ IHtmlContent content = null;
+
+ // Create a cancellation token that will be used
+ // to release the task from the memory cache.
+ var tokenSource = new CancellationTokenSource();
+
+ if (Enabled)
+ {
+ var key = GenerateKey(context);
+
+ content = await _distributedCacheService.ProcessContentAsync(output, key, GetDistributedCacheEntryOptions());
+ }
+ else
+ {
+ content = await output.GetChildContentAsync();
+ }
+
+ // Clear the contents of the "cache" element since we don't want to render it.
+ output.SuppressOutput();
+
+ output.Content.SetContent(content);
+ }
+
+ // Internal for unit testing
+ internal DistributedCacheEntryOptions GetDistributedCacheEntryOptions()
+ {
+ var options = new DistributedCacheEntryOptions();
+ if (ExpiresOn != null)
+ {
+ options.SetAbsoluteExpiration(ExpiresOn.Value);
+ }
+
+ if (ExpiresAfter != null)
+ {
+ options.SetAbsoluteExpiration(ExpiresAfter.Value);
+ }
+
+ if (ExpiresSliding != null)
+ {
+ options.SetSlidingExpiration(ExpiresSliding.Value);
+ }
+
+ return options;
+ }
+
+ protected override string GetUniqueId(TagHelperContext context)
+ {
+ return Name;
+ }
+
+ protected override string GetKeyPrefix(TagHelperContext context)
+ {
+ return CacheKeyPrefix;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Microsoft.AspNetCore.Mvc/MvcServiceCollectionExtensions.cs b/src/Microsoft.AspNetCore.Mvc/MvcServiceCollectionExtensions.cs
index 36c876bdaa..0dfe509e96 100644
--- a/src/Microsoft.AspNetCore.Mvc/MvcServiceCollectionExtensions.cs
+++ b/src/Microsoft.AspNetCore.Mvc/MvcServiceCollectionExtensions.cs
@@ -35,6 +35,7 @@ namespace Microsoft.Extensions.DependencyInjection
builder.AddFormatterMappings();
builder.AddViews();
builder.AddRazorViewEngine();
+ builder.AddCacheTagHelper();
// +1 order
builder.AddDataAnnotations(); // +1 order
diff --git a/src/Microsoft.AspNetCore.Mvc/project.json b/src/Microsoft.AspNetCore.Mvc/project.json
index 3f368436c5..994c10a995 100644
--- a/src/Microsoft.AspNetCore.Mvc/project.json
+++ b/src/Microsoft.AspNetCore.Mvc/project.json
@@ -20,6 +20,7 @@
"Microsoft.AspNetCore.Mvc.Formatters.Json": "1.0.0-*",
"Microsoft.AspNetCore.Mvc.Localization": "1.0.0-*",
"Microsoft.AspNetCore.Mvc.Razor": "1.0.0-*",
+ "Microsoft.AspNetCore.Mvc.TagHelpers": "1.0.0-*",
"Microsoft.AspNetCore.Mvc.ViewFeatures": "1.0.0-*",
"Microsoft.Extensions.Caching.Memory": "1.0.0-*",
"Microsoft.Extensions.DependencyInjection": "1.0.0-*",
diff --git a/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/CacheTagHelperTest.cs b/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/CacheTagHelperTest.cs
index eca0adfd4e..fe8eaa7a87 100644
--- a/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/CacheTagHelperTest.cs
+++ b/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/CacheTagHelperTest.cs
@@ -11,6 +11,7 @@ using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Html;
+using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Internal;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.ModelBinding;
@@ -74,13 +75,13 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
[Theory]
[InlineData("Cookie0", "CacheTagHelper||testid||VaryByCookie(Cookie0||Cookie0Value)")]
[InlineData("Cookie0,Cookie1",
- "CacheTagHelper||testid||VaryByCookie(Cookie0||Cookie0Value||Cookie1||Cookie1Value)")]
+ "CacheTagHelper||testid||VaryByCookie(Cookie0||Cookie0Value||Cookie1||Cookie1Value)")]
[InlineData("Cookie0, Cookie1",
- "CacheTagHelper||testid||VaryByCookie(Cookie0||Cookie0Value||Cookie1||Cookie1Value)")]
+ "CacheTagHelper||testid||VaryByCookie(Cookie0||Cookie0Value||Cookie1||Cookie1Value)")]
[InlineData(" Cookie0, , Cookie1 ",
- "CacheTagHelper||testid||VaryByCookie(Cookie0||Cookie0Value||Cookie1||Cookie1Value)")]
+ "CacheTagHelper||testid||VaryByCookie(Cookie0||Cookie0Value||Cookie1||Cookie1Value)")]
[InlineData(",Cookie0,,Cookie1,",
- "CacheTagHelper||testid||VaryByCookie(Cookie0||Cookie0Value||Cookie1||Cookie1Value)")]
+ "CacheTagHelper||testid||VaryByCookie(Cookie0||Cookie0Value||Cookie1||Cookie1Value)")]
public void GenerateKey_UsesVaryByCookieName(string varyByCookie, string expected)
{
// Arrange
@@ -103,9 +104,9 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
[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||)")]
+ "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||)")]
+ "CacheTagHelper||testid||VaryByHeader(X-CustomHeader||Header-Value||Accept-Encoding||utf8||NotAvailable||)")]
public void GenerateKey_UsesVaryByHeader(string varyByHeader, string expected)
{
// Arrange
@@ -143,7 +144,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
VaryByQuery = varyByQuery
};
cacheTagHelper.ViewContext.HttpContext.Request.QueryString =
- new Http.QueryString("?sortoption=Adorability&Category=cats&sortOrder=");
+ new QueryString("?sortoption=Adorability&Category=cats&sortOrder=");
// Act
var key = cacheTagHelper.GenerateKey(tagHelperContext);
@@ -222,7 +223,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
{
// Arrange
var expected = GetHashedBytes("CacheTagHelper||testid||VaryBy||custom-value||" +
- "VaryByHeader(content-type||text/html)||VaryByUser||someuser");
+ "VaryByHeader(content-type||text/html)||VaryByUser||someuser");
var tagHelperContext = GetTagHelperContext();
var cacheTagHelper = new CacheTagHelper(Mock.Of(), new HtmlTestEncoder())
{
@@ -249,14 +250,12 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
var id = "unique-id";
var childContent = "original-child-content";
var cache = new Mock();
- cache.CallBase = true;
var value = new DefaultTagHelperContent().SetContent("ok");
cache.Setup(c => c.Set(
/*key*/ It.IsAny(),
/*value*/ value,
/*optons*/ It.IsAny()))
- .Returns(value)
- .Verifiable();
+ .Returns(value);
object cacheResult;
cache.Setup(c => c.TryGetValue(It.IsAny(), out cacheResult))
.Returns(false);
@@ -289,15 +288,13 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
var id = "unique-id";
var childContent = "original-child-content";
var cache = new Mock();
- cache.CallBase = true;
var value = new DefaultTagHelperContent().SetContent("ok");
cache.Setup(c => c.CreateLinkingScope()).Returns(new Mock().Object);
cache.Setup(c => c.Set(
/*key*/ It.IsAny(),
/*value*/ It.IsAny