Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
«Если суть работы программиста в автоматизации работы других людей, то почему моя работа так мало автоматизирована» — думал я, копируя в очередной раз всю необходимую в проекте обвязку для добавления новой сущности в БД. И решил избавиться от этой рутины по добавлению шаблонных классов, сделав заодно «хорошо» проекту, разгрузив БД от лишних операций чтения.
Небольшое отступление про систему, которую мы разрабатываем и её состояние на момент начала этого эксперимента:
Во время обдумывания ситуации с БД и возникла мысль: не убрать ли из нагрузки на БД эти самые 10% запросов (а заодно и нужные для них открытия подключений к БД, удержание открытых транзакций и шаблонный код для доступа к БД через наши репозитории). При этом, надо было учесть:
на тот момент уже несколько надоело.
— Новая фича не должна глобально затрагивать других разработчиков, миграция на новый подход должна происходить плавно и нежно.
Хотелось при этом сделать так, чтобы добавление новой сущности в механизмы кеширования занимало минимум усилий, код остался читаемым и прямолинейным, и при получении данных из кеша можно было бы минимально думать о том, какой вспомогательный код надо написать. Два последних пункта сильно срезали ассортимент вариантов. По сути, надо или городить что-то с рефлексией и generic-классами, или же обратиться к старому доброму метапрограммированию.
Так как основной инструмент разработки — Visual Studio, а много сил тратить на то, что не факт что привнесет грандиозный эффект, не хотелось — решил сделать решение «в лоб» максимально стандартными средствами и только уже на этапе готового Proof Of Concept на паре самых частоиспользуемых сущностей — предъявить решение коллегам на суд.
Дальше была некоторая моральная дилемма. Использовать ли в качестве первоисточника какой-то класс обвешанный атрибутами на все случаи жизни (в стиле Fluent-маппинга Nhibernate на стероидах), или же написать милую аккуратную XML. Помня, что лень — лучший друг программиста, а описание классов атрибутами более трудозатратно, чем написание небольшой XML, сделал выбор в пользу последнего.
По сути, что мне нужно было от описания сущностей?
И большой страшный T4 шаблон для её парсинга и генерации разномастных, но столь нужных классов:
Сам шаблон, с логической точки зрения, состоит из двух частей: парсинг исходной xml в классы, описывающие нужную структуру классов (для уменьшения риска образования какого-либо неявного поведения, при этом, было решено сделать именно через явный парсинг тегов, а не через маппинг атрибутами.
И куда же без некоторого количества хитрого Generic-кода, для подписки на внесение изменений в БД, обновления кешей по требованию, и прочих радостей жизни. Тут generic оказалось вполне достаточно, чтобы закрыть все сценарии использования, вкупе с уже использующимися с
Интересным, на мой взгляд получился класс следящий за консистентностью кеша и управляющий его обновлением, главное место где пришлось задуматься над реализацией блокировок чтобы это все было оптимально и минимально блокирующе, но при этом защищенно
Интересные косяки, которые вскрылись в процессе разработки и раскатки на бою:
Небольшое отступление про систему, которую мы разрабатываем и её состояние на момент начала этого эксперимента:
- Система в которой 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 + " :\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;
}
}
Интересные косяки, которые вскрылись в процессе разработки и раскатки на бою:
- Просто так нельзя вытаскивать и кешировать связанные сущности с прямыми ссылками между ними. Иначе, когда мы изменяем одну из сущностей, устаревшая копия остается в объектах, ссылающихся на неё. Вместо того чтобы делать хитрую логику инвалидации таких связей, решили просто при обращении по свойству — всегда доставать свежую информацию из кеша
- Надо учитывать момент, что даже самый простой запрос на инициализацию кеша может упасть при обращении к БД, приводя при этом к некорректности данных в кеше (дифферинциальные обновления при этом ловко подтягивали изменяемые данные, а вот то, что должно было лежать там с момента старта и никем не трогалось, отсутствовало). Изначально не подумалось об этом, в итоге код отслеживания таких ситуаций и сброса инициализации кеша получился малость запутанным
- Партиционирование с обновлением только «нужных» сегментов кеша (благо у нас данные очень хорошо разделены на несвязанные сегменты) стоило заложить сразу, теперь уже не факт что до него дойдут руки, благо пока что операции обновления кеша проходят достаточно быстро