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>
1 2 | 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
1 | 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | 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.
1 2 3 | //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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | 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.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | 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
1 2 3 4 5 6 7 8 9 | [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.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | 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.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 | 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 !!!
1 COMMENT