If you are building a Kentico 12 MVC application and you want to increase your page response performance, you might be looking for a solution that has been within arms reach on all Kentico Portal Engine sites - Output Caching โก.
In Portal Engine, output caching was simple as checking a box to enable output caching.
While it's not that simple in an MVC application, Kentico does provide clear solutions for enabling and configuring output cache for your site.
Let's quickly review what output caching is and then discuss a common scenario that complicates things - output caching and user context ๐.
Output Caching
Microsoft's documentation explains output caching through a simple real-world scenario:
Imagine, for example, that your ASP.NET MVC application displays a list of database records in a view named
Index
. Normally, each and every time that a user [loads a page that] invokes the Controller action that returns theIndex
view, the set of database records must be retrieved from the database by executing a database query.If, on the other hand, you take advantage of the output cache then you can avoid executing a database query every time any user invokes the same controller action. The view can be retrieved from the cache instead of being regenerated from the controller action. Caching enables you to avoid performing redundant work on the server.
So, output caching allows us to cache the rendered HTML output of our Controller actions. When the next request arrives for the same content, a little work is performed on the server to analyze the request, but no Controller actions are executed - the HTML is served from memory ๐.
Benefits
The benefits here are massive ๐ช!
Imagine we have a Controller Index()
action that is called when a user visits our home page:
public class HomeController : Controller
{
public ActionResult Index()
{
var topProducts = SKUInfoProvider.GetSKUs()...;
var topBlogPosts = BlogPostPageProvider.GetBlogPostPages()...;
var featuredSales = partnerService.GetSalesViaHTTPRequest("...");
var viewModel = new IndexViewModel(
topProducts,
topBlogPosts,
featuredSales);
return View(viewModel);
}
[ChildActionOnly]
public ActionResult UserProfileIcon()
{
// ...
}
}
This code is for explanation purposes only. I definitely don't recommend putting a bunch of data-access in our Controllers.
Instead, we should keep our Controller classes thin and declarative ๐. Check out the section titled "Thin Controllers" in my blog post Kentico 12: Design Patterns Part 3 - Tips and Tricks, Application Structure:
Kentico 12: Design Patterns Part 3 - Tips and Tricks, Application Structure
Sean G. Wright ใป Jun 1 '19 ใป 7 min read
#mvc #designpatterns #kentico #aspnet
Every time the home page is loaded, we have 2 calls to the database (for topProducts
and topBlogPosts
), 1 call to an external web service (for featuredSales
), and finally a call to the UserProfileIcon()
child action to get the user specific information.
You can read more about "child actions" on Phil Haack's blog post.
What are the problems here?
- โ That's a lot of work, and it will slow down the response time of a request for the home page.
- โ It won't scale well as the number of visitors to the site increases.
- โ It opens up opportunities for failures to load the page at all if there is and issue connecting to the database or the web service.
I've previously written about query caching patterns, and we could definitely use those here to cache the results of our database and web service calls in-memory:
Kentico 12: Design Patterns Part 12 - Database Query Caching Patterns
Sean G. Wright ใป Aug 26 '19 ใป 13 min read
In fact, we can get pretty far by performing query caching only.
However, if we want super-fast page load speeds for SEO, increased goal-conversions, and improved user-experience for users located far from where our application is hosted, we want to use output caching too.
There is no need to run the Controller code if we already have the resulting HTML, right ๐?
We can apply output caching to our Controller by adding an attribute to our Controller or action method:
// We can add an [OutputCache] attribute here
// to cache the output of all actions...
[OutputCache(Duration=60)]
public class HomeController : Controller
{
// or here to only cache the output of specific actions
[OutputCache(Duration=60)]
public ActionResult Index()
{
// ...
}
[ChildActionOnly]
public ActionResult UserProfileIcon()
{
// ...
}
}
Pretty simple ๐!
You can read more about how to configure output caching in Kentico's documentation for caching in MVC applications, or Microsoft's documentation on enabling output caching.
Output Caching seems great! Let's add those attributes, sit back, and profit!
Not so fast ๐คจ!
If our application allows for users to interact with the site, and those interactions change how the site looks and feels to each specific user, then we will run into a problem with output caching ๐ค.
Here's a simple, concrete scenario:
- Our site allows users to log in to view information not available to unauthenticated users.
- When a user is anonymous (has not yet logged in), the site content is not personalized for them.
- When the user logs in they see a small Profile icon in the top right of the screen with their name next it.
If we apply output caching to our home page (or any page on the site) and that page is first visited by an unauthenticated users, then the resulting HTML will be cached and served to all users.
This means, even if a user logs in, they won't see the profile icon in the top right and they won't see their name ๐.
If the first user to visit a page after the cache has expired is an authenticated user, then the resulting HTML will be cached and served to all users - except this HTML will include the profile icon and the name of the user that requested the page ๐ฃ!
So, while a simple application of output caching is great for performance, it's bad for personalization.
Fortunately, there are several ways to resolve this problem, each requiring a bit of coding on our part (you, know, the fun stuff ๐!)
Server-side Solutions
All of the solutions below occur server-side, by changing what HTML is cached, or how it is cached.
VaryByCustom
The [OutputCache]
attribute provides a VaryByParam
property that allows us to create a unique cache item for every variation of a "form parameter or query string parameter".
Since the information about whether or not an authenticated user is making the request for a page (and who that user might be) is not included in form or query string parameter, we can't use VaryByParam
, but there is another similar property that we can use called VaryByCustom
.
VaryByCustom
is a string value that is used to designate what kind of variation the output caching for a given Controller or action method should perform.
This string value is passed to a method in our Global.asax.cs
class called GetVaryByCustomString(HttpContext context, string arg)
.
Kentico's documentation gives an example of how we can use this method to create unique cache entries, personalized per request as defined by our business requirements ๐ง.
If two requests would result in the same returned value from this method, then the two requests can share the same cached content ๐ฎ.
A simple implementation of this method that demonstrates how two users would have their own cache entry for a page would look as follows:
public override string GetVaryByCustomString(
HttpContext context, string custom)
{
bool varyByUser = User.Identity.IsAuthenticated
&& string.Equals(custom, "user", StringComparison.OrdinalIgnoreCase);
if (varyByUser)
{
return User.Identity.Name;
}
return base.GetVaryByCustomString(context, custom);
}
In this example, since each request by an unauthenticated user will result in the same returned value, those requests will all share the same cache entry.
Also, since each authenticated user will result in a unique value being returned (the user's Name
), each user's request will create their own distinct cache entry.
Therefore, each authenticated user will see the page rendered with the profile icon in the top right and their username beside it ๐.
We'd take advantage of this implementation by updating our [OutputCache]
attribute as follows:
[OutputCache(Duration=60, VaryByCustom="user")]
public class HomeController : Controller
{
// or here to only cache the output of specific actions
[OutputCache(Duration=60)]
public ActionResult Index()
{
// ...
}
[ChildActionOnly]
public ActionResult UserProfileIcon()
{
// ...
}
}
VaryByCustom
definitely works, and can be tailored to any use case by making the GetVaryByCustomString
generate unique strings for all the variations we require.
That said, there are some downsides ๐:
- We create a cache entry per-user, so lot's of users = lots of cache entries = lots of memory usage.
- Users only get the caching benefits after their first visit of a page because they can't rely on cache entries from previous visitors.
- Determining the validity of an output cache entry and the cached state of a page can be tricky as our cache variations grow in complexity.
- We have to remember to assign the right values to
VaryByCustom
everywhere we use[OutputCache]
.
Donut Hole Caching
If we only want to output cache certain parts of the page and leave others un-cached (and dynamic), we can try Donut Hole Caching.
This pattern treats the page as a Donut ๐ฉ (gotta love these delicious analogies!)
We can implement this approach by using MVC Child Actions and applying our [OutputCache]
attribute along with a [ChildActionOnly]
attribute (to indicate the action shouldn't be used as a route-able action):
public class HomeController : Controller
{
public ActionResult Index()
{
// ...
return View(viewModel);
}
[OutputCache(Duration=60)]
[ChildActionOnly]
public ActionResult UserProfileIcon()
{
// ...
}
}
We can call a child action from our Razor layout or View by using Html.RenderAction()
:
<!-- Index.cshtml -->
<div>
<h1>@Model.Title</h1>
@{ Html.RenderAction(nameof(HomeController.UserProfileIcon)); }
</div>
<!-- UserProfileIcon.cshtml -->
@if (Model.IsAuthenticated)
{
<div class="user-icon">@Model.Username</div>
}
Here we are caching only the "hole" of the donut, which in our case is HTML produced by the UserProfileIcon()
method of our Controller.
So, Index()
will be called for every request, but UserProfileIcon()
will only be called, at most, every 60 seconds, based on our cache duration.
You can read the full details of an example on Tugberk Ugurlu's blog or DotNetTutorials.
This approach is helpful for many scenarios, but not for ours ๐, since we really want to cache everything but the hole (user context specific HTML, like the profile icon and user name).
Donut Caching
If we want to invert the way that Donut Hole Caching works, we end up with Donut Caching, where everything is cached but the hole that we punch through the cache.
MVC doesn't support this out of the box, but there is a popular library called MvcDonutCaching that allows us to add this functionality to our application in a similar pattern to using the [OutputCache]
attribute ๐ค.
Instead of using [OutputCache]
on a Controller or action method, we use [DonutOutputCaching]
:
public class HomeController : Controller
{
[DonutOutputCache(Duration=60)]
public ActionResult Index()
{
// ...
return View(viewModel);
}
[ChildActionOnly]
public ActionResult UserProfileIcon()
{
// ...
}
}
Then, in our Index
view, we use an overload of Html.RenderAction()
passing a value of true
to exclude that child action from output caching
<!-- Index.cshtml -->
<div>
<h1>@Model.Title</h1>
<!-- Notice how `true` is passed as the last param here -->
@{ Html.RenderAction(nameof(HomeController.UserProfileIcon), true); }
</div>
You can see an example of using MvcDonutCaching on Denis Huvelle's blog.
Although this library helps us solve our issue of wanting to exclude this user specific data from the output cache, it has some caveats:
- โ We have to remember to use the overload of
RenderAction()
that takes abool
value oftrue
as the last parameter, otherwise we end up with users seeing each other's cached data! - โ It can be hard to tell what will and won't be cached when looking at a Controller class file since it's the call to
Html.RenderAction()
that punches a "donut hole" in the output cache. - โ The response for a page request still requires some MVC Controller code execution and HTML rendering, so the time for the first HTML will always be a little delayed.
- โ Rendering will be slightly delayed even for unauthenticated users, who should be able to share the same cache.
Client-side Solutions
We've come up with some server-side solutions for output caching that give us the HTML result we want, but both VaryByCustom
and [DonutOutputCache]
result in missing some performance optimizations.
If we are using output caching, we are probably performance sensitive, so let's look at one last option that deals with all the above requirements and caveats ๐.
Output Caching + Client-side Rendering
My favorite approach is to keep the user context specific HTML out of the MVC rendering pipeline.
That's right - don't even deal with the requesting user on the server side.
Instead, we use full output caching for all MVC rendered views ๐ฒ!
Instead, we make a JavaScript client-side request to a Web API endpoint in our MVC application for any user specific data, and render the HTML client-side.
What benefits does this bring?
- โ The caching model is simple - full output caching for all MVC Controllers.
- โ We separate user context decision making from server-side markup rendering, thus making our MVC business logic simpler.
- โ No visitor, authenticated or unauthenticated (or even web crawlers) to our site has to wait for a page to render (after the first request) - the time to "first meaningful paint" is as short as possible.
- โ The site ends up being "progressive", giving all users a standard view and then enhancing it as the client-side processing executes.
- โ We can still perform data/query caching behind our Web API endpoints.
UserProfileIcon Example
So, let's take a look at what the MVC + client-side rendering solution looks like!
We start out with our HomeController
, leveraging [OutputCache]
, but without the UserProfileIcon()
action since that method will be moved to an API endpoint.
If you need help setting up Web API 2 in your Kentico 12 MVC code base, check out my blog post Kentico 12: Design Patterns Part 7 - Integrating Web API 2:
Kentico 12: Design Patterns Part 7 - Integrating Web API 2
Sean G. Wright ใป Jul 1 '19 ใป 12 min read
#aspnet #kentico #webapi #mvc
public class HomeController : Controller
{
[DonutOutputCache(Duration=60)]
public ActionResult Index()
{
// ...
return View(viewModel);
}
// No more UserProfileIcon()
}
HomeController
is now completely user context independent, so the same cached HTML can be served to all visitors!
Our Index.cshtml
is updated to make our client-side XHR request and perform the client-side rendering:
Using the
<script type="module">
and<script nomodule>
elements means both script blocks can be be in the page and each browser type (legacy and modern) will only execute the appropriate version ๐.
<!-- Index.cshtml -->
<div>
<h1>@Model.Title</h1>
<div class="" data-app="user-profile-icon"></div>
<!-- For Modern Browsers -->
<script type="module">
(async () => {
const response = await fetch('/api/home/user-profile-icon');
const result = await response.json();
const appElem = document
.querySelector('[data-app="user-profile-icon"]');
if (!result.isAuthenticated) {
return;
}
appElem.classList.Add('user-icon');
appElem.innerText = result.username;
}());
</script>
<!-- For Legacy Browsers ex) IE11 -->
<script nomodule>
(function () {
'use strict';
var request = new XMLHttpRequest();
request.onload = function(e) {
var result = JSON.parse(request.responseText);
var appElem = document
.querySelector('[data-app="user-profile-icon"]');
if (!result.isAuthenticated) {
return;
}
appElem.classList.Add('user-icon');
appElem.innerText = result.username;
}
request.overrideMimeType("application/json");
request.open("GET", '/api/home/user-profile-icon');
request.send();
}());
</script>
</div>
Now we create a Web API Controller, HomeApiController
, and add the UserProfileIcon
action method we removed from HomeController
:
[RoutePrefix("home")]
public class HomeApiController : ApiController
{
[Route("user-profile-icon")]
public HttpActionResult UserProfileIcon()
{
var response = new
{
isAuthenticated = User.Identity.IsAuthenticated,
username = User.Identity.Name
};
return Ok(response);
}
}
That's all the pieces we need!
Our Razor rendered HTML is suitable for every visitor as it excludes all user context, so that means we can use simple output caching and respond to all requests as fast as possible ๐.
Once the page loads in the browser, our JavaScript will make a request to the API endpoint, which will return the user context specific data (isAuthenticated
and username
).
The JavaScript then renders this user context into the <div>
acting as the hook point for our client-side rendering.
The more we branch out into rich client-side experiences, the more appealing this pattern gets ๐.
Caveats
So there are some issues with this approach that might cause you to use one of the others mentioned above.
By pushing user context specific rendering to the client, we push rendering to the client. If we need to support localization we'll need to figure out a way to either do translations server side and send them down the client, or do localization client-side ๐ค.
Many EMS features in Kentico 12 MVC depend on A/B testing and advanced personalization.
These features are meant to be handled server-side, so they won't simply translate to this client-side rendering approach ๐.
To leverage the EMS marketing features for personalization, we'll probably want to use VaryByCustom
combined with [DonutOuputCaching]
to keep the cache variations low but also personalized.
Also, if you don't feel confident working with JavaScript or doing client-side rendering of HTML, you might want to stick with the above server-side options...
However, I do recommend brushing up on your JavaScript skills as it's a great tool to put in your toolbox and opens up many new solutions to common problems ๐ค.
Summary
To improve the performance of our Kentico 12 MVC application and ensure scalibility as traffic increases, we want to leverage caching โก.
We can cache data within the application and also cache the HTML output of our MVC layer.
Out of the box, output caching doesn't handle user context specific HTML, so we need to customize the output caching process to ensure each user sees the correct data.
VaryByCustom
, Donut Hole Caching, and Donut Caching are all server-side options for cutomizing how output cache entries are created and which entries are served to each visitor ๐ง.
However, there are some caveats to the server-side approaches.
If we want a simple server-side MVC request pipeline that doesn't need to consider user context data, then moving the rendering of this user-specific HTML to the client-side lets us use out-of-the-box output caching and super fast page response times.
Client-side user context HTML rendering requires setting up Web API endpoints in our app to get the user-specific data, and then rendering the HTML in the browser using standard DOM APIs ๐.
This approach works well, but has some caveats of its own, like relying on JavaScript skills which some teams might not feel comfortable relying on.
It also isn't a scenario supported by the built-in personalization features in Kentico EMS, so developers will need to build custom solution integrations if these features are being used with the client-side rendering approach ๐ค.
I hope you found this post interesting. In the future I'd like to delve a bit more into patterns for effective integration of client-side solutions, with traditional server-rendered HTML applications, to get the best of both worlds!
Thanks for reading ๐!
If you are looking for additional Kentico content, checkout the Kentico tag here on DEV:
Or my Kentico blog series:
Top comments (0)