Views: 1389
Number of votes: 1
Average rating:

Clearing the Local Object Cache

We all know that one of the best ways to improve site performance is to implement a successful caching strategy, and Episerver employs an aggressive one. However, caching can be frustrating during development, and sometimes it’s nice to be able to remove some, or all the cached objects on-demand.

A couple of years ago I worked on a Web Forms based solution to remove the cache items for an Episerver site. It was okay, but you had to make sure the files were on the server correctly (not always a sure thing with the mix of MVC and Web Forms and site publishing, at least in my experience), and you had to remember a special URL to get to the page. Sure, it all worked, but it was a hassle, there was very little security, and it wasn’t MVC.

When I went through the Episerver Development Fundamentals course by Mark Price (which I highly recommend, even if you are a seasoned developer), one of the exercises (exercise G1) included steps for building an MVC page template for viewing the object cache. I remember thinking that this would be a much better solution than the web forms example that I had used before and could be modified easily to include functionality for not just showing the cached items but removing them as well. Then in the Episerver Advanced Development course, Module F talked about plugins, with an exercise building an admin tool plug-in.

Ding! Ding! Ding! It hit me like a lightbulb full of bricks…I could make an admin plugin to view and remove items from the cache. As a plugin, it would be easy to include in deployments. It would be in a logical, secure location, and I wouldn’t have to remember a special URL to get to it. Bonus!

It’s important to note that this example is primarily focused on the local object cache. It does provide a method for servers in a load balanced environment, although this is not the preferred way of clearing cache items for remote servers. Episerver provides functionality and documentation about the recommended way to invalidate cache across multiple servers:

https://world.episerver.com/documentation/developer-guides/CMS/caching/Object-caching/

The Route

First thing we need to do is to create a new Initialization Module, to set up a custom route so that we can access the controller functions and make this thing work without having a page object.

using EPiServer.Framework;
using EPiServer.Framework.Initialization;
using System.Web.Mvc;
using System.Web.Routing;

namespace LOC.Cache.Business.Initialization
{
	[InitializableModule]
	[ModuleDependency(typeof(EPiServer.Web.InitializationModule))]
	public class LocalObjectCacheInitialzationModule : IInitializableModule
	{
		private bool initialized = false;

		public void Initialize(InitializationEngine context)
		{
			if (!initialized)
			{
				RouteTable.Routes.MapRoute(
					name: "LocalObjectCache",
					url: "localobjectcache/{action}", 
					defaults: new { controller = "LocalObjectCache", action = "Index" });

				initialized = true;
			}
		}

		public void Uninitialize(InitializationEngine context) { }
	}
}

 This module creates a new route named “LocalObjectCache”, that will call the LocalObjectCacheController’s Index function by default.

 

The ViewModel

Next, we need a viewmodel to pass to the data to the view for rendering.

using System.Collections;
using System.Collections.Generic;
using System.Web.Mvc;

namespace LOC.Cache.Models.ViewModels
{
	public class LocalObjectCacheViewModel
	{
		public IEnumerable<DictionaryEntry> CachedItems { get; set; }

		public string FilteredBy { get; set; }

		public IEnumerable<SelectListItem> Choices { get; set; }
	}
}

Here we are setting an IEnumerable of type DictionaryEntry to store the cache object data, a string value for filtering the CachedItems list, and a select list for the filter choices.

The Controller

Now we get into the Controller. The workhorse of this plug-in.

Full credit where it’s due…if this code looks familiar, it’s because it’s based on the code from exercise G1 in the Development Fundamentals course, and exercise F3 in the Advanced Developers course, both taught by Mark Price (https://world.episerver.com/System/Users-and-profiles/Community-Profile-Card/?userid=9ec4e873-a200-e611-9afb-0050568d2da8).

using System.Linq;
using System.Web.Mvc;
using LOC.Cache.Models.ViewModels;
using EPiServer.PlugIn;
using System.Collections;
using EPiServer.Core;
using EPiServer.Framework.Cache;

namespace LOC.Cache.Controllers
{
	[Authorize(Roles = "CmsAdmins")]
	[GuiPlugIn(Area = PlugInArea.AdminMenu, Url = "~/localobjectcache", DisplayName = "Clear Local Object Cache")]
	public class LocalObjectCacheController : Controller
	{
		private readonly ISynchronizedObjectInstanceCache cache;

		public LocalObjectCacheController(ISynchronizedObjectInstanceCache cache)
		{
			this.cache = cache;
		}

		public ActionResult Index(string FilteredBy)
		{
			var viewmodel = new LocalObjectCacheViewModel();

			var cachedEntries = HttpContext.Cache.Cast<DictionaryEntry>();

			switch (FilteredBy)
			{
				case "pages":
					viewmodel.CachedItems = cachedEntries.Where(item => item.Value is PageData);
					break;
				case "content":
					viewmodel.CachedItems = cachedEntries.Where(item => item.Value is IContent);
					break;
				default:
					viewmodel.CachedItems = cachedEntries;
					break;
			}

			viewmodel.FilteredBy = FilteredBy;

			viewmodel.Choices = new[]
			{
				new SelectListItem { Text = "All Cached Objects", Value = "all" },
				new SelectListItem { Text = "Any Content", Value = "content" },
				new SelectListItem { Text = "Pages Only", Value = "pages" }
			};

			return View("~/Features/LocalObjectCache/LocalObjectCache.cshtml", viewmodel);
		}

		[HttpParamAction]
		public ActionResult RemoveLocalCache(string[] cacheKey, LocalObjectCacheViewModel model)
		{
			if (cacheKey != null)
			{
				foreach (string key in cacheKey)
				{
					cache.RemoveLocal(key);
				}
			}
			return RedirectToAction("Index");
		}

		[HttpParamAction]
		public ActionResult RemoveLocalRemoteCache(string[] cacheKey)
		{
			if (cacheKey != null)
			{
				foreach(string key in cacheKey)
				{
					cache.RemoveLocal(key);
					cache.RemoveRemote(key);
				}
			}
			return RedirectToAction("Index");
		}
	}
}

The controller has a few key areas that we should go over.

[Authorize(Roles = "CmsAdmins")]
[GuiPlugIn(Area = PlugInArea.AdminMenu, Url = "~/localobjectcache", DisplayName = "Clear Local Object Cache")]
public class LocalObjectCacheController : Controller

The Authorize decorator sets the permissions for who can use this plugin. This should be set to an administrators role, since it will be on the Admin UI.

The GuiPlugin is part of the EPiServer.Plugin namespace. This is where you define where the plugin will be displayed, the route URL it will use when clicked, and the text to be displayed.

When you build the project, Episerver will look at the decorators, and add a link to the Admin Tools menu.

Next is the Index ActionResult function.

public ActionResult Index(string FilteredBy)
{
	var viewmodel = new LocalObjectCacheViewModel();

	var cachedEntries = HttpContext.Cache.Cast<DictionaryEntry>();

	switch (FilteredBy)
	{
		case "pages":
			viewmodel.CachedItems = cachedEntries.Where(item => item.Value is PageData);
			break;
		case "content":
			viewmodel.CachedItems = cachedEntries.Where(item => item.Value is IContent);
			break;
		default:
			viewmodel.CachedItems = cachedEntries;
			break;
	}

	viewmodel.FilteredBy = FilteredBy;

	viewmodel.Choices = new[]
	{
		new SelectListItem { Text = "All Cached Objects", Value = "all" },
		new SelectListItem { Text = "Any Content", Value = "content" },
		new SelectListItem { Text = "Pages Only", Value = "pages" }
	};

	return View("~/Features/LocalObjectCache/LocalObjectCache.cshtml", viewmodel);
}

This is the function that is called when you click the link in the Tools menu.

It first creates a new instance of the LocalObjectCacheViewModel, gets the list of cached items, filters the list of a filter is passed to it if appropriate, and sets up the filter options in the Choices select list IEnumerable. Then returning the view to be rendered.

And then we come to the RemoveLocalCache and RemofeLocalRemoteCache functions. These functions take an array of the cache keys from the selected items, and loops over the array to remove each key from the cache using the ISynchronizedObjectInstanceCache.RemoveLocal or ISynchronizedObjectInstanceCache.RemoveRemote functions.

[HttpParamAction]
public ActionResult RemoveLocalCache(string[] cacheKey, LocalObjectCacheViewModel model)
{
	if (cacheKey != null)
	{
		foreach (string key in cacheKey)
		{
			cache.RemoveLocal(key);
		}
	}
	return RedirectToAction("Index");
}

[HttpParamAction]
public ActionResult RemoveLocalRemoteCache(string[] cacheKey)
{
	if (cacheKey != null)
	{
		foreach(string key in cacheKey)
		{
			cache.RemoveLocal(key);
			cache.RemoveRemote(key);
		}
	}
	return RedirectToAction("Index");
}

There is also a function to remove the Local and Remote cache items, although as I mentioned above, this is not necessarily the best way to clear the remote cache, although it should work. Please be aware that this option will generate a lot of traffic between the servers that could negatively affect the site performance. Use this option with caution.

I’m using a foreach loop to target specific keys, which is handy, but is also the reason why the RemoveLocalRemoteCache acts on both the local and remote servers. If you were to clear the local cache first, you wouldn’t have the cache keys to send to the remote server.

Another element that I need to mention is the [HttpParamAction] decorator. One of the challenges that I faced here was that I wanted to have two separate submit buttons, that submitted the same data to different ActionResult functions in the controller.

A quick check on Google and StackOverflow showed several possible answers, but I thought the solution by Andrey Shchekin was the best one. (This is an old blog, but it works great in this instance)

To make this functionality work, we need to add a new class to work with the HttpParamAction attribute.

using System;
using System.Reflection;
using System.Web.Mvc;

namespace LOC.Cache
{
	public class HttpParamActionAttribute : ActionNameSelectorAttribute
	{
		public override bool IsValidName(ControllerContext controllerContext, string actionName, MethodInfo methodInfo)
		{
			if (actionName.Equals(methodInfo.Name, StringComparison.InvariantCultureIgnoreCase))
				return true;

			if (!actionName.Equals("Action", StringComparison.InvariantCultureIgnoreCase))
				return false;

			var request = controllerContext.RequestContext.HttpContext.Request;
			return request[methodInfo.Name] != null;
		}
	}
}

Here, we are looking for a controller action name of “Action”. If that is not found, it passes everything through as it was. If it is found, then it submits to the ActionResult function with the same name as the button, decorated with the [HttpParamAction] decorator. Slick!

 

The View

Finally, the RAZOR view. This is based on the plug-in view template that was introduced in the Advanced Development course, to ensure that the page looks like it belongs in the Episerver Admin UI, and outputs that current local cached item keys and their type. If a filter is applied, the Name, ID, and Published date will be displayed.

This is also where we set the actionName value for the Html.BeginForm to the generic “Action” value, to get the correct function using the HttpParamAction class.

@using EPiServer.Framework.Web.Resources
@using System.Collections
@using EPiServer.Core

@model LOC.Cache.Models.ViewModels.LocalObjectCacheViewModel

@{
	Layout = null;
}

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" lang="en" xml:lang="en">
<head>
	<title>Local Object Cache</title>
	<meta http-equiv="X-UA-Compatible" content="IE=Edge" />

	<!-- Shell -->
	@Html.Raw(ClientResources.RenderResources("ShellCore"))

	<!-- Light Theme -->
	@Html.Raw(ClientResources.RenderResources("ShellCoreLightTheme"))

	<link href="~/App_Themes/Default/Styles/system.css" type="text/css" rel="stylesheet">
	<link href="~/App_Themes/Default/Styles/ToolButton.css" type="text/css" rel="stylesheet">

	<style type="text/css">
		.table-column-width {
			width: 30%;
		}

		.stripe tbody tr:nth-child(even) {
			background-color: #f0f2f2;
		}
	</style>
</head>
<body id="body">
	@Html.Raw(Html.ShellInitializationScript())

	<div class="epi-contentContainer epi-padding">
		<div class="epi-contentArea">
			<h1 class="EP-Prefix">
				Local Object Cache
			</h1>
			<p class="EP-systemInfo">This tool shows all of the current local object cache, and allows the deletion of one or more cached items.</p>
		</div>

		<div class="epi-contentArea epi-formArea">
			@using (Html.BeginForm("Index", "LocalObjectCache", FormMethod.Post))
			{
				<table class="table">
					<tr>
						<td>Filter By</td>
						<td>@Html.DropDownListFor(m => m.FilteredBy, Model.Choices)</td>
						<td>
							<span class="epi-cmsButton">
								<input class="epi-cmsButton-text epi-cmsButton-tools epi-cmsButton-Refresh" type="submit" name="filter" id="filter" value="Filter" onmouseover="EPi.ToolButton.MouseDownHandler(this)" onmouseout="EPi.ToolButton.ResetMouseDownHandler(this)" />
							</span>
						</td>
					</tr>
				</table>

			}

			@using (Html.BeginForm("Action", "LocalObjectCache", FormMethod.Post))
			{

				<div class="epi-buttonDefault">
					<span class="epi-cmsButton">
						<input class="epi-cmsButton-text epi-cmsButton-tools epi-cmsButton-Delete" type="submit" name="RemoveLocalCache" id="RemoveLocalCache" value="Remove Local Cache Items" onmouseover="EPi.ToolButton.MouseDownHandler(this)" onmouseout="EPi.ToolButton.ResetMouseDownHandler(this)" />
					</span>
					<span class="epi-cmsButton">
						<input class="epi-cmsButton-text epi-cmsButton-tools epi-cmsButton-Delete" type="submit" name="removeLocalRemoteCache" id="removeLocalRemoteCache" value="Remove Local and Remote Cache Items" onmouseover="EPi.ToolButton.MouseDownHandler(this)" onmouseout="EPi.ToolButton.ResetMouseDownHandler(this)" />
					</span>
				</div>

				<table class="table table-condensed table-bordered table-condensed stripe">
					<thead>
						<tr>
							<th><input type="checkbox" id="clearAll" name="clearAll" onClick="toggle(this)" value="true" /></th>
							<th class="table-column-width">Key</th>
							<th class="table-column-width">Type</th>
							<th class="table-column-width">@(string.IsNullOrWhiteSpace(Model.FilteredBy) ? "Value" : "Name (ID) Published")</th>
						</tr>
					</thead>
					<tbody>
						@foreach (DictionaryEntry item in Model.CachedItems)
						{
							<tr>
								<td class="center"><input type="checkbox" id="@item.Key" name="cacheKey" value="@item.Key" /></td>
								<td>@item.Key</td>
								<td>@item.Value.GetType()</td>
								<td>
									@if (item.Value is IContent)
									{
										@((item.Value as IContent).Name)
										<span class="badge badge-warning">@((item.Value as IContent).ContentLink.ID)</span>
									}
									@if (item.Value is PageData)
									{
										@((item.Value as PageData).StartPublish)
									}
								</td>
							</tr>
						}
					</tbody>
				</table>

				<div class="epi-buttonDefault">
					<span class="epi-cmsButton">
						<input class="epi-cmsButton-text epi-cmsButton-tools epi-cmsButton-Delete" type="submit" name="RemoveLocalCache" id="RemoveLocalCacheBottom" value="Remove Local Cache Items" onmouseover="EPi.ToolButton.MouseDownHandler(this)" onmouseout="EPi.ToolButton.ResetMouseDownHandler(this)" />
					</span>
					<span class="epi-cmsButton">
						<input class="epi-cmsButton-text epi-cmsButton-tools epi-cmsButton-Delete" type="submit" name="removeLocalRemoteCache" id="removeLocalRemoteCacheBottom" value="Remove Local and Remote Cache Items" onmouseover="EPi.ToolButton.MouseDownHandler(this)" onmouseout="EPi.ToolButton.ResetMouseDownHandler(this)" />
					</span>
				</div>
			}
		</div>
	</div>

	<script language="JavaScript">
		function toggle(source) {
			checkboxes = document.getElementsByName('cacheKey');
			for (var i = 0, n = checkboxes.length; i < n; i++) {
				checkboxes[i].checked = source.checked;
			}
		}
	</script>
</body>
</html>

 

Alright, it’s time to build and run the site. Log in as an administrator and navigate to the Admin UI. You should now see an option in the Admin -> Tools section labeled "Clear Local Object Cache".

If you want to remove a specific object, select the checkbox next to the key, and click the “Remove Local Cache Items” button. The RemoveLocalCache or RemoveLocalRemoteCache ActionResult functions in the LocalObjectCacheController will loop over all the selected cache keys and remove them.

Since this list can be very long, there is also a simple JavaScript function to check or uncheck all the checkboxes.

One other thing to note is that for this plug-in I am grouping all the code files together into a Features folder for easy maintenance and convenience. You don’t have to do that. These files can be organized into the standard MVC structure (Business, Controllers, Models, Views) if you prefer. If you do use the Features folder, then it would be a good idea to update the _ViewStart.cshtml and web.config files in the Features folder with the ones from your Views folder.

That’s it. There are probably several ways to accomplish this same functionality, and I would be excited to see something that uses the CacheEvictionPolicy and master keys for clearing cache on a remote server. Hopefully someone will find this useful. I know that I did.

All of the code is available on GitHub

https://github.com/jaytem/Episerver-LocalObjectCache

Feb 28, 2019

valdis
(By valdis, 2/28/2019 7:22:29 PM)

it's been a while since I'm thinking about re-activated old Geta's plugin (CacheManager) and pull it into DeveloperTools. what do you think of this? maybe it's worth to unite and merge this code into dev tools? I see it would add value to the package..

Joe Mayberry
(By Joe Mayberry, 2/28/2019 7:51:20 PM)

Hi Valdis, I think that would be great. 

Please login to comment.