Skip to content

Commit

Permalink
Merge pull request #128 from cloudscribe/feature/127
Browse files Browse the repository at this point in the history
#127 caching nav view component
  • Loading branch information
JimKerslake authored May 17, 2024
2 parents f619f9c + e146c64 commit 01a086c
Show file tree
Hide file tree
Showing 9 changed files with 355 additions and 2 deletions.
8 changes: 8 additions & 0 deletions src/NavigationDemo.Web/Views/Shared/_Layout.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
</environment>
</head>
<body>

<div class="navbar navbar-inverse navbar-fixed-top">
<div class="container">
<div class="navbar-header">
Expand All @@ -32,6 +33,13 @@
<a asp-controller="Home" asp-action="Index" asp-area="" class="navbar-brand">NavigationDemo.Web</a>
</div>
<div class="collapse navbar-collapse">
<p>caching version</p>
@await Component.InvokeAsync("CachingNavigation", new { viewName = "Bootstrap5TopNavWithDropdowns_Caching",
filterName = NamedNavigationFilters.TopNav,
startingNodeKey = "",
expirationSeconds = 120,
testMode = true })
<p>non cached version</p>
@await Component.InvokeAsync("Navigation", new { viewName = "Bootstrap5TopNavWithDropdowns", filterName = NamedNavigationFilters.TopNav, startingNodeKey= "" })
@await Html.PartialAsync("_LoginPartial")
</div>
Expand Down
49 changes: 49 additions & 0 deletions src/cloudscribe.Web.Navigation/Caching/DOMTreeCache.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using System;
using System.Threading.Tasks;

namespace cloudscribe.Web.Navigation.Caching
{
public class DOMTreeCache : IDOMTreeCache
{
private readonly IDistributedCache _cache;
private readonly ILogger<DistributedTreeCache> _logger;

public DOMTreeCache(
IDistributedCache cache,
ILogger<DistributedTreeCache> logger)
{
_cache = cache;
_logger = logger;
}

public async Task<string> GetDOMTree(string cacheKey)
{
var dom = await _cache.GetAsync<string>(cacheKey);
return dom;
}

public async Task StoreDOMTree(string cacheKey, string tree, int expirationSeconds)
{
try
{
var options = new DistributedCacheEntryOptions();
options.SetSlidingExpiration(TimeSpan.FromSeconds(expirationSeconds));
await _cache.SetAsync<string>(cacheKey, tree, options);
_logger.LogDebug($"Added navigation DOM tree to distributed cache: {cacheKey}");
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to add navigation DOM tree to distributed cache: {cacheKey}");
}
}

public async Task ClearDOMTreeCache(string cacheKey)
{
await _cache.RemoveAsync(cacheKey);
// ((MemoryCache)_cache).Compact(1);
}
}
}
11 changes: 11 additions & 0 deletions src/cloudscribe.Web.Navigation/Caching/IDOMTreeCache.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using System.Threading.Tasks;

namespace cloudscribe.Web.Navigation.Caching
{
public interface IDOMTreeCache
{
Task ClearDOMTreeCache(string cacheKey);
Task<string> GetDOMTree(string cacheKey);
Task StoreDOMTree(string cacheKey, string tree, int expirationSeconds);
}
}
70 changes: 70 additions & 0 deletions src/cloudscribe.Web.Navigation/Caching/NavViewRenderer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewEngines;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.Extensions.Logging;
using System;
using System.IO;
using System.Threading.Tasks;

namespace cloudscribe.Web.Navigation.Caching
{
/// <summary>
/// produce an html string representing the site nav - for use incaching - using
/// Razor templates and models
///
/// JimK - this is based on the main CS version, but simplified -
/// passing in to it the actionContext etc etc. from the consuming method
/// rather than relying on DI services in here
/// seems to prevent a proliferation of "object disposed" errors.
/// </summary>
public class NavViewRenderer
{
public NavViewRenderer(ILogger<NavViewRenderer> logger)
{
_logger = logger;
}

private readonly ILogger<NavViewRenderer> _logger;

public async Task<string> RenderViewAsStringWithActionContext<TModel>(string viewName,
TModel model,
ViewEngineResult viewResult,
ActionContext actionContext,
TempDataDictionary tempData)
{
var viewData = new ViewDataDictionary<TModel>(
metadataProvider: new EmptyModelMetadataProvider(),
modelState: new ModelStateDictionary())
{
Model = model
};


try
{
using (StringWriter output = new StringWriter())
{
ViewContext viewContext = new ViewContext(
actionContext,
viewResult.View,
viewData,
tempData,
output,
new HtmlHelperOptions()
);

await viewResult.View.RenderAsync(viewContext);

return output.GetStringBuilder().ToString();
}
}
catch (Exception ex)
{
_logger.LogError(ex, "NavViewRenderer - error in view rendering for view " + viewName);
throw ex;
}
}
}
}
173 changes: 173 additions & 0 deletions src/cloudscribe.Web.Navigation/CachingNavigationViewComponent.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
// Copyright (c) Source Tree Solutions, LLC. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using cloudscribe.Web.Navigation.Caching;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.AspNetCore.Mvc.Razor;
using Microsoft.AspNetCore.Mvc.Routing;
using Microsoft.AspNetCore.Mvc.ViewEngines;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;

namespace cloudscribe.Web.Navigation
{
public class CachingNavigationViewComponent : ViewComponent
{
public CachingNavigationViewComponent(
NavigationTreeBuilderService siteMapTreeBuilder,
IEnumerable<INavigationNodePermissionResolver> permissionResolvers,
IEnumerable<IFindCurrentNode> nodeFinders,
IUrlHelperFactory urlHelperFactory,
IActionContextAccessor actionContextAccesor,
INodeUrlPrefixProvider prefixProvider,
ILogger<NavigationViewComponent> logger,
IDOMTreeCache DomCache,
IRazorViewEngine viewEngine,
NavViewRenderer viewRenderer,
ITempDataProvider tempDataProvider)
{
_builder = siteMapTreeBuilder;
_permissionResolvers = permissionResolvers;
_nodeFinders = nodeFinders;
_urlHelperFactory = urlHelperFactory;
_actionContextAccesor = actionContextAccesor;
_prefixProvider = prefixProvider;
_log = logger;
_domCache = DomCache;
_viewEngine = viewEngine;
_viewRenderer = viewRenderer;
_tempDataProvider = tempDataProvider;
}

private ILogger _log;
private readonly IDOMTreeCache _domCache;
private readonly IRazorViewEngine _viewEngine;
private readonly NavViewRenderer _viewRenderer;
private readonly ITempDataProvider _tempDataProvider;
private NavigationTreeBuilderService _builder;
private IEnumerable<INavigationNodePermissionResolver> _permissionResolvers;
private IEnumerable<IFindCurrentNode> _nodeFinders;
private IUrlHelperFactory _urlHelperFactory;
private IActionContextAccessor _actionContextAccesor;
private INodeUrlPrefixProvider _prefixProvider;


// intention here is not to re-compute the whole navigation DOM tree repeatedly
// when you get a large number of unauthenticated page requests e.g. from a PWA
// The main problem is clearing this cache again on new page creation etc
// since .Net memory cache has no method for enumerating its keys -
// you need to know the specific key name.
// In a simplecontent system I'd probably need to use the IHandlePageCreated (etc)
// hooks to clear a navcache of known name.

public async Task<IViewComponentResult> InvokeAsync(string viewName,
string filterName,
string startingNodeKey,
int expirationSeconds = 60,
bool testMode = false)
{
NavigationViewModel model = null;

string cacheKey = $"{viewName}_{filterName}_{startingNodeKey}";

// authenticated users - always do what the stadard version of this component does:
// build the tree afresh
if (User.Identity.IsAuthenticated)
{
// maybe kill cache key here under certain circumstances?
// if(User.IsInRole("Administrators") || User.IsInRole("Content Administrators"))
// {
// // await _domCache.ClearDOMTreeCache(cacheKey);
// }

model = await CreateNavigationTree(filterName, startingNodeKey);
return View(viewName, model);
}
else
{
var result = await _domCache.GetDOMTree(cacheKey); // use the viewname as the key in the cache

if (string.IsNullOrEmpty(result))
{
model = await CreateNavigationTree(filterName, startingNodeKey);

ViewEngineResult viewResult = null;
var actionContext = _actionContextAccesor.ActionContext;
var tempData = new TempDataDictionary(actionContext.HttpContext, _tempDataProvider);

string fullViewName = $"Components/CachingNavigation/{viewName}";
try
{
// beware the 'IFeatureCollection has been disposed' System.ObjectDisposedException error here
viewResult = _viewEngine.FindView(actionContext, fullViewName, true);
}
catch (Exception ex)
{
_log.LogError(ex, $"CachingNavigationViewComponent: Failed to search for View {fullViewName}");
}

if (viewResult == null || !viewResult.Success || viewResult.View == null)
{
_log.LogError($"CachingNavigationViewComponent: Failed to find a matching view {fullViewName}");
}
else
{
try
{
result = await _viewRenderer.RenderViewAsStringWithActionContext(fullViewName,
model,
viewResult,
actionContext,
tempData
);

if (!string.IsNullOrEmpty(result))
{
if(testMode)
{
await _domCache.StoreDOMTree(cacheKey, $"<h2>Cached copy from {cacheKey}</h2> {result}", expirationSeconds);
}
else
{
await _domCache.StoreDOMTree(cacheKey, result, expirationSeconds);
}
}

_log.LogInformation($"CachingNavigationViewComponent: Rendered view successfully for {fullViewName}");
}
catch (Exception ex)
{
_log.LogError(ex, $"CachingNavigationViewComponent: Failed to render view for {fullViewName}");
throw (ex);
}
}
}
return View("CachedNav", result);
}
}

/// <summary>
/// The expensive thing...
/// </summary>
private async Task<NavigationViewModel> CreateNavigationTree(string filterName, string startingNodeKey)
{
var rootNode = await _builder.GetTree();
var urlHelper = _urlHelperFactory.GetUrlHelper(_actionContextAccesor.ActionContext);
NavigationViewModel model = new NavigationViewModel(
startingNodeKey,
filterName,
Request.HttpContext,
urlHelper,
rootNode,
_permissionResolvers,
_nodeFinders,
_prefixProvider.GetPrefix(),
_log);
return model;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@ public static IServiceCollection AddCloudscribeNavigation(
services.AddDistributedMemoryCache();

services.TryAddScoped<ITreeCache, DistributedTreeCache>();

services.TryAddScoped<IDOMTreeCache, DOMTreeCache>();
services.AddScoped<NavViewRenderer>();


services.TryAddScoped<INavigationTreeBuilder, XmlNavigationTreeBuilder>();
services.TryAddScoped<NavigationTreeBuilderService, NavigationTreeBuilderService>();
services.TryAddScoped<INodeUrlPrefixProvider, DefaultNodeUrlPrefixProvider>();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
@using cloudscribe.Web.Navigation
@using System.Text
@model NavigationViewModel
@using Microsoft.Extensions.Localization
@inject IStringLocalizer<cloudscribe.Web.Navigation.MenuResources> sr
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@addTagHelper *, cloudscribe.Web.Navigation

@{
Layout = null;
}

<ul class="navbar-nav me-auto" role="menubar" aria-label="@sr["Top menu"]">
@if (await Model.ShouldAllowView(Model.RootNode))
{
<li role="none" cwn-data-attributes="@Model.RootNode.Value.DataAttributes" class='@Model.GetClass(Model.RootNode.Value, "nav-item")'><a role="menuitem" class="nav-link" href="@Url.Content(Model.AdjustUrl(Model.RootNode))">@Html.Raw(Model.GetIcon(Model.RootNode.Value))@sr[Model.AdjustText(Model.RootNode)]</a></li>
}

@if (await Model.HasVisibleChildren(Model.RootNode))
{
@foreach (var node in Model.RootNode.Children)
{
if (!await Model.ShouldAllowView(node)) { continue; }
if (!await Model.HasVisibleChildren(node))
{
<li role="none" class='@Model.GetClass(node.Value, "nav-item")' cwn-data-attributes="@node.Value.DataAttributes"><a role="menuitem" class="nav-link" href="@Url.Content(Model.AdjustUrl(node))">@Html.Raw(Model.GetIcon(node.Value))@sr[Model.AdjustText(node)]</a></li>
}
else
{
<li role="none" class='@Model.GetClass(node.Value, "nav-item dropdown", "active", true)' cwn-data-attributes="@node.Value.DataAttributes">
<a role="menuitem" class="nav-link dropdown-toggle" id="[email protected]" aria-haspopup="true" aria-expanded="false" href="@Url.Content(Model.AdjustUrl(node))">@Html.Raw(Model.GetIcon(node.Value))@sr[Model.AdjustText(node)] </a>
@Model.UpdateTempNode(node) <partial name="Bootstrap5NavigationNodeChildDropdownPartial" model="@Model" />
</li>
}
}
}
</ul>
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
@model string
@Html.Raw(Model)
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

<PropertyGroup>
<Description>an ASP.NET Core viewcomponent for menus and breadcrumbs</Description>
<Version>6.0.3</Version>
<Version>6.0.4</Version>
<TargetFramework>net6.0</TargetFramework>
<Authors>Joe Audette</Authors>
<AddRazorSupportForMvc>true</AddRazorSupportForMvc>
Expand Down

0 comments on commit 01a086c

Please sign in to comment.