Как сделать легконастраиваемое кеширование в проекте и спасти коллег от написания однотипного кода

в 15:03, , рубрики: .net, C#, performance, t4, t4 template, архитектура по, леньдвигательпрогресса, метапрограммирование, разработка под windows

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

Небольшое отступление про систему, которую мы разрабатываем и её состояние на момент начала этого эксперимента:

  • Система в которой 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
    {
        [NotNull]
        private static readonly ILog Log = IikoBizLogManager.GetLogger(typeof(RareChangedObjectsCache<T, K, V>));

        [NotNull]
        protected readonly ReaderWriterLockSlim LockObj = new ReaderWriterLockSlim(LockRecursionPolicy.SupportsRecursion);

        [NotNull]
        protected readonly Dictionary<K, V> Cache = new Dictionary<K, V>();

        [NotNull]
        protected abstract QueryOver<T> GetQuery();

        [NotNull]
        private readonly ConcurrentDictionary<String, bool> changesByTransaction = new ConcurrentDictionary<string, bool>();

        private DateTime lastRefreshTime = DateTime.MinValue;

        [NotNull]
        public HibernateSessionManager SessionManager { get; set; }

        /// <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 + " :nttBaseEntity,ntt"
              + (LogicallyDeletable ? "ILogicallyDeletable,ntt" : string.Empty)
          + (Implements.Any() ? string.Join("ntt", Implements) + "ntt" : string.Empty)
          + "I" + Name;
    }

    public string GetCachedClassDefinition()
    {
        return "Cached" + Name + " :ntt"
          + (LogicallyDeletable ? "Deletable" : string.Empty)
          + "CachedEntity<" + BasedOn + ">,ntt"
          + (Implements.Any() ? string.Join("ntt", Implements) + "ntt" : 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("nttt", classNode.Fields.Where(f => f.PropertySource == null).Select(f=>f.GetConstructorInitValueExpression()))#>
        }

        <#=string.Join("nntt", 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("nntt", 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("ntt", 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("nntt", 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;
        }
    }

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

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

Автор: Fulborg

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js