Хабрахабр

[Из песочницы] Как сделать легконастраиваемое кеширование в проекте и спасти коллег от написания однотипного кода

«Если суть работы программиста в автоматизации работы других людей, то почему моя работа так мало автоматизирована» — думал я, копируя в очередной раз всю необходимую в проекте обвязку для добавления новой сущности в БД. И решил избавиться от этой рутины по добавлению шаблонных классов, сделав заодно «хорошо» проекту, разгрузив БД от лишних операций чтения.
Небольшое отступление про систему, которую мы разрабатываем и её состояние на момент начала этого эксперимента:

  • Система в которой 90% данных активно меняются и обрабатываются (транзакции, анкетные данные, предрассчитываемые агрегации), но редко читаются, а оставшиеся 10% очень редко меняются, но зато используются на чтение при каждом удобном случае
  • Почти монолитный сервис на .Net Framework, в котором все это реализовано
  • Nhibernate с минимальной обвязкой, используемый для доступа к БД и некоторый объем основанных на нем кешей (подписки на изменения BO-сущностей, обработчики вызываемые при коммите транзакций)
  • Десяток негласных правил «как написать код для доступа к БД, не убив производительность особенностями NHibernate», регулярно отлавливаемые на Core Review
  • Несколько подтупливающая БД, нуждающаяся в оптимизации

Во время обдумывания ситуации с БД и возникла мысль: не убрать ли из нагрузки на БД эти самые 10% запросов (а заодно и нужные для них открытия подключений к БД, удержание открытых транзакций и шаблонный код для доступа к БД через наши репозитории). При этом, надо было учесть:

  • Мы уже пытались работать с кешем Nhibernate, но нашли его поведение не всегда явным и предсказуемым
  • Менять принципиально что-то в платформе или инфраструктуре без веских на то оснований тоже не хотелось
  • Количество _рукописного_ кода, как у любого достаточно ленивого программиста, должно было в результате уменьшиться, писать руками сотни строк оберток и подписок для существующих кешей

Пример реализации одного из таких старых кешей

/// <summary> /// Base class for caches, containing rarely changed entities. Updated by subscription to nHibernate session commits. /// </summary> /// <typeparam name="T">Type, used as a base for the cache. Sessions, containing changes to any instance of this class will cause cache refresh.</typeparam> /// <typeparam name="K">Key used for cache search.</typeparam> /// <typeparam name="V">Value, stored in cache.</typeparam> public abstract class RareChangedObjectsCache<T, K, V> : EmptySessionNotificationListener, ITransactionNotificationListener, IRareChangedObjectsCache<K, V> where T : class /// <summary> /// Interval of automatic data renewal for cases of read access to cache. Also, cache is forcibly refreshed on any commit, changing base entities of this cache. /// </summary> public TimeSpan AutoRefreshInterval { get; set; } public void Reset() { lastRefreshTime = DateTime.MinValue; } protected void ReloadCacheIfNeeded(ISession session = null) { if (SystemTime.Now - lastRefreshTime <= AutoRefreshInterval) return; LockObj.EnterWriteLock(); try { if (SystemTime.Now - lastRefreshTime <= AutoRefreshInterval) return; IList<object> result; if (session == null) result = SessionManager.CallTransacted(s => GetQuery().GetExecutableQueryOver(s).List<object>()); else //TODO At that moment, the transaction may have been closed, so a new transaction opens implicitly result = GetQuery().GetExecutableQueryOver(session).List<object>(); Cache.Clear(); ProcessResult(result); lastRefreshTime = SystemTime.Now; } catch (Exception e) { Log.Error("Exception on cache invalidation: ", e); } finally { LockObj.ExitWriteLock(); } } protected abstract void ProcessResult(IList<object> result); public override void OnSaveOrUpdate(ISession session, object entity, Guid id, object[] currentState, object[] previousState, string[] propertyNames, IType[] types) { if (entity is T) { var transactionName = HibernateSessionManager.GetTransactionName(); if (!string.IsNullOrEmpty(transactionName)) { changesByTransaction.TryAdd(transactionName, true); } } } public override void OnDelete(ISession session, object entity, Guid id) { if (entity is T) { var transactionName = HibernateSessionManager.GetTransactionName(); if (!string.IsNullOrEmpty(transactionName)) { changesByTransaction.TryAdd(transactionName, true); } } } void ITransactionNotificationListener.AfterCommit(ISession session, string transactionName) { bool tmp; if (changesByTransaction.TryRemove(transactionName, out tmp)) Reset(); ReloadCacheIfNeeded(session); } void ITransactionNotificationListener.AfterRollback(ISession session, string transactionName) { } public bool Contains(K key) { ReloadCacheIfNeeded(); LockObj.EnterReadLock(); try { return Cache.ContainsKey(key); } finally { LockObj.ExitReadLock(); } } public virtual V TryGet(K key) { ReloadCacheIfNeeded(); LockObj.EnterReadLock(); try { return Cache.TryGetValue(key, out var value) ? value : default; } finally { LockObj.ExitReadLock(); } } protected TV GetOrAddEntry<TK, TV>(IDictionary<TK, TV> dictionary, TK key) where TV : new() { TV list; if (!dictionary.TryGetValue(key, out list)) dictionary[key] = (list = new TV()); return list; } } /// <summary> /// Caches all guest categories, grouped by organization or network they belong. /// </summary> [UsedImplicitly] public sealed class GuestCategoryCache : RareChangedObjectsCache<GuestCategory, Guid, HashSet<GuestCategory>> { private static readonly QueryOver<GuestCategory> SelectAllEntitiesQuery = QueryOver.Of<GuestCategory>() .Fetch(gc => gc.Organization).Eager .Fetch(gc => gc.Network).Eager; private readonly Dictionary<Guid, string> invertedCache = new Dictionary<Guid, string>(); public bool TryGetNetworkExternalId(Guid id, out string extId) { ReloadCacheIfNeeded(); LockObj.EnterReadLock(); try { return invertedCache.TryGetValue(id, out extId); } finally { LockObj.ExitReadLock(); } } protected override QueryOver<GuestCategory> GetQuery() { return SelectAllEntitiesQuery; } protected override void ProcessResult(IList<object> result) { invertedCache.Clear(); foreach (GuestCategory gc in result) { var orgOrNetworkId = gc.Organization?.Id ?? gc.Network.Id; GetOrAddEntry(Cache, orgOrNetworkId).Add(gc); } } }

на тот момент уже несколько надоело.
— Новая фича не должна глобально затрагивать других разработчиков, миграция на новый подход должна происходить плавно и нежно.

Два последних пункта сильно срезали ассортимент вариантов. Хотелось при этом сделать так, чтобы добавление новой сущности в механизмы кеширования занимало минимум усилий, код остался читаемым и прямолинейным, и при получении данных из кеша можно было бы минимально думать о том, какой вспомогательный код надо написать. По сути, надо или городить что-то с рефлексией и generic-классами, или же обратиться к старому доброму метапрограммированию.

Так как основной инструмент разработки — Visual Studio, а много сил тратить на то, что не факт что привнесет грандиозный эффект, не хотелось — решил сделать решение «в лоб» максимально стандартными средствами и только уже на этапе готового Proof Of Concept на паре самых частоиспользуемых сущностей — предъявить решение коллегам на суд.

Использовать ли в качестве первоисточника какой-то класс обвешанный атрибутами на все случаи жизни (в стиле Fluent-маппинга Nhibernate на стероидах), или же написать милую аккуратную XML. Дальше была некоторая моральная дилемма. Помня, что лень — лучший друг программиста, а описание классов атрибутами более трудозатратно, чем написание небольшой XML, сделал выбор в пользу последнего.

По сути, что мне нужно было от описания сущностей?

  • Описание кешируемых полей
  • Возможность указать свойства, по которым мы будем делать выборки из этих данных, по необходимости возможность заоптимизировать эти выборки избавившись от линейного прохода по спискам (коли играть в оптимизации, так по полной)
  • Всякое дополнительное удобство для того чтобы разнести классы по разным папкам и использовать имеющиеся наработки в коде для уменьшения его количества

Получили вот такую структуру xml

<class name="GuestCategory" logicallyDeletable="true" revisioned="true" organizationNetworkBased="true" basedOn="BO.GuestCategory" > <emitBo emitMapping="true"/> <emitRepository dependsOn="guestCategoryCache">IGuestCategoryRepository</emitRepository> <field name="IsActive" type="bool"/> <field name="IsDefaultForNewGuests" type="bool"/> <field name="Name" type="string" notNull="true"/> </class>

И большой страшный T4 шаблон для её парсинга и генерации разномастных, но столь нужных классов:

  • «Кешируемый» тип с теми же полями что и BO, но не допускающий редактирования
  • Реализацию кеша для типа с методами выборок по фильтрам
  • Реестр кешей для подписки на механизмы Nhibernate об уведомлениях и регистрации в DI (Spring в нашем случае)
  • Интерфейсы для всего этого для сокрытия внутренностей и возможности при необходимости замены генерируемого кода на рукописный и назад.
  • Как приятный незапланированный изначально бонус, коли уже была готова работа с основными сущностями — сделал еще пол-шажочка к счастью, и сбоку добавил возможность генерации простых BO-типов и маппингов к ним, чтобы дать коллегам возможность добавлять новые классы с пол-пинка.

Сам шаблон, с логической точки зрения, состоит из двух частей: парсинг исходной xml в классы, описывающие нужную структуру классов (для уменьшения риска образования какого-либо неявного поведения, при этом, было решено сделать именно через явный парсинг тегов, а не через маппинг атрибутами.

Классы, описывающие структуру

class DalClass
{ public string Name { get; } public bool LogicallyDeletable { get; } public string BasedOn { get; } public string DalBOType { get; } public string[] Implements { get; } public string[] Include { get; } public bool CustomConsistencyManager { get; } public List<DalField> Fields { get; } public List<DalField> ExplicitlyDefinedFields { get; } public DalGetBy[] GetBy { get; } public DalGetBy[] GetAllBy { get; } public bool GenerateInterface { get; } public DalEmitBo EmitBo { get; } public DalEmitRepository EmitRepository { get; } public DalClass(XmlElement sourceXml) { Implements = sourceXml.SelectNodes("*[local-name()='implements']") .Cast<XmlElement>() .Select(f => f.InnerText + ",") .ToArray(); Include = sourceXml.SelectNodes("*[local-name()='include']") .Cast<XmlElement>() .Select(f => f.InnerText) .ToArray(); Name = sourceXml.GetAttribute("name"); LogicallyDeletable = sourceXml.HasAttribute("logicallyDeletable"); BasedOn = sourceXml.GetAttribute("basedOn"); DalBOType = sourceXml.HasAttribute("dalBoType") ? sourceXml.GetAttribute("dalBoType") : BasedOn; CustomConsistencyManager = sourceXml.HasAttribute("customConsistencyManager") ? sourceXml.GetAttribute("customConsistencyManager") == "true" : false; Fields = sourceXml.SelectNodes("*[local-name()='field']") .Cast<XmlElement>() .Select(f => new DalField(f)) .ToList(); ExplicitlyDefinedFields = Fields.ToList(); Fields = Fields.OrderBy(f=>f.Name).ToList(); GetBy = sourceXml.SelectNodes("*[local-name()='getBy']") .Cast<XmlElement>() .Select(f => new DalGetBy(f)) .ToArray(); GetAllBy = sourceXml.SelectNodes("*[local-name()='getAllBy']") .Cast<XmlElement>() .Select(f => new DalGetBy(f)) .ToArray(); EmitBo = sourceXml.SelectNodes("*[local-name()='emitBo']") .Cast<XmlElement>() .Select(f => new DalEmitBo(f)) .SingleOrDefault(); EmitRepository = sourceXml.SelectNodes("*[local-name()='emitRepository']") .Cast<XmlElement>() .Select(f => new DalEmitRepository(f, Name)) .SingleOrDefault(); GenerateInterface = true; } public string GetIncludedNamespaces() { return string.Join("/n", Include.Select(i => "using " + i + ";")); } public string GetBoClassDefinition() { return Name + " :\n\t\tBaseEntity,\n\t\t" + (LogicallyDeletable ? "ILogicallyDeletable,\n\t\t" : string.Empty) + (Implements.Any() ? string.Join("\n\t\t", Implements) + "\n\t\t" : string.Empty) + "I" + Name; } public string GetCachedClassDefinition() { return "Cached" + Name + " :\n\t\t" + (LogicallyDeletable ? "Deletable" : string.Empty) + "CachedEntity<" + BasedOn + ">,\n\t\t" + (Implements.Any() ? string.Join("\n\t\t", Implements) + "\n\t\t" : string.Empty) + "I" + Name; } public string TryGetIsDeletedParameter() { if (LogicallyDeletable) return ",bool getDeleted"; else return String.Empty; } public string TryGetIsDeletedFilter() { if (LogicallyDeletable) return @" if (!getDeleted) entities = entities.Where(e => !e.IsDeleted); "; else return String.Empty; } public string GetFilterParameters(DalGetBy getBy) { var filters = new List<FieldDescription>(); foreach (var filter in getBy.Filters) filters.Add(new FieldDescription { TypeName = Fields.Single(f => f.Name == filter.Key).FieldType, Alias = filter.Value }); return string.Join(", ", filters.Select(f => f.TypeName + " " + f.Alias)); } private struct FieldDescription { public string TypeName; public string Alias; } }
class DalField
{ public string Name { get; } public string FieldType { get; } public string Source { get; } public string PropertySource { get; } public bool NotNull { get; } public DalField(string name, string type) { Name = name; FieldType = type; Source = name; } public DalField(XmlElement sourceXml) { Name = sourceXml.GetAttribute("name"); FieldType = sourceXml.GetAttribute("type"); Source = sourceXml.HasAttribute("source") ? sourceXml.GetAttribute("source") : sourceXml.GetAttribute("name"); PropertySource = sourceXml.HasAttribute("propertySource") ? sourceXml.GetAttribute("propertySource") : null; NotNull = sourceXml.HasAttribute("notNull") ? sourceXml.GetAttribute("notNull") == "true" : false; } public string GetConstructorInitValueExpression() { var fieldIsArray = FieldType.EndsWith("[]"); var fieldRealType = FieldType.Replace("[]", ""); if (PropertySource!=null && fieldRealType.StartsWith("I")) fieldRealType = "Cached" + fieldRealType.Substring(1); return UpperInitial(Name) + " = source." + Source + (fieldIsArray ? ".Select(i=>new " + fieldRealType + "(i)).ToArray()" : "") + ";"; } public string GetPropertyDefinitionExpression() { return "public " + GetType() + " " + UpperInitial(Name) + (PropertySource != null ? " => " + PropertySource + ";" : " { get; private set; }"); } public string GetBOPropertyDefinitionExpression() { return "public virtual " + GetBoType() + " " + UpperInitial(Name) + " { get; set; }"; } public string GetInterfacePropertyDefinitionExpression() { return GetNullAttribute() + GetType() + " " + UpperInitial(Name) + " { get; }"; } public string GetType() { var fieldIsArray = FieldType.EndsWith("[]"); if (fieldIsArray) return "IEnumerable<" + FieldType.Replace("[]", "") + ">"; return FieldType; } public string GetBoType() { var fieldIsArray = FieldType.EndsWith("[]"); if (fieldIsArray) return "IList<" + FieldType.Replace("[]", "") + ">"; return FieldType; } private string GetNullAttribute() => TypeCanBeNull() ? NotNull ? "[NotNull] " : "[CanBeNull] " : string.Empty; private static string[] NotNullTypes = { "Guid", "DateTime", "DateTimeOffset", "bool", "int", "long", "short", "ProgramType", "GuestSubscriptionTypes", "SenderType", "ApiClientType" }; public bool TypeCanBeNull() => !NotNullTypes.Contains(FieldType); private string UpperInitial(string name) { return name[0].ToString().ToUpperInvariant() + name.Substring(1); } } class DalGetBy
{ public bool IsTry { get; } public string Alias { get; } public Dictionary<string, string> Filters { get; } = new Dictionary<string, string>(); public DalGetBy(XmlElement sourceXml) { IsTry = sourceXml.HasAttribute("try"); Alias = sourceXml.GetAttribute("alias"); foreach (XmlElement filterNode in sourceXml.SelectNodes("*[local-name()='field']")) Filters.Add(filterNode.GetAttribute("field"), filterNode.GetAttribute("alias")); } public string GetConditions() { return string.Join(" && ", Filters.Select(f => $"e.{f.Key} == {f.Value}")); return string.Join(" && ", Filters.Select(f => $"e.{f.Key} == {f.Value}")); }
}
class DalEmitBo
{ public string Namespace { get; } public bool EmitMapping { get; } private XmlElement sourceXml; public DalEmitBo(XmlElement sourceXml) { Namespace = sourceXml.GetAttribute("ns"); EmitMapping = sourceXml.HasAttribute("emitMapping"); this.sourceXml = sourceXml; } public string GetMapping(DalField field) { bool notNull = !field.TypeCanBeNull() || field.NotNull; var overridenXml = sourceXml.SelectNodes("*[local-name()='column']").Cast<XmlElement>().SingleOrDefault(e => e.GetAttribute("name") == field.Name); var props = overridenXml != null ? new OverridenProperties(overridenXml) : null; switch(field.FieldType) { case "string": return $"<property name=\"{field.Name}\"><column name=\"{field.Name}\" {(notNull ? "not-null=\"true\"" : string.Empty)} {(props?.SqlType !=null ? "sql-type=\"" + props.SqlType+"\"" : string.Empty)}/></property>"; case "bool": return $"<property name=\"{field.Name}\" not-null=\"true\" type=\"boolean\"><column name=\"{field.Name}\" not-null=\"true\" default=\"{props?.DefaultValue??"0"}\" sql-type=\"bit\" /></property>"; case "DateTime": return $"<property name=\"{field.Name}\" not-null=\"true\"/>"; case "DateTime?": return $"<property name=\"{field.Name}\" />"; case "ProgramType": return $"<property name=\"{field.Name}\"><column name=\"{field.Name}\" default=\"{props?.DefaultValue??"0"}\" {(notNull ? "not-null=\"true\"" : string.Empty)}/></property>"; case "Guid?": return $"<property name=\"{field.Name}\" />"; default: throw new ArgumentOutOfRangeException($"Not supported type {field.FieldType}. Edit DAL.tt to add mapping definition."); } } private class OverridenProperties { public string DefaultValue { get; } public string SqlType { get; } public OverridenProperties(XmlElement sourceXml) { DefaultValue = sourceXml.GetAttribute("default"); SqlType = sourceXml.GetAttribute("sql-type"); } }
}
class DalEmitRepository
{ public string Interface { get; } public string DependsOn { get; } public DalEmitRepository(XmlElement sourceXml, string className) { Interface = string.IsNullOrEmpty(sourceXml.InnerText) ? "IRepository<"+className+">" : sourceXml.InnerText; DependsOn = sourceXml.GetAttribute("dependsOn"); } public string GetRepositoryClassName() => Interface.Substring(1);
}

Код получившегося шаблона

<#@ template debug="false" hostspecific="true" language="C#" #>
<#@ assembly name="System.Xml" #>
<#@ assembly name="System.Core" #>
<#@ assembly name="EnvDTE" #>
<#@ import namespace="System.Xml" #>
<#@ import namespace="System.Collections.Generic" #>
<#@ import namespace="System.IO" #>
<#@ import namespace="System.Linq" #>
<#@ output extension="/" #>
<# EnvDTE.DTE dte = (EnvDTE.DTE) ((IServiceProvider) this.Host) .GetService(typeof(EnvDTE.DTE)); XmlDocument doc = new XmlDocument(); doc.Load(System.IO.Path.Combine(dte.ActiveDocument.Path, "DAL.xml")); var classes = doc.SelectNodes("//*[local-name()='class']").Cast<XmlElement>().Select(classXml=>new DalClass(classXml)).ToArray(); //fields foreach(var classNode in classes) {
#>
/* The file was generated automatically and should not be edited manually. For any changes edit DAL.xml or DAL.tt. */ using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.Serialization;
using JetBrains.Annotations;
<#=classNode.GetIncludedNamespaces()#>
namespace Domain.DALV2
{ public class <#=classNode.GetCachedClassDefinition()#> { public Cached<#=classNode.Name#>(<#=classNode.DalBOType#> source) : base(source) { <#=string.Join("\n\t\t\t", classNode.Fields.Where(f => f.PropertySource == null).Select(f=>f.GetConstructorInitValueExpression()))#> } <#=string.Join("\n\n\t\t", classNode.Fields.Select(f=>f.GetPropertyDefinitionExpression()))#> }
}
<#if (classNode.GenerateInterface){#> namespace Domain.DAL
{ public interface I<#=classNode.Name#>
<#if (classNode.LogicallyDeletable){#> :ILogicallyDeletableReadonly
<#}#> { Guid Id { get; } <#=string.Join("\n\n\t\t", classNode.Fields.Select(f=>f.GetInterfacePropertyDefinitionExpression()))#> }
}
<#SaveOutput("Entities//gen//"+classNode.Name+".g.cs");#>
<#}#>
/* The file was generated automatically and should not be edited manually. For any changes edit DAL.xml or DAL.tt. */ using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.Serialization;
using Resto.Framework.Common;
using JetBrains.Annotations;
<#=classNode.GetIncludedNamespaces()#>
namespace Domain.DALV2
{ public partial class <#=classNode.Name#>DAL : DAL<Cached<#=classNode.Name#>, <#=classNode.BasedOn#>, <#=classNode.DalBOType#>> { internal <#=classNode.Name#>DAL(ISessionManager sessionManager, ICacheConsistencyManager<<#=classNode.BasedOn#>, <#=classNode.DalBOType#>> consistencyManager) : base(sessionManager, Repositories.<#=classNode.Name#>, consistencyManager) { } <#foreach (var getBy in classNode.GetBy){#> <#=getBy.IsTry?"[CanBeNull]":"[NotNull]"#> public Cached<#=classNode.Name#> <#=getBy.IsTry?"Try":""#>GetBy<#=getBy.Alias#>(<#=classNode.GetFilterParameters(getBy)#>) { UpdateCacheIfNeeded(); using (ConsistencyManager.GetReadLock()) { return Cache.Values.Single<#=getBy.IsTry?"OrDefault":""#>(e => <#=getBy.GetConditions()#>); } }
<#}#>
<#foreach (var fieldNode in classNode.GetAllBy){#> [NotNull] [ItemNotNull] public IList<Cached<#=classNode.Name#>> GetAllBy<#=fieldNode.Alias#>(<#=classNode.GetFilterParameters(fieldNode)#>) { UpdateCacheIfNeeded(); using (ConsistencyManager.GetReadLock()) { return Cache.Values.Where(e => <#=fieldNode.GetConditions()#>).ToList(); } } <#}#> [NotNull] protected override Cached<#=classNode.Name#> Convert(<#=classNode.DalBOType#> source) { return new Cached<#=classNode.Name#>(source); } }
}
<#SaveOutput("Repositories//gen//"+ classNode.Name+".g.cs");#>
<#if(classNode.EmitBo != null){#>
<?xml version="1.0" encoding="utf-8"?> <hibernate-mapping xmlns="urn:nhibernate-mapping-2.2" assembly="Domain" namespace="<#=classNode.EmitBo.Namespace#>"> <class name="<#=classNode.Name#>" dynamic-update="true" dynamic-insert="true"> <id name="Id"> <generator class="assigned" /> </id> <#=string.Join("\n\t\t", classNode.ExplicitlyDefinedFields.Select(f=>classNode.EmitBo.GetMapping(f)))#> <#if (classNode.LogicallyDeletable){#> <property name="IsDeleted" not-null="true" type="boolean"> <column name="IsDeleted" not-null="true" default="0" /> </property> <property name="WhenDeleted" type="DateTime" not-null="false"/>
<#}#> </class>
</hibernate-mapping>
<#SaveOutput("..\\..\\DAL.Hibernate\\Mapping\\gen\\"+classNode.Name+".hbm.xml");#>
/* The file was generated automatically and should not be edited manually. For any changes edit DAL.xml or DAL.tt. */ using System;
using Common.Domain.BO.DB;
using Domain.DAL;
using Domain.DALV2;
using JetBrains.Annotations;
<#=classNode.GetIncludedNamespaces()#> namespace <#=classNode.EmitBo.Namespace#>
{ public partial class <#=classNode.GetBoClassDefinition()#> { [UsedImplicitly] protected <#=classNode.Name#>(){} <#=string.Join("\n\n\t\t", classNode.ExplicitlyDefinedFields.Select(f=>f.GetBOPropertyDefinitionExpression()))#> <#if (classNode.LogicallyDeletable){#> public virtual bool IsDeleted { get; set; } public virtual DateTime? WhenDeleted { get; set; }
<#}#> }
}
<# SaveOutput("..//BO//gen//"+ classNode.Name+".g.cs"); }
}
#>
<!-- The file was generated automatically and should not be edited manually. For any changes edit DAL.xml or DAL.tt. -->
<objects xmlns="http://www.springframework.net" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:db="http://www.springframework.net/database" xsi:schemaLocation="http://www.springframework.net http://www.springframework.net/xsd/spring-objects.xsd"> <# foreach(var classNode in classes.Where(c => !c.CustomConsistencyManager)) { #> <object id="<#=LowerInitial(classNode.Name)#>CacheManager" type="Domain.DALV2.TransactionSubscribedManager<<#=classNode.BasedOn#>, <#=classNode.DalBOType#>>, Domain" singleton="true"> </object>
<#}#> <object id="dalGeneratedListeners" type="System.Collections.Generic.List<Common.Hibernate.DAL.ISessionNotificationListener>, mscorlib"> <constructor-arg> <list element-type="Common.Hibernate.DAL.ISessionNotificationListener, Common.Hibernate">
<# foreach(var classNode in classes) { #> <ref object="<#=LowerInitial(classNode.Name)#>CacheManager"/>
<#}#> </list> </constructor-arg> </object>
</objects>
<#SaveOutput("..//Config//DALSpringDefinitions.g.xml");#>
/* The file was generated automatically and should not be edited manually. For any changes edit DAL.xml or DAL.tt. */ using Common;
using Common.Domain.DAL;
using Domain.BO;
using Domain.DALV2;
using Spring.Context.Support;
using JetBrains.Annotations;
<#=string.Join("\n", classes.Select(c=>c.GetIncludedNamespaces()).Where(s=>!string.IsNullOrEmpty(s)))#> namespace Domain.DAL
{ public partial class DALs { private static readonly SafeLazy<DALs> instance = new SafeLazy<DALs>(() => new DALs()); private DALs() { var sessionManager = (ISessionManager)ContextRegistry.GetContext().GetObject("sessionManager");
<# foreach(var classNode in classes){
#> this.<#=LowerInitial(classNode.Name)#> = new <#=classNode.Name#>DAL(sessionManager, (ICacheConsistencyManager<<#=classNode.BasedOn#>, <#=classNode.DalBOType#>>)ContextRegistry.GetContext().GetObject("<#=LowerInitial(classNode.Name)#>CacheManager"));
<# }
#> } <# foreach(var classNode in classes) { #> [NotNull] private readonly <#=classNode.Name#>DAL <#=LowerInitial(classNode.Name)#>; [NotNull] public static <#=classNode.Name#>DAL <#=classNode.Name#> => instance.Value.<#=LowerInitial(classNode.Name)#>;
<#}#> public static void ResetAll() => instance.Value.ResetAllImpl(); private void ResetAllImpl() {
<#foreach(var classNode in classes){#> this.<#=LowerInitial(classNode.Name)#>.Reset();
<#}#> } }
}
<#SaveOutput("DALs.g.cs");#>
/* The file was generated automatically and should not be edited manually. For any changes edit DAL.xml or DAL.tt. */ using JetBrains.Annotations;
namespace Domain.DAL
{ public partial interface IRepositoryFactory {
<#foreach(var classNode in classes.Where(c => c.EmitRepository != null)){#> [NotNull] <#=classNode.EmitRepository.Interface#> <#=classNode.Name#> { get; }
<#}#> }
}
<#SaveOutput("IRepositoryFactory.g.cs");#>
/* The file was generated automatically and should not be edited manually. For any changes edit DAL.xml or DAL.tt. */ using JetBrains.Annotations;
namespace Domain.DAL
{ public static partial class Repositories {
<#foreach(var classNode in classes.Where(c => c.EmitRepository != null)){#> [NotNull] public static <#=classNode.EmitRepository.Interface#> <#=classNode.Name#> => Instance.Value.<#=classNode.Name#>;
<#}#> }
}
<#SaveOutput("Repositories.g.cs");#>
/* The file was generated automatically and should not be edited manually. For any changes edit DAL.xml or DAL.tt. */ using Common.Domain.DAL;
using Common.Hibernate.DAL;
using DAL.Hibernate.Cache.CachingProviders;
using Domain.BO;
using Domain.DAL;
using Domain.DAL.Cache;
using Domain.SaveManager;
using System;
using System.Collections.Generic;
using JetBrains.Annotations;
using Engine.Main;
<#=string.Join("\n", classes.Select(c=>c.GetIncludedNamespaces()).Where(s=>!string.IsNullOrEmpty(s)))#> namespace DAL.Hibernate.DAL
{ public partial class RepositoryFactory : IRepositoryFactory { public void Init([NotNull] IOrganizationsByNetworkCache organizationsByNetworkCache, [NotNull] INetworkCache networkCache, [NotNull] ITransactionSaveManager transactionSaveManager, [NotNull] ILoginEntryDataProvider loginEntryDataProvider, ListenerNotifier listenerNotifier) { <#foreach(var classNode in classes.Where(c => c.EmitRepository != null)){#> this.<#=classNode.Name#> = new <#=classNode.EmitRepository.GetRepositoryClassName()#>(<#=classNode.EmitRepository.DependsOn#>);
<#}#> } <#foreach(var classNode in classes.Where(c => c.EmitRepository != null)){#> [NotNull] public <#=classNode.EmitRepository.Interface#> <#=classNode.Name#> { get; private set; }
<#}#> }
}
<#SaveOutput("..\\..\\DAL.Hibernate\\DAL\\RepositoryFactory.g.cs");#>
<#+ private string LowerInitial(string name) { return name[0].ToString().ToLowerInvariant() + name.Substring(1); } private string UpperInitial(string name) { return name[0].ToString().ToUpperInvariant() + name.Substring(1); } void SaveOutput(string outputFileName) { string templateDirectory = Path.GetDirectoryName(Host.TemplateFile); string outputFilePath = Path.Combine(templateDirectory, outputFileName); File.WriteAllText(outputFilePath, this.GenerationEnvironment.ToString()); this.GenerationEnvironment.Remove(0, this.GenerationEnvironment.Length); }
#>

И куда же без некоторого количества хитрого Generic-кода, для подписки на внесение изменений в БД, обновления кешей по требованию, и прочих радостей жизни. Тут generic оказалось вполне достаточно, чтобы закрыть все сценарии использования, вкупе с уже использующимися с

Общий код для доступа к данным

public abstract class DAL<T, TBase, TCachedBo> where T : CachedEntity<TBase> where TBase : class, IEntity where TCachedBo : class, IIdEntity<Guid> { [NotNull] private readonly ILog log; [NotNull] protected readonly ISessionManager SessionManager; [NotNull] protected readonly IReadonlyRepository<TBase> BaseRepository; [NotNull] protected readonly ICacheConsistencyManager<TBase, TCachedBo> ConsistencyManager; [NotNull] protected Dictionary<Guid, T> Cache = new Dictionary<Guid, T>(); protected DAL([NotNull] ISessionManager sessionManager, [NotNull] IReadonlyRepository<TBase> baseRepository, [NotNull] ICacheConsistencyManager<TBase, TCachedBo> consistencyManager) { this.SessionManager = sessionManager; this.BaseRepository = baseRepository; this.ConsistencyManager = consistencyManager; log = LogManager.GetLogger(GetType()); } [CanBeNull] public T TryGetById(Guid id) { UpdateCacheIfNeeded(); using (ConsistencyManager.GetReadLock()) { return Cache.GetOrDefault(id); } } [NotNull] public T GetById(Guid id) { UpdateCacheIfNeeded(); using (ConsistencyManager.GetReadLock()) { return ValidateEntityFound(Cache.GetOrDefault(id), "{0} with id {1} not found", null, typeof(T), id); } } [NotNull] public TBase GetEntity([NotNull] ISession session, Guid id) { return BaseRepository.GetById(session, id); } [NotNull] public TBase GetEntity([NotNull] ISession session, [NotNull] T e) { return BaseRepository.GetById(session, e.Id); } [NotNull] public HashSet<T> GetByIds([NotNull] HashSet<Guid> ids) { UpdateCacheIfNeeded(); using (ConsistencyManager.GetReadLock()) { return ids .Select(id => Cache.GetOrDefault(id)) .ToHashSet(); } } public void Reset() { ConsistencyManager.Reset(); } protected abstract T Convert(TCachedBo source); protected void UpdateCacheIfNeeded() { if (!ConsistencyManager.UpdateRequired) return; log.Debug($"{typeof(T).Name} DAL: Update required, getting writeLock"); using (var updateScope = ConsistencyManager.GetWriteLock()) { if (updateScope.UpdateRequired ?? ConsistencyManager.UpdateRequired) SessionManager.RunTransacted(session => { try { var updatedEntities = updateScope.GetUpdatedEntities(BaseRepository, session); foreach (var updatedEntity in updatedEntities) if (updatedEntity.Value != null) Cache[updatedEntity.Key] = Convert(updatedEntity.Value); else Cache.Remove(updatedEntity.Key); Reindex(); } catch (Exception) { ConsistencyManager.Reset(); throw; } }); } } /// <summary> /// Reevaluate specific indexes, used for search in cached entities. Called after cache has been updated. /// </summary> protected virtual void Reindex() { } [NotNull] protected T1 ValidateEntityFound<T1>([CanBeNull] T1 entity, [NotNull] string errorMessage, [NotNull] string frontMessage, [NotNull] params object[] p) { if (entity == null) throw new DataAccessException(Util.GetMessage(errorMessage, p), Util.GetMessage(frontMessage, p)); return entity; } }

Интересным, на мой взгляд получился класс следящий за консистентностью кеша и управляющий его обновлением, главное место где пришлось задуматься над реализацией блокировок чтобы это все было оптимально и минимально блокирующе, но при этом защищенно

Собственно реализация

[UsedImplicitly] public class TransactionSubscribedManager<TBase, TCachedBo> : EmptySessionNotificationListener, ICacheConsistencyManager<TBase, TCachedBo>, ITransactionNotificationListener where TBase : class, IEntity where TCachedBo : class, IIdEntity<Guid> { public bool UpdateRequired => needFullUpdate || !entitiesToUpdate.IsEmpty; [NotNull] protected readonly ReaderWriterLockSlim LockObj = new ReaderWriterLockSlim(LockRecursionPolicy.SupportsRecursion); [NotNull] private readonly ConcurrentDictionary<string, ConcurrentBag<Guid>> changesByTransaction = new ConcurrentDictionary<string, ConcurrentBag<Guid>>(); [NotNull] protected ConcurrentBag<Guid> entitiesToUpdate = new ConcurrentBag<Guid>(); protected bool needFullUpdate = true; public virtual CacheWriteLockScope<TBase, TCachedBo> GetWriteLock() { try { LockObj.EnterWriteLock(); if (needFullUpdate) { needFullUpdate = false; return new CacheWriteLockScope<TBase, TCachedBo>(LockObj); } return new IdBasedCacheWriteLockScope<TBase, TCachedBo>(LockObj, ChangedEntitiesIdsProvider); } catch (Exception) { LockObj.ExitWriteLock(); throw; } } protected ICollection<Guid> ChangedEntitiesIdsProvider() { var ids = new List<Guid>(); var newBag = new ConcurrentBag<Guid>(); var oldBag = Interlocked.Exchange(ref entitiesToUpdate, newBag); while (oldBag.TryTake(out var id)) ids.Add(id); return ids; } public CacheReadLockScope GetReadLock() { return new CacheReadLockScope(LockObj); } public override void OnSaveOrUpdate(ISession session, object entity, Guid id, object[] currentState, object[] previousState, string[] propertyNames, IType[] types) { if (entity is TBase) { var transactionName = HibernateSessionManager.GetTransactionName(); if (!string.IsNullOrEmpty(transactionName)) { if (changesByTransaction.TryAdd(transactionName, new ConcurrentBag<Guid>() { id })) return; changesByTransaction.TryGetValue(transactionName, out var transactionChangedEntities); // ReSharper disable once PossibleNullReferenceException // R# does not have attributes telling that value in dictionary is not null. But we know it. transactionChangedEntities.Add(id); } } } public override void OnDelete(ISession session, object entity, Guid id) { if (entity is TBase) { var transactionName = HibernateSessionManager.GetTransactionName(); if (string.IsNullOrEmpty(transactionName)) return; if (changesByTransaction.TryAdd(transactionName, new ConcurrentBag<Guid>() { id })) return; changesByTransaction.TryGetValue(transactionName, out var transactionChangedEntities); // ReSharper disable once PossibleNullReferenceException // R# does not have attributes telling that value in dictionary is not null. But we know it. transactionChangedEntities.Add(id); } } public void Reset() { needFullUpdate = true; } void ITransactionNotificationListener.AfterCommit(ISession session, string transactionName) { if (changesByTransaction.TryRemove(transactionName, out var transactionChangedEntities) && !transactionChangedEntities.IsEmpty) while (transactionChangedEntities.TryTake(out var id)) entitiesToUpdate.Add(id); } void ITransactionNotificationListener.AfterRollback(ISession session, string transactionName) { changesByTransaction.TryRemove(transactionName, out _); } }

public class IdBasedCacheWriteLockScope<TBase, TCachedBo> : CacheWriteLockScope<TBase, TCachedBo> where TBase : class, IEntity where TCachedBo : class, IIdEntity<Guid> { [NotNull] private readonly ICollection<Guid> changedEntitiesIds; public override bool? UpdateRequired => changedEntitiesIds.Any(); public IdBasedCacheWriteLockScope([NotNull] ReaderWriterLockSlim lockObj, [NotNull] Func<ICollection<Guid>> changedEntitiesIdsProvider) : base(lockObj) { changedEntitiesIds = changedEntitiesIdsProvider() ?? throw new InvalidOperationException(nameof(changedEntitiesIdsProvider)); } public override IDictionary<Guid, TCachedBo> GetUpdatedEntities(IReadonlyRepository<TBase> repository, ISession session) { var entities = repository.GetByIds(session, changedEntitiesIds, true).ToDictionary(e => e.Id, be => be as TCachedBo); foreach (var id in changedEntitiesIds.Where(i => !entities.ContainsKey(i))) entities.Add(id, null); return entities; } }

Интересные косяки, которые вскрылись в процессе разработки и раскатки на бою:

  • Просто так нельзя вытаскивать и кешировать связанные сущности с прямыми ссылками между ними. Иначе, когда мы изменяем одну из сущностей, устаревшая копия остается в объектах, ссылающихся на неё. Вместо того чтобы делать хитрую логику инвалидации таких связей, решили просто при обращении по свойству — всегда доставать свежую информацию из кеша
  • Надо учитывать момент, что даже самый простой запрос на инициализацию кеша может упасть при обращении к БД, приводя при этом к некорректности данных в кеше (дифферинциальные обновления при этом ловко подтягивали изменяемые данные, а вот то, что должно было лежать там с момента старта и никем не трогалось, отсутствовало). Изначально не подумалось об этом, в итоге код отслеживания таких ситуаций и сброса инициализации кеша получился малость запутанным
  • Партиционирование с обновлением только «нужных» сегментов кеша (благо у нас данные очень хорошо разделены на несвязанные сегменты) стоило заложить сразу, теперь уже не факт что до него дойдут руки, благо пока что операции обновления кеша проходят достаточно быстро
Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть