Skip to content

Commit

Permalink
Improve Boilerplate response caching (#9734)
Browse files Browse the repository at this point in the history
  • Loading branch information
ysmoradi committed Jan 27, 2025
1 parent 3cd0cb0 commit e0dc6ab
Show file tree
Hide file tree
Showing 7 changed files with 96 additions and 90 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ public async Task RemoveProfileImage(CancellationToken cancellationToken)

[AllowAnonymous]
[HttpGet("{userId}")]
[AppResponseCache(MaxAge = 3600 * 24 * 7)]
[AppResponseCache(MaxAge = 3600 * 24 * 7, UserAgnostic = true)]
public async Task<IActionResult> GetProfileImage(Guid userId, CancellationToken cancellationToken)
{
var user = await userManager.FindByIdAsync(userId.ToString());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ public partial class StatisticsController : AppControllerBase, IStatisticsContro

[AllowAnonymous]
[HttpGet("{packageId}")]
[AppResponseCache(MaxAge = 3600 * 24)]
[AppResponseCache(MaxAge = 3600 * 24, UserAgnostic = true)]
public async Task<NugetStatsDto> GetNugetStats(string packageId, CancellationToken cancellationToken)
{
return await nugetHttpClient.GetPackageStats(packageId, cancellationToken);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
//+:cnd:noEmit
using System.Reflection;
using Microsoft.AspNetCore.Mvc.Controllers;

namespace Microsoft.AspNetCore.Http;

internal static class HttpContextExtensions
{
internal static AppResponseCacheAttribute? GetResponseCacheAttribute(this HttpContext context)
{
if (context.GetEndpoint()?.Metadata.OfType<AppResponseCacheAttribute>().FirstOrDefault() is AppResponseCacheAttribute attr) // minimal api
if (context.GetEndpoint()?.Metadata.OfType<AppResponseCacheAttribute>().FirstOrDefault() is AppResponseCacheAttribute attr)
{
if (attr is not null)
{
Expand All @@ -17,18 +15,7 @@ internal static class HttpContextExtensions
}
}

if (context.GetEndpoint()?.Metadata.OfType<ControllerActionDescriptor>().FirstOrDefault() is ControllerActionDescriptor action) // web api mvc action
{
var att = action.MethodInfo.GetCustomAttribute<AppResponseCacheAttribute>(inherit: true) ??
action.ControllerTypeInfo.GetCustomAttribute<AppResponseCacheAttribute>(inherit: true);

if (att is not null)
{
att.ResourceKind = ResourceKind.Api;
return att;
}
}

return null;
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -3,42 +3,58 @@

namespace Boilerplate.Server.Api.Services;

internal class AppResponseCachePolicy(IHostEnvironment env, ILogger<AppResponseCachePolicy> logger) : IOutputCachePolicy
internal class AppResponseCachePolicy(IHostEnvironment env) : IOutputCachePolicy
{
public async ValueTask CacheRequestAsync(OutputCacheContext context, CancellationToken cancellation)
{
var responseCacheAtt = context.HttpContext.GetResponseCacheAttribute();

if (responseCacheAtt is null || context.HttpContext.User.IsAuthenticated() is true)
if (responseCacheAtt is null)
{
context.EnableOutputCaching = false;
return;
}

if (responseCacheAtt.MaxAge == -1 && responseCacheAtt.SharedMaxAge == -1)
throw new InvalidOperationException("Invalid configuration: Both MaxAge and SharedMaxAge are unset. At least one of them must be specified in the ResponseCache attribute.");

var requestUrl = new Uri(context.HttpContext.Request.GetUri().GetUrlWithoutCulture()).PathAndQuery;

if (env.IsDevelopment())
if (responseCacheAtt.SharedMaxAge == -1)
{
context.EnableOutputCaching = false;
logger.LogInformation("In production, the result response of {Url} url would be cached.", requestUrl);
return; // To enhance the developer experience, return here to make it easier for developers to debug cacheable pages.
responseCacheAtt.SharedMaxAge = responseCacheAtt.MaxAge;
}

var duration = TimeSpan.FromSeconds(responseCacheAtt.MaxAge > 0 ? responseCacheAtt.MaxAge : responseCacheAtt.SharedMaxAge);
var browserCacheTtl = responseCacheAtt.MaxAge;
var edgeCacheTtl = responseCacheAtt.SharedMaxAge;
var outputCacheTtl = responseCacheAtt.SharedMaxAge;

context.ResponseExpirationTimeSpan = duration;
context.Tags.Add(requestUrl);
if (context.HttpContext.User.IsAuthenticated() && responseCacheAtt.UserAgnostic is false)
{
edgeCacheTtl = -1;
}

context.HttpContext.Response.GetTypedHeaders().CacheControl = new()
if (browserCacheTtl != -1 || edgeCacheTtl != -1)
{
Public = true,
MaxAge = TimeSpan.FromSeconds(responseCacheAtt.MaxAge),
//#if (cloudflare == true)
SharedMaxAge = TimeSpan.FromSeconds(responseCacheAtt.SharedMaxAge)
//#endif
};

context.HttpContext.Response.Headers.Remove("Pragma");
context.HttpContext.Response.GetTypedHeaders().CacheControl = new()
{
Public = edgeCacheTtl > 0,
MaxAge = browserCacheTtl == -1 ? null : TimeSpan.FromSeconds(browserCacheTtl),
SharedMaxAge = edgeCacheTtl == -1 ? null : TimeSpan.FromSeconds(edgeCacheTtl)
};
context.HttpContext.Response.Headers.Remove("Pragma");
}

if (env.IsDevelopment() // To enhance the developer experience, return here to make it easier for developers to debug cacheable pages.
|| outputCacheTtl == -1)
{
context.EnableOutputCaching = false;
return;
}

context.Tags.Add(requestUrl);
context.EnableOutputCaching = true;
context.ResponseExpirationTimeSpan = TimeSpan.FromSeconds(outputCacheTtl);

if (CultureInfoManager.MultilingualEnabled)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
//+:cnd:noEmit
using System.Reflection;
using Boilerplate.Shared.Attributes;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.AspNetCore.Components.Endpoints;

namespace Microsoft.AspNetCore.Http;
Expand All @@ -10,39 +8,16 @@ internal static class HttpContextExtensions
{
internal static AppResponseCacheAttribute? GetResponseCacheAttribute(this HttpContext context)
{
if (context.GetEndpoint()?.Metadata.OfType<AppResponseCacheAttribute>().FirstOrDefault() is AppResponseCacheAttribute attr) // minimal api
if (context.GetEndpoint()?.Metadata.OfType<AppResponseCacheAttribute>().FirstOrDefault() is AppResponseCacheAttribute attr)
{
if (attr is not null)
{
attr.ResourceKind = ResourceKind.Api;
var isPageRequest = context.GetEndpoint()?.Metadata.OfType<ComponentTypeMetadata>().FirstOrDefault() is ComponentTypeMetadata;
attr.ResourceKind = isPageRequest ? ResourceKind.Page : ResourceKind.Api;
return attr;
}
}

if (context.GetEndpoint()?.Metadata.OfType<ComponentTypeMetadata>().FirstOrDefault() is ComponentTypeMetadata component) // razor page
{
var att = component.Type.GetCustomAttribute<AppResponseCacheAttribute>(inherit: true);
if (att is not null)
{
att.ResourceKind = ResourceKind.Page;
return att;
}
}

//#if (IsInsideProjectTemplate)
if (context.GetEndpoint()?.Metadata.OfType<ControllerActionDescriptor>().FirstOrDefault() is ControllerActionDescriptor action) // web api mvc action
{
var att = action.MethodInfo.GetCustomAttribute<AppResponseCacheAttribute>(inherit: true) ??
action.ControllerTypeInfo.GetCustomAttribute<AppResponseCacheAttribute>(inherit: true);

if (att is not null)
{
att.ResourceKind = ResourceKind.Api;
return att;
}
}
//#endif

return null;
}
}
Original file line number Diff line number Diff line change
@@ -1,55 +1,71 @@
using Microsoft.AspNetCore.OutputCaching;
//+:cnd:noEmit
using Microsoft.AspNetCore.OutputCaching;

namespace Boilerplate.Server.Web.Services;

public class AppResponseCachePolicy(IHostEnvironment env, ILogger<AppResponseCachePolicy> logger) : IOutputCachePolicy
public class AppResponseCachePolicy(IHostEnvironment env) : IOutputCachePolicy
{
public async ValueTask CacheRequestAsync(OutputCacheContext context, CancellationToken cancellation)
{
var responseCacheAtt = context.HttpContext.GetResponseCacheAttribute();

if (responseCacheAtt is null || context.HttpContext.User.IsAuthenticated() is true)
if (responseCacheAtt is null)
{
context.EnableOutputCaching = false;
return;
}

if (responseCacheAtt.MaxAge == -1 && responseCacheAtt.SharedMaxAge == -1)
throw new InvalidOperationException("Invalid configuration: Both MaxAge and SharedMaxAge are unset. At least one of them must be specified in the ResponseCache attribute.");

var requestUrl = new Uri(context.HttpContext.Request.GetUri().GetUrlWithoutCulture()).PathAndQuery;

if (env.IsDevelopment())
if (responseCacheAtt.SharedMaxAge == -1)
{
context.EnableOutputCaching = false;
logger.LogInformation("In production, the result response of {Url} url would be cached.", requestUrl);
return; // To enhance the developer experience, return here to make it easier for developers to debug cacheable pages.
responseCacheAtt.SharedMaxAge = responseCacheAtt.MaxAge;
}

var duration = TimeSpan.FromSeconds(responseCacheAtt.MaxAge > 0 ? responseCacheAtt.MaxAge : responseCacheAtt.SharedMaxAge);
var browserCacheTtl = responseCacheAtt.MaxAge;
var edgeCacheTtl = responseCacheAtt.SharedMaxAge;
var outputCacheTtl = responseCacheAtt.SharedMaxAge;

context.ResponseExpirationTimeSpan = duration;
context.Tags.Add(requestUrl);
if (context.HttpContext.User.IsAuthenticated() && responseCacheAtt.UserAgnostic is false)
{
edgeCacheTtl = -1;
}

//#if (cloudflare == true)
if (responseCacheAtt.ResourceKind is Shared.Attributes.ResourceKind.Page &&
CultureInfoManager.MultilingualEnabled)
if (responseCacheAtt.ResourceKind is Shared.Attributes.ResourceKind.Page || CultureInfoManager.MultilingualEnabled)
{
responseCacheAtt.SharedMaxAge = 0; // Edge caching for page responses is not supported when `CultureInfoManager.MultilingualEnabled` is set to `true`.
// Note: Edge caching for page responses is not supported when `CultureInfoManager.MultilingualEnabled` is enabled.
edgeCacheTtl = -1;
}
//#endif

context.HttpContext.Response.GetTypedHeaders().CacheControl = new()
if (browserCacheTtl != -1 || edgeCacheTtl != -1)
{
Public = true,
MaxAge = TimeSpan.FromSeconds(responseCacheAtt.MaxAge),
//#if (cloudflare == true)
SharedMaxAge = TimeSpan.FromSeconds(responseCacheAtt.SharedMaxAge)
//#endif
};
context.HttpContext.Response.Headers.Remove("Pragma");
context.HttpContext.Response.GetTypedHeaders().CacheControl = new()
{
Public = edgeCacheTtl > 0,
MaxAge = browserCacheTtl == -1 ? null : TimeSpan.FromSeconds(browserCacheTtl),
SharedMaxAge = edgeCacheTtl == -1 ? null : TimeSpan.FromSeconds(edgeCacheTtl)
};
context.HttpContext.Response.Headers.Remove("Pragma");
}

if (env.IsDevelopment() // To enhance the developer experience, return here to make it easier for developers to debug cacheable pages.
|| outputCacheTtl == -1)
{
context.EnableOutputCaching = false;
return;
}

context.Tags.Add(requestUrl);
context.EnableOutputCaching = true;
context.ResponseExpirationTimeSpan = TimeSpan.FromSeconds(outputCacheTtl);

if (CultureInfoManager.MultilingualEnabled)
{
context.CacheVaryByRules.VaryByValues.Add("Culture", CultureInfo.CurrentUICulture.Name);
}
context.CacheVaryByRules.VaryByValues.Add("Culture", CultureInfo.CurrentUICulture.Name);
}
}

public async ValueTask ServeFromCacheAsync(OutputCacheContext context, CancellationToken cancellation)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,26 @@ public class AppResponseCacheAttribute : Attribute
/// Specifies the cache duration in seconds. This setting caches the response in ASP.NET Core's output cache,
/// CDN edge servers, and the browser's cache. Note that this cache cannot be purged automatically, so use it with caution.
/// </summary>
public int MaxAge { get; set; }
public int MaxAge { get; set; } = -1;

/// <summary>
/// Specifies the cache duration in seconds for shared caches. This setting caches the response in ASP.NET Core's output cache
/// and CDN edge servers. The cache can be purged at any time using the ResponseCacheService.
/// Default value is 86400 seconds (1 day).
/// </summary>
public int SharedMaxAge { get; set; } = 3600 * 24; // 1 Day
public int SharedMaxAge { get; set; } = -1;

/// <summary>
/// If the current request is authenticated, the pre-rendered HTML response might include the user's name,
/// or the JSON content of API calls might be based on the user's roles or tenant.
/// Storing such a response in the browser's cache is generally not an issue,
/// but caching the response on a CDN's edge or in the output cache of ASP.NET Core
/// could result in serving that response to other users.
///
/// If you are certain that your page or API is not affected by the user,
/// you can set this property to true to cache those responses and improve performance.
/// </summary>
public bool UserAgnostic { get; set; }

public ResourceKind ResourceKind { get; set; }
}
Expand Down

0 comments on commit e0dc6ab

Please sign in to comment.