Blog Series: Episerver Settings Tab Improvements – Balanced Environment Support

Blog Series: Episerver Settings Tab Improvements – Balanced Environment Support

Following this blog series, we will continue improving the 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). This issue was found when we tried to implement the original Site Settings service for a client which has its own servers (1 content server and 2 delivery servers). When we made changes to a setting item in the content server, the setting item changes were not reflected in the 2 other servers. In a DXP scenario it may also be possible to have this issue, but we have not tested it yet. The root of this issue is due to the concurrent dictionary which saves the setting items in memory. Memory is not shared between servers. Therefore, if one change is made in one server it will not be reflected in the other ones. So, in this post we are going to show how can we improve the current implementation to handle this scenario.

For this implementation we will modify the SettingService.cs class which is already capable of handling multiple languages, as specified in the previous blog post of this blog series, and we will add the needed functionality to handle balanced environments. The implementation is also based on the Events Api provided by Episerver. The changes we made to support the feature are the following:

First, we are going to create a new class which has the Data Contract and Events Service Know Type attributes. The class will have three properties: one for site id, one for content id and one for language. Each of these properties have its data member attribute so they can be serialized. The name of the class is SettingEventData

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

Second, we will add two new private variables to the Settings Service class which will save GUIDs for the raiser of the event and one for the 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");

In the Setting Service constructor we will add a line at the end which will initialize the raiser id variable to a new Guid value. This will ensure that each server which instantiate the SettingsService class will have a different raiser id. In addition, we will add a new dependency to the Event Registry which will be allow us to subscribe and unsubscribe to events.

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


            _raiserId = Guid.NewGuid();
        }

Next, we will add two new private methods to the Settings Service class. The first one called SettingsEvent_Raised will capture a raised event in any server. If the one who received the event is different from the one who raised it, it will use the data received and update the concurrent dictionary with the corresponding setting item for the corresponding language.

        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), CultureInfo.GetCultureInfo(settingUpdate.Language));
                    if (content != null)
                    {
                        UpdateSettings(Guid.Parse(settingUpdate.SiteId), settingUpdate.Language, content);
                    }
                }
            }
        }

The second method is the one who uses the event registry dependency to raise an event using the Events Api, the raiser id and a message

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

We will also need to modify the Initialize and Uninitialized methods so we can subscribe and unsubscribe to events using the SettingsEvent_Raised method

        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 raise an event passing as data the SettingsEventData class when the editor publishes any change in the CMS, and after it is updated in the server where the editor is located. We send as part of the event data, the site where the item has been modified, the language of the item and the content Guid so when we handle the event in another server we will have all the data we need to update the concurrent dictionary.

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

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

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

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.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.Web;
using EPiServer.DataAccess;
using EPiServer.Events;
using EPiServer.Events.Clients;
using EPiServer.Framework.TypeScanner;
using System.Globalization;
using System.Runtime.Serialization;
using System.Threading;

namespace Foundation.Cms.Settings
{
    public interface ISettingsService
    {
        ContentReference GlobalSettingsRoot { get; set; }
        ConcurrentDictionary<string, Dictionary<Type, object>> SiteSettings { get; }
        T GetSiteSettings<T>(Guid? siteId = null, string language = null);
        void InitializeSettings();
        void UnInitializeSettings();
        void UpdateSettings(Guid siteId, string language, 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 ILanguageBranchRepository _languageBranchRepository;
        private readonly IEventRegistry _eventRegistry;

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

            _raiserId = Guid.NewGuid();
        }

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

        public ContentReference GlobalSettingsRoot { get; set; }

        public T GetSiteSettings<T>(Guid? siteId = null, string language = null)
        {
            if (!siteId.HasValue)
            {
                siteId = ResolveSiteId();
                if (siteId == Guid.Empty)
                {
                    return default;
                }
            }

            if (string.IsNullOrEmpty(language))
            {
                language = GetCurrentLanguage().LanguageID.ToLower();
                if (string.IsNullOrEmpty(language))
                {
                    return default;
                }
            }

            try
            {
                var key = siteId.Value + "_" + language;
                if (SiteSettings.TryGetValue(key, out var siteSettings) && siteSettings.TryGetValue(typeof(T), out var setting))
                {
                    return (T)setting;
                }
            }
            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, string language, IContent content)
        {
            var contentType = content.GetOriginalType();
            try
            {
                var key = siteId + "_" + language;
                if (!SiteSettings.ContainsKey(key))
                {
                    SiteSettings[key] = new Dictionary<Type, object>();
                }

                SiteSettings[key][contentType] = content;
            }
            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 languages = GetAvailableLanguages();
                var folder = children.Find(x => x.Name.Equals(site.Name, StringComparison.InvariantCultureIgnoreCase));
                if (folder != null)
                {
                    foreach (var language in languages)
                    {
                        foreach (var child in _contentRepository.GetChildren<SettingsBase>(folder.ContentLink,
                            language.Culture))
                        {
                            UpdateSettings(site.Id, language.LanguageID.ToLower(), child);
                        }
                    }

                    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, GetCurrentLanguage().LanguageID.ToLower(), 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 setting))
            {
                return;
            }

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

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

        private List<LanguageBranch> GetAvailableLanguages()
        {
            return _languageBranchRepository.ListEnabled()?.ToList() ?? new List<LanguageBranch>();
        }

        private LanguageBranch GetCurrentLanguage()
        {
            var availableLanguages = GetAvailableLanguages();
            var defaultLanguage = availableLanguages.FirstOrDefault();

            // If language is not detected properly by the current thread property use the content language preferred culture instead
            //var currentLanguage = availableLanguages.Find(x => x.LanguageID == Thread.CurrentThread.CurrentUICulture.ToString());
            var currentLanguage = availableLanguages.Find(x => x.LanguageID == ContentLanguage.PreferredCulture.Name);
            return currentLanguage ?? defaultLanguage;
        }

        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), CultureInfo.GetCultureInfo(settingUpdate.Language));
                    if (content != null)
                    {
                        UpdateSettings(Guid.Parse(settingUpdate.SiteId), settingUpdate.Language, 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; }
    [DataMember]
    public string Language { get; set; }
}

And that is it. Now, your site settings implementation will be able to handle languages and balanced environments without problems. 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

1 COMMENT

Leave a Reply