One of my first question about Sitecore ASP .NET Core SDK was that, is there any built in rendering side caching mechanism implemented already?
This is in consideration in the roadmap, we are looking at ways to expose publishing events to the rendering host to enable this.
Nick Wesselman
Meanwhile, I thought I implement something simple to achieve this. The basic idea is to open and endpoint in the ASP .NET Core rendering host which will “clear” the cache. This endpoint can be called by the Sitecore CM while publishing. This is not a production ready implementation but could be a good starting point for you. So let’s start with it!
1. Sitecore CM server implementation
Let’s start the implementation on the CM server, this is not that much. I want to subscribe for the publishing event and call different rendering host endpoints based on the item path, to clear it’s caches. For that, I could only subscribe for the publish:itemProcessed
event. This event is called on every item which is processed by a publish. To not call the rendering host endpoint multiple times on a publish, a batching mechanism is implemented. I got this implementation from an existing solution with minor modifications (kudos to my teammates who originally implemented 🙏).
Show me the code! 💻
Here is the cache clear trigger batching implementation first.
namespace Discovering.Sitecore10.Processors.PublishEnd.Services | |
{ | |
public class RenderingHostBatchingCacheClearer | |
{ | |
private readonly HttpClient _httpClient; | |
private readonly object _lock = new object(); | |
private readonly Timer _timer; | |
private readonly HashSet<string> _endpoints = new HashSet<string>(); | |
public RenderingHostBatchingCacheClearer(TimeSpan batchingTimeout, HttpClient httpClient) | |
{ | |
_httpClient = httpClient; | |
_timer = new Timer(batchingTimeout.TotalMilliseconds) | |
{ | |
AutoReset = false, | |
Enabled = false | |
}; | |
_timer.Elapsed += (s, a) => { TriggerCacheClear(); }; | |
} | |
public void AddEndpoints(IEnumerable<string> endpoints) | |
{ | |
if (endpoints == null || !endpoints.Any()) | |
return; | |
lock (_lock) | |
foreach (var endpoint in endpoints) | |
_endpoints.Add(endpoint); | |
_timer.Stop(); | |
_timer.Start(); | |
} | |
public int GetEndPointsCount() | |
{ | |
lock (_lock) | |
return _endpoints.Count; | |
} | |
private async void TriggerCacheClear() | |
{ | |
string[] endpoints; | |
lock (_lock) | |
{ | |
if (_endpoints.Count == 0) | |
return; | |
endpoints = _endpoints.ToArray(); | |
} | |
foreach (var endpoint in endpoints) | |
{ | |
try | |
{ | |
var response = await _httpClient.GetAsync(endpoint); | |
if (!response.IsSuccessStatusCode) | |
continue; | |
} | |
catch (Exception e) | |
{ | |
Log.Error(e.Message, e, this); | |
} | |
finally | |
{ | |
// remove items from the list if calling the endpoint returned with successful status or exception happened | |
lock (_lock) | |
_endpoints.Remove(endpoint); | |
} | |
} | |
} | |
} | |
} |
Then the implementation and the configuration of the publish:itemProcessed
event. This implementation is prepared for a multitenant solution as you can see. In the configuration I can define tenant-endpoints pairs. So on publish only the relevant rendering host caches will be cleared. I can define multiple endpoints for one tenant, for example if it’s a load balanced internal environment with multiple rendering hosts.
<?xml version="1.0"?> | |
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/" xmlns:role="http://www.sitecore.net/xmlconfig/role/"> | |
<sitecore role:require="Standalone or ContentManagement"> | |
<RenderingHostCacheClear.Tenants> | |
<Tenant startPath="/sitecore/content/Discovering-Sitecore10"> | |
<Endpoint>https://www.renderinhost1.localhost/api/outputcache/clear</Endpoint> | |
<Endpoint>https://www.renderinhost2.localhost/api/outputcache/clear</Endpoint> | |
</Tenant> | |
</RenderingHostCacheClear.Tenants> | |
<events> | |
<event name="publish:itemProcessed"> | |
<handler | |
type="Discovering.Sitecore10.Processors.PublishEnd.TriggerRenderingHostCacheClear, Discovering.Sitecore10" | |
method="OnItemPublished" | |
resolve="true" /> | |
</event> | |
</events> | |
</sitecore> | |
</configuration> |
namespace Discovering.Sitecore10.Processors.PublishEnd | |
{ | |
public class Tenant | |
{ | |
public Tenant(string startPath, IEnumerable<string> endpoints) | |
{ | |
this.StartPath = startPath; | |
this.Endpoints = endpoints; | |
} | |
public string StartPath { get; set; } | |
public IEnumerable<string> Endpoints { get; set; } | |
} | |
public class TriggerRenderingHostCacheClear | |
{ | |
private readonly RenderingHostBatchingCacheClearer _batchedSender; | |
private const string TenantsConfigNodePath = "RenderingHostCacheClear.Tenants/Tenant"; | |
private readonly IEnumerable<Tenant> _tenants; | |
public TriggerRenderingHostCacheClear() | |
{ | |
// NOTE: use DI for HttpClient, though the event is singleton | |
_batchedSender = new RenderingHostBatchingCacheClearer(TimeSpan.FromSeconds(3), new HttpClient()); | |
_tenants = this.GetTenants(); | |
} | |
public void OnItemPublished(object sender, EventArgs args) | |
{ | |
var itemProcessedEventArgs = args as ItemProcessedEventArgs; | |
var context = itemProcessedEventArgs?.Context; | |
if ((context.PublishContext?.DeleteCandidates?.Count ?? 0) > 0 && _batchedSender.GetEndPointsCount() <= 0) | |
{ | |
_batchedSender.AddEndpoints( | |
this.GetTenantByItemPath(context.PublishOptions.RootItem.Paths.FullPath)?.Endpoints); | |
} | |
if (context.Result.Operation == PublishOperation.None || context.Result.Operation == PublishOperation.Skipped) | |
return; | |
_batchedSender.AddEndpoints(this.GetTenantByItemPath(context.VersionToPublish.Paths.FullPath)?.Endpoints); | |
} | |
private IEnumerable<Tenant> GetTenants() | |
{ | |
return Factory | |
.GetConfigNodes(TenantsConfigNodePath) | |
?.Cast<XmlNode>() | |
?.Select(x => new Tenant( | |
XmlUtil.GetAttribute("startPath", x), | |
x.ChildNodes.Cast<XmlNode>().Select(e => e.InnerText))); | |
} | |
private Tenant GetTenantByItemPath(string itemPath) | |
{ | |
return _tenants?.FirstOrDefault(t => | |
itemPath.Equals(t.StartPath, StringComparison.CurrentCultureIgnoreCase) | |
|| itemPath.StartsWith(t.StartPath + "/", StringComparison.CurrentCultureIgnoreCase)); | |
} | |
} | |
} |
2. ASP .NET Core rendering host implementation
In the rendering host I have implemented a new output cache taghelper, called sitecore-output-cache
, then in the components I can use it like this.
@model ContentBlockModel | |
<sitecore-output-cache> | |
<div> | |
@* Important: Self-closing tags will not work when rendering text fields with the Sitecore tag helpers. *@ | |
<h1 class="contentTitle" asp-for="Title"></h1> | |
<div class="contentDescription"> | |
<sc-text asp-for="Text"></sc-text> | |
</div> | |
</div> | |
</sitecore-output-cache> |
The related taghelper implementation is pretty simple. Caching is done based on the request path and the last time the cache clear date was modified.
namespace Discovering.Sitecore10.Caching | |
{ | |
public class OutputCacheOptions | |
{ | |
public bool Enabled { get; set; } | |
} | |
[HtmlTargetElement("sitecore-output-cache")] | |
public class OutputCacheTagHelper : CacheTagHelper | |
{ | |
public OutputCacheTagHelper( | |
CacheTagHelperMemoryCacheFactory factory, | |
HtmlEncoder htmlEncoder, | |
IHttpContextAccessor httpContextAccessor, | |
IOptions<OutputCacheOptions> cachingOptions, | |
IOutputCacheService outputCacheService) | |
: base(factory, htmlEncoder) | |
{ | |
ExpiresOn = DateTimeOffset.MaxValue; | |
VaryBy = httpContextAccessor.HttpContext.Request.Path + "," + outputCacheService.GetLastCacheReload().ToString(CultureInfo .InvariantCulture); | |
Enabled = cachingOptions.Value.Enabled; | |
} | |
} | |
} |
Next step is to implement the endpoint which is changing the LastCacheReload
date.
namespace Discovering.Sitecore10.Controllers.Caching | |
{ | |
public class OutputCacheController : Controller | |
{ | |
private readonly IOutputCacheService _outputCacheService; | |
public OutputCacheController(IOutputCacheService outputCacheService) | |
{ | |
_outputCacheService = outputCacheService; | |
} | |
[HttpGet] | |
public IActionResult Clear() | |
{ | |
// NOTE: Implement shared secret checking | |
_outputCacheService.SetLastCacheReload(DateTime.Now); | |
return Ok(); | |
} | |
} | |
} |
In the Startup
of the rendering host some changes are needed to register the new services, options and controller endpoint. I recommend to only enable this output caching on real environments and disable it for local development.
namespace Discovering.Sitecore10 | |
{ | |
public class Startup | |
{ | |
public void ConfigureServices(IServiceCollection services) | |
{ | |
... | |
// Sitecore output caching | |
services.AddSingleton<IOutputCacheService, OutputCacheService>(); | |
services.Configure<OutputCacheOptions>(options => options.Enabled = true); | |
services.AddControllers(); | |
... | |
} | |
public void Configure(IApplicationBuilder app, IWebHostEnvironment env) | |
{ | |
app.UseEndpoints(endpoints => | |
{ | |
... | |
// Sitecore output caching | |
endpoints.MapControllerRoute( | |
"outputCache", | |
"api/outputcache/clear", | |
new { controller = "OutputCache", action = "Clear" } | |
); | |
... | |
}); | |
} | |
} | |
} |
As I mentioned in the beginning this is not production ready, I did not tested on a production environment and I also see some points to improve:
- Adding a shared secret checking to the
OutputCacheController.Clear()
action to not able call the cache clear without this secret key - Instead of calling the endpoint from the CM server, would be nicer to use a queue messagin framework for communication between CM and the rendering hosts (e.g. Message Bus)
One thought on “Sitecore ASP .NET Core SDK output caching PoC”