Home » Design Patterns » Adapter pattern
Category Archives: Adapter pattern
Restart Your Sitecore Content Management (CM) and Content Delivery (CD) Instances using Custom Events
Last Friday, I had worked on a PowerShell script which is executed by clicking a custom button in the Sitecore Content Editor ribbon using the Content Editor Ribbon integration point offered through Sitecore PowerShell Extensions (SPE) in a UAT environment. This UAT environment has separate Content Management (CM) and Content Delivery (CD) Sitecore instances on two different servers. The script updates some records in an external datasource (data not in Sitecore) but its data is cached in the Sitecore instances outside of the stand Sitecore caching framework. In order to see these updates on the sites which live on these Sitecore instances , I needed a programmatic way to restart all Sitecore instances in the UAT environment I had built this solution for, and the following solution reflects what I had worked on to accomplish this spanning Friday until today — this post defines two custom events in Sitecore — just so I could get this out as soon as possible as it may help you understand custom events, and maybe even help you do something similar to what I am doing here.
I do want to caution you on using code as follows in a production environment — this could cause disruption to your production CDs which are live to the public; be sure to take your CD servers out of a load-balancer before restarting the Sitecore instance on them as you don’t want people showing up to your door with pitchforks and torches asking why production had been down for a bit.
I’ve also incorporated the concept of Sitecore configuration-driven feature toggles which I had discussed in a previous blog post where I had altered the appearance of the Sitecore content tree using a pipeline-backed MasterDataView, and will be repurposing code from that post here — I strongly suggest reading that post to understand the approach being used for this post as I will not discuss much how this works on this post.
Moreover, I have used numerous Configuration Objects sourced from the Sitecore IoC container in this post which I had discuss in another previous post; you might also want to have a read of that post before proceeding in order to understand why I am decorating some classes on this post with the [ServiceConfigObject] attribute.
I first defined the following Configuration Object which will live in the Sitecore IoC container:
using Foundation.DependencyInjection; using Foundation.DependencyInjection.Enums; using Foundation.Kernel.Services.FeatureToggle; namespace Foundation.Kernel.Models.RestartServer { [ServiceConfigObject(ConfigPath = "moduleSettings/foundation/kernel/restartServerSettings", Lifetime = Lifetime.Singleton)] public class RestartServerSettings : IFeatureToggleable { public bool Enabled { get; set; } } }
This Configuration Object will act is the main feature toggle to turn the entire feature on/off based on the Enabled property’s value from Sitecore configuration (see the Sitecore patch configuration file towards the bottom of this post).
I then created the following interface which will ascertain when the entire feature should be turned on/off at a global level, or at an individual piece of functionality’s level:
using Foundation.Kernel.Services.FeatureToggle; namespace Foundation.Kernel.Services.RestartServer { public interface IRestartServerFeatureToggleService { bool IsFeatureEnabled(); bool IsEnabled(IFeatureToggleable toggleable); } }
The following is the implementation of the interface above.
using Foundation.Kernel.Models.RestartServer; using Foundation.Kernel.Services.FeatureToggle; namespace Foundation.Kernel.Services.RestartServer { public class RestartServerFeatureToggleService : IRestartServerFeatureToggleService { private readonly RestartServerSettings _settings; private readonly IFeatureToggleService _featureToggleService; public RestartServerFeatureToggleService(RestartServerSettings settings, IFeatureToggleService featureToggleService) { _settings = settings; _featureToggleService = featureToggleService; } public bool IsEnabled(IFeatureToggleable toggleable) => _featureToggleService.IsEnabled(_settings, toggleable); public bool IsFeatureEnabled() => _featureToggleService.IsEnabled(_settings); } }
I won’t explain much here as I had talked about feature toggles on a previous configuration-driven post; I suggest reading that to understand this pattern.
Now that we have our feature toggle magic defined above, let’s dive into the code which defines and handles custom events which do the actual restarting of the Sitecore instances.
I created the following Config Object to hold log messaging formatted strings which we will use when writing to the Sitecore log when restarting Sitecore instances:
using Foundation.DependencyInjection; using Foundation.DependencyInjection.Enums; namespace Foundation.Kernel.Models.RestartServer.Events { [ServiceConfigObject(ConfigPath = "moduleSettings/foundation/kernel/restartServerEventHandlerSettings", Lifetime = Lifetime.Singleton)] public class RestartServerEventHandlerSettings { public string RestartServerLogMessageFormat { get; set; } public string RestartServerRemoteLogMessageFormat { get; set; } } }
We need a way to identify the Sitecore instance we are on in order to determine if the Sitecore instance needs to be restarted, and also when logging information that a restart is happening on that instance. The following Config Object will provide the name of a Sitecore setting which holds thie identifier for the Sitecore instance we are on:
using Foundation.DependencyInjection; using Foundation.DependencyInjection.Enums; namespace Foundation.Kernel.Models.Server { [ServiceConfigObject(ConfigPath = "moduleSettings/foundation/kernel/serverServiceSettings", Lifetime = Lifetime.Singleton)] public class ServerServiceSettings { public string InstanceNameSetting { get; set; } } }
The following interface/implementation class will provide the name of the Sitecore instance we are on:
namespace Foundation.Kernel.Services.Server { public interface IServerService { string GetCurrentServerName(); } }
Here’s the implementation of the interface above:
using Sitecore.Abstractions; using Foundation.Kernel.Models.Server; namespace Foundation.Kernel.Services.Server { public class ServerService : IServerService { private readonly ServerServiceSettings _settings; private readonly BaseSettings _settingsProvider; public ServerService(ServerServiceSettings settings, BaseSettings settingsProvider) { _settings = settings; _settingsProvider = settingsProvider; } public string GetCurrentServerName() => GetSetting(GetInstanceNameSetting()); protected virtual string GetInstanceNameSetting() => _settings.InstanceNameSetting; protected virtual string GetSetting(string settingName) => _settingsProvider.GetSetting(settingName); } }
The class above consumes the ServerServiceSettings config object then uses the BaseSettings service to get the name of the Sitecore instance based on the value in the setting, and returns it to the caller. There’s really nothing more to it.
Back in 2014, I had written a post on restarting your Sitecore instance using a custom FileWatcher, and in that post I had used the RestartServer() method on the Sitecore.Install.Installer class in Sitecore.Kernel. I will be using this same class but backed by a service class. Here is the interface of that service class:
namespace Foundation.Kernel.Services.Installer { public interface IInstallerService { void RestartServer(); } }
The following implements the interface above:
namespace Foundation.Kernel.Services.Installer { public class InstallerService : IInstallerService { public void RestartServer() => Sitecore.Install.Installer.RestartServer(); } }
The class above just calls the RestartServer() method on the Sitecore.Install.Installer class; ultimately this just does a “touch” on your Web.config to recycle your Sitecore instance.
Like all good Sitecore custom events, we need a custom EventArgs class — well, you don’t really need one if you aren’t passing any data beyond what lives on the System.EventArgs class but I’m sure you will seldom come across such a scenario 😉 — so I defined the following class to be just that class:
using System; using System.Collections.Generic; using Sitecore.Events; namespace Foundation.Kernel.Models.RestartServer.Events { [Serializable] public class RestartServerEventArgs : EventArgs, IPassNativeEventArgs { public List<string> ServerNames { get; protected set; } public RestartServerEventArgs(List<string> serverNames) { ServerNames = serverNames; } } }
We will be passing a List of server names just in case there are multiple servers we would like to reboot simultaneously.
Now it’s time to create the event handlers. I created the following abstract class for my two event handler classes to have a centralized place for most of this logic:
using System; using System.Linq; using Sitecore.Abstractions; using Foundation.Kernel.Models.RestartServer.Events; using Foundation.Kernel.Services.Installer; using Foundation.Kernel.Services.Server; using Foundation.Kernel.Services.RestartServer; using Foundation.Kernel.Services.FeatureToggle; namespace Foundation.Kernel.Events.RestartServer { public abstract class BaseRestartServerEventHandler : IFeatureToggleable { private readonly RestartServerEventHandlerSettings _settings; private readonly IRestartServerFeatureToggleService _restartServerFeatureToggleService; private readonly IServerService _serverService; private readonly IInstallerService _installerService; private readonly BaseLog _log; public bool Enabled { get; set; } public BaseRestartServerEventHandler(RestartServerEventHandlerSettings settings, IRestartServerFeatureToggleService restartServerFeatureToggleService, IServerService serverService, IInstallerService installerService, BaseLog log) { _settings = settings; _restartServerFeatureToggleService = restartServerFeatureToggleService; _serverService = serverService; _installerService = installerService; _log = log; } protected void ExecuteRestart(object sender, EventArgs args) { if(!IsEnabled()) { return; } RestartServerEventArgs restartServerArgs = GetArguments<RestartServerEventArgs>(args); if (!ShouldRestartServer(restartServerArgs)) { return; } string restartLogMessageFormat = GetRestartLogMessageFormat(); if (!string.IsNullOrWhiteSpace(restartLogMessageFormat)) { LogInfo(GetRestartServerMessage(restartLogMessageFormat, GetCurrentServerName())); } RestartServer(); } protected virtual bool IsEnabled() => _restartServerFeatureToggleService.IsEnabled(this); protected abstract string GetRestartLogMessageFormat(); protected virtual TArgs GetArguments<TArgs>(EventArgs args) where TArgs : EventArgs => args as TArgs; protected virtual bool ShouldRestartServer(RestartServerEventArgs restartServerArgs) { if(restartServerArgs.ServerNames == null || !restartServerArgs.ServerNames.Any()) { return false; } string currentServerName = GetCurrentServerName(); if (string.IsNullOrWhiteSpace(currentServerName)) { return false; } return restartServerArgs.ServerNames.Any(serverName => string.Equals(serverName, currentServerName, StringComparison.OrdinalIgnoreCase)); } protected virtual string GetCurrentServerName() => _serverService.GetCurrentServerName(); protected virtual string GetRestartServerMessage(string messageFormat, string serverName) => string.Format(messageFormat, serverName); protected virtual void RestartServer() => _installerService.RestartServer(); protected virtual void LogInfo(string message) => _log.Info(message, this); } }
Subclasses of the class above will have methods to handle their events which will delegate to the ExecuteRestart() method. This method will use the feature toggle service class to determine if it should execute or not; if not, it just gracefully exits.
If it does proceed forward, the passed System.EventArgs class is casted as RestartServerEventArgs instance, and the current Sitecore instance’s name is checked against the collection server names in the RestartServerEventArgs instance. If the current server’s name is in the list, the server is restarted though proceeded by a log message indicating that the server is being restarted.
Subclasses of thie abstract class above must provide the log message formatted string so the log message can be written to the Sitecore log.
I then created the following interface for an event handler class to handle a local server restart:
using System; namespace Foundation.Kernel.Events.RestartServer { public interface IRestartLocalServerEventHandler { void OnRestartTriggered(object sender, EventArgs args); } }
Here’s the implementation of the interface above:
using System; using Sitecore.Abstractions; using Foundation.Kernel.Models.RestartServer.Events; using Foundation.Kernel.Services.Installer; using Foundation.Kernel.Services.Server; using Foundation.Kernel.Services.RestartServer; namespace Foundation.Kernel.Events.RestartServer { public class RestartLocalServerEventHandler : BaseRestartServerEventHandler, IRestartLocalServerEventHandler { private readonly RestartServerEventHandlerSettings _settings; public RestartLocalServerEventHandler(RestartServerEventHandlerSettings settings, IRestartServerFeatureToggleService restartServerFeatureToggleService, IServerService serverService, IInstallerService installerService, BaseLog log) : base(settings, restartServerFeatureToggleService, serverService, installerService, log) { _settings = settings; } public void OnRestartTriggered(object sender, EventArgs args) => ExecuteRestart(sender, args); protected override string GetRestartLogMessageFormat() => _settings.RestartServerLogMessageFormat; } }
This subclass of the abstract class defined above just supplies its own log formatted message, and the rest of the logic is handled by its parent class.
I then created the following interface of an event handler class to remote events (those that run on CD servers):
using System; namespace Foundation.Kernel.Events.RestartServer { public interface IRestartRemoteServerEventHandler { void OnRestartTriggeredRemote(object sender, EventArgs args); } }
The following class implements the interface above:
using System; using Sitecore.Abstractions; using Foundation.Kernel.Models.RestartServer.Events; using Foundation.Kernel.Services.Installer; using Foundation.Kernel.Services.Server; using Foundation.Kernel.Services.RestartServer; namespace Foundation.Kernel.Events.RestartServer { public class RestartRemoteServerEventHandler : BaseRestartServerEventHandler, IRestartRemoteServerEventHandler { private readonly RestartServerEventHandlerSettings _settings; public RestartRemoteServerEventHandler(RestartServerEventHandlerSettings settings, IRestartServerFeatureToggleService restartServerFeatureToggleService, IServerService serverService, IInstallerService installerService, BaseLog log) : base(settings, restartServerFeatureToggleService, serverService, installerService, log) { _settings = settings; } public void OnRestartTriggeredRemote(object sender, EventArgs args) => ExecuteRestart(sender, args); protected override string GetRestartLogMessageFormat() => _settings.RestartServerRemoteLogMessageFormat; } }
Just as the previous event handler class had done, this class inherits from the abstract class defined above but just defines the GetRestartLogMessageFormat() method to provide its own log message formatted string; we need to identify that this was executed on a CD server in the Sitecore log.
In order to get remote events to work on your CD instances, you need to subscribe to your remote event on your CD server — this is done by listening for an instance of your custom EventArgs, we are using RestartServerEventArgs here, being sent across via the EventQueue — and then raise your custom remote event on our CD instance so your handlers are executed. The following code will be wrappers around static classes in the Sitecore API so I can use Dependency Injection in a custom <initialize> pipeline processor which ties all of this together with these injected service classes.
I will need to call methods on Sitecore.Events.Event in Sitecore.Kernel. I created the following interface/implementation for it:
using System; using Sitecore.Events; namespace Foundation.Kernel.Services.Events { public interface IEventService { TParameter ExtractParameter<TParameter>(EventArgs args, int index) where TParameter : class; object ExtractParameter(EventArgs args, int index); void RaiseEvent(string eventName, IPassNativeEventArgs args); EventResult RaiseEvent(string eventName, params object[] parameters); } }
using System; using Sitecore.Events; namespace Foundation.Kernel.Services.Events { public class EventService : IEventService { public TParameter ExtractParameter<TParameter>(EventArgs args, int index) where TParameter : class => Event.ExtractParameter<TParameter>(args, index); public object ExtractParameter(EventArgs args, int index) => Event.ExtractParameter(args, index); public void RaiseEvent(string eventName, IPassNativeEventArgs args) => Event.RaiseEvent(eventName, args); public EventResult RaiseEvent(string eventName, params object[] parameters) => Event.RaiseEvent(eventName, parameters); } }
I then created the following Config Object to provide the names of the two custom events (check out the patch configuration file at the end of this post to see what these values are):
using Foundation.DependencyInjection; using Foundation.DependencyInjection.Enums; namespace Foundation.Kernel.Models.RestartServer.Events { [ServiceConfigObject(ConfigPath = "moduleSettings/foundation/kernel/restartServerEventSettings", Lifetime = Lifetime.Singleton)] public class RestartServerEventSettings { public string RestartCurrentServerEventName { get; set; } public string RestartRemoteServerEventName { get; set; } } }
Now, I need a way to raise events, both on a local Sitecore instance but also on a remote instance. I defined the following interface for such a service class:
namespace Foundation.Kernel.Services.Events { public interface IEventTriggerer { void TriggerEvent(string eventName, params object[] parameters); void TriggerRemoteEvent<TEvent>(TEvent evt); void TriggerRemoteEvent<TEvent>(TEvent evt, bool triggerGlobally, bool triggerLocally); } }
I then implemented the interface above:
using Sitecore.Abstractions; namespace Foundation.Kernel.Services.Events { public class EventTriggerer : IEventTriggerer { private readonly IEventService _eventService; private readonly BaseEventQueueProvider _eventQueueProvider; public EventTriggerer(IEventService eventService, BaseEventQueueProvider eventQueueProvider) { _eventService = eventService; _eventQueueProvider = eventQueueProvider; } public void TriggerEvent(string eventName, params object[] parameters) => _eventService.RaiseEvent(eventName, parameters); public void TriggerRemoteEvent<TEvent>(TEvent evt) => _eventQueueProvider.QueueEvent(evt); public void TriggerRemoteEvent<TEvent>(TEvent evt, bool triggerGlobally, bool triggerLocally) => _eventQueueProvider.QueueEvent(evt, triggerGlobally, triggerLocally); } }
The class above consumes the IEventService service class we defined above so that we can “trigger”/raise local events on the current Sitecore instance, and it also consumes the BaseEventQueueProvider service which comes with stock Sitecore so we can “trigger”/raise events to run on remote servers via the EventQueue.
I also need to use methods on the Sitecore.Eventing.EventManager class in Sitecore.Kernel. The following interface/implementation do just that:
using System; using Sitecore.Eventing; namespace Foundation.Kernel.Services.Events { public interface IEventManagerService { SubscriptionId Subscribe<TEvent>(Action<TEvent> eventHandler); } }
using System; using Sitecore.Eventing; namespace Foundation.Kernel.Services.Events { public class EventManagerService : IEventManagerService { public SubscriptionId Subscribe<TEvent>(Action<TEvent> eventHandler) => EventManager.Subscribe(eventHandler); } }
I do want to call out that there is an underlying service behind the Sitecore.Eventing.EventManager class in Sitecore.Kernel but it’s brought into this static class through some service locator method I had never seen before, and was afraid of traversing too much down this rabbit hole out of fear of sticking my hands into a hornets nest, or even worse, a nest of those murderer hornets we keep hearing about on the news these days; I felt it was best not to go much further into this today. 😉
We can now dive into the <initialize> pipeline processor which binds the “subscribing and trigger remote event” functionality together.
I first created the following interface for the pipeline processor:
using Sitecore.Pipelines; namespace Foundation.Kernel.Pipelines.Initialize.RestartServer { public interface IRestartServerSubscriber { void Process(PipelineArgs args); } }
I then implemented the interface above:
using System; using Sitecore.Eventing; using Sitecore.Pipelines; using Foundation.Kernel.Services.Events; using Foundation.Kernel.Services.FeatureToggle; using Foundation.Kernel.Services.RestartServer; using Foundation.Kernel.Models.RestartServer.Events; namespace Foundation.Kernel.Pipelines.Initialize.RestartServer { public class RestartServerSubscriber: IFeatureToggleable, IRestartServerSubscriber { private readonly RestartServerEventSettings _settings; private readonly IRestartServerFeatureToggleService _restartServerFeatureToggleService; private readonly IEventTriggerer _eventTriggerer; private readonly IEventManagerService _eventManagerService; public bool Enabled { get; set; } public RestartServerSubscriber(RestartServerEventSettings settings, IRestartServerFeatureToggleService restartServerFeatureToggleService, IEventTriggerer eventTriggerer, IEventManagerService eventManagerService) { _settings = settings; _restartServerFeatureToggleService = restartServerFeatureToggleService; _eventTriggerer = eventTriggerer; _eventManagerService = eventManagerService; } public void Process(PipelineArgs args) { if(!IsEnabled()) { return; } Subscribe(GetRaiseRestartServerEventMethod()); } protected virtual bool IsEnabled() => _restartServerFeatureToggleService.IsEnabled(this); protected virtual Action<RestartServerEventArgs> GetRaiseRestartServerEventMethod() => new Action<RestartServerEventArgs>(RaiseRestartServerEvent); protected virtual void RaiseRestartServerEvent(RestartServerEventArgs args) => RaiseEvent(GetRestartRemoteServerEventName(), args); protected virtual string GetRestartRemoteServerEventName() => _settings.RestartRemoteServerEventName; protected virtual void RaiseEvent(string eventName, params object[] parameters) => _eventTriggerer.TriggerEvent(eventName, parameters); protected virtual SubscriptionId Subscribe<TEvent>(Action<TEvent> eventHandler) => _eventManagerService.Subscribe(eventHandler); } }
The Process() method of the pipeline processor class above uses the feature toggle service we had discuss earlier in this post to determine if it should execute or not.
If it can execute, it will subscribe to the remote event by listening for an RestartServerEventArgs instance being sent across by the EventQueue.
If it one is sent across, it will “trigger” (raise) the custom remote event on the CD server this pipeline processor lives on.
Are you still with me? 😉
Now we need a service class which glues all of the stuff above into a nice simple API which we can call. I defined the following Config Object which will be consumed by this service class; this Config Object determines if Remote events should also run on local servers — I had set this up so I can turn this on for testing/troubleshooting/debugging on my local development instance:
using Foundation.DependencyInjection; using Foundation.DependencyInjection.Enums; namespace Foundation.Kernel.Models.RestartServer.Events { [ServiceConfigObject(ConfigPath = "moduleSettings/foundation/kernel/restartServerRemoteEventSettings", Lifetime = Lifetime.Singleton)] public class RestartServerRemoteEventSettings { public bool TriggerRemoteEventLocally { get; set; } public bool TriggerRemoteEventGlobally { get; set; } } }
The following interface is for our “simple” API for restarting Sitecore instances:
using System.Collections.Generic; namespace Foundation.Kernel.Services.RestartServer { public interface IRestartServerService { void RestartCurrentServer(); void RestartRemoteServer(string serverName); void RestartRemoteServers(List<string> serverNames); } }
Here’s the implementation of the interface above:
using System.Collections.Generic; using System.Linq; using Foundation.Kernel.Models.RestartServer.Events; using Foundation.Kernel.Services.Events; using Foundation.Kernel.Services.Server; namespace Foundation.Kernel.Services.RestartServer { public class RestartServerService : IRestartServerService { private readonly RestartServerEventSettings _eventSettings; private readonly RestartServerRemoteEventSettings _remoteEventSettings; private readonly IRestartServerFeatureToggleService _restartServerFeatureToggleService; private readonly IServerService _serverService; private readonly IEventTriggerer _eventTriggerer; public RestartServerService(RestartServerEventSettings eventSettings, RestartServerRemoteEventSettings remoteEventSettings, IRestartServerFeatureToggleService restartServerFeatureToggleService, IServerService serverService, IEventTriggerer eventTriggerer) { _eventSettings = eventSettings; _remoteEventSettings = remoteEventSettings; _restartServerFeatureToggleService = restartServerFeatureToggleService; _serverService = serverService; _eventTriggerer = eventTriggerer; } public void RestartCurrentServer() { if(!IsFeatureEnabled()) { return; } TriggerEvent(GetRestartCurrentServerEventName(), CreateRestartServerEventArgs(CreateList(GetCurrentServerName()))); } protected virtual string GetRestartCurrentServerEventName() => _eventSettings.RestartCurrentServerEventName; protected virtual string GetCurrentServerName() => _serverService.GetCurrentServerName(); public void RestartRemoteServer(string serverName) => RestartRemoteServers(CreateList(serverName)); protected virtual List<string> CreateList(params string[] values) => values?.ToList(); public void RestartRemoteServers(List<string> serverNames) { if (!IsFeatureEnabled()) { return; } TriggerRemoteEvent(CreateRestartServerEventArgs(serverNames)); } protected virtual bool IsFeatureEnabled() => _restartServerFeatureToggleService.IsFeatureEnabled(); protected virtual RestartServerEventArgs CreateRestartServerEventArgs(List<string> serverNames) => new RestartServerEventArgs(serverNames); protected virtual void TriggerEvent(string eventName, params object[] parameters) => _eventTriggerer.TriggerEvent(eventName, parameters); protected virtual void TriggerRemoteEvent<TEvent>(TEvent evt) => _eventTriggerer.TriggerRemoteEvent(evt, GetTriggerRemoteEventGlobally(), GetTriggerRemoteEventGlobally()); protected virtual bool GetTriggerRemoteEventGlobally() => _remoteEventSettings.TriggerRemoteEventGlobally; protected virtual bool GetTriggerRemoteEventLocally() => _remoteEventSettings.TriggerRemoteEventLocally; } }
The class above implements methods for restarting the current server, one remote server, or multiple remote servers — ultimately, it just delegates to the IEventTriggerer service class we defined further above, and uses other services discussed earlier in this post; there’s really not much to it.
I then stitched everything above together in the following Sitecore patch configuration file:
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/" xmlns:set="http://www.sitecore.net/xmlconfig/set/" xmlns:role="http://www.sitecore.net/xmlconfig/role/"> <sitecore> <events> <event name="server:restart"> <handler type="Foundation.Kernel.Events.RestartServer.IRestartLocalServerEventHandler, Foundation.Kernel" method="OnRestartTriggered" resolve="true"> <Enabled>true</Enabled> </handler> </event> <event name="server:restart:remote"> <handler type="Foundation.Kernel.Events.RestartServer.IRestartRemoteServerEventHandler, Foundation.Kernel" method="OnRestartTriggeredRemote" resolve="true"> <Enabled>true</Enabled> </handler> </event> </events> <pipelines> <initialize> <processor type="Foundation.Kernel.Pipelines.Initialize.RestartServer.IRestartServerSubscriber, Foundation.Kernel" resolve="true"> <Enabled>true</Enabled> </processor> </initialize> </pipelines> <moduleSettings> <foundation> <kernel> <serverServiceSettings type="Foundation.Kernel.Models.Server.ServerServiceSettings, Foundation.Kernel" singleInstance="true"> <InstanceNameSetting>InstanceName</InstanceNameSetting> </serverServiceSettings> <restartServerSettings type="Foundation.Kernel.Models.RestartServer.RestartServerSettings, Foundation.Kernel" singleInstance="true"> <Enabled>true</Enabled> </restartServerSettings> <restartServerEventHandlerSettings type="Foundation.Kernel.Models.RestartServer.Events.RestartServerEventHandlerSettings, Foundation.Kernel" singleInstance="true"> <RestartServerLogMessageFormat>Restart Server Event Triggered: Shutting down server {0}</RestartServerLogMessageFormat> <RestartServerRemoteLogMessageFormat>Restart Server Remote Event Triggered: Shutting down server {0}</RestartServerRemoteLogMessageFormat> </restartServerEventHandlerSettings> <restartServerEventSettings type="Foundation.Kernel.Models.RestartServer.Events.RestartServerEventSettings, Foundation.Kernel" singleInstance="true"> <RestartCurrentServerEventName>server:restart</RestartCurrentServerEventName> <RestartRemoteServerEventName>server:restart:remote</RestartRemoteServerEventName> </restartServerEventSettings> <restartServerRemoteEventSettings type="Foundation.Kernel.Models.RestartServer.Events.RestartServerRemoteEventSettings, Foundation.Kernel" singleInstance="true"> <!-- setting this to "true" so I can test/debug locally --> <TriggerRemoteEventLocally>true</TriggerRemoteEventLocally> <TriggerRemoteEventGlobally>true</TriggerRemoteEventGlobally> </restartServerRemoteEventSettings> </kernel> </foundation> </moduleSettings> <services> <!-- General Services --> <register serviceType="Foundation.Kernel.Services.Installer.IInstallerService, Foundation.Kernel" implementationType="Foundation.Kernel.Services.Installer.InstallerService, Foundation.Kernel" lifetime="Singleton" /> <register serviceType="Foundation.Kernel.Services.Server.IServerService, Foundation.Kernel" implementationType="Foundation.Kernel.Services.Server.ServerService, Foundation.Kernel" lifetime="Singleton" /> <register serviceType="Foundation.Kernel.Services.RestartServer.IRestartServerFeatureToggleService, Foundation.Kernel" implementationType="Foundation.Kernel.Services.RestartServer.RestartServerFeatureToggleService, Foundation.Kernel" lifetime="Singleton" /> <register serviceType="Foundation.Kernel.Services.RestartServer.IRestartServerService, Foundation.Kernel" implementationType="Foundation.Kernel.Services.RestartServer.RestartServerService, Foundation.Kernel" lifetime="Singleton" /> <!-- Event Related Services --> <register serviceType="Foundation.Kernel.Services.Events.IEventService, Foundation.Kernel" implementationType="Foundation.Kernel.Services.Events.EventService, Foundation.Kernel" lifetime="Singleton" /> <register serviceType="Foundation.Kernel.Services.Events.IEventManagerService, Foundation.Kernel" implementationType="Foundation.Kernel.Services.Events.EventManagerService, Foundation.Kernel" lifetime="Singleton" /> <register serviceType="Foundation.Kernel.Services.Events.IEventTriggerer, Foundation.Kernel" implementationType="Foundation.Kernel.Services.Events.EventTriggerer, Foundation.Kernel" lifetime="Singleton" /> <!-- Event Handler Services --> <register serviceType="Foundation.Kernel.Events.RestartServer.IRestartLocalServerEventHandler, Foundation.Kernel" implementationType="Foundation.Kernel.Events.RestartServer.RestartLocalServerEventHandler, Foundation.Kernel" lifetime="Singleton" /> <register serviceType="Foundation.Kernel.Events.RestartServer.IRestartRemoteServerEventHandler, Foundation.Kernel" implementationType="Foundation.Kernel.Events.RestartServer.RestartRemoteServerEventHandler, Foundation.Kernel" lifetime="Singleton" /> <!-- Pipeline Processor Services --> <register serviceType="Foundation.Kernel.Pipelines.Initialize.RestartServer.IRestartServerSubscriber, Foundation.Kernel" implementationType="Foundation.Kernel.Pipelines.Initialize.RestartServer.RestartServerSubscriber, Foundation.Kernel" lifetime="Singleton" /> </services> <settings> <!-- This is set here for testing. Ideally, you would have this set for every Sitecore instance you have in your specific custom patch file where identify your server --> <setting name="InstanceName"> <patch:attribute name="value">Sandbox</patch:attribute> </setting> </settings> </sitecore> </configuration>
Considering I had built this to be executed from a PowerShell script hooked into a custom button through the Content Editor Ribbon integration point via Sitecore PowerShell Extensions (SPE), I will be testing this using two simple PowerShell scripts; both will be executed from the Sitecore PowerShell Extensions ISE (why yes, Sitecore MVP Michael West, I had tested this on SPE v6.1.1 😉 ):
Let’s see how we did.
Let’s test this by restarting the current Sitecore instance:
$serviceType = [Foundation.Kernel.Services.RestartServer.IRestartServerService] $service = [Sitecore.DependencyInjection.ServiceLocator]::ServiceProvider.GetService($serviceType) -as $serviceType #use service locator to get the IRestartServerService service $service.RestartCurrentServer() #Let's call the method to restart the current server (CM)
As expected, my Sitecore instance froze up as it was restarting. I then saw this in my logs:
Let’s now restart the remote Sitecore instance:
$serviceType = [Foundation.Kernel.Services.RestartServer.IRestartServerService] $service = [Sitecore.DependencyInjection.ServiceLocator]::ServiceProvider.GetService($serviceType) -as $serviceType #use service locator to get the IRestartServerService service $service.RestartRemoteServer("Sandbox") #Let's call the method to restart the remote server (CD)
Also as expected, my Sitecore instance froze up as it was restarting — remember, I am testing this on a single development instance of Sitecore where I don’t have a separate CM and CD . I then saw this in my logs:
Let me know if you have questions/comments/fears/dreams/hopes/apsirations/whatever by dropping a comment. 😉
Write Sitecore Experience Forms Log Entries to a Custom SQL Server Database Table
Not long after I wrote the code for my last post, I continued exploring ways of changing service classes in Sitecore Experience Forms.
One thing that popped out when continuing on this quest was Sitecore.ExperienceForms.Diagnostics.ILogger. I immediately thought “I just wrote code for retrieving Forms configuration settings from a SQL Server database table, why not create a new ILogger service class for storing log entries in a custom SQL table?”
Well, that’s what I did, and the code in this post captures how I went about doing that.
You might be asking “Mike, you know you can just use a SQL appender in log4net, right?”
Well, I certainly could have but what fun would that have been?
Anyways, let’s get started.
We first need a class that represents a log entry. I created the following POCO class to serve that purpose:
using System; namespace Sandbox.Foundation.Forms.Models.Logging { public class LogEntry { public Exception Exception { get; set; } public string LogEntryType { get; set; } public string LogMessage { get; set; } public string Message { get; set; } public object Owner { get; set; } public DateTime CreatedDate { get; set; } } }
Since I hate calling the “new” keyword when creating new instances of classes, I chose to create a factory class. The following interface will be for instances of classes that create LogEntry instances:
using System; using Sandbox.Foundation.Forms.Models.Logging; namespace Sandbox.Foundation.Forms.Services.Factories.Diagnostics { public interface ILogEntryFactory { LogEntry CreateLogEntry(string logEntryType, string message, Exception exception, Type ownerType, DateTime createdDate); LogEntry CreateLogEntry(string logEntryType, string message, Exception exception, object owner, DateTime createdDate); LogEntry CreateLogEntry(string logEntryType, string message, Type ownerType, DateTime createdDate); LogEntry CreateLogEntry(string logEntryType, string message, object owner, DateTime createdDate); } }
Well, we can’t do much with just an interface. The following class implements the interface above. It creates an instance of LogEntry with the passed parameters to all methods (assuming the required parameters are passed with the proper values on them):
using System; using Sandbox.Foundation.Forms.Models.Logging; namespace Sandbox.Foundation.Forms.Services.Factories.Diagnostics { public class LogEntryFactory : ILogEntryFactory { public LogEntry CreateLogEntry(string logEntryType, string message, Exception exception, Type ownerType, DateTime createdDate) { return CreateLogEntry(logEntryType, message, exception, ownerType, createdDate); } public LogEntry CreateLogEntry(string logEntryType, string message, Exception exception, object owner, DateTime createdDate) { if (!CanCreateLogEntry(logEntryType, message, owner, createdDate)) { return null; } return new LogEntry { LogEntryType = logEntryType, Message = message, Exception = exception, Owner = owner, CreatedDate = createdDate }; } public LogEntry CreateLogEntry(string logEntryType, string message, Type ownerType, DateTime createdDate) { return CreateLogEntry(logEntryType, message, ownerType, createdDate); } public LogEntry CreateLogEntry(string logEntryType, string message, object owner, DateTime createdDate) { if(!CanCreateLogEntry(logEntryType, message, owner, createdDate)) { return null; } return new LogEntry { LogEntryType = logEntryType, Message = message, Owner = owner, CreatedDate = createdDate }; } protected virtual bool CanCreateLogEntry(string logEntryType, string message, object owner, DateTime createdDate) { return !string.IsNullOrWhiteSpace(logEntryType) && !string.IsNullOrWhiteSpace(message) && owner != null && createdDate != DateTime.MinValue && createdDate != DateTime.MaxValue; } } }
I didn’t want to send LogEntry instances directly to a repository class instance directly, so I created the following class to represent the entities which will ultimately be stored in the database:
using System; namespace Sandbox.Foundation.Forms.Models.Logging { public class RepositoryLogEntry { public string LogEntryType { get; set; } public string LogMessage { get; set; } public DateTime Created { get; set; } } }
As I had done with LogEntry, I created a factory class for it. The difference here is we will be passing an instance of LogEntry to this new factory so we can create a RepositoryLogEntry instance from it.
The following interface is for factories of RepositoryLogEntry:
using System; using Sandbox.Foundation.Forms.Models.Logging; namespace Sandbox.Foundation.Forms.Services.Factories.Diagnostics { public interface IRepositoryLogEntryFactory { RepositoryLogEntry CreateRepositoryLogEntry(LogEntry entry); RepositoryLogEntry CreateRepositoryLogEntry(string logEntryType, string logMessage, DateTime created); } }
Now that we have the interface ready to go, we need an implementation class for it. The following class does the job:
using System; using Sandbox.Foundation.Forms.Models.Logging; namespace Sandbox.Foundation.Forms.Services.Factories.Diagnostics { public class RepositoryLogEntryFactory : IRepositoryLogEntryFactory { public RepositoryLogEntry CreateRepositoryLogEntry(LogEntry entry) { return CreateRepositoryLogEntry(entry.LogEntryType, entry.LogMessage, entry.CreatedDate); } public RepositoryLogEntry CreateRepositoryLogEntry(string logEntryType, string logMessage, DateTime created) { if (!CanCreateRepositoryLogEntry(logEntryType, logMessage, created)) { return null; } return new RepositoryLogEntry { LogEntryType = logEntryType, LogMessage = logMessage, Created = created }; } protected virtual bool CanCreateRepositoryLogEntry(string logEntryType, string logMessage, DateTime created) { return !string.IsNullOrWhiteSpace(logEntryType) && !string.IsNullOrWhiteSpace(logMessage) && created != DateTime.MinValue && created != DateTime.MaxValue; } } }
I’m following a similiar structure here as I had done in the LogEntryFactory class above. The CanCreateRepositoryLogEntry() method ensures required parameters are passed to methods on the class. If they are not, then a null reference is returned to the caller.
Since I hate hardcoding things, I decided to create a service class that gets the newline character. The following interface is for classes that do that:
namespace Sandbox.Foundation.Forms.Services.Environment { public interface IEnvironmentService { string GetNewLine(); } }
This next class implements the interface above:
namespace Sandbox.Foundation.Forms.Services.Environment { public class EnvironmentService : IEnvironmentService { public string GetNewLine() { return System.Environment.NewLine; } } }
In the class above, I’m taking advantage of stuff build into the .NET library for getting the newline character.
I love when I discover things like this, albeit wish I had found something like this when trying to find an html break string for something I was working on the other day, but I digress (if you know of a way, please let me know in a comment below 😉 ).
The above interface and class might seem out of place in this post but I am using them when formatting messages for the LogEntry instances further down in another service class. Just keep an eye out for it.
Since I loathe hardcoding strings with a passion, I like to hide these away in Sitecore configuration patch files and hydrate a POCO class instance with the values from the aforementioned configuration. The following class is such a POCO settings object for a service class I will discuss further down in the post:
namespace Sandbox.Foundation.Forms.Models.Logging { public class LogEntryServiceSettings { public string DebugLogEntryType { get; set; } public string ErrorLogEntryType { get; set; } public string FatalLogEntryType { get; set; } public string InfoLogEntryType { get; set; } public string WarnLogEntryType { get; set; } public string ExceptionPrefix { get; set; } public string MessagePrefix { get; set; } public string SourcePrefix { get; set; } public string NestedExceptionPrefix { get; set; } public string LogEntryTimeFormat { get; set; } } }
Okay, so need we need to know what “type” of LogEntry we are dealing with — is it an error or a warning or what? — before sending to a repository to save in the database. I created the following interface for service classes that return back strings for the different LogEntry types, and also generate a log message from the data on properties on the LogEntry instance — this is the message that will end up in the database for the LogEntry:
using Sandbox.Foundation.Forms.Models.Logging; namespace Sandbox.Foundation.Forms.Services.Diagnostics { public interface ILogEntryService { string GetDebugLogEntryType(); string GetErrorLogEntryType(); string GetFatalLogEntryType(); string GetInfoLogEntryType(); string GetWarnLogEntryType(); string GenerateLogMessage(LogEntry entry); } }
And here is its implementation class:
using System; using System.Text; using Sandbox.Foundation.Forms.Models.Logging; using Sandbox.Foundation.Forms.Services.Environment; namespace Sandbox.Foundation.Forms.Services.Diagnostics { public class LogEntryService : ILogEntryService { private readonly string _newLine; private readonly LogEntryServiceSettings _logEntryServiceSettings; public LogEntryService(IEnvironmentService environmentService, LogEntryServiceSettings logEntryServiceSettings) { _newLine = GetNewLine(environmentService); _logEntryServiceSettings = logEntryServiceSettings; } protected virtual string GetNewLine(IEnvironmentService environmentService) { return environmentService.GetNewLine(); } public string GetDebugLogEntryType() { return _logEntryServiceSettings.DebugLogEntryType; } public string GetErrorLogEntryType() { return _logEntryServiceSettings.ErrorLogEntryType; } public string GetFatalLogEntryType() { return _logEntryServiceSettings.FatalLogEntryType; } public string GetInfoLogEntryType() { return _logEntryServiceSettings.InfoLogEntryType; } public string GetWarnLogEntryType() { return _logEntryServiceSettings.WarnLogEntryType; } public string GenerateLogMessage(LogEntry entry) { if(!CanGenerateLogMessage(entry)) { return string.Empty; } string exceptionMessage = GenerateExceptionMessage(entry.Exception); if(string.IsNullOrWhiteSpace(exceptionMessage)) { return $"{entry.Message}"; } return $"{entry.Message} {exceptionMessage}"; } protected virtual bool CanGenerateLogMessage(LogEntry entry) { return entry != null && !string.IsNullOrWhiteSpace(entry.Message) && entry.Owner != null; } protected virtual string GenerateExceptionMessage(Exception exception) { if(exception == null) { return string.Empty; } StringBuilder messageBuilder = new StringBuilder(); messageBuilder.Append(_logEntryServiceSettings.ExceptionPrefix).Append(exception.GetType().FullName); ; AppendNewLine(messageBuilder); messageBuilder.Append(_logEntryServiceSettings.MessagePrefix).Append(exception.Message); AppendNewLine(messageBuilder); if (!string.IsNullOrWhiteSpace(exception.Source)) { messageBuilder.Append(_logEntryServiceSettings.SourcePrefix).Append(exception.Source); AppendNewLine(messageBuilder); } if(!string.IsNullOrWhiteSpace(exception.StackTrace)) { messageBuilder.Append(exception.StackTrace); AppendNewLine(messageBuilder); } if (exception.InnerException != null) { AppendNewLine(messageBuilder); messageBuilder.Append(_logEntryServiceSettings.NestedExceptionPrefix); AppendNewLine(messageBuilder, 3); messageBuilder.Append(GenerateExceptionMessage(exception.InnerException)); AppendNewLine(messageBuilder); } return messageBuilder.ToString(); } protected virtual void AppendNewLine(StringBuilder builder, int repeatCount = 1) { AppendRepeat(builder, _newLine, repeatCount); } protected virtual void AppendRepeat(StringBuilder builder, string stringToAppend, int repeatCount) { if (builder == null || string.IsNullOrWhiteSpace(stringToAppend) || repeatCount < 1) { return; } for(int i = 0; i < repeatCount; i++) { builder.Append(stringToAppend); } } } }
I’m not going to discuss all the code in the above class as it should be self-explanatory.
I do want to point out GenerateLogMessage() will generate one of two strings, depending on whether an Exception was set on the LogEntry instance.
If an Exception was set, we append the Exception details — the GenerateExceptionMessage() method generates a string from the Exception — onto the end of the LogEntry message
If it was not set, we just return the LogEntry message to the caller.
Well, now we need a place to store the log entries. I used the following SQL script to create a new table for storing these:
USE [ExperienceFormsSettings] GO CREATE TABLE [dbo].[ExperienceFormsLog]( [ID] [uniqueidentifier] NOT NULL, [LogEntryType] [nvarchar](max) NOT NULL, [LogMessage] [nvarchar](max) NOT NULL, [Created] [datetime] NOT NULL, CONSTRAINT [PK_ExperienceFormsLog] PRIMARY KEY CLUSTERED ( [ID] ASC )WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY] ) ON [PRIMARY] TEXTIMAGE_ON [PRIMARY] GO ALTER TABLE [dbo].[ExperienceFormsLog] ADD DEFAULT (newsequentialid()) FOR [ID] GO
I also sprinkled some magical database dust onto the table:
😉
Wonderful, we now can move on to the fun bit — actually writing some code to store these entries into the database table created from the SQL script above.
I wrote the following POCO class to represent a SQL command — either a query or statement (it really doesn’t matter as it will support both):
namespace Sandbox.Foundation.Forms.Models.Logging { public class SqlCommand { public string Sql { get; set; } public object[] Parameters { get; set; } } }
I’m sure I could have found something in Sitecore.Kernel.dll that does exactly what the class above does but I couldn’t find such a thing (if you know of such a class, please share in a comment below).
Now we need a settings class for the SQL Logger I am writing further down in this post. As I had done for the LogEntryService class above, this data will be coming from Sitecore configuration:
namespace Sandbox.Foundation.Forms.Models.Logging { public class SqlLoggerSettings { public string LogPrefix { get; set; } public string LogDatabaseConnectionStringName { get; set; } public string InsertLogEntrySqlFormat { get; set; } public string ConnectionStringNameColumnName { get; set; } public string FieldsPrefixColumnName { get; set; } public string FieldsIndexNameColumnName { get; set; } public int NotFoundOrdinal { get; set; } public string LogEntryTypeParameterName { get; set; } public string LogMessageParameterName { get; set; } public string CreatedParameterName { get; set; } } }
Now the fun part — creating an implementation of Sitecore.ExperienceForms.Diagnostics.ILogger:
using System; using Sitecore.Abstractions; using Sitecore.Data.DataProviders.Sql; using Sitecore.ExperienceForms.Diagnostics; using Sandbox.Foundation.Forms.Services.Factories; using Sandbox.Foundation.Forms.Models.Logging; using Sandbox.Foundation.Forms.Services.Factories.Diagnostics; namespace Sandbox.Foundation.Forms.Services.Diagnostics { public class SqlLogger : ILogger { private readonly SqlLoggerSettings _sqlLoggerSettings; private readonly BaseSettings _settings; private readonly BaseFactory _factory; private readonly SqlDataApi _sqlDataApi; private readonly ILogEntryFactory _logEntryFactory; private readonly ILogEntryService _logEntryService; private readonly IRepositoryLogEntryFactory _repositoryLogEntryFactory; public SqlLogger(SqlLoggerSettings sqlLoggerSettings, BaseSettings settings, BaseFactory factory, ISqlDataApiFactory sqlDataApiFactory, ILogEntryFactory logEntryFactory, IRepositoryLogEntryFactory repositoryLogEntryFactory, ILogEntryService logEntryService) { _sqlLoggerSettings = sqlLoggerSettings; _settings = settings; _factory = factory; _sqlDataApi = CreateSqlDataApi(sqlDataApiFactory); _logEntryFactory = logEntryFactory; _logEntryService = logEntryService; _repositoryLogEntryFactory = repositoryLogEntryFactory; } protected virtual SqlDataApi CreateSqlDataApi(ISqlDataApiFactory sqlDataApiFactory) { return sqlDataApiFactory.CreateSqlDataApi(GetLogDatabaseConnectionString()); } protected virtual string GetLogDatabaseConnectionString() { return _settings.GetConnectionString(GetLogDatabaseConnectionStringName()); } protected virtual string GetLogDatabaseConnectionStringName() { return _sqlLoggerSettings.LogDatabaseConnectionStringName; } public void Debug(string message) { Debug(message, GetDefaultOwner()); } public void Debug(string message, object owner) { SaveLogEntry(CreateLogEntry(GetDebugLogEntryType(), message, owner, GetLogEntryDateTime())); } protected virtual string GetDebugLogEntryType() { return _logEntryService.GetDebugLogEntryType(); } public void LogError(string message) { LogError(message, null, GetDefaultOwner()); } public void LogError(string message, object owner) { LogError(message, null, owner); } public void LogError(string message, Exception exception, Type ownerType) { LogError(message, exception, (object)ownerType); } public void LogError(string message, Exception exception, object owner) { SaveLogEntry(CreateLogEntry(GetErrorLogEntryType(), message, exception, owner, GetLogEntryDateTime())); } protected virtual string GetErrorLogEntryType() { return _logEntryService.GetErrorLogEntryType(); } public void Fatal(string message) { Fatal(message, null, GetDefaultOwner()); } public void Fatal(string message, object owner) { Fatal(message, null, owner); } public void Fatal(string message, Exception exception, Type ownerType) { Fatal(message, exception, (object)ownerType); } public void Fatal(string message, Exception exception, object owner) { SaveLogEntry(CreateLogEntry(GetFatalLogEntryType(), message, exception, owner, GetLogEntryDateTime())); } protected virtual string GetFatalLogEntryType() { return _logEntryService.GetFatalLogEntryType(); } public void Info(string message) { Info(message, GetDefaultOwner()); } public void Info(string message, object owner) { SaveLogEntry(CreateLogEntry(GetInfoLogEntryType(), message, owner, GetLogEntryDateTime())); } protected virtual string GetInfoLogEntryType() { return _logEntryService.GetInfoLogEntryType(); } public void Warn(string message) { Warn(message, GetDefaultOwner()); } public void Warn(string message, object owner) { SaveLogEntry(CreateLogEntry(GetWarnLogEntryType(), message, owner, GetLogEntryDateTime())); } protected virtual string AddPrefixToMessage(string message) { return string.Concat(_sqlLoggerSettings.LogPrefix, message); } protected virtual object GetDefaultOwner() { return this; } protected virtual LogEntry CreateLogEntry(string logEntryType, string message, Exception exception, Type ownerType, DateTime createdDate) { return _logEntryFactory.CreateLogEntry(logEntryType, message, exception, ownerType, createdDate); } protected virtual LogEntry CreateLogEntry(string logEntryType, string message, Exception exception, object owner, DateTime createdDate) { return _logEntryFactory.CreateLogEntry(logEntryType, message, exception, owner, createdDate); } protected virtual LogEntry CreateLogEntry(string logEntryType, string message, Type ownerType, DateTime createdDate) { return _logEntryFactory.CreateLogEntry(logEntryType, message, ownerType, createdDate); } protected virtual LogEntry CreateLogEntry(string logEntryType, string message, object owner, DateTime createdDate) { return _logEntryFactory.CreateLogEntry(logEntryType, message, owner, createdDate); } protected virtual string GetWarnLogEntryType() { return _logEntryService.GetWarnLogEntryType(); } protected virtual DateTime GetLogEntryDateTime() { return DateTime.Now.ToUniversalTime(); } protected virtual void SaveLogEntry(LogEntry entry) { if (entry == null) { return; } entry.LogMessage = _logEntryService.GenerateLogMessage(entry); RepositoryLogEntry repositoryEntry = CreateRepositoryLogEntry(entry); if (repositoryEntry == null) { return; } SaveRepositoryLogEntry(repositoryEntry); } protected virtual string GenerateLogMessage(LogEntry entry) { return _logEntryService.GenerateLogMessage(entry); } protected virtual RepositoryLogEntry CreateRepositoryLogEntry(LogEntry entry) { return _repositoryLogEntryFactory.CreateRepositoryLogEntry(entry); } protected virtual void SaveRepositoryLogEntry(RepositoryLogEntry entry) { if(!CanLogEntry(entry)) { return; } SqlCommand insertCommand = GetinsertCommand(entry); if(insertCommand == null) { return; } ExecuteNoResult(insertCommand); } protected virtual bool CanLogEntry(RepositoryLogEntry entry) { return entry != null && !string.IsNullOrWhiteSpace(entry.LogEntryType) && !string.IsNullOrWhiteSpace(entry.LogMessage) && entry.Created > DateTime.MinValue && entry.Created < DateTime.MaxValue; } protected virtual SqlCommand GetinsertCommand(RepositoryLogEntry entry) { return new SqlCommand { Sql = GetInsertLogEntrySql(), Parameters = GetinsertCommandParameters(entry) }; } protected virtual object[] GetinsertCommandParameters(RepositoryLogEntry entry) { return new object[] { GetLogEntryTypeParameterName(), entry.LogEntryType, GetLogMessageParameterName(), entry.LogMessage, GetCreatedParameterName(), entry.Created }; } protected virtual string GetLogEntryTypeParameterName() { return _sqlLoggerSettings.LogEntryTypeParameterName; } protected virtual string GetLogMessageParameterName() { return _sqlLoggerSettings.LogMessageParameterName; } protected virtual string GetCreatedParameterName() { return _sqlLoggerSettings.CreatedParameterName; } protected virtual string GetInsertLogEntrySql() { return _sqlLoggerSettings.InsertLogEntrySqlFormat; } protected virtual void ExecuteNoResult(SqlCommand sqlCommand) { _factory.GetRetryer().ExecuteNoResult(() => { _sqlDataApi.Execute(sqlCommand.Sql, sqlCommand.Parameters); }); } } }
Since there is a lot of code in the class above, I’m not going to talk about all of it — it should be clear on what this class is doing for the most part.
I do want to highlight that the SaveRepositoryLogEntry() method takes in a RepositoryLogEntry instance; builds up a SqlCommand instance from it as well as the insert SQL statement and parameters from the SqlLoggerSettings instance (these are coming from Sitecore configuration, and there are hooks on this class to allow for overriding these if needed); and passes the SqlCommand instance to the ExecuteNoResult() method which uses the SqlDataApi instance for saving to the database. Plus, I’m leveraging an “out of the box” “retryer” from the Sitecore.Kernel.dll to ensure it makes its way into the database table.
Moreover, I’m reusing the ISqlDataApiFactory instance above from my previous post. Have a read of it so you can see what this factory class does.
Since Experience Forms was built perfectly — 😉 — I couldn’t see any LogEntry instances being saved to my database right away. So went ahead and created some <forms.renderField> pipeline processors to capture some.
The following interface is for a <forms.renderField> pipeline processor to just throw an exception by dividing by zero:
using Sitecore.ExperienceForms.Mvc.Pipelines.RenderField; namespace Sandbox.Foundation.Forms.Pipelines.RenderField { public interface IThrowExceptionProcessor { void Process(RenderFieldEventArgs args); } }
Here is its implementation class:
using System; using Sitecore.ExperienceForms.Diagnostics; using Sitecore.ExperienceForms.Mvc.Pipelines.RenderField; namespace Sandbox.Foundation.Forms.Pipelines.RenderField { public class ThrowExceptionProcessor : IThrowExceptionProcessor { private readonly ILogger _logger; public ThrowExceptionProcessor(ILogger logger) { _logger = logger; } public void Process(RenderFieldEventArgs args) { try { int i = 1 / GetZero(); } catch(Exception ex) { _logger.LogError(ToString(), ex, this); } } private int GetZero() { return 0; } } }
I’m sure you would never do such a thing, right? 😉
I then created the following interface for another <forms.renderField> pipeline processor to log some information on the RenderFieldEventArgs instance sent to the Process() method:
using Sitecore.ExperienceForms.Mvc.Pipelines.RenderField; namespace Sandbox.Foundation.Forms.Pipelines.RenderField { public interface ILogRenderedFieldInfo { void Process(RenderFieldEventArgs args); } }
Here is the implementation class for this:
using Sitecore.ExperienceForms.Diagnostics; using Sitecore.ExperienceForms.Mvc.Pipelines.RenderField; using Sitecore.Mvc.Pipelines; namespace Sandbox.Foundation.Forms.Pipelines.RenderField { public class LogRenderedFieldInfo : MvcPipelineProcessor<RenderFieldEventArgs>, ILogRenderedFieldInfo { private readonly ILogger _logger; public LogRenderedFieldInfo(ILogger logger) { _logger = logger; } public override void Process(RenderFieldEventArgs args) { LogInfo($"ViewModel Details:\n\nName: {args.ViewModel.Name}, ItemId: {args.ViewModel.ItemId}, TemplateId: {args.ViewModel.TemplateId}, FieldTypeItemId: {args.ViewModel.FieldTypeItemId}"); LogInfo($"RenderingSettings Details\n\nFieldTypeName: {args.RenderingSettings.FieldTypeName}, FieldTypeId: {args.RenderingSettings.FieldTypeId}, FieldTypeIcon: {args.RenderingSettings.FieldTypeIcon}, FieldTypeDisplayName: {args.RenderingSettings.FieldTypeDisplayName}, FieldTypeBackgroundColor: {args.RenderingSettings.FieldTypeBackgroundColor}"); LogInfo($"Item Details: {args.Item.ID}, Name: {args.Item.Name} FullPath: {args.Item.Paths.FullPath}, TemplateID: {args.Item.TemplateID}"); } protected virtual void LogInfo(string message) { if(string.IsNullOrWhiteSpace(message)) { return; } _logger.Info(message); } } }
I then registered everything in the Sitecore IoC container using the following configurator:
using System; using Microsoft.Extensions.DependencyInjection; using Sitecore.Abstractions; using Sitecore.DependencyInjection; using Sitecore.ExperienceForms.Diagnostics; using Sandbox.Foundation.Forms.Services.Factories.Diagnostics; using Sandbox.Foundation.Forms.Services.Factories; using Sandbox.Foundation.Forms.Models.Logging; using Sandbox.Foundation.Forms.Services.Environment; using Sandbox.Foundation.Forms.Services.Diagnostics; using Sandbox.Foundation.Forms.Pipelines.RenderField; namespace Sandbox.Foundation.Forms { public class SqlLoggerConfigurator : IServicesConfigurator { public void Configure(IServiceCollection serviceCollection) { ConfigureConfigObjects(serviceCollection); ConfigureFactories(serviceCollection); ConfigureServices(serviceCollection); ConfigurePipelineProcessors(serviceCollection); } private void ConfigureConfigObjects(IServiceCollection serviceCollection) { serviceCollection.AddSingleton(provider => GetLogEntryServiceSettings(provider)); serviceCollection.AddSingleton(provider => GetSqlLoggerSettings(provider)); } private LogEntryServiceSettings GetLogEntryServiceSettings(IServiceProvider provider) { return CreateConfigObject<LogEntryServiceSettings>(provider, "moduleSettings/foundation/forms/logEntryServiceSettings"); } private SqlLoggerSettings GetSqlLoggerSettings(IServiceProvider provider) { return CreateConfigObject<SqlLoggerSettings>(provider, "moduleSettings/foundation/forms/sqlLoggerSettings"); } private TConfigObject CreateConfigObject<TConfigObject>(IServiceProvider provider, string path) where TConfigObject : class { BaseFactory factory = GetService<BaseFactory>(provider); return factory.CreateObject(path, true) as TConfigObject; } private TService GetService<TService>(IServiceProvider provider) { return provider.GetService<TService>(); } private void ConfigureFactories(IServiceCollection serviceCollection) { serviceCollection.AddSingleton<ILogEntryFactory, LogEntryFactory>(); serviceCollection.AddSingleton<IRepositoryLogEntryFactory, RepositoryLogEntryFactory>(); serviceCollection.AddSingleton<ISqlDataApiFactory, SqlDataApiFactory>(); } private void ConfigureServices(IServiceCollection serviceCollection) { serviceCollection.AddSingleton<IEnvironmentService, EnvironmentService>(); serviceCollection.AddSingleton<ILogEntryService, LogEntryService>(); serviceCollection.AddSingleton<ILogger, SqlLogger>(); } private void ConfigurePipelineProcessors(IServiceCollection serviceCollection) { serviceCollection.AddSingleton<ILogRenderedFieldInfo, LogRenderedFieldInfo>(); serviceCollection.AddSingleton<IThrowExceptionProcessor, ThrowExceptionProcessor>(); } } }
Note: the GetLogEntryServiceSettings() and the GetSqlLoggerSettings() methods both create settings objects by using the Sitecore Configuration Factory. Ultimately, these settings objects are thrown into the container so they can be injected into the service classes that need them.
I then strung everything together using the following the Sitecore patch configuration file.
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/"> <sitecore> <pipelines> <forms.renderField> <processor type="Sandbox.Foundation.Forms.Pipelines.RenderField.LogRenderedFieldInfo, Sandbox.Foundation.Forms" resolve="true"/> <processor type="Sandbox.Foundation.Forms.Pipelines.RenderField.ThrowExceptionProcessor, Sandbox.Foundation.Forms" resolve="true"/> </forms.renderField> </pipelines> <services> <configurator type="Sandbox.Foundation.Forms.SqlLoggerConfigurator, Sandbox.Foundation.Forms" /> <register serviceType="Sitecore.ExperienceForms.Diagnostics.ILogger, Sitecore.ExperienceForms"> <patch:delete /> </register> </services> <moduleSettings> <foundation> <forms> <logEntryServiceSettings type="Sandbox.Foundation.Forms.Models.Logging.LogEntryServiceSettings, Sandbox.Foundation.Forms" singleInstance="true"> <DebugLogEntryType>DEBUG</DebugLogEntryType> <ErrorLogEntryType>ERROR</ErrorLogEntryType> <FatalLogEntryType>FATAL</FatalLogEntryType> <InfoLogEntryType>INFO</InfoLogEntryType> <WarnLogEntryType>WARN</WarnLogEntryType> <ExceptionPrefix>Exception: </ExceptionPrefix> <MessagePrefix>Message: </MessagePrefix> <SourcePrefix>Source: </SourcePrefix> <NestedExceptionPrefix>Nested Exception</NestedExceptionPrefix> <LogEntryTimeFormat>HH:mm:ss.ff</LogEntryTimeFormat> </logEntryServiceSettings> <sqlLoggerSettings type="Sandbox.Foundation.Forms.Models.Logging.SqlLoggerSettings, Sandbox.Foundation.Forms" singleInstance="true"> <LogPrefix>[Experience Forms]:</LogPrefix> <LogDatabaseConnectionStringName>ExperienceFormsSettings</LogDatabaseConnectionStringName> <InsertLogEntrySqlFormat>INSERT INTO {0}ExperienceFormsLog{1}({0}LogEntryType{1},{0}LogMessage{1},{0}Created{1})VALUES({2}logEntryType{3},{2}logMessage{3},{2}created{3});</InsertLogEntrySqlFormat> <LogEntryTypeParameterName>logEntryType</LogEntryTypeParameterName> <LogMessageParameterName>logMessage</LogMessageParameterName> <CreatedParameterName>created</CreatedParameterName> </sqlLoggerSettings> </forms> </foundation> </moduleSettings> </sitecore> </configuration>
Ok, let’s take this for a spin.
After building and deploying everything above, I spun up my Sitecore instance:
I then navigated to a form I had created in a previous post:
After the page with my form was done loading, I ran a query on my custom log table and saw this:
As you can see, it worked.
If you have any questions or comments, don’t hesitate to drop these in a comment below.
Until next time, have yourself a Sitecoretastic day!
Display How Many Bucketed Items Live Within a Sitecore Item Bucket Using a Custom DataView
Over the past few weeks — if you haven’t noticed — I’ve been having a blast experimenting with Sitecore Item Buckets. It seems new ideas on what to build for it keep flooding my thoughts everyday. 😀
However, the other day an old idea that I wanted to solve a while back bubbled its way up into the forefront of my consciousness: displaying the count of Bucketed Items which live within each Item Bucket in the Content Tree.
I’m sure someone has built something to do this before though I didn’t really do any research on it as I was up for the challenge.
In all honesty, I enjoy spending my nights after work and on weekends building things in Sitecore — even if someone has built something like it before — as it’s a great way to not only discover new treasures hidden within the Sitecore assemblies, but also improve my programming skills — the saying “you lose it if you don’t use it” applies here.
You might be asking “Mike, we don’t store that many Sitecore Items in our Item Buckets; I can just go count them all by hand”.
Well, if that’s the case for you then you might want to reconsider why you are using the Item Buckets feature.
However, in theory, thousands if not millions of Items can live within an Item Bucket in Sitecore. If counting by hand is your thing — or even writing some sort of “script” (I’m not referring to PowerShell scripts that you would write using Sitecore PowerShell Extensions (SPE) — I definitely recommend harnessing all of the power this module has to offer — but instead to standalone ASP.NET Web Forms which some people erroneously call “scripts”) to generate some kind of report, then by all means go for it.
That’s just not how I roll.
So how are we going to display these counts to the user? We are ultimately going to create a custom Sitecore DataView.
If you aren’t familiar with DataViews in Sitecore, they basically allow you to change how Items are displayed in the Sitecore Content Tree.
I’m not going to go too much into details of how these work. I recommend having a read of the following posts by two fellow Sitecore MVPs for more information and to see other examples:
- Sitecore dataviews by Sitecore MVP Mikael Högberg
- Custom Sitecore DataViews by Sitecore MVP Mike Edwards
I do want to warn you: there is a lot of code in this post.
You might want to go get a snack for this as it might take a while to get through all the code that I am showing here. Don’t worry, I’ll wait for you to get back.
Anyways, let’s jump right into it.
For this feature, I want to add a checkbox toggle in the Sitecore Ribbon to give users the ability turn this feature on and off.
In order to save the state of this checkbox, I defined the following interface:
namespace Sitecore.Sandbox.Web.UI.HtmlControls.Registries { public interface IRegistry { bool GetBool(string key); bool GetBool(string key, bool defaultvalue); int GetInt(string key); int GetInt(string key, int defaultvalue); string GetString(string key); string GetString(string key, string defaultvalue); string GetValue(string key); void SetBool(string key, bool val); void SetInt(string key, int val); void SetString(string key, string value); void SetValue(string key, string value); } }
Classes of the above interface will keep track of settings which need to be stored somewhere.
The following class implements the interface above:
namespace Sitecore.Sandbox.Web.UI.HtmlControls.Registries { public class Registry : IRegistry { public virtual bool GetBool(string key) { return Sitecore.Web.UI.HtmlControls.Registry.GetBool(key); } public virtual bool GetBool(string key, bool defaultvalue) { return Sitecore.Web.UI.HtmlControls.Registry.GetBool(key, defaultvalue); } public virtual int GetInt(string key) { return Sitecore.Web.UI.HtmlControls.Registry.GetInt(key); } public virtual int GetInt(string key, int defaultvalue) { return Sitecore.Web.UI.HtmlControls.Registry.GetInt(key, defaultvalue); } public virtual string GetString(string key) { return Sitecore.Web.UI.HtmlControls.Registry.GetString(key); } public virtual string GetString(string key, string defaultvalue) { return Sitecore.Web.UI.HtmlControls.Registry.GetString(key, defaultvalue); } public virtual string GetValue(string key) { return Sitecore.Web.UI.HtmlControls.Registry.GetValue(key); } public virtual void SetBool(string key, bool val) { Sitecore.Web.UI.HtmlControls.Registry.SetBool(key, val); } public virtual void SetInt(string key, int val) { Sitecore.Web.UI.HtmlControls.Registry.SetInt(key, val); } public virtual void SetString(string key, string value) { Sitecore.Web.UI.HtmlControls.Registry.SetString(key, value); } public virtual void SetValue(string key, string value) { Sitecore.Web.UI.HtmlControls.Registry.SetValue(key, value); } } }
I’m basically wrapping calls to methods on the static Sitecore.Web.UI.HtmlControls.Registry class which is used for saving state on the checkboxes in the Sitecore ribbon — it might be used for keeping track of other things in the Sitecore Content Editor though that is beyond the scope of this post. Nothing magical going on here.
I then defined the following interface for keeping track of Content Editor settings for things related to Item Buckets:
namespace Sitecore.Sandbox.Buckets.Settings { public interface IBucketsContentEditorSettings { bool ShowBucketedItemsCount { get; set; } bool AreItemBucketsEnabled { get; } } }
The ShowBucketedItemsCount boolean property lets the caller know if we are to show the Bucketed Items count, and the AreItemBucketsEnabled boolean property lets the caller know if the Item Buckets feature is enabled in Sitecore.
The following class implements the interface above:
using Sitecore.Diagnostics; using Sitecore.Sandbox.Determiners.Features; using Sitecore.Sandbox.Web.UI.HtmlControls.Registries; namespace Sitecore.Sandbox.Buckets.Settings { public class BucketsContentEditorSettings : IBucketsContentEditorSettings { protected IFeatureDeterminer ItemBucketsFeatureDeterminer { get; set; } protected IRegistry Registry { get; set; } protected string ShowBucketedItemsCountRegistryKey { get; set; } public bool ShowBucketedItemsCount { get { return ShouldShowBucketedItemsCount(); } set { ToggleShowBucketedItemsCount(value); } } public bool AreItemBucketsEnabled { get { return GetAreItemBucketsEnabled(); } } protected virtual bool ShouldShowBucketedItemsCount() { if (!AreItemBucketsEnabled) { return false; } EnsureRegistryDependencies(); return Registry.GetBool(ShowBucketedItemsCountRegistryKey, false); } protected virtual void ToggleShowBucketedItemsCount(bool turnOn) { if (!AreItemBucketsEnabled) { return; } EnsureRegistryDependencies(); Registry.SetBool(ShowBucketedItemsCountRegistryKey, turnOn); } protected virtual void EnsureRegistryDependencies() { Assert.IsNotNull(Registry, "Registry must be defined in configuration!"); Assert.IsNotNullOrEmpty(ShowBucketedItemsCountRegistryKey, "ShowBucketedItemsCountRegistryKey must be defined in configuration!"); } protected virtual bool GetAreItemBucketsEnabled() { Assert.IsNotNull(ItemBucketsFeatureDeterminer, "ItemBucketsFeatureDeterminer must be defined in configuration!"); return ItemBucketsFeatureDeterminer.IsEnabled(); } } }
I’m injecting an IFeatureDeterminer instance into the instance of the class above via the Sitecore Configuration Factory — have a look at the patch configuration file further down in this post — specifically the ItemBucketsFeatureDeterminer which is defined in a previous blog post. The IFeatureDeterminer instance determines whether the Item Buckets feature is turned on/off (I’m not going to repost that code here so if you haven’t seen this code, please go have a look now so you have an understanding of what it’s doing).
Its instance is used in the GetAreItemBucketsEnabled() method which just delegates to its IsEnabled() method and returns the value from that call. The GetAreItemBucketsEnabled() method is used in the get accessor of the AreItemBucketsEnabled property.
I’m also injecting an IRegistry instance into the instance of the class above — this is also defined in the patch configuration file further down — which is used for storing/retrieving the value of the ShowBucketedItemsCount property.
It is leveraged in the ShouldShowBucketedItemsCount() and ToggleShowBucketedItemsCount() methods where a boolean value is saved or retrieved, respectively, in the Sitecore Registry under a certain key — this key is also injected into the ShowBucketedItemsCountRegistryKey property via the Sitecore Configuration Factory.
So, we now have a way to keep track of whether we should display the Bucketed Items count. We just need a way to let the user turn this on/off. To do that, I need to create a custom Sitecore.Shell.Framework.Commands.Command.
Since Sitecore Commands are instantiated by the CreateObject() method on the MainUtil class (this lives in the Sitecore namespace in Sitecore.Kernel.dll and isn’t as advanced as the Sitecore.Configuration.Factory class as it won’t instantiate nested objects defined in configuration as does the Sitecore Configuration Factory), I built the following Command which will decorate Commands defined in Sitecore configuration:
using System.Xml; using Sitecore.Configuration; using Sitecore.Diagnostics; using Sitecore.Shell.Framework.Commands; using Sitecore.Web.UI.HtmlControls; using Sitecore.Xml; namespace Sitecore.Sandbox.Shell.Framework.Commands { public class ExtendedConfigCommand : Command { private Command command; protected Command Command { get { if(command == null) { command = GetCommand(); EnsureCommand(); } return command; } } protected virtual Command GetCommand() { XmlNode currentCommandNode = Factory.GetConfigNode(string.Format("commands/command[@name='{0}']", Name)); string configPath = XmlUtil.GetAttribute("extendedCommandPath", currentCommandNode); Assert.IsNotNullOrEmpty(configPath, string.Format("The extendedCommandPath attribute must be set {0}!", currentCommandNode)); Command command = Factory.CreateObject(configPath, false) as Command; Assert.IsNotNull(command, string.Format("The command defined at '{0}' was either not properly set or is not an instance of Sitecore.Shell.Framework.Commands.Command. Double-check it!", configPath)); return command; } protected virtual void EnsureCommand() { Assert.IsNotNull(Command, "GetCommand() cannot return a null Sitecore.Shell.Framework.Commands.Command instance!"); } public override void Execute(CommandContext context) { Command.Execute(context); } public override string GetClick(CommandContext context, string click) { return Command.GetClick(context, click); } public override string GetHeader(CommandContext context, string header) { return Command.GetHeader(context, header); } public override string GetIcon(CommandContext context, string icon) { return Command.GetIcon(context, icon); } public override Control[] GetSubmenuItems(CommandContext context) { return Command.GetSubmenuItems(context); } public override string GetToolTip(CommandContext context, string tooltip) { return Command.GetToolTip(context, tooltip); } public override string GetValue(CommandContext context, string value) { return Command.GetValue(context, value); } public override CommandState QueryState(CommandContext context) { return Command.QueryState(context); } } }
The GetCommand() method reads the XmlNode for the current command, and gets the value set on its extendedCommandPath attribute. This value must to be a config path defined under the <sitecore> element in Sitecore configuration.
If the attribute doesn’t exist or is empty, or a Command instance isn’t properly created, an exception is thrown.
Otherwise, it is set on the Command property on the class.
All methods here delegate to the same methods on the Command stored in the Command property.
I then defined the following Command which will be used by the checkbox we are adding to the Sitecore Ribbon:
using Sitecore.Diagnostics; using Sitecore.Shell.Framework.Commands; using Sitecore.Web.UI.Sheer; using Sitecore.Sandbox.Buckets.Settings; namespace Sitecore.Sandbox.Buckets.Shell.Framework.Commands { public class ToggleBucketedItemsCountCommand : Command { protected IBucketsContentEditorSettings BucketsContentEditorSettings { get; set; } public override void Execute(CommandContext context) { if (!AreItemBucketsEnabled()) { return; } ToggleShowBucketedItemsCount(); Reload(); } protected virtual void ToggleShowBucketedItemsCount() { Assert.IsNotNull(BucketsContentEditorSettings, "BucketsContentEditorSettings must be defined in configuration!"); BucketsContentEditorSettings.ShowBucketedItemsCount = !BucketsContentEditorSettings.ShowBucketedItemsCount; } protected virtual void Reload() { SheerResponse.SetLocation(string.Empty); } public override CommandState QueryState(CommandContext context) { if(!AreItemBucketsEnabled()) { return CommandState.Hidden; } if(!ShouldShowBucketedItemsCount()) { return CommandState.Enabled; } return CommandState.Down; } protected virtual bool AreItemBucketsEnabled() { Assert.IsNotNull(BucketsContentEditorSettings, "BucketsContentEditorSettings must be defined in configuration!"); return BucketsContentEditorSettings.AreItemBucketsEnabled; } protected virtual bool ShouldShowBucketedItemsCount() { Assert.IsNotNull(BucketsContentEditorSettings, "BucketsContentEditorSettings must be defined in configuration!"); return BucketsContentEditorSettings.ShowBucketedItemsCount; } } }
The QueryState() method determines whether we should display the checkbox — it will only be displayed if the Item Buckets feature is on — and what the state of the checkbox should be — if we are currently showing Bucketed Items count, the checkbox will be checked (this is represented by CommandState.Down). Otherwise, it will be unchecked (this is represented by CommandState.Enabled).
The Execute() method encapsulates the logic of what we are to do when the user checks/unchecks the checkbox. It’s basically delegating to the ToggleShowBucketedItemsCount() method to toggle the value of whether we are to display the Bucketed Items count, and then reloads the Content Editor to refresh the display in the Content Tree.
I then had to define this checkbox in the Core database:
I’m not going to go into details of how the above works as I’ve written over a gazillion posts on the subject. I recommend having a read of one of these older posts.
After going back to my Master database, I saw the new checkbox in the Sitecore Ribbon:
Since we could be dealing with thousands — if not millions — of Bucketed Items for each Item Bucket, we need a performant way to grab the count of these Items. In this solution, I am leveraging the Sitecore.ContentSearch API to get these counts though needed to add some custom
Computed Index Field classes:
using Sitecore.Buckets.Managers; using Sitecore.Configuration; using Sitecore.ContentSearch; using Sitecore.ContentSearch.ComputedFields; using Sitecore.Data.Items; using Sitecore.Diagnostics; using Sitecore.Sandbox.Buckets.Util.Methods; namespace Sitecore.Sandbox.Buckets.ContentSearch.ComputedFields { public class IsBucketed : AbstractComputedIndexField { protected IItemBucketsFeatureMethods ItemBucketsFeatureMethods { get; private set; } public IsBucketed() { ItemBucketsFeatureMethods = GetItemBucketsFeatureMethods(); Assert.IsNotNull(ItemBucketsFeatureMethods, "GetItemBucketsFeatureMethods() cannot return null!"); } protected virtual IItemBucketsFeatureMethods GetItemBucketsFeatureMethods() { IItemBucketsFeatureMethods methods = Factory.CreateObject("buckets/methods/itemBucketsFeatureMethods", false) as IItemBucketsFeatureMethods; Assert.IsNotNull(methods, "the IItemBucketsFeatureMethods instance was not defined properly in /sitecore/buckets/methods/itemBucketsFeatureMethods!"); return methods; } public override object ComputeFieldValue(IIndexable indexable) { Item item = indexable as SitecoreIndexableItem; if (item == null) { return null; } return IsBucketable(item) && IsItemContainedWithinBucket(item); } protected virtual bool IsBucketable(Item item) { Assert.ArgumentNotNull(item, "item"); return BucketManager.IsBucketable(item); } protected virtual bool IsItemContainedWithinBucket(Item item) { Assert.ArgumentNotNull(item, "item"); if(IsItemBucket(item)) { return false; } return ItemBucketsFeatureMethods.IsItemContainedWithinBucket(item); } protected virtual bool IsItemBucket(Item item) { Assert.ArgumentNotNull(item, "item"); if (!ItemBucketsFeatureMethods.IsItemBucket(item)) { return false; } return true; } } }
An instance of the class above ultimately determines if an Item is bucketed within an Item Bucket, and passes a boolean value to its caller denoting this via its ComputeFieldValue() method.
What determines whether an Item is bucketed? The code above says it’s bucketed only when the Item is bucketable and is contained within an Item Bucket.
The IsBucketable() method above ascertains whether the Item is bucketable by delegating to the IsBucketable() method on the BucketManager class in Sitecore.Buckets.dll.
The IsItemContainedWithinBucket() method determines if the Item is contained within an Item Bucket — you might be laughing as the name on the method is self-documenting — by delegating to the IsItemContainedWithinBucket() method on the IItemBucketsFeatureMethods instance — I’ve defined the code for this in this post so go have a look.
Moreover, the code does not consider Item Buckets to be Bucketed as that just doesn’t make much sense. 😉 This would also give us an inaccurate count.
The following Computed Index Field’s ComputeFieldValue() method returns the string representation of the ancestor Item Bucket’s Sitecore.Data.ID for the Item — if it is contained within an Item Bucket:
using Sitecore.Configuration; using Sitecore.ContentSearch; using Sitecore.ContentSearch.ComputedFields; using Sitecore.ContentSearch.Utilities; using Sitecore.Data; using Sitecore.Data.Items; using Sitecore.Diagnostics; using Sitecore.Sandbox.Buckets.Util.Methods; namespace Sitecore.Sandbox.Buckets.ContentSearch.ComputedFields { public class ItemBucketAncestorId : AbstractComputedIndexField { protected IItemBucketsFeatureMethods ItemBucketsFeatureMethods { get; private set; } public ItemBucketAncestorId() { ItemBucketsFeatureMethods = GetItemBucketsFeatureMethods(); Assert.IsNotNull(ItemBucketsFeatureMethods, "GetItemBucketsFeatureMethods() cannot return null!"); } protected virtual IItemBucketsFeatureMethods GetItemBucketsFeatureMethods() { IItemBucketsFeatureMethods methods = Factory.CreateObject("buckets/methods/itemBucketsFeatureMethods", false) as IItemBucketsFeatureMethods; Assert.IsNotNull(methods, "the IItemBucketsFeatureMethods instance was not defined properly in /sitecore/buckets/methods/itemBucketsFeatureMethods!"); return methods; } public override object ComputeFieldValue(IIndexable indexable) { Item item = indexable as SitecoreIndexableItem; if (item == null) { return null; } Item itemBucketAncestor = GetItemBucketAncestor(item); if(itemBucketAncestor == null) { return null; } return NormalizeGuid(itemBucketAncestor.ID); } protected virtual Item GetItemBucketAncestor(Item item) { Assert.ArgumentNotNull(item, "item"); if(IsItemBucket(item)) { return null; } Item itemBucket = ItemBucketsFeatureMethods.GetItemBucket(item); if(!IsItemBucket(itemBucket)) { return null; } return itemBucket; } protected virtual bool IsItemBucket(Item item) { Assert.ArgumentNotNull(item, "item"); if (!ItemBucketsFeatureMethods.IsItemBucket(item)) { return false; } return true; } protected virtual string NormalizeGuid(ID id) { return IdHelper.NormalizeGuid(id); } } }
Not to go too much into details of the class above, it will only return an Item Bucket’s Sitecore.Data.ID as a string if the Item lives within an Item Bucket and is not itself an Item Bucket.
If the Item is not within an Item Bucket or is an Item Bucket, null is returned to the caller via the ComputeFieldValue() method.
I then created the following subclass of Sitecore.ContentSearch.SearchTypes.SearchResultItem — this lives in Sitecore.ContentSearch.dll — in order to use the values in the index that the previous Computed Field Index classes returned for their storage in the search index:
using System.ComponentModel; using Sitecore.ContentSearch; using Sitecore.ContentSearch.Converters; using Sitecore.ContentSearch.SearchTypes; using Sitecore.Data; namespace Sitecore.Sandbox.Buckets.ContentSearch.SearchTypes { public class BucketedSearchResultItem : SearchResultItem { [IndexField("item_bucket_ancestor_id")] [TypeConverter(typeof(IndexFieldIDValueConverter))] public ID ItemBucketAncestorId { get; set; } [IndexField("is_bucketed")] public bool IsBucketed { get; set; } } }
Now, we need a class to get the Bucketed Item count for an Item Bucket. I defined the following interface for class implementations that do just that:
using Sitecore.Data.Items; namespace Sitecore.Sandbox.Buckets.Providers.Items { public interface IBucketedItemsCountProvider { int GetBucketedItemsCount(Item itemBucket); } }
I then created the following class that implements the interface above:
using System; using System.Collections.Generic; using System.Linq; using System.Linq.Expressions; using System.Xml; using Sitecore.Configuration; using Sitecore.ContentSearch; using Sitecore.ContentSearch.Linq; using Sitecore.ContentSearch.Linq.Utilities; using Sitecore.ContentSearch.SearchTypes; using Sitecore.ContentSearch.Utilities; using Sitecore.Data; using Sitecore.Data.Items; using Sitecore.Diagnostics; using Sitecore.Xml; using Sitecore.Sandbox.Buckets.ContentSearch.SearchTypes; namespace Sitecore.Sandbox.Buckets.Providers.Items { public class BucketedItemsCountProvider : IBucketedItemsCountProvider { protected IDictionary<string, ISearchIndex> SearchIndexMap { get; private set; } public BucketedItemsCountProvider() { SearchIndexMap = CreateNewSearchIndexMap(); } protected virtual IDictionary<string, ISearchIndex> CreateNewSearchIndexMap() { return new Dictionary<string, ISearchIndex>(); } protected virtual void AddSearchIndexMap(XmlNode configNode) { if(configNode == null) { return; } string databaseName = XmlUtil.GetAttribute("database", configNode, null); Assert.IsNotNullOrEmpty(databaseName, "The database attribute on the searchIndexMap configuration element cannot be null or the empty string!"); Assert.ArgumentCondition(!SearchIndexMap.ContainsKey(databaseName), "database", "The searchIndexMap configuration element's database attribute values must be unique!"); Database database = Factory.GetDatabase(databaseName); Assert.IsNotNull(database, string.Format("No database exists with the name of '{0}'! Make sure the database attribute on your searchIndexMap configuration element is set correctly!", databaseName)); string searchIndexName = XmlUtil.GetAttribute("searchIndex", configNode, null); Assert.IsNotNullOrEmpty(searchIndexName, "The searchIndex attribute on the searchIndexMap configuration element cannot be null or the empty string!"); ISearchIndex searchIndex = GetSearchIndex(searchIndexName); Assert.IsNotNull(searchIndex, string.Format("No search index exists with the name of '{0}'! Make sure the searchIndex attribute on your searchIndexMap configuration element is set correctly", searchIndexName)); SearchIndexMap.Add(databaseName, searchIndex); } public virtual int GetBucketedItemsCount(Item bucketItem) { Assert.ArgumentNotNull(bucketItem, "bucketItem"); ISearchIndex searchIndex = GetSearchIndex(); using (IProviderSearchContext searchContext = searchIndex.CreateSearchContext()) { var predicate = GetSearchPredicate<BucketedSearchResultItem>(bucketItem.ID); IQueryable<SearchResultItem> query = searchContext.GetQueryable<BucketedSearchResultItem>().Filter(predicate); SearchResults<SearchResultItem> results = query.GetResults(); return results.Count(); } } protected virtual ISearchIndex GetSearchIndex() { string databaseName = GetContentDatabaseName(); Assert.IsNotNullOrEmpty(databaseName, "The GetContentDatabaseName() method cannot return null or the empty string!"); Assert.ArgumentCondition(SearchIndexMap.ContainsKey(databaseName), "databaseName", string.Format("There is no ISearchIndex instance mapped to the database: '{0}'!", databaseName)); return SearchIndexMap[databaseName]; } protected virtual string GetContentDatabaseName() { Database database = Context.ContentDatabase ?? Context.Database; Assert.IsNotNull(database, "Argggggh! There's no content database! Houston, we have a problem!"); return database.Name; } protected virtual ISearchIndex GetSearchIndex(string searchIndexName) { Assert.ArgumentNotNullOrEmpty(searchIndexName, "searchIndexName"); return ContentSearchManager.GetIndex(searchIndexName); } protected virtual Expression<Func<TSearchResultItem, bool>> GetSearchPredicate<TSearchResultItem>(ID itemBucketId) where TSearchResultItem : BucketedSearchResultItem { Assert.ArgumentCondition(!ID.IsNullOrEmpty(itemBucketId), "itemBucketId", "itemBucketId cannot be null or empty!"); var predicate = PredicateBuilder.True<TSearchResultItem>(); predicate = predicate.And(item => item.ItemBucketAncestorId == itemBucketId); predicate = predicate.And(item => item.IsBucketed); return predicate; } } }
Ok, so what’s going on in the class above? The AddSearchIndexMap() method is called by the Sitecore Configuration Factory to add database-to-search-index mappings — have a look at the patch configuration file further below. The code is looking up the appropriate search index for the content/context database.
The GetBucketedItemsCount() method gets the “predicate” from the GetSearchPredicate() method which basically says “Hey, I want an Item that has an ancestor Item Bucket Sitecore.Data.ID which is the same as the Sitecore.Data.ID passed to the method, and also this Item should be bucketed”.
The GetBucketedItemsCount() method then employs the Sitecore.ContentSearch API to get the result-set of the Items for the query, and returns the count of those Items.
Just as Commands, DataViews in Sitecore are instantiated by the CreateObject() method on MainUtil. I want to utilize the Sitecore Configuration Factory instead so that my nested configuration elements are instantiated and injected into my custom DataView. I built the following interface to make that possible:
using System.Collections; using Sitecore.Collections; using Sitecore.Data; using Sitecore.Data.Items; using Sitecore.Globalization; namespace Sitecore.Sandbox.Web.UI.HtmlControls.DataViews { public interface IDataViewBaseExtender { void FilterItems(ref ArrayList children, string filter); void GetChildItems(ItemCollection items, Item item); Database GetDatabase(); Item GetItemFromID(string id, Language language, Version version); Item GetParentItem(Item item); bool HasChildren(Item item, string filter); void Initialize(string parameters); bool IsAncestorOf(Item ancestor, Item item); void SortItems(ArrayList children, string sortBy, bool sortAscending); } }
All of the methods in the above interface correspond to virtual methods defined on the Sitecore.Web.UI.HtmlControl.DataViewBase class in Sitecore.Kernel.dll.
I then built the following abstract class which inherits from any DataView class that inherits from Sitecore.Web.UI.HtmlControl.DataViewBase:
using System.Collections; using Sitecore.Collections; using Sitecore.Configuration; using Sitecore.Data; using Sitecore.Data.Items; using Sitecore.Diagnostics; using Sitecore.Globalization; using Sitecore.Web.UI.HtmlControls; namespace Sitecore.Sandbox.Web.UI.HtmlControls.DataViews { public abstract class ExtendedDataView<TDataView> : DataViewBase where TDataView : DataViewBase { protected IDataViewBaseExtender DataViewBaseExtender { get; private set; } protected ExtendedDataView() { DataViewBaseExtender = GetDataViewBaseExtender(); EnsureDataViewBaseExtender(); } protected virtual IDataViewBaseExtender GetDataViewBaseExtender() { string configPath = GetDataViewBaseExtenderConfigPath(); Assert.IsNotNullOrEmpty(configPath, "GetDataViewBaseExtenderConfigPath() cannot return null or the empty string!"); IDataViewBaseExtender dataViewBaseExtender = Factory.CreateObject(configPath, false) as IDataViewBaseExtender; Assert.IsNotNull(dataViewBaseExtender, string.Format("the IDataViewBaseExtender instance was not defined properly in '{0}'!", configPath)); return dataViewBaseExtender; } protected abstract string GetDataViewBaseExtenderConfigPath(); protected virtual void EnsureDataViewBaseExtender() { Assert.IsNotNull(DataViewBaseExtender, "GetDataViewBaseExtender() cannot return a null IDataViewBaseExtender instance!"); } protected override void FilterItems(ref ArrayList children, string filter) { DataViewBaseExtender.FilterItems(ref children, filter); } protected override void GetChildItems(ItemCollection items, Item item) { DataViewBaseExtender.GetChildItems(items, item); } public override Database GetDatabase() { return DataViewBaseExtender.GetDatabase(); } protected override Item GetItemFromID(string id, Language language, Version version) { return DataViewBaseExtender.GetItemFromID(id, language, version); } protected override Item GetParentItem(Item item) { return DataViewBaseExtender.GetParentItem(item); } public override bool HasChildren(Item item, string filter) { return DataViewBaseExtender.HasChildren(item, filter); } public override void Initialize(string parameters) { DataViewBaseExtender.Initialize(parameters); } public override bool IsAncestorOf(Item ancestor, Item item) { return DataViewBaseExtender.IsAncestorOf(ancestor, item); } protected override void SortItems(ArrayList children, string sortBy, bool sortAscending) { DataViewBaseExtender.SortItems(children, sortBy, sortAscending); } } }
The GetDataViewBaseExtender() method gets the config path for the configuration-defined IDataViewBaseExtender — these IDataViewBaseExtender configuration definitions may or may not have nested configuration elements which will also be instantiated by the Sitecore Configuration Factory — from the abstract GetDataViewBaseExtenderConfigPath() method (subclasses must define this method).
The GetDataViewBaseExtender() then employs the Sitecore Configuration Factory to create this IDataViewBaseExtender instance, and return it to the caller (it’s being called in the class’ constructor).
If the instance is null, an exception is thrown.
All other methods in the above class delegate to methods with the same name and parameters on the IDataViewBaseExtender instance.
I then built the following subclass of the abstract class above:
using Sitecore.Web.UI.HtmlControls; namespace Sitecore.Sandbox.Web.UI.HtmlControls.DataViews { public class ExtendedMasterDataView : ExtendedDataView<MasterDataView> { protected override string GetDataViewBaseExtenderConfigPath() { return "extendedDataViews/extendedMasterDataView"; } } }
The above class is used for extending the MasterDataView in Sitecore.
It’s now time for the “real deal” DataView that does what we want: show the Bucketed Item counts for Item Buckets. The instance of the following class does just that:
using System.Collections; using Sitecore.Buckets.Forms; using Sitecore.Collections; using Sitecore.Data; using Sitecore.Data.Fields; using Sitecore.Data.Items; using Sitecore.Diagnostics; using Sitecore.Globalization; using Sitecore.Sandbox.Buckets.Providers.Items; using Sitecore.Sandbox.Buckets.Settings; using Sitecore.Sandbox.Buckets.Util.Methods; using Sitecore.Sandbox.Web.UI.HtmlControls.DataViews; namespace Sitecore.Sandbox.Buckets.Forms { public class BucketedItemsCountDataView : BucketDataView, IDataViewBaseExtender { protected IBucketsContentEditorSettings BucketsContentEditorSettings { get; set; } protected IItemBucketsFeatureMethods ItemBucketsFeatureMethods { get; set; } protected IBucketedItemsCountProvider BucketedItemsCountProvider { get; set; } protected string SingularBucketedItemsDisplayNameFormat { get; set; } protected string PluralBucketedItemsDisplayNameFormat { get; set; } void IDataViewBaseExtender.FilterItems(ref ArrayList children, string filter) { FilterItems(ref children, filter); } void IDataViewBaseExtender.GetChildItems(ItemCollection children, Item parent) { GetChildItems(children, parent); } protected override void GetChildItems(ItemCollection children, Item parent) { base.GetChildItems(children, parent); if(!ShouldShowBucketedItemsCount()) { return; } for (int i = children.Count - 1; i >= 0; i--) { Item child = children[i]; if (IsItemBucket(child)) { int count = GetBucketedItemsCount(child); Item alteredItem = GetCountDisplayNameItem(child, count); children.RemoveAt(i); children.Insert(i, alteredItem); } } } protected virtual bool ShouldShowBucketedItemsCount() { Assert.IsNotNull(BucketsContentEditorSettings, "BucketsContentEditorSettings must be defined in configuration!"); return BucketsContentEditorSettings.ShowBucketedItemsCount; } protected virtual bool IsItemBucket(Item item) { Assert.IsNotNull(ItemBucketsFeatureMethods, "ItemBucketsFeatureMethods must be set in configuration!"); Assert.ArgumentNotNull(item, "item"); return ItemBucketsFeatureMethods.IsItemBucket(item); } protected virtual int GetBucketedItemsCount(Item itemBucket) { Assert.IsNotNull(BucketedItemsCountProvider, "BucketedItemsCountProvider must be set in configuration!"); Assert.ArgumentNotNull(itemBucket, "itemBucket"); return BucketedItemsCountProvider.GetBucketedItemsCount(itemBucket); } protected virtual Item GetCountDisplayNameItem(Item item, int count) { FieldList fields = new FieldList(); item.Fields.ReadAll(); foreach (Field field in item.Fields) { fields.Add(field.ID, field.Value); } int bucketedCount = GetBucketedItemsCount(item); string displayName = GetItemNameWithBucketedCount(item, bucketedCount); ItemDefinition itemDefinition = new ItemDefinition(item.ID, displayName, item.TemplateID, ID.Null); return new Item(item.ID, new ItemData(itemDefinition, item.Language, item.Version, fields), item.Database) { RuntimeSettings = { Temporary = true } }; } protected virtual string GetItemNameWithBucketedCount(Item item, int bucketedCount) { Assert.IsNotNull(SingularBucketedItemsDisplayNameFormat, "SingularBucketedItemsDisplayNameFormat must be set in configuration!"); Assert.IsNotNull(PluralBucketedItemsDisplayNameFormat, "PluralBucketedItemsDisplayNameFormat must be set in configuration!"); if (bucketedCount == 1) { return ReplaceTokens(SingularBucketedItemsDisplayNameFormat, item, bucketedCount); } return ReplaceTokens(PluralBucketedItemsDisplayNameFormat, item, bucketedCount); } protected virtual string ReplaceTokens(string format, Item item, int bucketedCount) { Assert.ArgumentNotNullOrEmpty(format, "format"); Assert.ArgumentNotNull(item, "item"); string replaced = format; replaced = replaced.Replace("$displayName", item.DisplayName); replaced = replaced.Replace("$bucketedCount", bucketedCount.ToString()); return replaced; } Database IDataViewBaseExtender.GetDatabase() { return GetDatabase(); } Item IDataViewBaseExtender.GetItemFromID(string id, Language language, Version version) { return GetItemFromID(id, language, version); } Item IDataViewBaseExtender.GetParentItem(Item item) { return GetParentItem(item); } bool IDataViewBaseExtender.HasChildren(Item item, string filter) { return HasChildren(item, filter); } void IDataViewBaseExtender.Initialize(string parameters) { Initialize(parameters); } bool IDataViewBaseExtender.IsAncestorOf(Item ancestor, Item item) { return IsAncestorOf(ancestor, item); } void IDataViewBaseExtender.SortItems(ArrayList children, string sortBy, bool sortAscending) { SortItems(children, sortBy, sortAscending); } } }
You might be saying to yourself “Mike, what in the world is going on here?” 😉 Let me explain by starting with the GetChildItems() method.
The GetChildItems() method is used to build up the collection of child Items that display in the Content Tree when you expand a parent node. It does this by populating the ItemCollection instance passed to it.
The particular implementation above is delegating to the base class’ implementation to get the list of child Items for display in the Content Tree.
If we should not show the Bucketed Items count — this is determined by the ShouldShowBucketedItemsCount() method which just returns the boolean value set on the ShowBucketedItemsCount property of the injected IBucketsContentEditorSettings instance — the code just exits.
If we are to show the Bucketed Items count, we iterate over the ItemCollection collection and see if any of these child Items are Item Buckets — this is determined by the IsItemBucket() method.
If we find an Item Bucket, we get its count of Bucketed Items via the GetBucketedItemsCount() method which delegates to the GetBucketedItemsCount() method on the injected IBucketedItemsCountProvider instance.
Once we have the count, we call the GetCountDisplayNameItem() method which populates a FieldList collection with all of the fields defined on the Item Bucket; call the GetItemNameWithBucketedCount() method to get the new display name to show in the Content Tree — this method determines which display name format to use depending on whether we should use singular or pluralized messaging, and expands value on tokens via the ReplaceTokens() method — these tokens are defined in the patch configuration file below; creates an ItemDefinition instance so we can set the new display name; and returns a new Sitecore.Data.Items.Item instance to the caller.
No, don’t worry, we aren’t adding a new Item in the content tree but creating a fake “wrapper” of the real one, and replacing this in the ItemCollection.
We also have to fully implement the IDataViewBaseExtender interface. For most methods, I just delegate to the corresponding methods defined on the base class except for the IDataViewBaseExtender.GetChildItems() method which uses the GetChildItems() method defined above.
I then bridged everything above together via the following patch configuration file:
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/"> <sitecore> <buckets> <extendedCommands> <toggleBucketedItemsCountCommand type="Sitecore.Sandbox.Buckets.Shell.Framework.Commands.ToggleBucketedItemsCountCommand, Sitecore.Sandbox" singleInstance="on"> <BucketsContentEditorSettings ref="buckets/settings/bucketsContentEditorSettings" /> </toggleBucketedItemsCountCommand> </extendedCommands> <providers> <items> <bucketedItemsCountProvider type="Sitecore.Sandbox.Buckets.Providers.Items.BucketedItemsCountProvider, Sitecore.Sandbox" singleInstance="true"> <searchIndexMaps hint="raw:AddSearchIndexMap"> <searchIndexMap database="master" searchIndex="sitecore_master_index" /> <searchIndexMap database="web" searchIndex="sitecore_web_index" /> </searchIndexMaps> </bucketedItemsCountProvider> </items> </providers> <settings> <bucketsContentEditorSettings type="Sitecore.Sandbox.Buckets.Settings.BucketsContentEditorSettings, Sitecore.Sandbox" singleInstance="true"> <ItemBucketsFeatureDeterminer ref="determiners/features/itemBucketsFeatureDeterminer"/> <Registry ref="registries/registry" /> <ShowBucketedItemsCountRegistryKey>/Current_User/UserOptions.View.ShowBucketedItemsCount</ShowBucketedItemsCountRegistryKey> </bucketsContentEditorSettings> </settings> </buckets> <commands> <command name="contenteditor:togglebucketeditemscount" type="Sitecore.Sandbox.Shell.Framework.Commands.ExtendedConfigCommand, Sitecore.Sandbox" extendedCommandPath="buckets/extendedCommands/toggleBucketedItemsCountCommand" /> </commands> <contentSearch> <indexConfigurations> <defaultLuceneIndexConfiguration> <fieldMap> <fieldNames> <field fieldName="item_bucket_ancestor_id" storageType="YES" indexType="TOKENIZED" vectorType="NO" boost="1f" type="System.String" settingType="Sitecore.ContentSearch.LuceneProvider.LuceneSearchFieldConfiguration, Sitecore.ContentSearch.LuceneProvider"> <analyzer type="Sitecore.ContentSearch.LuceneProvider.Analyzers.LowerCaseKeywordAnalyzer, Sitecore.ContentSearch.LuceneProvider" /> </field> <field fieldName="is_bucketed" storageType="YES" indexType="TOKENIZED" vectorType="NO" boost="1f" type="System.Boolean" settingType="Sitecore.ContentSearch.LuceneProvider.LuceneSearchFieldConfiguration, Sitecore.ContentSearch.LuceneProvider" /> </fieldNames> </fieldMap> <documentOptions> <fields hint="raw:AddComputedIndexField"> <field fieldName="item_bucket_ancestor_id">Sitecore.Sandbox.Buckets.ContentSearch.ComputedFields.ItemBucketAncestorId, Sitecore.Sandbox</field> <field fieldName="is_bucketed">Sitecore.Sandbox.Buckets.ContentSearch.ComputedFields.IsBucketed, Sitecore.Sandbox</field> </fields> </documentOptions> </defaultLuceneIndexConfiguration> </indexConfigurations> </contentSearch> <dataviews> <dataview name="Master"> <patch:attribute name="assembly">Sitecore.Sandbox</patch:attribute> <patch:attribute name="type">Sitecore.Sandbox.Web.UI.HtmlControls.DataViews.ExtendedMasterDataView</patch:attribute> </dataview> </dataviews> <extendedDataViews> <extendedMasterDataView type="Sitecore.Sandbox.Buckets.Forms.BucketedItemsCountDataView, Sitecore.Sandbox" singleInstance="true"> <BucketsContentEditorSettings ref="buckets/settings/bucketsContentEditorSettings" /> <ItemBucketsFeatureMethods ref="buckets/methods/itemBucketsFeatureMethods" /> <BucketedItemsCountProvider ref="buckets/providers/items/bucketedItemsCountProvider" /> <SingularBucketedItemsDisplayNameFormat>$displayName <span style="font-style: italic; color: blue;">($bucketedCount bucketed item)<span></SingularBucketedItemsDisplayNameFormat> <PluralBucketedItemsDisplayNameFormat>$displayName <span style="font-style: italic; color: blue;">($bucketedCount bucketed items)<span></PluralBucketedItemsDisplayNameFormat> </extendedMasterDataView> </extendedDataViews> <registries> <registry type="Sitecore.Sandbox.Web.UI.HtmlControls.Registries.Registry, Sitecore.Sandbox" singleInstance="true" /> </registries> </sitecore> </configuration>
Let’s see this in action:
As you can see, it is working as intended.
Magical, right?
Well, not really — it just appears that way. 😉
If you have any thoughts on this, please drop a comment.
Yet Another Way to Store Data Outside of the Sitecore Experience Platform
Last February, Sitecore MVP Nick Wesselman shared an awesome blog post on storing data outside of the Sitecore® Experience Platform™ using the NHibernate ORM framework — if you haven’t had a chance to read this, I strongly recommend that you do — which is complete magic, and a simple solution where you don’t have to worry about spinning up your own database tables for storing information.
But, let’s suppose you aren’t allowed to use an ORM like NHibernate in your solution for some reason — I won’t go into potential reasons but let’s make pretend there is one — and you have to find a way to store Sitecore specific information but don’t want to go through the trouble of spinning up a new Sitecore database due to the overhead involved. What can you do?
Well, you can still store information in a non-Sitecore database using the Sitecore API. The following “proof of concept” does this, and is basically modeled after how Sitecore manages data stored in the IDTable and Links Database.
The code in the following “proof of concept” adds/retrieves/deletes alternative URLs for Sitecore Items in the following custom database table:
I’m not going to talk much about the SQL table or SQL statements used in this “proof of concept” since it’s beyond the scope of this post.
Of course we all love things that are performant — and our clients love when we make things performant — so I decided to start off my solution using the following adapter — this includes the interface and concrete class — for an instance of Sitecore.Caching.Cache (this lives in Sitecore.Kernel.dll):
using System; using System.Collections; using Sitecore.Caching; using Sitecore.Data; using Sitecore.Diagnostics.PerformanceCounters; namespace Sitecore.Sandbox.Caching { public interface ICacheProvider { bool CacheWriteEnabled { get; set; } int Count { get; } CachePriority DefaultPriority { get; set; } bool Enabled { get; set; } AmountPerSecondCounter ExternalCacheClearingsCounter { get; set; } ID Id { get; } long MaxSize { get; set; } string Name { get; } long RemainingSpace { get; } bool Scavengable { get; set; } long Size { get; } object SyncRoot { get; } object this[object key] { get; } void Add(ID key, ICacheable data); void Add(ID key, string value); void Add(string key, ICacheable data); void Add(string key, ID value); Cache.CacheEntry Add(string key, string data); void Add(ID key, object data, long dataLength); void Add(object key, object data, long dataLength); void Add(string key, object data, long dataLength); void Add(object key, object data, long dataLength, DateTime absoluteExpiration); void Add(object key, object data, long dataLength, TimeSpan slidingExpiration); Cache.CacheEntry Add(string key, object data, long dataLength, DateTime absoluteExpiration); void Add(string key, object data, long dataLength, EventHandler<EntryRemovedEventArgs> removedHandler); Cache.CacheEntry Add(string key, object data, long dataLength, TimeSpan slidingExpiration); void Add(object key, object data, long dataLength, TimeSpan slidingExpiration, DateTime absoluteExpiration); void Clear(); bool ContainsKey(ID key); bool ContainsKey(object key); ArrayList GetCacheKeys(); ArrayList GetCacheKeys(string keyPrefix); Cache.CacheEntry GetEntry(object key, bool updateAccessed); object GetValue(object key); void Remove(object key); void Remove<TKey>(Predicate<TKey> predicate); void RemoveKeysContaining(string value); void RemovePrefix(string keyPrefix); void Scavenge(); } }
using System; using System.Collections; using Sitecore.Caching; using Sitecore.Data; using Sitecore.Diagnostics; using Sitecore.Diagnostics.PerformanceCounters; namespace Sitecore.Sandbox.Caching { public class CacheProvider : ICacheProvider { private Cache Cache { get; set; } public CacheProvider(string cacheName, string cacheSize) { Assert.ArgumentNotNullOrEmpty(cacheName, "cacheName"); Assert.ArgumentNotNullOrEmpty(cacheSize, "cacheSize"); Cache = new Cache(cacheName, StringUtil.ParseSizeString(cacheSize)); } public bool CacheWriteEnabled { get { return Cache.CacheWriteEnabled; } set { Cache.CacheWriteEnabled = value; } } public int Count { get { return Cache.Count; } } public CachePriority DefaultPriority { get { return Cache.DefaultPriority; } set { Cache.DefaultPriority = value; } } public bool Enabled { get { return Cache.Enabled; } set { Cache.Enabled = value; } } public AmountPerSecondCounter ExternalCacheClearingsCounter { get { return Cache.ExternalCacheClearingsCounter; } set { Cache.ExternalCacheClearingsCounter = value; } } public ID Id { get { return Cache.Id; } } public long MaxSize { get { return Cache.MaxSize; } set { Cache.MaxSize = value; } } public string Name { get { return Cache.Name; } } public long RemainingSpace { get { return Cache.RemainingSpace; } } public bool Scavengable { get { return Cache.Scavengable; } set { Cache.Scavengable = value; } } public long Size { get { return Cache.Size; } } public object SyncRoot { get { return Cache.SyncRoot; } } public object this[object key] { get { return Cache[key]; } } public void Add(ID key, ICacheable data) { Cache.Add(key, data); } public void Add(ID key, string value) { Cache.Add(key, value); } public void Add(string key, ICacheable data) { Cache.Add(key, data); } public void Add(string key, ID value) { Cache.Add(key, value); } public Cache.CacheEntry Add(string key, string data) { return Cache.Add(key, data); } public void Add(ID key, object data, long dataLength) { Cache.Add(key, data, dataLength); } public void Add(object key, object data, long dataLength) { Cache.Add(key, data, dataLength); } public void Add(string key, object data, long dataLength) { Cache.Add(key, data, dataLength); } public void Add(object key, object data, long dataLength, DateTime absoluteExpiration) { Cache.Add(key, data, dataLength, absoluteExpiration); } public void Add(object key, object data, long dataLength, TimeSpan slidingExpiration) { Cache.Add(key, data, dataLength, slidingExpiration); } public Cache.CacheEntry Add(string key, object data, long dataLength, DateTime absoluteExpiration) { return Cache.Add(key, data, dataLength, absoluteExpiration); } public void Add(string key, object data, long dataLength, EventHandler<EntryRemovedEventArgs> removedHandler) { Cache.Add(key, data, dataLength, removedHandler); } public Cache.CacheEntry Add(string key, object data, long dataLength, TimeSpan slidingExpiration) { return Cache.Add(key, data, dataLength, slidingExpiration); } public void Add(object key, object data, long dataLength, TimeSpan slidingExpiration, DateTime absoluteExpiration) { Cache.Add(key, data, dataLength, slidingExpiration, absoluteExpiration); } public void Clear() { Cache.Clear(); } public bool ContainsKey(ID key) { return Cache.ContainsKey(key); } public bool ContainsKey(object key) { return Cache.ContainsKey(key); } public ArrayList GetCacheKeys() { return Cache.GetCacheKeys(); } public ArrayList GetCacheKeys(string keyPrefix) { return Cache.GetCacheKeys(keyPrefix); } public Cache.CacheEntry GetEntry(object key, bool updateAccessed) { return Cache.GetEntry(key, updateAccessed); } public object GetValue(object key) { return Cache.GetValue(key); } public void Remove(object key) { Cache.Remove(key); } public void Remove<TKey>(Predicate<TKey> predicate) { Cache.Remove<TKey>(predicate); } public void RemoveKeysContaining(string value) { Cache.RemoveKeysContaining(value); } public void RemovePrefix(string keyPrefix) { Cache.RemovePrefix(keyPrefix); } public void Scavenge() { Cache.Scavenge(); } } }
I’m not going to talk about the above interface or class since it just wraps Sitecore.Caching.Cache, and there isn’t much to talk about here.
Next, I spun up the following class that represents an entry in our custom SQL table:
using System; using Sitecore.Caching; using Sitecore.Data; using Sitecore.Reflection; using Newtonsoft.Json; namespace Sitecore.Sandbox.Data.Providers.ItemUrls { public class ItemUrlEntry : ICacheable, ICloneable { public ID ItemID { get; set; } public string Site { get; set; } public string Database { get; set; } public string Url { get; set; } bool cacheable; bool ICacheable.Cacheable { get { return cacheable; } set { cacheable = value; } } bool ICacheable.Immutable { get { return true; } } event DataLengthChangedDelegate ICacheable.DataLengthChanged { add { } remove { } } long ICacheable.GetDataLength() { return TypeUtil.SizeOfID() + TypeUtil.SizeOfString(Site) + TypeUtil.SizeOfString(Database) + TypeUtil.SizeOfString(Url); } public object Clone() { return new ItemUrlEntry { ItemID = ItemID, Site = Site, Database = Database, Url = Url }; } public override string ToString() { return JsonConvert.SerializeObject(this); } } }
Entries can contain the ID of the Sitecore Item; the specific site we are storing this url for; and the target Database.
You’ll notice I’ve implemented the Sitecore.Caching.ICacheable interface. I’ve done this so I can store entries in cache for performance. I’m not going to go much into the details of how this works since there isn’t much to point out.
I also override the ToString() method for testing purposes. You’ll see this in action later on when we test this together.
Next, we need some sort of provider to manage these entries. I’ve defined the following interface for such a provider:
using System.Collections.Generic; using Sitecore.Data; using Sitecore.Data.Items; using Sitecore.Sites; namespace Sitecore.Sandbox.Data.Providers.ItemUrls { public interface IItemUrlsProvider { void AddEntry(ItemUrlEntry entry); void RemoveEntry(ItemUrlEntry entry); Item GetItem(ItemUrlEntry entry); ItemUrlEntry GetEntry(ItemUrlEntry entry); IEnumerable<ItemUrlEntry> GetAllEntries(); } }
IItemUrlsProviders should have the ability to add/remove/retrieve entries. They should also offer the ability to get all entries — I need this for testing later on in this post.
Plus, as a “nice to have”, these providers should return a Sitecore Item for a given entry. Such would be useful when retrieving and setting the context Sitecore Item via a custom Item Resolver (you would typically have an <httpRequestBegin> pipeline processor that does this).
I then created the following class that implements the IItemUrlsProvider interface defined above. This class is specific to adding/removing/retrieving entries from a custom SQL database:
using System; using System.Collections.Generic; using Sitecore.Caching; using Sitecore.Configuration; using Sitecore.Data; using Sitecore.Data.DataProviders.Sql; using Sitecore.Data.Items; using Sitecore.Diagnostics; using Sitecore.Sandbox.Caching; namespace Sitecore.Sandbox.Data.Providers.ItemUrls.SqlServer { public class SqlServerItemUrlsProvider : IItemUrlsProvider { private SqlDataApi SqlDataApi { get; set; } protected ICacheProvider CacheProvider { get; private set; } protected string CachePrefix { get; private set; } public SqlServerItemUrlsProvider(SqlDataApi sqlDataApi, ICacheProvider cacheProvider, string cachePrefix) { Assert.ArgumentNotNull(sqlDataApi, "sqlDataApi"); Assert.ArgumentNotNull(cacheProvider, "cacheProvider"); Assert.ArgumentNotNullOrEmpty(cachePrefix, "cachePrefix"); SqlDataApi = sqlDataApi; CacheProvider = cacheProvider; CachePrefix = cachePrefix; } public void AddEntry(ItemUrlEntry entry) { Assert.ArgumentNotNull(entry, "entry"); Assert.ArgumentCondition(!ID.IsNullOrEmpty(entry.ItemID), "entry.ItemID", "entry.ItemID cannot be null or empty"); Assert.ArgumentNotNullOrEmpty(entry.Site, "entry.Site"); Assert.ArgumentNotNullOrEmpty(entry.Database, "entry.Database"); Assert.ArgumentNotNullOrEmpty(entry.Url, "entry.Url"); const string addEntrySql = "INSERT INTO {0}ItemUrls{1} ( {0}ItemID{1}, {0}Site{1}, {0}Database{1}, {0}Url{1} ) VALUES ( {2}itemID{3}, {2}site{3}, {2}database{3}, {2}url{3} )"; var success = Factory.GetRetryer().Execute(() => { object[] parameters = new object[] { "itemID", entry.ItemID, "site", entry.Site, "database", entry.Database, "url", entry.Url }; return SqlDataApi.Execute(addEntrySql, parameters) > 0; }); if (success) { AddToCache(entry); } } public void RemoveEntry(ItemUrlEntry entry) { const string deleteEntrySql = "DELETE FROM {0}ItemUrls{1} WHERE {0}Site{1} = {2}site{3} AND {0}Database{1} = {2}database{3} AND {0}Url{1} = {2}url{3}"; var success = Factory.GetRetryer().Execute(() => { object[] parameters = new object[] { "site", entry.Site, "database", entry.Database, "url", entry.Url }; return SqlDataApi.Execute(deleteEntrySql, parameters) > 0; }); if (success) { RemoveFromCache(entry); } } public Item GetItem(ItemUrlEntry entry) { ItemUrlEntry foundEntry = GetEntry(entry); if(foundEntry == null) { return null; } Database database = Factory.GetDatabase(foundEntry.Database); if(database == null) { return null; } try { return database.Items[foundEntry.ItemID]; } catch(Exception ex) { Log.Error(ToString(), ex, this); } return null; } public ItemUrlEntry GetEntry(ItemUrlEntry entry) { ItemUrlEntry foundEntry = GetFromCache(entry); if (foundEntry != null) { return foundEntry; } const string getEntrySql = "SELECT {0}ItemID{1} FROM {0}ItemUrls{1} WHERE {2}Site = {2}site{3} AND {2}Database{3} = {2}database{3} AND {0}Url{1} = {2}url{3}"; object[] parameters = new object[] { "site", entry.Site, "database", entry.Database, "url", entry.Url }; using (DataProviderReader reader = SqlDataApi.CreateReader(getEntrySql, parameters)) { if (!reader.Read()) { return null; } ID itemID = ID.Parse(SqlDataApi.GetGuid(0, reader)); if (ID.IsNullOrEmpty(itemID)) { return null; } foundEntry = entry.Clone() as ItemUrlEntry; foundEntry.ItemID = itemID; AddToCache(entry); return foundEntry; } } public IEnumerable<ItemUrlEntry> GetAllEntries() { const string getAllEntriesSql = "SELECT {0}ItemID{1}, {0}Site{1}, {0}Database{1}, {0}Url{1} FROM {0}ItemUrls{1}"; IList<ItemUrlEntry> entries = new List<ItemUrlEntry>(); using (DataProviderReader reader = SqlDataApi.CreateReader(getAllEntriesSql, new object[0])) { while(reader.Read()) { ID itemID = ID.Parse(SqlDataApi.GetGuid(0, reader)); if (!ID.IsNullOrEmpty(itemID)) { entries.Add ( new ItemUrlEntry { ItemID = itemID, Site = SqlDataApi.GetString(1, reader), Database = SqlDataApi.GetString(2, reader), Url = SqlDataApi.GetString(3, reader) } ); } } } return entries; } protected virtual void AddToCache(ItemUrlEntry entry) { CacheProvider.Add(GetCacheKey(entry), entry); } protected virtual void RemoveFromCache(ItemUrlEntry entry) { CacheProvider.Remove(GetCacheKey(entry)); } protected virtual ItemUrlEntry GetFromCache(ItemUrlEntry entry) { return CacheProvider[GetCacheKey(entry)] as ItemUrlEntry; } protected virtual string GetCacheKey(ItemUrlEntry entry) { Assert.ArgumentNotNull(entry, "entry"); Assert.ArgumentNotNull(entry.Site, "entry.Site"); Assert.ArgumentNotNull(entry.Database, "entry.Database"); Assert.ArgumentNotNull(entry.Url, "entry.Url"); return string.Join("#", CachePrefix, entry.Site, entry.Database, entry.Url); } } }
Sitecore.Data.DataProviders.Sql.SqlDataApi and ICacheProvider instances along with a cache prefix are injected into the class instance’s constructor using the Sitecore Configuration Factory (you’ll get a better idea of how this happens when you have a look at the patch configuration file towards the bottom of this post). These are saved to properties on the class instance so they can be leveraged by the methods on the class.
One thing I would like to point out is the Sitecore.Data.DataProviders.Sql.SqlDataApi class is an abstraction — it’s an abstract class that is subclassed by Sitecore.Data.SqlServer.SqlServerDataApi in Sitecore.Kernel.dll. This concrete class does most of the leg work on talking to the SQL Server database, and we just utilize methods on it for adding/deleting/removing entries.
The AddEntry() method delegates the database saving operation to the Sitecore.Data.DataProviders.Sql.SqlDataApi instance, and then uses the ICacheProvider instance for storing the entry in cache.
The RemoveEntry() method also leverages the Sitecore.Data.DataProviders.Sql.SqlDataApi instance for deleting the entry from the database, and then removes the entry from cache via the ICacheProvider instance.
The GetEntry() method does exactly what you think it does. It tries to get the entry first from cache via the ICacheProvider instance and then the database via the Sitecore.Data.DataProviders.Sql.SqlDataApi instance if the entry was not found in cache. If the Item was not in cache but was in the database, the GetEntry() method then saves the entry to cache.
I then created the following Singleton for testing:
using System; using System.Collections.Generic; using Sitecore.Configuration; using Sitecore.Data.Items; using Sitecore.Diagnostics; namespace Sitecore.Sandbox.Data.Providers.ItemUrls { public class ItemUrlsProvider : IItemUrlsProvider { private static readonly Lazy<IItemUrlsProvider> lazyInstance = new Lazy<IItemUrlsProvider>(() => new ItemUrlsProvider()); public static IItemUrlsProvider Current { get { return lazyInstance.Value; } } private IItemUrlsProvider InnerProvider { get; set; } private ItemUrlsProvider() { InnerProvider = GetInnerProvider(); } public void AddEntry(ItemUrlEntry entry) { InnerProvider.AddEntry(entry); } public void RemoveEntry(ItemUrlEntry entry) { InnerProvider.RemoveEntry(entry); } public Item GetItem(ItemUrlEntry entry) { return InnerProvider.GetItem(entry); } public ItemUrlEntry GetEntry(ItemUrlEntry entry) { return InnerProvider.GetEntry(entry); } public IEnumerable<ItemUrlEntry> GetAllEntries() { return InnerProvider.GetAllEntries(); } protected virtual IItemUrlsProvider GetInnerProvider() { IItemUrlsProvider provider = Factory.CreateObject("itemUrlsProvider", true) as IItemUrlsProvider; Assert.IsNotNull(provider, "itemUrlsProvider must be set in configuration!"); return provider; } } }
The Singleton above basically decorates the IItemUrlsProvider instance defined in Sitecore configuration — see the configuration file below — and delegates method calls to it.
I then wired everything together using the following patch configuration file:
<?xml version="1.0" encoding="utf-8" ?> <configuration xmlns:patch="http://www.sitecore.net/xmlconfig/"> <sitecore> <itemUrlsProvider id="custom" type="Sitecore.Sandbox.Data.Providers.ItemUrls.$(database).$(database)ItemUrlsProvider, Sitecore.Sandbox" singleInstance="true"> <param type="Sitecore.Data.$(database).$(database)DataApi, Sitecore.Kernel" desc="sqlDataApi"> <param connectionStringName="$(id)"/> </param> <param type="Sitecore.Sandbox.Caching.CacheProvider, Sitecore.Sandbox" desc="cacheProvider"> <param desc="cacheName">[ItemUrls]</param> <param desc="cacheSize">500KB</param> </param> <param desc="cachePrefix">ItemUrlsEntry</param> </itemUrlsProvider> </sitecore> </configuration>
For testing, I whipped up a standalone ASP.NET Web Form (yes, there are more elegant ways to do this but it’s Sunday so cut me some slack 😉 ):
using System; using System.Text; using System.Collections.Generic; using System.Linq; using Sitecore.Data.Items; using Sitecore.Sandbox.Data.Providers.ItemUrls; namespace Sitecore.Sandbox.Web.tests { public partial class ItemUrlsProviderTest : System.Web.UI.Page { protected void Page_Load(object sender, EventArgs e) { IItemUrlsProvider provider = ItemUrlsProvider.Current; Item home = Sitecore.Context.Database.GetItem("/sitecore/content/home"); StringBuilder output = new StringBuilder(); ItemUrlEntry firstEntry = new ItemUrlEntry { ItemID = home.ID, Site = Sitecore.Context.Site.Name, Database = Sitecore.Context.Database.Name, Url = "/this/does/not/exist" }; output.AppendFormat("Adding {0} as an entry.<br />", firstEntry); provider.AddEntry(firstEntry); ItemUrlEntry secondEntry = new ItemUrlEntry { ItemID = home.ID, Site = Sitecore.Context.Site.Name, Database = Sitecore.Context.Database.Name, Url = "/fake/url" }; output.AppendFormat("Adding {0} as an entry.<br />", secondEntry); provider.AddEntry(secondEntry); ItemUrlEntry thirdEntry = new ItemUrlEntry { ItemID = home.ID, Site = Sitecore.Context.Site.Name, Database = Sitecore.Context.Database.Name, Url = "/another/fake/url" }; output.AppendFormat("Adding {0} as an entry.<hr />", thirdEntry); provider.AddEntry(thirdEntry); ItemUrlEntry fourthEntry = new ItemUrlEntry { ItemID = home.ID, Site = Sitecore.Context.Site.Name, Database = Sitecore.Context.Database.Name, Url = "/blah/blah/blah" }; output.AppendFormat("Adding {0} as an entry.<hr />", fourthEntry); provider.AddEntry(fourthEntry); ItemUrlEntry fifthEntry = new ItemUrlEntry { ItemID = home.ID, Site = Sitecore.Context.Site.Name, Database = Sitecore.Context.Database.Name, Url = "/i/am/a/url" }; output.AppendFormat("Adding {0} as an entry.<hr />", fifthEntry); provider.AddEntry(fifthEntry); output.AppendFormat("Current saved entries:<br /><br />{0}<hr />", string.Join("<br />", provider.GetAllEntries().Select(entry => entry.ToString()))); output.AppendFormat("Removing entry {0}.<br /><br />", firstEntry.ToString()); provider.RemoveEntry(firstEntry); output.AppendFormat("Current saved entries:<br /><br />{0}", string.Join("<br />", provider.GetAllEntries().Select(entry => entry.ToString()))); litResults.Text = output.ToString(); } } }
The test above adds five entries, and then deletes one. It also outputs what’s in the database after specific operations.
After doing a build, I pulled up the above Web Form in my browser and saw this once the page was done rendering:
If you have any thoughts on this, please share in a comment.
Until next time, have a Sitecoredatalicious day!
Make Incompatible Class Interfaces Work Together using the Adapter Pattern in Sitecore
This post is a continuation of a series of blog posts I’m putting together around using design patterns in Sitecore, and will share a “proof of concept” on employing the Adapter pattern — a structural pattern used when you need classes of different interfaces to work together. In other words, you need one class’ interface to “adapt” to another.
Believe it or not, most developers — and hopefully most reading this post — are already quite familiar with the Adapter pattern even if it’s not recognizable by name.
How so?
Well, I don’t know about you but I spend a lot of time making code from different APIs work together. I typically have to do this when making use of a third-party library that I cannot change, and usually do this by having one class “wrap” another and its methods. Commonly, the Adapter pattern is known as a “wrapper”.
I showcased the following “proof of concept” during my presentation at SUGCON Europe 2015. This code flips images upside down after they are uploaded to the Media Library — yeah, I know, pretty useful, right? 😉 Keep in mind this code is for educational purposes only, and serves no utility in any practical sense in your Sitecore solutions — if you do have a business need for flipping images upside down after uploading them to the Media Library, please share in a comment.
I first started off with the following interface:
using Sitecore.Data.Items; namespace Sitecore.Sandbox.Resources.Media { public interface IMediaImageFlipper { MediaItem MediaItem { get; set; } void Flip(); } }
Classes that implement the interface above basically flip images within Sitecore.Data.Items.MediaItem instances — this is defined in Sitecore.Kernel.dll — upside down via their Flip() method.
The following class implements the above interface:
using System; using System.Collections.Generic; using System.Linq; using System.IO; using Sitecore.Data.Items; using Sitecore.Diagnostics; using ImageProcessor; namespace Sitecore.Sandbox.Resources.Media { public class ImageFactoryFlipper : IMediaImageFlipper { public MediaItem MediaItem { get; set; } private List<string> TargetMimeTypes { get; set; } private ImageFactory ImageFactory { get; set; } public ImageFactoryFlipper() : this(new ImageFactory()) { } public ImageFactoryFlipper(ImageFactory imageFactory) { TargetMimeTypes = new List<string>(); Assert.ArgumentNotNull(imageFactory, "imageFactory"); ImageFactory = imageFactory; } public void Flip() { if (!ShouldFlip(MediaItem)) { return; } using (MemoryStream outputStream = new MemoryStream()) { ImageFactory.Load(MediaItem.GetMediaStream()).Rotate(180.0f).Save(outputStream); using (new EditContext(MediaItem)) { MediaItem.InnerItem.Fields["Blob"].SetBlobStream(outputStream); } } } protected virtual bool ShouldFlip(MediaItem mediaItem) { if (mediaItem == null || string.IsNullOrWhiteSpace(mediaItem.MimeType) || !TargetMimeTypes.Any() || ImageFactory == null) { return false; } return TargetMimeTypes.Any(targetMimeType => string.Equals(targetMimeType, mediaItem.MimeType, StringComparison.CurrentCultureIgnoreCase)); } } }
In the above class, I am “wrapping” an ImageFactory class instance — this class comes with the ImageProcessor .NET library which does some image manipulation (I found this .NET library via a Google search and have no idea how good it is, but it’s good enough for this “proof of concept”) — and inject it using Poor man’s dependency injection via the default constructor.
The Flip() method is where the magic happens. It calls the ShouldFlip() method which ascertains whether the MediaItem property is set on the class instance and whether the image found within it should be flipped — an image should be flipped if it has a MIME type that is within the list of MIME types that are injected into the class instance via the Sitecore Configuration Factory (see the patch configuration file below).
If the image should be flipped, the Flip() method uses the ImageFactory instance to flip the image upside down — it does this by rotating it 180 degrees — and then saves the flipped image contained within the MemoryStream instance into the MediaItem’s Blob field (this is where images are saved on Media Library Items).
Now that we have a class that flips images, we need a MediaCreator — a subclass of Sitecore.Resources.Media.MediaCreator (this lives in Sitecore.Kernel.dll) — to leverage an instance of the IMediaImageFlipper to do the image manipulation. The follow class does this:
using System.IO; using Sitecore.Data.Items; using Sitecore.Resources.Media; namespace Sitecore.Sandbox.Resources.Media { public class ImageFlipperMediaCreator : MediaCreator { private IMediaImageFlipper Flipper { get; set; } public override Item CreateFromStream(Stream stream, string filePath, bool setStreamIfEmpty, MediaCreatorOptions options) { MediaItem mediaItem = base.CreateFromStream(stream, filePath, setStreamIfEmpty, options); if (Flipper == null) { return mediaItem; } Flipper.MediaItem = mediaItem; Flipper.Flip(); return mediaItem; } } }
After an image is uploaded to the Media Library, we pass the new MediaItem to the IMediaImageFlipper instance — this instance is injected using the Sitecore Configuration Factory (see the configuration file below) — and invoke its Flip() method to flip the image, and return the new MediaItem when complete.
I then utilize an instance of the MediaCreator above in a subclass of Resources.Media.MediaProvider.MediaProvider (I am going to replace the “out of the box” MediaProvider with the following class using the configuration file below):
using Sitecore.Diagnostics; using Sitecore.Resources.Media; namespace Sitecore.Sandbox.Resources.Media { public class ImageFlipperMediaProvider : MediaProvider { private MediaCreator FlipperCreator { get; set; } public override MediaCreator Creator { get { return FlipperCreator ?? base.Creator; } set { Assert.ArgumentNotNull(value, "value"); FlipperCreator = value; } } } }
The MediaCreator that lives in the FlipperCreator property is injected into the class instance through the Sitecore Configuration Factory (see the patch configuration file below), and is returned by the Creator property’s accessor if it’s not null.
I then registered all of the above in Sitecore using the following patch configuration file:
<?xml version="1.0" encoding="utf-8" ?> <configuration xmlns:patch="http://www.sitecore.net/xmlconfig/"> <sitecore> <mediaLibrary> <mediaProvider> <patch:attribute name="type">Sitecore.Sandbox.Resources.Media.ImageFlipperMediaProvider, Sitecore.Sandbox</patch:attribute> <FlipperCreator type="Sitecore.Sandbox.Resources.Media.ImageFlipperMediaCreator, Sitecore.Sandbox"> <Flipper type="Sitecore.Sandbox.Resources.Media.ImageFactoryFlipper, Sitecore.Sandbox"> <TargetMimeTypes hint="list"> <TargetMimeType>image/jpeg</TargetMimeType> <TargetMimeType>image/png</TargetMimeType> </TargetMimeTypes> </Flipper> </FlipperCreator> </mediaProvider> </mediaLibrary> </sitecore> </configuration>
Let’s test this.
I selected the following images for uploading to the Media Library:
As you can see, all uploaded images were flipped upside down:
If you have any thoughts on this, or examples where you’ve employed the Adapter pattern in your Sitecore solutions, please share in a comment.
Until next time, have a Sitecorelicious day!
Utilize the Strategy Design Pattern for Content Editor Warnings in Sitecore
This post is a continuation of a series of posts I’m putting together around using design patterns in Sitecore implementations, and will show a “proof of concept” around using the Strategy pattern — a pattern where a family of “algorithms” (for simplicity you can think of these as classes that implement the same interface) which should be interchangeable when used by client code, and such holds true even when each do something completely different than others within the same family.
The Strategy pattern can serve as an alternative to the Template method pattern — a pattern where classes have an abstract base class that defines most of an “algorithm” for how classes that inherit from it work but provides method stubs (abstract methods) and method hooks (virtual methods) for subclasses to implement or override — and will prove this in this post by providing an alternative solution to the one I had shown in my previous post on the Template method pattern.
In this “proof of concept”, I will be adding a processor to the <getContentEditorWarnings> pipeline in order to add custom content editor warnings for Items — if you are unfamiliar with content editor warnings in Sitecore, the following screenshot illustrates an “out of the box” content editor warning around publishing and workflow state:
To start, I am reusing the following interface and classes from my previous post on the Template method pattern:
using System.Collections.Generic; namespace Sitecore.Sandbox.Pipelines.GetContentEditorWarnings { public interface IWarning { string Title { get; set; } string Message { get; set; } List<CommandLink> Links { get; set; } bool HasContent(); IWarning Clone(); } }
Warnings will have a title, an error message for display, and a list of Sheer UI command links — the CommandLink class is defined further down in this post — to be displayed and invoked when clicked.
You might be asking why I am defining this when I can just use what’s available in the Sitecore API? Well, I want to inject these values via the Sitecore Configuration Factory, and hopefully this will become clear once you have a look at the Sitecore configuration file further down in this post.
Next, we have a class that implements the interface above:
using System.Collections.Generic; namespace Sitecore.Sandbox.Pipelines.GetContentEditorWarnings { public class Warning : IWarning { public string Title { get; set; } public string Message { get; set; } public List<CommandLink> Links { get; set; } public Warning() { Links = new List<CommandLink>(); } public bool HasContent() { return !string.IsNullOrWhiteSpace(Title) || !string.IsNullOrWhiteSpace(Title) || !string.IsNullOrWhiteSpace(Message); } public IWarning Clone() { IWarning clone = new Warning { Title = Title, Message = Message }; foreach (CommandLink link in Links) { clone.Links.Add(new CommandLink { Text = link.Text, Command = link.Command }); } return clone; } } }
The HasContent() method just returns “true” if the instance has any content to display though this does not include CommandLinks — what’s the point in displaying these if there is no warning content to be displayed with them?
The Clone() method makes a new instance of the Warning class, and copies values into it — this is useful when defining tokens in strings that must be expanded before being displayed. If we expand them on the instance that is injected via the Sitecore Configuration Factory, the changed strings will persistent in memory until the application pool is recycled for the Sitecore instance.
The following class represents a Sheer UI command link to be displayed in the content editor warning so content editors/authors can take action on the warning:
namespace Sitecore.Sandbox.Pipelines.GetContentEditorWarnings { public class CommandLink { public string Text { get; set; } public string Command { get; set; } } }
The Strategy pattern calls for a family of “algorithms” which can be interchangeably used. In order for us to achieve this, we need to define an interface for this family of “algorithms”:
using System.Collections.Generic; using Sitecore.Data.Items; namespace Sitecore.Sandbox.Pipelines.GetContentEditorWarnings.Strategy_Pattern { public interface IWarningsGenerator { Item Item { get; set; } IEnumerable<IWarning> Generate(); } }
Next, I created the following class that implements the interface above to ascertain whether a supplied Item has too many child Items:
using System.Collections.Generic; using Sitecore.Data.Items; using Sitecore.Diagnostics; namespace Sitecore.Sandbox.Pipelines.GetContentEditorWarnings.Strategy_Pattern { public class TooManyChildItemsWarningsGenerator : IWarningsGenerator { private int MaxNumberOfChildItems { get; set; } private IWarning Warning { get; set; } public Item Item { get; set; } public IEnumerable<IWarning> Generate() { AssertProperties(); if (Item.Children.Count <= MaxNumberOfChildItems) { return new List<IWarning>(); } return new[] { Warning }; } private void AssertProperties() { Assert.ArgumentCondition(MaxNumberOfChildItems > 0, "MaxNumberOfChildItems", "MaxNumberOfChildItems must be set correctly in configuration!"); Assert.IsNotNull(Warning, "Warning", "Warning must be set in configuration!"); Assert.ArgumentCondition(Warning.HasContent(), "Warning", "Warning should have some fields populated from configuration!"); Assert.IsNotNull(Item, "Item", "Item must be set!"); } } }
The “maximum number of child items allowed” value — this is stored in the MaxNumberOfChildItems integer property of the class — is passed to the class instance via the Sitecore Configuration Factory (you’ll see this defined in the Sitecore configuration file further down in this post).
The IWarning instance that is injected into the instance of this class will give content authors/editors the ability to convert the Item into an Item Bucket when it has too many child Items.
I then defined another class that implements the interface above — a class whose instances determine whether Items have invalid characters in their names:
using System.Collections.Generic; using System.Linq; using Sitecore.Data.Items; using Sitecore.Diagnostics; namespace Sitecore.Sandbox.Pipelines.GetContentEditorWarnings.Strategy_Pattern { public class HasInvalidCharacetersInNameWarningsGenerator : IWarningsGenerator { private string CharacterSeparator { get; set; } private string Conjunction { get; set; } private List<string> InvalidCharacters { get; set; } private IWarning Warning { get; set; } public Item Item { get; set; } public HasInvalidCharacetersInNameWarningsGenerator() { InvalidCharacters = new List<string>(); } public IEnumerable<IWarning> Generate() { AssertProperties(); HashSet<string> charactersFound = new HashSet<string>(); foreach (string character in InvalidCharacters) { if (Item.Name.Contains(character)) { charactersFound.Add(character.ToString()); } } if(!charactersFound.Any()) { return new List<IWarning>(); } IWarning warning = Warning.Clone(); string charactersFoundString = string.Join(CharacterSeparator, charactersFound); int lastSeparator = charactersFoundString.LastIndexOf(CharacterSeparator); if (lastSeparator < 0) { warning.Message = ReplaceInvalidCharactersToken(warning.Message, charactersFoundString); return new[] { warning }; } warning.Message = ReplaceInvalidCharactersToken(warning.Message, Splice(charactersFoundString, lastSeparator, CharacterSeparator.Length, Conjunction)); return new[] { warning }; } private void AssertProperties() { Assert.IsNotNullOrEmpty(CharacterSeparator, "CharacterSeparator", "CharacterSeparator must be set in configuration!"); Assert.ArgumentCondition(InvalidCharacters != null && InvalidCharacters.Any(), "InvalidCharacters", "InvalidCharacters must be set in configuration!"); Assert.IsNotNull(Warning, "Warning", "Warning must be set in configuration!"); Assert.ArgumentCondition(Warning.HasContent(), "Warning", "Warning should have some fields populated from configuration!"); Assert.IsNotNull(Item, "Item", "Item must be set!"); } private static string Splice(string value, int startIndex, int length, string replacement) { if(string.IsNullOrWhiteSpace(value)) { return value; } return string.Concat(value.Substring(0, startIndex), replacement, value.Substring(startIndex + length)); } private static string ReplaceInvalidCharactersToken(string value, string replacement) { return value.Replace("$invalidCharacters", replacement); } } }
The above class will return an IWarning instance when an Item has invalid characters in its name — these invalid characters are defined in Sitecore configuration.
The Generate() method iterates over all invalid characters passed from Sitecore configuration and determines if they exist in the Item name. If they do, they are added to a HashSet<string> instance — I’m using a HashSet<string> to ensure the same character isn’t added more than once to the collection — which is used for constructing the warning message to be displayed to the content author/editor.
Once the Generate() method has iterated through all invalid characters, a string is built using the HashSet<string> instance, and is put in place wherever the $invalidCharacters token is defined in the Message property of the IWarning instance.
Now that we have our family of “algorithms” defined, we need a class to encapsulate and invoke these. I defined the following interface for classes that perform this role:
using System.Collections.Generic; using Sitecore.Data.Items; namespace Sitecore.Sandbox.Pipelines.GetContentEditorWarnings.Strategy_Pattern { public interface IWarningsGeneratorContext { IWarningsGenerator Generator { get; set; } IEnumerable<IWarning> GetWarnings(Item item); } }
I then defined the following class which implements the interface above:
using System.Collections.Generic; using Sitecore.Data.Items; using Sitecore.Diagnostics; namespace Sitecore.Sandbox.Pipelines.GetContentEditorWarnings.Strategy_Pattern { public class WarningsGeneratorContext : IWarningsGeneratorContext { public IWarningsGenerator Generator { get; set; } public IEnumerable<IWarning> GetWarnings(Item item) { Assert.IsNotNull(Generator, "Generator", "Generator must be set!"); Assert.ArgumentNotNull(item, "item"); Generator.Item = item; return Generator.Generate(); } } }
Instances of the class above take in an instance of IWarningsGenerator via its Generator property — in a sense, we are “lock and loading” WarningsGeneratorContext instances to get them ready. Instances then pass a supplied Item instance to the IWarningsGenerator instance, and invoke its GetWarnings() method. This method returns a collection of IWarning instances.
In a way, the IWarningsGeneratorContext instances are really adapters for IWarningsGenerator instances — IWarningsGeneratorContext instances provide a bridge for client code to use IWarningsGenerator instances via its own little API.
Now that we have all of the stuff above — yes, I know, there is a lot of code in this post, and we’ll reflect on this at the end of the post — we need a class whose instance will serve as a <getContentEditorWarnings> pipeline processor:
using System.Collections.Generic; using System.Linq; using Sitecore.Data.Items; using Sitecore.Diagnostics; using Sitecore.Globalization; using Sitecore.Pipelines.GetContentEditorWarnings; namespace Sitecore.Sandbox.Pipelines.GetContentEditorWarnings.Strategy_Pattern { public class ContentEditorWarnings { private List<IWarningsGenerator> WarningsGenerators { get; set; } private IWarningsGeneratorContext WarningsGeneratorContext { get; set; } public ContentEditorWarnings() { WarningsGenerators = new List<IWarningsGenerator>(); } public void Process(GetContentEditorWarningsArgs args) { AssertProperties(); Assert.ArgumentNotNull(args, "args"); Assert.ArgumentNotNull(args.Item, "args.Item"); IEnumerable<IWarning> warnings = GetWarnings(args.Item); if(warnings == null || !warnings.Any()) { return; } foreach(IWarning warning in warnings) { AddWarning(args, warning); } } private IEnumerable<IWarning> GetWarnings(Item item) { List<IWarning> warnings = new List<IWarning>(); foreach(IWarningsGenerator generator in WarningsGenerators) { IEnumerable<IWarning> generatorWarnings = GetWarnings(generator, item); if(generatorWarnings != null && generatorWarnings.Any()) { warnings.AddRange(generatorWarnings); } } return warnings; } private IEnumerable<IWarning> GetWarnings(IWarningsGenerator generator, Item item) { WarningsGeneratorContext.Generator = generator; return WarningsGeneratorContext.GetWarnings(item); } private void AddWarning(GetContentEditorWarningsArgs args, IWarning warning) { if(!warning.HasContent()) { return; } GetContentEditorWarningsArgs.ContentEditorWarning editorWarning = args.Add(); if(!string.IsNullOrWhiteSpace(warning.Title)) { editorWarning.Title = TranslateText(warning.Title); } if(!string.IsNullOrWhiteSpace(warning.Message)) { editorWarning.Text = TranslateText(warning.Message); } if (!warning.Links.Any()) { return; } foreach(CommandLink link in warning.Links) { editorWarning.AddOption(TranslateText(link.Text), link.Command); } } private string TranslateText(string text) { if(string.IsNullOrWhiteSpace(text)) { return text; } return Translate.Text(text); } private void AssertProperties() { Assert.IsNotNull(WarningsGeneratorContext, "WarningsGeneratorContext", "WarningsGeneratorContext must be set in configuration!"); Assert.ArgumentCondition(WarningsGenerators != null && WarningsGenerators.Any(), "WarningsGenerators", "At least one WarningsGenerator must be set in configuration!"); } } }
The Process() method is the main entry into the pipeline processor. The method delegates to the GetWarnings() method to get a collection of IWarning instances from all IWarningGenerator instances that were injected into the class instance via the Sitecore Configuration Factory.
The GetWarnings() method iterates over all IWarningsGenerator instances, and passes each to the other GetWarnings() method overload which basically sets the IWarningGenerator on the IWarningsGeneratorContext instance, and invokes its GetWarnings() method with the supplied Item instance.
Once all IWarning instances have been collected, the Process() method iterates over the IWarning collection, and adds them to the GetContentEditorWarningsArgs instance via the AddWarning() method.
I then registered everything above in Sitecore using the following Sitecore patch configuration file:
<?xml version="1.0" encoding="utf-8" ?> <configuration xmlns:patch="http://www.sitecore.net/xmlconfig/"> <sitecore> <pipelines> <getContentEditorWarnings> <processor type="Sitecore.Sandbox.Pipelines.GetContentEditorWarnings.Strategy_Pattern.ContentEditorWarnings, Sitecore.Sandbox"> <WarningsGenerators hint="list"> <WarningsGenerator type="Sitecore.Sandbox.Pipelines.GetContentEditorWarnings.Strategy_Pattern.TooManyChildItemsWarningsGenerator, Sitecore.Sandbox"> <MaxNumberOfChildItems>20</MaxNumberOfChildItems> <Warning type="Sitecore.Sandbox.Pipelines.GetContentEditorWarnings.Warning, Sitecore.Sandbox"> <Title>This Item has too many child items!</Title> <Message>Please consider converting this Item into an Item Bucket.</Message> <Links hint="list"> <Link type="Sitecore.Sandbox.Pipelines.GetContentEditorWarnings.CommandLink"> <Text>Convert to Item Bucket</Text> <Command>item:bucket</Command> </Link> </Links> </Warning> </WarningsGenerator> <WarningsGenerator type="Sitecore.Sandbox.Pipelines.GetContentEditorWarnings.Strategy_Pattern.HasInvalidCharacetersInNameWarningsGenerator, Sitecore.Sandbox"> <CharacterSeparator>,&nbsp;</CharacterSeparator> <Conjunction>&nbsp;and&nbsp;</Conjunction> <InvalidCharacters hint="list"> <Character>-</Character> <Character>$</Character> <Character>1</Character> </InvalidCharacters> <Warning type="Sitecore.Sandbox.Pipelines.GetContentEditorWarnings.Warning, Sitecore.Sandbox"> <Title>The name of this Item has invalid characters!</Title> <Message>The name of this Item contains $invalidCharacters. Please consider renaming the Item.</Message> <Links hint="list"> <Link type="Sitecore.Sandbox.Pipelines.GetContentEditorWarnings.CommandLink"> <Text>Rename Item</Text> <Command>item:rename</Command> </Link> </Links> </Warning> </WarningsGenerator> </WarningsGenerators> <WarningsGeneratorContext type="Sitecore.Sandbox.Pipelines.GetContentEditorWarnings.Strategy_Pattern.WarningsGeneratorContext, Sitecore.Sandbox" /> </processor> </getContentEditorWarnings> </pipelines> </sitecore> </configuration>
Let’s test this out.
I set up an Item with more than 20 child Items, and gave it a name that includes -, $ and 1 — these are defined as invalid in the configuration file above:
As you can see, both warnings appear on the Item in the content editor.
Let’s convert the Item into an Item Bucket:
As you can see the Item is now an Item Bucket:
Let’s fix the Item’s name:
The Item’s name is now fixed, and there are no more content editor warnings:
You might be thinking “Mike, that is a lot of code — a significant amount over what you had shown in your previous post where you used the Template method pattern — so why bother with the Strategy pattern?”
Yes, there is more code here, and definitely more moving parts to the Strategy pattern over the Template method pattern.
So, what’s the benefit here?
Well, in the Template method pattern, subclasses are tightly coupled to their abstract base class. A change to the parent class could potentially break code in the subclasses, and this will require code in all subclasses to be changed. This could be quite a task if subclasses are defined in multiple projects that don’t reside in the same solution as the parent class.
The Strategy pattern forces loose coupling among all instances within the pattern thus reducing the likelihood that changes in one class will adversely affect others.
However, with that said, it does add complexity by introducing more code, so you should consider the pros and cons of using the Strategy pattern over the Template method pattern, or perhaps even decide if you should use a pattern to begin with.
Remember, the KISS principle should be followed wherever/whenever possible when designing and developing software.
If you have any thoughts on this, please drop a comment.