Skip to content

Commit 01a086c

Browse files
authored
Merge pull request #128 from cloudscribe/feature/127
#127 caching nav view component
2 parents f619f9c + e146c64 commit 01a086c

File tree

9 files changed

+355
-2
lines changed

9 files changed

+355
-2
lines changed

src/NavigationDemo.Web/Views/Shared/_Layout.cshtml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
</environment>
2121
</head>
2222
<body>
23+
2324
<div class="navbar navbar-inverse navbar-fixed-top">
2425
<div class="container">
2526
<div class="navbar-header">
@@ -32,6 +33,13 @@
3233
<a asp-controller="Home" asp-action="Index" asp-area="" class="navbar-brand">NavigationDemo.Web</a>
3334
</div>
3435
<div class="collapse navbar-collapse">
36+
<p>caching version</p>
37+
@await Component.InvokeAsync("CachingNavigation", new { viewName = "Bootstrap5TopNavWithDropdowns_Caching",
38+
filterName = NamedNavigationFilters.TopNav,
39+
startingNodeKey = "",
40+
expirationSeconds = 120,
41+
testMode = true })
42+
<p>non cached version</p>
3543
@await Component.InvokeAsync("Navigation", new { viewName = "Bootstrap5TopNavWithDropdowns", filterName = NamedNavigationFilters.TopNav, startingNodeKey= "" })
3644
@await Html.PartialAsync("_LoginPartial")
3745
</div>
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
using Microsoft.Extensions.Caching.Distributed;
2+
using Microsoft.Extensions.Caching.Memory;
3+
using Microsoft.Extensions.Logging;
4+
using System;
5+
using System.Threading.Tasks;
6+
7+
namespace cloudscribe.Web.Navigation.Caching
8+
{
9+
public class DOMTreeCache : IDOMTreeCache
10+
{
11+
private readonly IDistributedCache _cache;
12+
private readonly ILogger<DistributedTreeCache> _logger;
13+
14+
public DOMTreeCache(
15+
IDistributedCache cache,
16+
ILogger<DistributedTreeCache> logger)
17+
{
18+
_cache = cache;
19+
_logger = logger;
20+
}
21+
22+
public async Task<string> GetDOMTree(string cacheKey)
23+
{
24+
var dom = await _cache.GetAsync<string>(cacheKey);
25+
return dom;
26+
}
27+
28+
public async Task StoreDOMTree(string cacheKey, string tree, int expirationSeconds)
29+
{
30+
try
31+
{
32+
var options = new DistributedCacheEntryOptions();
33+
options.SetSlidingExpiration(TimeSpan.FromSeconds(expirationSeconds));
34+
await _cache.SetAsync<string>(cacheKey, tree, options);
35+
_logger.LogDebug($"Added navigation DOM tree to distributed cache: {cacheKey}");
36+
}
37+
catch (Exception ex)
38+
{
39+
_logger.LogError(ex, $"Failed to add navigation DOM tree to distributed cache: {cacheKey}");
40+
}
41+
}
42+
43+
public async Task ClearDOMTreeCache(string cacheKey)
44+
{
45+
await _cache.RemoveAsync(cacheKey);
46+
// ((MemoryCache)_cache).Compact(1);
47+
}
48+
}
49+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
using System.Threading.Tasks;
2+
3+
namespace cloudscribe.Web.Navigation.Caching
4+
{
5+
public interface IDOMTreeCache
6+
{
7+
Task ClearDOMTreeCache(string cacheKey);
8+
Task<string> GetDOMTree(string cacheKey);
9+
Task StoreDOMTree(string cacheKey, string tree, int expirationSeconds);
10+
}
11+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
using Microsoft.AspNetCore.Mvc;
2+
using Microsoft.AspNetCore.Mvc.ModelBinding;
3+
using Microsoft.AspNetCore.Mvc.Rendering;
4+
using Microsoft.AspNetCore.Mvc.ViewEngines;
5+
using Microsoft.AspNetCore.Mvc.ViewFeatures;
6+
using Microsoft.Extensions.Logging;
7+
using System;
8+
using System.IO;
9+
using System.Threading.Tasks;
10+
11+
namespace cloudscribe.Web.Navigation.Caching
12+
{
13+
/// <summary>
14+
/// produce an html string representing the site nav - for use incaching - using
15+
/// Razor templates and models
16+
///
17+
/// JimK - this is based on the main CS version, but simplified -
18+
/// passing in to it the actionContext etc etc. from the consuming method
19+
/// rather than relying on DI services in here
20+
/// seems to prevent a proliferation of "object disposed" errors.
21+
/// </summary>
22+
public class NavViewRenderer
23+
{
24+
public NavViewRenderer(ILogger<NavViewRenderer> logger)
25+
{
26+
_logger = logger;
27+
}
28+
29+
private readonly ILogger<NavViewRenderer> _logger;
30+
31+
public async Task<string> RenderViewAsStringWithActionContext<TModel>(string viewName,
32+
TModel model,
33+
ViewEngineResult viewResult,
34+
ActionContext actionContext,
35+
TempDataDictionary tempData)
36+
{
37+
var viewData = new ViewDataDictionary<TModel>(
38+
metadataProvider: new EmptyModelMetadataProvider(),
39+
modelState: new ModelStateDictionary())
40+
{
41+
Model = model
42+
};
43+
44+
45+
try
46+
{
47+
using (StringWriter output = new StringWriter())
48+
{
49+
ViewContext viewContext = new ViewContext(
50+
actionContext,
51+
viewResult.View,
52+
viewData,
53+
tempData,
54+
output,
55+
new HtmlHelperOptions()
56+
);
57+
58+
await viewResult.View.RenderAsync(viewContext);
59+
60+
return output.GetStringBuilder().ToString();
61+
}
62+
}
63+
catch (Exception ex)
64+
{
65+
_logger.LogError(ex, "NavViewRenderer - error in view rendering for view " + viewName);
66+
throw ex;
67+
}
68+
}
69+
}
70+
}
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
// Copyright (c) Source Tree Solutions, LLC. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using cloudscribe.Web.Navigation.Caching;
5+
using Microsoft.AspNetCore.Mvc;
6+
using Microsoft.AspNetCore.Mvc.Infrastructure;
7+
using Microsoft.AspNetCore.Mvc.Razor;
8+
using Microsoft.AspNetCore.Mvc.Routing;
9+
using Microsoft.AspNetCore.Mvc.ViewEngines;
10+
using Microsoft.AspNetCore.Mvc.ViewFeatures;
11+
using Microsoft.Extensions.Logging;
12+
using System;
13+
using System.Collections.Generic;
14+
using System.Threading.Tasks;
15+
16+
namespace cloudscribe.Web.Navigation
17+
{
18+
public class CachingNavigationViewComponent : ViewComponent
19+
{
20+
public CachingNavigationViewComponent(
21+
NavigationTreeBuilderService siteMapTreeBuilder,
22+
IEnumerable<INavigationNodePermissionResolver> permissionResolvers,
23+
IEnumerable<IFindCurrentNode> nodeFinders,
24+
IUrlHelperFactory urlHelperFactory,
25+
IActionContextAccessor actionContextAccesor,
26+
INodeUrlPrefixProvider prefixProvider,
27+
ILogger<NavigationViewComponent> logger,
28+
IDOMTreeCache DomCache,
29+
IRazorViewEngine viewEngine,
30+
NavViewRenderer viewRenderer,
31+
ITempDataProvider tempDataProvider)
32+
{
33+
_builder = siteMapTreeBuilder;
34+
_permissionResolvers = permissionResolvers;
35+
_nodeFinders = nodeFinders;
36+
_urlHelperFactory = urlHelperFactory;
37+
_actionContextAccesor = actionContextAccesor;
38+
_prefixProvider = prefixProvider;
39+
_log = logger;
40+
_domCache = DomCache;
41+
_viewEngine = viewEngine;
42+
_viewRenderer = viewRenderer;
43+
_tempDataProvider = tempDataProvider;
44+
}
45+
46+
private ILogger _log;
47+
private readonly IDOMTreeCache _domCache;
48+
private readonly IRazorViewEngine _viewEngine;
49+
private readonly NavViewRenderer _viewRenderer;
50+
private readonly ITempDataProvider _tempDataProvider;
51+
private NavigationTreeBuilderService _builder;
52+
private IEnumerable<INavigationNodePermissionResolver> _permissionResolvers;
53+
private IEnumerable<IFindCurrentNode> _nodeFinders;
54+
private IUrlHelperFactory _urlHelperFactory;
55+
private IActionContextAccessor _actionContextAccesor;
56+
private INodeUrlPrefixProvider _prefixProvider;
57+
58+
59+
// intention here is not to re-compute the whole navigation DOM tree repeatedly
60+
// when you get a large number of unauthenticated page requests e.g. from a PWA
61+
// The main problem is clearing this cache again on new page creation etc
62+
// since .Net memory cache has no method for enumerating its keys -
63+
// you need to know the specific key name.
64+
// In a simplecontent system I'd probably need to use the IHandlePageCreated (etc)
65+
// hooks to clear a navcache of known name.
66+
67+
public async Task<IViewComponentResult> InvokeAsync(string viewName,
68+
string filterName,
69+
string startingNodeKey,
70+
int expirationSeconds = 60,
71+
bool testMode = false)
72+
{
73+
NavigationViewModel model = null;
74+
75+
string cacheKey = $"{viewName}_{filterName}_{startingNodeKey}";
76+
77+
// authenticated users - always do what the stadard version of this component does:
78+
// build the tree afresh
79+
if (User.Identity.IsAuthenticated)
80+
{
81+
// maybe kill cache key here under certain circumstances?
82+
// if(User.IsInRole("Administrators") || User.IsInRole("Content Administrators"))
83+
// {
84+
// // await _domCache.ClearDOMTreeCache(cacheKey);
85+
// }
86+
87+
model = await CreateNavigationTree(filterName, startingNodeKey);
88+
return View(viewName, model);
89+
}
90+
else
91+
{
92+
var result = await _domCache.GetDOMTree(cacheKey); // use the viewname as the key in the cache
93+
94+
if (string.IsNullOrEmpty(result))
95+
{
96+
model = await CreateNavigationTree(filterName, startingNodeKey);
97+
98+
ViewEngineResult viewResult = null;
99+
var actionContext = _actionContextAccesor.ActionContext;
100+
var tempData = new TempDataDictionary(actionContext.HttpContext, _tempDataProvider);
101+
102+
string fullViewName = $"Components/CachingNavigation/{viewName}";
103+
try
104+
{
105+
// beware the 'IFeatureCollection has been disposed' System.ObjectDisposedException error here
106+
viewResult = _viewEngine.FindView(actionContext, fullViewName, true);
107+
}
108+
catch (Exception ex)
109+
{
110+
_log.LogError(ex, $"CachingNavigationViewComponent: Failed to search for View {fullViewName}");
111+
}
112+
113+
if (viewResult == null || !viewResult.Success || viewResult.View == null)
114+
{
115+
_log.LogError($"CachingNavigationViewComponent: Failed to find a matching view {fullViewName}");
116+
}
117+
else
118+
{
119+
try
120+
{
121+
result = await _viewRenderer.RenderViewAsStringWithActionContext(fullViewName,
122+
model,
123+
viewResult,
124+
actionContext,
125+
tempData
126+
);
127+
128+
if (!string.IsNullOrEmpty(result))
129+
{
130+
if(testMode)
131+
{
132+
await _domCache.StoreDOMTree(cacheKey, $"<h2>Cached copy from {cacheKey}</h2> {result}", expirationSeconds);
133+
}
134+
else
135+
{
136+
await _domCache.StoreDOMTree(cacheKey, result, expirationSeconds);
137+
}
138+
}
139+
140+
_log.LogInformation($"CachingNavigationViewComponent: Rendered view successfully for {fullViewName}");
141+
}
142+
catch (Exception ex)
143+
{
144+
_log.LogError(ex, $"CachingNavigationViewComponent: Failed to render view for {fullViewName}");
145+
throw (ex);
146+
}
147+
}
148+
}
149+
return View("CachedNav", result);
150+
}
151+
}
152+
153+
/// <summary>
154+
/// The expensive thing...
155+
/// </summary>
156+
private async Task<NavigationViewModel> CreateNavigationTree(string filterName, string startingNodeKey)
157+
{
158+
var rootNode = await _builder.GetTree();
159+
var urlHelper = _urlHelperFactory.GetUrlHelper(_actionContextAccesor.ActionContext);
160+
NavigationViewModel model = new NavigationViewModel(
161+
startingNodeKey,
162+
filterName,
163+
Request.HttpContext,
164+
urlHelper,
165+
rootNode,
166+
_permissionResolvers,
167+
_nodeFinders,
168+
_prefixProvider.GetPrefix(),
169+
_log);
170+
return model;
171+
}
172+
}
173+
}

src/cloudscribe.Web.Navigation/ServiceCollectionExtensions.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,10 @@ public static IServiceCollection AddCloudscribeNavigation(
3333
services.AddDistributedMemoryCache();
3434

3535
services.TryAddScoped<ITreeCache, DistributedTreeCache>();
36-
36+
services.TryAddScoped<IDOMTreeCache, DOMTreeCache>();
37+
services.AddScoped<NavViewRenderer>();
38+
39+
3740
services.TryAddScoped<INavigationTreeBuilder, XmlNavigationTreeBuilder>();
3841
services.TryAddScoped<NavigationTreeBuilderService, NavigationTreeBuilderService>();
3942
services.TryAddScoped<INodeUrlPrefixProvider, DefaultNodeUrlPrefixProvider>();
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
@using cloudscribe.Web.Navigation
2+
@using System.Text
3+
@model NavigationViewModel
4+
@using Microsoft.Extensions.Localization
5+
@inject IStringLocalizer<cloudscribe.Web.Navigation.MenuResources> sr
6+
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
7+
@addTagHelper *, cloudscribe.Web.Navigation
8+
9+
@{
10+
Layout = null;
11+
}
12+
13+
<ul class="navbar-nav me-auto" role="menubar" aria-label="@sr["Top menu"]">
14+
@if (await Model.ShouldAllowView(Model.RootNode))
15+
{
16+
<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>
17+
}
18+
19+
@if (await Model.HasVisibleChildren(Model.RootNode))
20+
{
21+
@foreach (var node in Model.RootNode.Children)
22+
{
23+
if (!await Model.ShouldAllowView(node)) { continue; }
24+
if (!await Model.HasVisibleChildren(node))
25+
{
26+
<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>
27+
}
28+
else
29+
{
30+
<li role="none" class='@Model.GetClass(node.Value, "nav-item dropdown", "active", true)' cwn-data-attributes="@node.Value.DataAttributes">
31+
<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>
32+
@Model.UpdateTempNode(node) <partial name="Bootstrap5NavigationNodeChildDropdownPartial" model="@Model" />
33+
</li>
34+
}
35+
}
36+
}
37+
</ul>
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
@model string
2+
@Html.Raw(Model)

src/cloudscribe.Web.Navigation/cloudscribe.Web.Navigation.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
<PropertyGroup>
44
<Description>an ASP.NET Core viewcomponent for menus and breadcrumbs</Description>
5-
<Version>6.0.3</Version>
5+
<Version>6.0.4</Version>
66
<TargetFramework>net6.0</TargetFramework>
77
<Authors>Joe Audette</Authors>
88
<AddRazorSupportForMvc>true</AddRazorSupportForMvc>

0 commit comments

Comments
 (0)