Skip to content

Commit

Permalink
Merge pull request #716 from Orckestra/donut-caching
Browse files Browse the repository at this point in the history
Donut caching + an ability to render pages without ASP.NET Forms' control tree
  • Loading branch information
napernik committed Jan 14, 2020
2 parents 1aee73d + db7a19a commit 39b610c
Show file tree
Hide file tree
Showing 34 changed files with 961 additions and 263 deletions.
41 changes: 41 additions & 0 deletions Composite/AspNet/Caching/DonutCacheEntry.cs
@@ -0,0 +1,41 @@
using System;
using System.Collections.Generic;
using System.Web;
using System.Web.Caching;
using System.Xml.Linq;

namespace Composite.AspNet.Caching
{
[Serializable]
internal class DonutCacheEntry
{
private XDocument _document;

public DonutCacheEntry()
{
}

public DonutCacheEntry(HttpContext context, XDocument document)
{
Document = new XDocument(document);

var headers = context.Response.Headers;

var headersCopy = new List<HeaderElement>(headers.Count);
foreach (var name in headers.AllKeys)
{
headersCopy.Add(new HeaderElement(name, headers[name]));
}

OutputHeaders = headersCopy;
}

public XDocument Document
{
get => new XDocument(_document);
set => _document = value;
}

public IReadOnlyCollection<HeaderElement> OutputHeaders { get; set; }
}
}
195 changes: 195 additions & 0 deletions Composite/AspNet/Caching/OutputCacheHelper.cs
@@ -0,0 +1,195 @@
using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Linq;
using System.Reflection;
using System.Runtime.Caching;
using System.Text;
using System.Web;
using System.Web.Caching;
using System.Web.Configuration;
using System.Web.Hosting;
using System.Web.UI;

namespace Composite.AspNet.Caching
{
internal static class OutputCacheHelper
{
private const string CacheProfileName = "C1Page";
private static readonly FieldInfo CacheabilityFieldInfo;

private static readonly Dictionary<string, OutputCacheProfile> _outputCacheProfiles;

static OutputCacheHelper()
{
CacheabilityFieldInfo = typeof(HttpCachePolicy).GetField("_cacheability", BindingFlags.Instance | BindingFlags.NonPublic);

var section = WebConfigurationManager.OpenWebConfiguration(HostingEnvironment.ApplicationVirtualPath)
.GetSection("system.web/caching/outputCacheSettings");

if (section is OutputCacheSettingsSection settings)
{
_outputCacheProfiles = settings.OutputCacheProfiles.OfType<OutputCacheProfile>()
.ToDictionary(_ => _.Name);
}
}

/// <summary>
/// Returns <value>true</value> and sets the cache key value for the current request if
/// ASP.NET full page caching is enabled.
/// </summary>
/// <param name="context"></param>
/// <param name="cacheKey"></param>
/// <returns></returns>
public static bool TryGetCacheKey(HttpContext context, out string cacheKey)
{
var cacheProfile = _outputCacheProfiles[CacheProfileName];

if (!cacheProfile.Enabled || cacheProfile.Duration <= 0
|| !(cacheProfile.Location == (OutputCacheLocation) (-1) /* Unspecified */
|| cacheProfile.Location == OutputCacheLocation.Any
|| cacheProfile.Location == OutputCacheLocation.Server
|| cacheProfile.Location == OutputCacheLocation.ServerAndClient))
{
cacheKey = null;
return false;
}

var request = context.Request;

var sb = new StringBuilder(1 + request.Path.Length + (request.PathInfo ?? "").Length );

sb.Append(request.HttpMethod[0]).Append(request.Path).Append(request.PathInfo);

if (cacheProfile.VaryByCustom != null)
{
string custom = context.ApplicationInstance.GetVaryByCustomString(context, cacheProfile.VaryByCustom);
sb.Append("c").Append(custom);
}

if (!string.IsNullOrEmpty(cacheProfile.VaryByParam))
{
var filter = GetVaryByFilter(cacheProfile.VaryByParam);

AppendParameters(sb, "Q", request.QueryString, filter);

if (request.HttpMethod == "POST")
{
AppendParameters(sb, "F", request.Form, filter);
}
}

if (!string.IsNullOrEmpty(cacheProfile.VaryByHeader))
{
var filter = GetVaryByFilter(cacheProfile.VaryByHeader);

AppendParameters(sb, "H", request.Headers, filter);
}

cacheKey = sb.ToString();
return true;
}

private static Func<string, bool> GetVaryByFilter(string varyBy)
{
if (varyBy == "*")
{
return parameter => true;
}

var list = varyBy.Split(';');
return parameter => list.Contains(parameter);
}


private static void AppendParameters(StringBuilder sb, string cacheKeyDelimiter, NameValueCollection collection, Func<string, bool> filter)
{
foreach (string key in collection.OfType<string>().Where(filter))
{
sb.Append(cacheKeyDelimiter).Append(key).Append("=").Append(collection[key]);
}
}


public static DonutCacheEntry GetFromCache(HttpContext context, string cacheKey)
{
var provider = GetCacheProvider(context);

if (provider == null)
{
return MemoryCache.Default.Get(cacheKey) as DonutCacheEntry;
}

return provider.Get(cacheKey) as DonutCacheEntry;
}


public static void AddToCache(HttpContext context, string cacheKey, DonutCacheEntry entry)
{
var provider = GetCacheProvider(context);

if (provider == null)
{
MemoryCache.Default.Add(cacheKey, entry, new CacheItemPolicy
{
SlidingExpiration = TimeSpan.FromSeconds(60)
});
return;
}

provider.Add(cacheKey, entry, DateTime.UtcNow.AddSeconds(60));
}


static OutputCacheProvider GetCacheProvider(HttpContext context)
{
var cacheName = context.ApplicationInstance.GetOutputCacheProviderName(context);

return cacheName != "AspNetInternalProvider" ? OutputCache.Providers?[cacheName] : null;
}


public static bool ResponseCacheable(HttpContext context)
{
if (context.Response.StatusCode != 200)
{
return false;
}

var cacheability = GetPageCacheability(context);

return cacheability > HttpCacheability.NoCache;
}


private static HttpCacheability GetPageCacheability(HttpContext context)
=> (HttpCacheability)CacheabilityFieldInfo.GetValue(context.Response.Cache);



public static void InitializeFullPageCaching(HttpContext context)
{
using (var page = new CacheableEmptyPage())
{
page.ProcessRequest(context);
}
}


private class CacheableEmptyPage : Page
{
protected override void FrameworkInitialize()
{
base.FrameworkInitialize();

// That's an equivalent of having <%@ OutputCache CacheProfile="C1Page" %>
// on an *.aspx page

InitOutputCache(new OutputCacheParameters
{
CacheProfile = CacheProfileName
});
}
}
}
}
159 changes: 159 additions & 0 deletions Composite/AspNet/CmsPageHttpHandler.cs
@@ -0,0 +1,159 @@
using System.Web;
using System.Xml.Linq;
using Composite.AspNet.Caching;
using Composite.Core.Configuration;
using Composite.Core.Instrumentation;
using Composite.Core.PageTemplates;
using Composite.Core.WebClient.Renderings;
using Composite.Core.WebClient.Renderings.Page;
using Composite.Core.Xml;

namespace Composite.AspNet
{
/// <summary>
/// Renders page templates without building a Web Form's control tree.
/// Contains a custom implementation of "donut caching".
/// </summary>
internal class CmsPageHttpHandler: IHttpHandler
{
public void ProcessRequest(HttpContext context)
{
OutputCacheHelper.InitializeFullPageCaching(context);

using (var renderingContext = RenderingContext.InitializeFromHttpContext())
{
bool cachingEnabled = false;
string cacheKey = null;
DonutCacheEntry cacheEntry = null;

bool consoleUserLoggedIn = Composite.C1Console.Security.UserValidationFacade.IsLoggedIn();

// "Donut caching" is enabled for logged in users, only if profiling is enabled as well.
if (!renderingContext.CachingDisabled
&& (!consoleUserLoggedIn || renderingContext.ProfilingEnabled))
{
cachingEnabled = OutputCacheHelper.TryGetCacheKey(context, out cacheKey);
using (Profiler.Measure("Cache lookup"))
{
cacheEntry = OutputCacheHelper.GetFromCache(context, cacheKey);
}
}

XDocument document;
var functionContext = PageRenderer.GetPageRenderFunctionContextContainer();

bool allFunctionsExecuted = false;
bool preventResponseCaching = false;

if (cacheEntry != null)
{
document = cacheEntry.Document;
foreach (var header in cacheEntry.OutputHeaders)
{
context.Response.Headers[header.Name] = header.Value;
}

// Making sure this response will not go to the output cache
preventResponseCaching = true;
}
else
{
if (renderingContext.RunResponseHandlers())
{
return;
}

var renderer = PageTemplateFacade.BuildPageRenderer(renderingContext.Page.TemplateId);

var slimRenderer = (ISlimPageRenderer) renderer;

using (Profiler.Measure($"{nameof(ISlimPageRenderer)}.Render"))
{
document = slimRenderer.Render(renderingContext.PageContentToRender, functionContext);
}

allFunctionsExecuted = PageRenderer.ExecuteCacheableFunctions(document.Root, functionContext);

if (cachingEnabled && !allFunctionsExecuted && OutputCacheHelper.ResponseCacheable(context))
{
preventResponseCaching = true;

if (!functionContext.ExceptionsSuppressed)
{
using (Profiler.Measure("Adding to cache"))
{
OutputCacheHelper.AddToCache(context, cacheKey, new DonutCacheEntry(context, document));
}
}
}
}

if (!allFunctionsExecuted)
{
using (Profiler.Measure("Executing embedded functions"))
{
PageRenderer.ExecuteEmbeddedFunctions(document.Root, functionContext);
}
}

using (Profiler.Measure("Resolving page fields"))
{
PageRenderer.ResolvePageFields(document, renderingContext.Page);
}

string xhtml;
if (document.Root.Name == RenderingElementNames.Html)
{
var xhtmlDocument = new XhtmlDocument(document);

PageRenderer.ProcessXhtmlDocument(xhtmlDocument, renderingContext.Page);
PageRenderer.ProcessDocumentHead(xhtmlDocument);

xhtml = xhtmlDocument.ToString();
}
else
{
xhtml = document.ToString();
}

if (renderingContext.PreRenderRedirectCheck())
{
return;
}

xhtml = renderingContext.ConvertInternalLinks(xhtml);

if (GlobalSettingsFacade.PrettifyPublicMarkup)
{
xhtml = renderingContext.FormatXhtml(xhtml);
}

var response = context.Response;

if (preventResponseCaching)
{
context.Response.Cache.SetNoServerCaching();
}

// Disabling ASP.NET cache if there's a logged-in user
if (consoleUserLoggedIn)
{
context.Response.Cache.SetCacheability(HttpCacheability.NoCache);
}

// Inserting performance profiling information
if (renderingContext.ProfilingEnabled)
{
xhtml = renderingContext.BuildProfilerReport();

response.ContentType = "text/xml";
}

response.Write(xhtml);
}
}


public bool IsReusable => true;
}
}

0 comments on commit 39b610c

Please sign in to comment.