Skip to content

Commit dabc79f

Browse files
committed
Established CachedLayoutControllerBase{T}
Generally, local caching is not an optimal solution; a decorator is much preferred. That said, the `CachedTopicMappingService` decorator is ineffective in this particular case because the `LayoutControllerBase{T}` manually creates children. As a result, instead of the entire view model _graph_ being cached, each individual _item_ is cached. This introduces undesirable behavior when those view model graphs overlap, as e.g. the `INavigationTopicViewModel<T>` for `Menu` might end up with additional children from the `INavigationTopicViewModel<T>` for `PageLevelNavigation`. This is generally not a problem for mapping, but `LayoutControllerBase{}` needs tighter control over the boundaries of its edges than most applications (where unnecessary spillage would not interfere with the functionality). To mitigate this, introduces the `CachedLayoutControllerBase{T}`. To better differentiate between the methods, renamed `AddNestedTopicsAsync()` to `GetViewModelAsync()`, and introduced a new "bootstrap" method, `GetRootModelAsync()`, which kickstarts the process, thus allowing implementers, such as the `CachedLayoutControllerBase<T>`, to differentiate between the root view model, and its descendents. Also updated documentation to call out this behavior.
1 parent 4c3faf0 commit dabc79f

11 files changed

Lines changed: 166 additions & 20 deletions

File tree

Ignia.Topics.Data.Caching/Properties/AssemblyInfo.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@
2121
[assembly: AssemblyTrademark("")]
2222
[assembly: AssemblyCulture("")]
2323
[assembly: ComVisible(false)]
24-
[assembly: AssemblyVersion("3.5.1739.0")]
25-
[assembly: AssemblyFileVersion("3.5.1771.0")]
24+
[assembly: AssemblyVersion("3.5.1741.0")]
25+
[assembly: AssemblyFileVersion("3.5.1773.0")]
2626
[assembly: CLSCompliant(true)]
2727
[assembly: Guid("206b7f91-ca25-4e9d-9576-60d2e54a2c0a")]
2828

Ignia.Topics.Tests/Properties/AssemblyInfo.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
[assembly: AssemblyTrademark("")]
2222
[assembly: AssemblyCulture("")]
2323
[assembly: ComVisible(false)]
24-
[assembly: AssemblyVersion("3.5.1743.0")]
25-
[assembly: AssemblyFileVersion("3.5.1791.0")]
24+
[assembly: AssemblyVersion("3.5.1747.0")]
25+
[assembly: AssemblyFileVersion("3.5.1795.0")]
2626
[assembly: CLSCompliant(true)]
2727
[assembly: Guid("27632801-bfe3-41d9-8678-3c4bbe45e6c9")]

Ignia.Topics.ViewModels/Properties/AssemblyInfo.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@
2121
[assembly: AssemblyTrademark("")]
2222
[assembly: AssemblyCulture("")]
2323
[assembly: ComVisible(false)]
24-
[assembly: AssemblyVersion("3.5.1741.0")]
25-
[assembly: AssemblyFileVersion("3.5.1772.0")]
24+
[assembly: AssemblyVersion("3.5.1742.0")]
25+
[assembly: AssemblyFileVersion("3.5.1773.0")]
2626
[assembly: CLSCompliant(true)]
2727
[assembly: Guid("e52fc633-b4c5-4a2b-8caf-30e756d7a6a7")]
2828

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/*==============================================================================================================================
2+
| Author Ignia, LLC
3+
| Client Ignia, LLC
4+
| Project Topics Library
5+
\=============================================================================================================================*/
6+
using System.Collections.Concurrent;
7+
using System.Threading.Tasks;
8+
using Ignia.Topics.Mapping;
9+
using Ignia.Topics.Repositories;
10+
using Ignia.Topics.ViewModels;
11+
12+
namespace Ignia.Topics.Web.Mvc.Controllers {
13+
14+
/*============================================================================================================================
15+
| CLASS: CACHED LAYOUT CONTROLLER
16+
\---------------------------------------------------------------------------------------------------------------------------*/
17+
/// <summary>
18+
/// Provides access to views for populating specific layout dependencies, such as the <see cref="Menu"/>, while caching
19+
/// the <see cref="INavigationTopicViewModel{T}"/> graphs for performance.
20+
/// </summary>
21+
/// <remarks>
22+
/// <para>
23+
/// As a best practice, global data required by the layout view are requested independently of the current page. This
24+
/// allows each layout element to be provided with its own layout data, in the form of <see
25+
/// cref="NavigationViewModel{T}"/>s, instead of needing to add this data to every view model returned by <see
26+
/// cref="TopicController"/>. The <see cref="LayoutController{T}"/> facilitates this by not only providing a default
27+
/// implementation for <see cref="Menu"/>, but additionally providing protected helper methods that aid in locating and
28+
/// assembling <see cref="Topic"/> and <see cref="INavigationTopicViewModelCore"/> references that are relevant to
29+
/// specific layout elements.
30+
/// </para>
31+
/// <para>
32+
/// In order to remain view model agnostic, the <see cref="LayoutController{T}"/> does not assume that a particular view
33+
/// model will be used, and instead accepts a generic argument for any view model that implements the interface <see
34+
/// cref="INavigationTopicViewModelCore"/>. Since generic controllers cannot be effectively routed to, however, that means
35+
/// implementors must, at minimum, provide a local instance of <see cref="LayoutController{T}"/> which sets the generic
36+
/// value to the desired view model. To help enforce this, while avoiding ambiguity, this class is marked as
37+
/// <c>abstract</c> and suffixed with <c>Base</c>.
38+
/// </para>
39+
/// <para>
40+
/// By comparison to the <see cref="LayoutControllerBase{T}"/>, the <see cref="CachedLayoutControllerBase{T}"/> will
41+
/// automatically cache the <see cref="INavigationTopicViewModel{T}"/> graph for each action that uses the protected <see
42+
/// cref="GetViewModelAsync(Topic, Boolean, Int32)"/> method to construct the graph. This is preferable over using e.g.
43+
/// the <see cref="CachedTopicMappingService"/> since the <see cref="LayoutControllerBase{T}"/> requires tight control
44+
/// over the shape of the <see cref="INavigationTopicViewModel{T}"/> graph. For instance, using a generic caching
45+
/// decorator for the mapping might result in the edges of the <see cref="Menu"/> action being expanded due to other
46+
/// actions reusing cached instances (e.g., for page-level navigation). To mitigate this, the <see
47+
/// cref="CachedLayoutControllerBase{T}"/> handles top-level caching at the level of the navigation root.
48+
/// </para>
49+
/// </remarks>
50+
public abstract class CachedLayoutControllerBase<T> : LayoutControllerBase<T>
51+
where T : class, INavigationTopicViewModel<T>, new() {
52+
53+
/*==========================================================================================================================
54+
| STATIC VARIABLES
55+
\-------------------------------------------------------------------------------------------------------------------------*/
56+
private static ConcurrentDictionary<int, T> _cache = new ConcurrentDictionary<int, T>();
57+
58+
/*==========================================================================================================================
59+
| CONSTRUCTOR
60+
\-------------------------------------------------------------------------------------------------------------------------*/
61+
/// <summary>
62+
/// Initializes a new instance of a Topic Controller with necessary dependencies.
63+
/// </summary>
64+
/// <returns>A topic controller for loading OnTopic views.</returns>
65+
protected CachedLayoutControllerBase(
66+
ITopicRepository topicRepository,
67+
ITopicRoutingService topicRoutingService,
68+
ITopicMappingService topicMappingService
69+
) : base(topicRepository, topicRoutingService, topicMappingService) {}
70+
71+
/*==========================================================================================================================
72+
| GET ROOT VIEW MODEL (ASYNC)
73+
\-------------------------------------------------------------------------------------------------------------------------*/
74+
/// <summary>
75+
/// Given a <paramref name="sourceTopic"/>, maps a <typeparamref name="T"/>, as well as <paramref name="tiers"/> of
76+
/// <see cref="INavigationTopicViewModel{T}.Children"/>. If the <see cref="INavigationTopicViewModel{T}"/> graph has been
77+
/// mapped before, then a cached instance is returned. Optionally excludes <see cref="Topic"/> instance with the
78+
/// <c>ContentType</c> of <c>PageGroup</c>.
79+
/// </summary>
80+
/// <param name="sourceTopic">The <see cref="Topic"/> to pull the values from.</param>
81+
/// <param name="allowPageGroups">Determines whether <see cref="PageGroupTopicViewModel"/>s should be crawled.</param>
82+
/// <param name="tiers">Determines how many tiers of children should be included in the graph.</param>
83+
protected override async Task<T> GetRootViewModelAsync(
84+
Topic sourceTopic,
85+
bool allowPageGroups = true,
86+
int tiers = 1
87+
) {
88+
89+
/*------------------------------------------------------------------------------------------------------------------------
90+
| Handle empty results
91+
\-----------------------------------------------------------------------------------------------------------------------*/
92+
if (sourceTopic == null) {
93+
return await Task<T>.FromResult<T>(null);
94+
}
95+
96+
/*------------------------------------------------------------------------------------------------------------------------
97+
| Handle cache hits
98+
\-----------------------------------------------------------------------------------------------------------------------*/
99+
if (_cache.TryGetValue(sourceTopic.Id, out var dto)) {
100+
return await Task<T>.FromResult<T>(dto);
101+
}
102+
103+
/*------------------------------------------------------------------------------------------------------------------------
104+
| Cache and return new version
105+
\-----------------------------------------------------------------------------------------------------------------------*/
106+
var viewModel = await GetViewModelAsync(sourceTopic, allowPageGroups, tiers);
107+
return _cache.GetOrAdd(sourceTopic.Id, viewModel);
108+
109+
}
110+
111+
} // Class
112+
113+
} // Namespace

Ignia.Topics.Web.Mvc/Controllers/LayoutControllerBase{T}.cs

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ public async virtual Task<PartialViewResult> Menu() {
117117
| Construct view model
118118
\-----------------------------------------------------------------------------------------------------------------------*/
119119
var navigationViewModel = new NavigationViewModel<T>() {
120-
NavigationRoot = await AddNestedTopicsAsync(navigationRootTopic, false, 3),
120+
NavigationRoot = await GetRootViewModelAsync(navigationRootTopic, false, 3),
121121
CurrentKey = CurrentTopic?.GetUniqueKey()
122122
};
123123

@@ -186,15 +186,40 @@ private static int DistanceFromRoot(Topic sourceTopic) {
186186
}
187187

188188
/*==========================================================================================================================
189-
| ADD NESTED TOPICS
189+
| GET ROOT VIEW MODEL (ASYNC)
190190
\-------------------------------------------------------------------------------------------------------------------------*/
191191
/// <summary>
192-
/// A helper function that allows a set number of tiers to be added to a <see cref="NavigationViewModel"/> tree.
192+
/// Given a <paramref name="sourceTopic"/>, maps a <typeparamref name="T"/>, as well as <paramref name="tiers"/> of
193+
/// <see cref="INavigationTopicViewModel{T}.Children"/>. Optionally excludes <see cref="Topic"/> instance with the
194+
/// <c>ContentType</c> of <c>PageGroup</c>.
195+
/// </summary>
196+
/// <remarks>
197+
/// In the out-of-the-box implementation, <see cref="GetRootViewModelAsync(Topic, Boolean, Int32)"/> and <see
198+
/// cref="GetViewModelAsync(Topic, Boolean, Int32)"/> provide the same functionality. It is recommended that actions call
199+
/// <see cref="GetRootViewModelAsync(Topic, Boolean, Int32)"/>, however, as it allows implementers the flexibility to
200+
/// differentiate between the root view model (which the client application will be binding to) and any child view models
201+
/// (which the client application may optionally iterate over).
202+
/// </remarks>
203+
/// <param name="sourceTopic">The <see cref="Topic"/> to pull the values from.</param>
204+
/// <param name="allowPageGroups">Determines whether <see cref="PageGroupTopicViewModel"/>s should be crawled.</param>
205+
/// <param name="tiers">Determines how many tiers of children should be included in the graph.</param>
206+
protected virtual async Task<T> GetRootViewModelAsync(
207+
Topic sourceTopic,
208+
bool allowPageGroups = true,
209+
int tiers = 1
210+
) => await GetViewModelAsync(sourceTopic, allowPageGroups, tiers);
211+
212+
/*==========================================================================================================================
213+
| GET VIEW MODEL (ASYNC)
214+
\-------------------------------------------------------------------------------------------------------------------------*/
215+
/// Given a <paramref name="sourceTopic"/>, maps a <typeparamref name="T"/>, as well as <paramref name="tiers"/> of
216+
/// <see cref="INavigationTopicViewModel{T}.Children"/>. Optionally excludes <see cref="Topic"/> instance with the
217+
/// <c>ContentType</c> of <c>PageGroup</c>.
193218
/// </summary>
194219
/// <param name="sourceTopic">The <see cref="Topic"/> to pull the values from.</param>
195220
/// <param name="allowPageGroups">Determines whether <see cref="PageGroupTopicViewModel"/>s should be crawled.</param>
196221
/// <param name="tiers">Determines how many tiers of children should be included in the graph.</param>
197-
protected async Task<T> AddNestedTopicsAsync(
222+
protected async Task<T> GetViewModelAsync(
198223
Topic sourceTopic,
199224
bool allowPageGroups = true,
200225
int tiers = 1
@@ -225,7 +250,7 @@ protected async Task<T> AddNestedTopicsAsync(
225250
\-----------------------------------------------------------------------------------------------------------------------*/
226251
if (tiers >= 0 && (allowPageGroups || !sourceTopic.ContentType.Equals("PageGroup")) && viewModel.Children.Count == 0) {
227252
foreach (var topic in sourceTopic.Children.Where(t => t.IsVisible())) {
228-
taskQueue.Add(AddNestedTopicsAsync(topic, allowPageGroups, tiers));
253+
taskQueue.Add(GetViewModelAsync(topic, allowPageGroups, tiers));
229254
}
230255
}
231256

Ignia.Topics.Web.Mvc/Ignia.Topics.Web.Mvc.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@
7777
<ItemGroup>
7878
<Compile Include="Controllers\ErrorControllerBase{T}.cs" />
7979
<Compile Include="Controllers\FallbackController.cs" />
80+
<Compile Include="Controllers\CachedLayoutControllerBase{T}.cs" />
8081
<Compile Include="Controllers\LayoutControllerBase{T}.cs" />
8182
<Compile Include="Controllers\RedirectController.cs" />
8283
<Compile Include="Controllers\SitemapController.cs" />

Ignia.Topics.Web.Mvc/Properties/AssemblyInfo.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
[assembly: AssemblyTrademark("")]
2222
[assembly: AssemblyCulture("")]
2323
[assembly: ComVisible(false)]
24-
[assembly: AssemblyVersion("3.5.1739.0")]
25-
[assembly: AssemblyFileVersion("3.5.1771.0")]
24+
[assembly: AssemblyVersion("3.5.1745.0")]
25+
[assembly: AssemblyFileVersion("3.5.1777.0")]
2626
[assembly: CLSCompliant(true)]
2727
[assembly: Guid("3b3ce34d-b5e5-47ca-bfef-e6740650f378")]

Ignia.Topics.Web.Mvc/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ There are six main controllers that ship with the MVC implementation. In additio
2424
- **`ErrorControllerBase<T>`**: Provides support for `Error`, `NotFound`, and `InternalServer` actions. Can accept any `IPageTopicViewModel` as a generic argument; that will be used as the view model.
2525
- **`FallbackController`**: Used in a [Controller Factory](#controller-factory) as a fallback, in case no other controllers can accept the request. Simply returns a `NotFoundResult` with a predefined message.
2626
- **`LayoutControllerBase<T>`**: Provides support for a navigation menu by automatically mapping the top three tiers of the current namespace (e.g., `Web`, its children, and grandchildren). Can accept any `INavigationTopicViewModel` as a generic argument; that will be used as the view model for each mapped instance.
27+
- **`CachedLayoutControllerBase<T>`**: Introduces specialized caching of `INavigationTopicViewModel<T>` graphs whenever a call to the `GetNavigationRoot()` is called. This avoids mapping the entirety of the navigation on each request.
2728
- **`RedirectController`**: Provides a single `Redirect` action which can be bound to a route such as `/Topic/{ID}/`; this provides support for permanent URLs that are independent of the `GetWebPath()`.
2829
- **`SitemapController`**: Provides a single `Sitemap` action which returns a reference to the `ITopicRepository`, thus allowing a sitemap view to recurse over the entire Topic graph, including all attributes.
2930

Ignia.Topics.Web/Properties/AssemblyInfo.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
[assembly: AssemblyTrademark("")]
2222
[assembly: AssemblyCulture("")]
2323
[assembly: ComVisible(false)]
24-
[assembly: AssemblyVersion("3.5.1739.0")]
25-
[assembly: AssemblyFileVersion("3.5.1763.0")]
24+
[assembly: AssemblyVersion("3.5.1741.0")]
25+
[assembly: AssemblyFileVersion("3.5.1765.0")]
2626
[assembly: CLSCompliant(true)]
2727
[assembly: Guid("c98f7b48-a085-4394-b820-c244f23868ce")]

Ignia.Topics/Mapping/README.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ This can be useful for filtering a collection. For instance, if a `CompanyTopicV
156156
While it's not a best practice, this also works for strongly-typed collections of `Topic` objects. Typically, collections should return view models, but if the collection is strongly-typed to `Topic` (or a derivative) then the source `Topic` will not be mapped, and will be used as-is assuming it implements (or derives from) the target `Topic` type. This can be useful for scenarios where a view needs full access to the object graph (such as the `SitemapController`). In such cases, it is impractical to map the entirety of an object graph, along with all attributes, to a corresponding view model graph, and makes more sense to simply return the `Topic` graph.
157157

158158
## Caching
159-
By default, the `TopicMappingService` will cache a reference to all types discovered that end with `TopicViewModel`, as well as all `MemberInfo` objects associated with each of those types. That mitigates much of the performance hit associated with the use of reflection. Despite that, simply setting properties—and, especially, on large object graphs—can require a lot of processing time. To mitigate this, OnTopic also offers two approaches.
159+
By default, the `TopicMappingService` will cache a reference to all `MemberInfo` objects associated with each of view model it maps. That mitigates much of the performance hit associated with the use of reflection. Despite that, simply setting properties—and, especially, on large object graphs—can require a lot of processing time. To address this, OnTopic also offers two approaches.
160160

161161
### Internal Caching
162162
When a request is made to `TopicMappingService`, and internal cache is constructed. If any mapping requests refer to a `Topic` that's already been mapped as part of the _current_ object graph, then that object will be returned. This prevents unnecessary duplication of mapping, and also avoids the potential for infinite loops. For instance, if a view model includes `Children`, and those children are set to `[Follow(Relationships.Parents)]`, the `TopicMappingService` will point back to the originally-mapped `Parent` object, instead of mapping a new instance of that `Topic`.
@@ -174,6 +174,12 @@ var topicMappingService = new TopicMappingService(topicRepository);
174174
var cachedTopicMappingService = new CachedTopicMappingService(topicMappingService);
175175
```
176176

177-
> *Note:* Be aware that the `CachedTopicMappingService` may take up considerable memory, depending on how many permutations of mapped objects the application has. This is especially true since it caches each unique object graph; no effort is made to centralize references to e.g. relationships that reference the same object instance.
177+
> _**Important**_: Due to limitations discussed below, the application of the `CachedTopicMappingService` is quite restricted. It is likely inapprorpiate for page content, since that wouldn't reflect changes made via the editor. And it isn't appropriate for e.g. the `LayoutControllerBase{T}`, since it manually constructs its tree.
178178
179-
> *Note:* The `CachedTopicMappingService` makes no effort to validate or evict cache entries. Topics whose values change during the lifetime of the `CachedTopicMappingService` will not be reflected in the mapped responses.
179+
180+
#### Limitations
181+
While the `CachedTopicMappingService` can be useful for particular scenarios, it introduces several limitations that should be accounted for.
182+
183+
1. It may take up considerable memory, depending on how many permutations of mapped objects the application has. This is especially true since it caches each unique object graph; no effort is made to centralize object instances referenced by e.g. relationships in multiple graphs.
184+
2. It makes no effort to validate or evict cache entries. Topics whose values change during the lifetime of the `CachedTopicMappingService` will not be reflected in the mapped responses.
185+
3. If a graph is manually constructed (by e.g. programmatically mapping `Children`) then each instance will be separated cached, thus potentially allowing an instance to be shared between multiple graphs. This can introduce concerns if edge maintenance is important.

0 commit comments

Comments
 (0)