Blog Series: Episerver Settings Tab Improvements – A Simpler Version

Blog Series: Episerver Settings Tab Improvements – A Simpler Version

Up to this point we have been able to improve the original Settings Service implementation, which is part of the Episerver Foundation and Episerver Foundation CMS solutions, so it is capable of working in balanced environments (two or more servers) and able to handle multiple languages. However, most of this issues are related with the concurrent dictionary which is a core component of the Settings Service class. What we decided to do in this post is to keep the dictionary but use it to save Guid references to the corresponding site setting items instead of saving the setting items themselves. This will probably impact performance because we will use the Episerver content repository instead of recovering the items in memory, but it will greatly simplify the code to achieve multi language support, the code for balanced environment support will be kept the same

For this implementation we will modify the original SettingService.cs class which is not capable of handling balanced environments nor multiple languages. The changes we made to handle these features are the following:

First, we will modify the ISettingsService interface so the GetSiteSettings method generic parameter T is restricted to the SettingsBase type. Then, we will modify the signature for the concurrent dictionary used in the class from a Guid, Dictionary<Type, Object> to a Guid, Dictionary<Type, Guid>

T GetSiteSettings<T>(Guid? siteId = null) where T : SettingsBase;  
ConcurrentDictionary<Guid, Dictionary<Type, Guid>> SiteSettings { get; }

We then modify the concurrent dictionary in the Setting Service class implementation

public ConcurrentDictionary<Guid, Dictionary<Type, Guid>> SiteSettings { get; } = new ConcurrentDictionary<Guid, Dictionary<Type, Guid>>();

Then, we will modify the method GetSiteSettings. In order to get the correct item for the specific language we use the content repository get event with the content guid

        public T GetSiteSettings<T>(Guid? siteId = null) where T : SettingsBase
        {
            if (!siteId.HasValue)
            {
                siteId = ResolveSiteId();
                if (siteId == Guid.Empty)
                {
                    return default;
                }
            }

            try
            {
                if (SiteSettings.TryGetValue(siteId.Value, out var siteSettings) &&
                    siteSettings.TryGetValue(typeof(T), out var settingId))
                {
                    return _contentRepository.Get<T>(settingId);
                }
            }
            catch (KeyNotFoundException keyNotFoundException)
            {
                _log.Error($"[Settings] {keyNotFoundException.Message}", exception: keyNotFoundException);
            }
            catch (ArgumentNullException argumentNullException)
            {
                _log.Error($"[Settings] {argumentNullException.Message}", exception: argumentNullException);
            }

            return default;
        }

Later, we will modify the method UpdateSettings with parameters which will use a similar implementation to the original one, but when the setting item is updated we do not save the setting item itself, but is guid reference

        public void UpdateSettings(Guid siteId, IContent content)
        {
            var contentType = content.GetOriginalType();
            try
            {
                if (!SiteSettings.ContainsKey(siteId))
                {
                    SiteSettings[siteId] = new Dictionary<Type, Guid>();
                }

                SiteSettings[siteId][contentType] = content.ContentGuid;
            }
            catch (KeyNotFoundException keyNotFoundException)
            {
                _log.Error($"[Settings] {keyNotFoundException.Message}", exception: keyNotFoundException);
            }
            catch (ArgumentNullException argumentNullException)
            {
                _log.Error($"[Settings] {argumentNullException.Message}", exception: argumentNullException);
            }
        }

For this settings service to work in balanced environments we will also need to add the same code that we implemented for the version with the concurrent dictionary that saves the site setting items in memory. You can find how to do it step by step in the previous blog post of this blog series. Here we will explain it in less detail.

We will first have to add the two needed variables two the Settings Service class, raiser id and event id.

        //Generate unique id for your event and the raiser
        private readonly Guid _raiserId;
        private static Guid EventId => new Guid("888B5C89-9B0F-4E67-A3B0-6E660AB9A60F");

Then, we will add a new dependency to the Event registry service and add it to the constructor of the Setting Service class. In addition, we will also initialize the raiser Id local variable with a new Guid value

        private readonly IEventRegistry _eventRegistry;

        public SettingsService(
            IContentRepository contentRepository,
            ContentRootService contentRootService,
            ITypeScannerLookup typeScannerLookup,
            IContentTypeRepository contentTypeRepository,
            IContentEvents contentEvents,
            ISiteDefinitionEvents siteDefinitionEvents,
            ISiteDefinitionRepository siteDefinitionRepository,
            ISiteDefinitionResolver siteDefinitionResolver,
            ServiceAccessor<HttpContextBase> httpContext,
                IEventRegistry eventRegistry)
        {
            _contentRepository = contentRepository;
            _contentRootService = contentRootService;
            _typeScannerLookup = typeScannerLookup;
            _contentTypeRepository = contentTypeRepository;
            _contentEvents = contentEvents;
            _siteDefinitionEvents = siteDefinitionEvents;
            _siteDefinitionRepository = siteDefinitionRepository;
            _siteDefinitionResolver = siteDefinitionResolver;
            _httpContext = httpContext;
            _eventRegistry = eventRegistry;

            _raiserId = Guid.NewGuid();
        }

We also need to add two private events to the Settings Service class, one to handle the raised event and another one raise the event.

        private void SettingsEvent_Raised(object sender, EventNotificationEventArgs e)
        {
            // don't process events locally raised
            if (e.RaiserId != _raiserId)
            {
                //Do something, e.g. invalidate cache
                if (e.Param is SettingEventData settingUpdate)
                {
                    var content = _contentRepository.Get<IContent>(Guid.Parse(settingUpdate.ContentId));
                    if (content != null)
                    {
                        UpdateSettings(Guid.Parse(settingUpdate.SiteId), content);
                    }
                }
            }
        }

        private void RaiseEvent(SettingEventData message)
        {
            _eventRegistry.Get(EventId).Raise(_raiserId, message);
        }

Next, we will add a new SettingEventData class which is a little bit different to the one used in the previous blog post because we do not require the language property anymore

[DataContract]
[EventsServiceKnownType]
public class SettingEventData
{
    [DataMember]
    public string SiteId { get; set; }
    [DataMember]
    public string ContentId { get; set; }
}

After setting up those two methods, we will subscribe and unsubscribe to the raiser event handler method in the initialize and uninitialize methods of the Setting Service class.

        public void InitializeSettings()
        {
            try
            {
                RegisterContentRoots();
            }
            catch (NotSupportedException notSupportedException)
            {
                _log.Error($"[Settings] {notSupportedException.Message}", exception: notSupportedException);
                throw;
            }

            _contentEvents.PublishedContent += PublishedContent;
            _siteDefinitionEvents.SiteCreated += SiteCreated;
            _siteDefinitionEvents.SiteUpdated += SiteUpdated;
            _siteDefinitionEvents.SiteDeleted += SiteDeleted;

            var settingsEvent = _eventRegistry.Get(EventId);
            settingsEvent.Raised += SettingsEvent_Raised;
        }

        public void UnInitializeSettings()
        {
            _contentEvents.PublishedContent -= PublishedContent;
            _siteDefinitionEvents.SiteCreated -= SiteCreated;
            _siteDefinitionEvents.SiteUpdated -= SiteUpdated;
            _siteDefinitionEvents.SiteDeleted -= SiteDeleted;

            var settingsEvent = _eventRegistry.Get(EventId);
            settingsEvent.Raised -= SettingsEvent_Raised;
        }

Finally, we will modify the Publish Content method to raise the event at the end of the method and after the content is updated in the first server.

        private void PublishedContent(object sender, ContentEventArgs e)
        {
            if (!(e?.Content is SettingsBase))
            {
                return;
            }

            var parent = _contentRepository.Get<IContent>(e.Content.ParentLink);
            var site = _siteDefinitionRepository.Get(parent.Name);

            var id = site?.Id;
            if (id == null || id == Guid.Empty)
            {
                return;
            }
            UpdateSettings(id.Value, e.Content);
            RaiseEvent(new SettingEventData
            {
                SiteId = id.ToString(),
                ContentId = e.Content.ContentGuid.ToString()
            });
        }

After all those changes the final implementation of the Settings Service class will look like this

using EPiServer;
using EPiServer.Core;
using EPiServer.DataAbstraction;
using EPiServer.DataAccess;
using EPiServer.Events;
using EPiServer.Events.Clients;
using EPiServer.Framework.TypeScanner;
using EPiServer.Logging;
using EPiServer.Security;
using EPiServer.ServiceLocation;
using EPiServer.Web;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.Serialization;
using System.Web;

namespace Foundation.Cms.Settings
{
    public interface ISettingsService
    {
        ContentReference GlobalSettingsRoot { get; set; }
        ConcurrentDictionary<Guid, Dictionary<Type, Guid>> SiteSettings { get; }
        T GetSiteSettings<T>(Guid? siteId = null) where T : SettingsBase;
        void InitializeSettings();
        void UnInitializeSettings();
        void UpdateSettings(Guid siteId, IContent content);
        void UpdateSettings();
    }

    public static class ISettingsServiceExtensions
    {
        public static T GetSiteSettingsOrThrow<T>(this ISettingsService settingsService,
            Func<T, bool> shouldThrow,
            string message) where T : SettingsBase
        {
            var settings = settingsService.GetSiteSettings<T>();
            if (settings == null || (shouldThrow?.Invoke(settings) ?? false))
            {
                throw new InvalidOperationException(message);
            }

            return settings;
        }

        public static bool TryGetSiteSettings<T>(this ISettingsService settingsService, out T value) where T : SettingsBase
        {
            value = settingsService.GetSiteSettings<T>();
            return value != null;
        }
    }

    public class SettingsService : ISettingsService
    {
        //Generate unique id for your event and the raiser
        private readonly Guid _raiserId;
        private static Guid EventId => new Guid("888B5C89-9B0F-4E67-A3B0-6E660AB9A60F");


        private readonly IContentRepository _contentRepository;
        private readonly ContentRootService _contentRootService;
        private readonly IContentTypeRepository _contentTypeRepository;
        private readonly ILogger _log = LogManager.GetLogger();
        private readonly ITypeScannerLookup _typeScannerLookup;
        private readonly IContentEvents _contentEvents;
        private readonly ISiteDefinitionEvents _siteDefinitionEvents;
        private readonly ISiteDefinitionRepository _siteDefinitionRepository;
        private readonly ISiteDefinitionResolver _siteDefinitionResolver;
        private readonly ServiceAccessor<HttpContextBase> _httpContext;
        private readonly IEventRegistry _eventRegistry;

        public SettingsService(
            IContentRepository contentRepository,
            ContentRootService contentRootService,
            ITypeScannerLookup typeScannerLookup,
            IContentTypeRepository contentTypeRepository,
            IContentEvents contentEvents,
            ISiteDefinitionEvents siteDefinitionEvents,
            ISiteDefinitionRepository siteDefinitionRepository,
            ISiteDefinitionResolver siteDefinitionResolver,
            ServiceAccessor<HttpContextBase> httpContext,
                IEventRegistry eventRegistry)
        {
            _contentRepository = contentRepository;
            _contentRootService = contentRootService;
            _typeScannerLookup = typeScannerLookup;
            _contentTypeRepository = contentTypeRepository;
            _contentEvents = contentEvents;
            _siteDefinitionEvents = siteDefinitionEvents;
            _siteDefinitionRepository = siteDefinitionRepository;
            _siteDefinitionResolver = siteDefinitionResolver;
            _httpContext = httpContext;
            _eventRegistry = eventRegistry;

            _raiserId = Guid.NewGuid();
        }

        public ConcurrentDictionary<Guid, Dictionary<Type, Guid>> SiteSettings { get; } = new ConcurrentDictionary<Guid, Dictionary<Type, Guid>>();

        public ContentReference GlobalSettingsRoot { get; set; }

        public T GetSiteSettings<T>(Guid? siteId = null) where T : SettingsBase
        {
            if (!siteId.HasValue)
            {
                siteId = ResolveSiteId();
                if (siteId == Guid.Empty)
                {
                    return default;
                }
            }

            try
            {
                if (SiteSettings.TryGetValue(siteId.Value, out var siteSettings) &&
                    siteSettings.TryGetValue(typeof(T), out var settingId))
                {
                    return _contentRepository.Get<T>(settingId);
                }
            }
            catch (KeyNotFoundException keyNotFoundException)
            {
                _log.Error($"[Settings] {keyNotFoundException.Message}", exception: keyNotFoundException);
            }
            catch (ArgumentNullException argumentNullException)
            {
                _log.Error($"[Settings] {argumentNullException.Message}", exception: argumentNullException);
            }

            return default;
        }

        public void UpdateSettings(Guid siteId, IContent content)
        {
            var contentType = content.GetOriginalType();
            try
            {
                if (!SiteSettings.ContainsKey(siteId))
                {
                    SiteSettings[siteId] = new Dictionary<Type, Guid>();
                }

                SiteSettings[siteId][contentType] = content.ContentGuid;
            }
            catch (KeyNotFoundException keyNotFoundException)
            {
                _log.Error($"[Settings] {keyNotFoundException.Message}", exception: keyNotFoundException);
            }
            catch (ArgumentNullException argumentNullException)
            {
                _log.Error($"[Settings] {argumentNullException.Message}", exception: argumentNullException);
            }
        }

        public void InitializeSettings()
        {
            try
            {
                RegisterContentRoots();
            }
            catch (NotSupportedException notSupportedException)
            {
                _log.Error($"[Settings] {notSupportedException.Message}", exception: notSupportedException);
                throw;
            }

            _contentEvents.PublishedContent += PublishedContent;
            _siteDefinitionEvents.SiteCreated += SiteCreated;
            _siteDefinitionEvents.SiteUpdated += SiteUpdated;
            _siteDefinitionEvents.SiteDeleted += SiteDeleted;

            var settingsEvent = _eventRegistry.Get(EventId);
            settingsEvent.Raised += SettingsEvent_Raised;
        }

        public void UnInitializeSettings()
        {
            _contentEvents.PublishedContent -= PublishedContent;
            _siteDefinitionEvents.SiteCreated -= SiteCreated;
            _siteDefinitionEvents.SiteUpdated -= SiteUpdated;
            _siteDefinitionEvents.SiteDeleted -= SiteDeleted;

            var settingsEvent = _eventRegistry.Get(EventId);
            settingsEvent.Raised -= SettingsEvent_Raised;
        }

        public void UpdateSettings()
        {
            var root = _contentRepository.GetItems(_contentRootService.List(), new LoaderOptions())
                 .FirstOrDefault(x => x.ContentGuid == SettingsFolder.SettingsRootGuid);

            if (root == null)
            {
                return;
            }

            GlobalSettingsRoot = root.ContentLink;
            var children = _contentRepository.GetChildren<SettingsFolder>(GlobalSettingsRoot).ToList();
            foreach (var site in _siteDefinitionRepository.List())
            {
                var folder = children.Find(x => x.Name.Equals(site.Name, StringComparison.InvariantCultureIgnoreCase));
                if (folder != null)
                {
                    foreach (var child in _contentRepository.GetChildren<SettingsBase>(folder.ContentLink))
                    {
                        UpdateSettings(site.Id, child);
                    }
                    continue;
                }
                CreateSiteFolder(site);
            }
        }

        private void RegisterContentRoots()
        {
            var registeredRoots = _contentRepository.GetItems(_contentRootService.List(), new LoaderOptions());
            var settingsRootRegistered = registeredRoots.Any(x => x.ContentGuid == SettingsFolder.SettingsRootGuid && x.Name.Equals(SettingsFolder.SettingsRootName));

            if (!settingsRootRegistered)
            {
                _contentRootService.Register<SettingsFolder>(SettingsFolder.SettingsRootName, SettingsFolder.SettingsRootGuid, ContentReference.RootPage);
            }

            UpdateSettings();
        }

        private void CreateSiteFolder(SiteDefinition siteDefinition)
        {
            var folder = _contentRepository.GetDefault<SettingsFolder>(GlobalSettingsRoot);
            folder.Name = siteDefinition.Name;
            var reference = _contentRepository.Save(folder, SaveAction.Publish, AccessLevel.NoAccess);

            var settingsModelTypes = _typeScannerLookup.AllTypes
                .Where(t => t.GetCustomAttributes(typeof(SettingsContentTypeAttribute), false).Length > 0);

            foreach (var settingsType in settingsModelTypes)
            {
                if (!(settingsType.GetCustomAttributes(typeof(SettingsContentTypeAttribute), false)
                    .FirstOrDefault() is SettingsContentTypeAttribute attribute))
                {
                    continue;
                }

                var contentType = _contentTypeRepository.Load(settingsType);
                var newSettings = _contentRepository.GetDefault<IContent>(reference, contentType.ID);
                newSettings.Name = attribute.SettingsName;
                _contentRepository.Save(newSettings, SaveAction.Publish, AccessLevel.NoAccess);
                UpdateSettings(siteDefinition.Id, newSettings);
            }
        }

        private void SiteCreated(object sender, SiteDefinitionEventArgs e)
        {
            if (_contentRepository.GetChildren<SettingsFolder>(GlobalSettingsRoot)
                .Any(x => x.Name.Equals(e.Site.Name, StringComparison.InvariantCultureIgnoreCase)))
            {
                return;
            }

            CreateSiteFolder(e.Site);
        }

        private void SiteDeleted(object sender, SiteDefinitionEventArgs e)
        {
            var folder = _contentRepository.GetChildren<SettingsFolder>(GlobalSettingsRoot)
                .FirstOrDefault(x => x.Name.Equals(e.Site.Name, StringComparison.InvariantCultureIgnoreCase));

            if (folder == null)
            {
                return;
            }

            _contentRepository.Delete(folder.ContentLink, true, AccessLevel.NoAccess);
        }

        private void SiteUpdated(object sender, SiteDefinitionEventArgs e)
        {
            if (e is SiteDefinitionUpdatedEventArgs updatedArgs)
            {
                var prevSite = updatedArgs.PreviousSite;
                var updatedSite = updatedArgs.Site;
                var settingsRoot = GlobalSettingsRoot;
                if (_contentRepository.GetChildren<IContent>(settingsRoot)
                    .FirstOrDefault(x => x.Name.Equals(prevSite.Name, StringComparison.InvariantCultureIgnoreCase)) is ContentFolder currentSettingsFolder)
                {
                    var cloneFolder = currentSettingsFolder.CreateWritableClone();
                    cloneFolder.Name = updatedSite.Name;
                    _contentRepository.Save(cloneFolder);
                    return;
                }
            }


            CreateSiteFolder(e.Site);
        }

        private void PublishedContent(object sender, ContentEventArgs e)
        {
            if (!(e?.Content is SettingsBase))
            {
                return;
            }

            var parent = _contentRepository.Get<IContent>(e.Content.ParentLink);
            var site = _siteDefinitionRepository.Get(parent.Name);

            var id = site?.Id;
            if (id == null || id == Guid.Empty)
            {
                return;
            }
            UpdateSettings(id.Value, e.Content);
            RaiseEvent(new SettingEventData
            {
                SiteId = id.ToString(),
                ContentId = e.Content.ContentGuid.ToString()
            });
        }

        private Guid ResolveSiteId()
        {
            var request = _httpContext()?.Request;
            if (request == null)
            {
                return Guid.Empty;
            }
            var site = _siteDefinitionResolver.Get(request);
            return site?.Id ?? Guid.Empty;
        }

        private void SettingsEvent_Raised(object sender, EventNotificationEventArgs e)
        {
            // don't process events locally raised
            if (e.RaiserId != _raiserId)
            {
                //Do something, e.g. invalidate cache
                if (e.Param is SettingEventData settingUpdate)
                {
                    var content = _contentRepository.Get<IContent>(Guid.Parse(settingUpdate.ContentId));
                    if (content != null)
                    {
                        UpdateSettings(Guid.Parse(settingUpdate.SiteId), content);
                    }
                }
            }
        }

        private void RaiseEvent(SettingEventData message)
        {
            _eventRegistry.Get(EventId).Raise(_raiserId, message);
        }
    }
}

[DataContract]
[EventsServiceKnownType]
public class SettingEventData
{
    [DataMember]
    public string SiteId { get; set; }
    [DataMember]
    public string ContentId { get; set; }
}

And that is it. Now, your site settings implementation will be able to handle languages and balanced environments with a simplified version of the original one. If you have any question let me know. I hope it will help someone and as always keep learning !!!

Written by:

Jorge Cardenas

Developer with several years of experience who is passionate about technology and how to solve problems through it.

View All Posts

Leave a Reply