Слой кэширования поверх Linq to SQL. Версия 1.0.
Введение было в предыдущей статье
Для того, чтобы полноценно использовать в рамках текущего DataContext’а сущности загруженные в другом DataContext’е, их необходимо клонировать и зааттачить. При аттаче сущность добавляется в IdentityMap текущего DataContext’а. Зааттачить одну и ту же сущность к двум разным DataContext’ам нельзя (поэтому каждый клон может использоваться только в одном DataContext’е, то есть по факту в одной бизнес транзакции). MemberwiseClone для клонирования использовать нельзя, так как при этом в клоне будут ссылки на сущности зааттаченные к DataContext’у, в котором сущность была загружена, а не к текущем DataContext’у.
В итоге для того, чтобы можно было использовать сущности из статического кэша, был написан следующий код, использующий Linq MetaModel и немного Reflection’а:
public TEntity CloneAndAttach(TEntity sourceEntity) { if (sourceEntity == null) throw new ArgumentNullException("sourceEntity"); var entityType = sourceEntity.GetType(); var entityMetaType = GetClonningMetaType(entityType); var entityKey = entityMetaType.GetKey(sourceEntity); // Проверка, не позволяющая клонировать одну и ту же сущность более одного раза. // Кроме прочего помогает разобраться с циклическими ссылками. if (attachedEntities.ContainsKey(entityKey)) { return attachedEntities[entityKey]; } var clonedObject = Activator.CreateInstance(entityType); var clonedEntity = (TEntity)clonedObject; attachedEntities.Add(entityKey, clonedEntity); // клонируем поля foreach (var dataMember in entityMetaType.Fields) { var value = dataMember.StorageAccessor.GetBoxedValue(sourceEntity); dataMember.StorageAccessor.SetBoxedValue(ref clonedObject, value); } // клонируем EntityRef'ы foreach (var dataMember in entityMetaType.EntityRefs) { var thisKeyValues = dataMember.Association.ThisKey .Select(keyDataMember => keyDataMember.StorageAccessor.GetBoxedValue(sourceEntity)) .ToArray(); if (thisKeyValues.All(keyValue => keyValue == null)) { continue; } var repository = Repositories.GetRepositoryCheckingBaseTypeFor(dataMember.Type); var value = repository.CloneAndAttach(dataMember.MemberAccessor.GetBoxedValue(sourceEntity)); dataMember.MemberAccessor.SetBoxedValue(ref clonedObject, value); } // клонируем EntitySet'ы foreach (var dataMember in entityMetaType.EntitySets) { var repository = Repositories .GetRepositoryCheckingBaseTypeFor(dataMember.Type.GenericTypeArguments[0]); var sourceEntitySet = (IList)dataMember.MemberAccessor.GetBoxedValue(sourceEntity); var clonedEntitySet = (IList)Activator.CreateInstance(dataMember.Type); foreach (var sourceItem in sourceEntitySet) { var clonedItem = repository.CloneAndAttach(sourceItem); clonedEntitySet.Add(clonedItem); } dataMember.MemberAccessor.SetBoxedValue(ref clonedObject, clonedEntitySet); } table.Attach(clonedEntity); return clonedEntity; } private ClonningMetaType GetClonningMetaType(Type type) { ClonningMetaType result; // Информация о клонируемом типе кэшируется в статическом ConcurrentDictionary, чтобы не юзать Reflection каждый раз if (!clonningMetaTypes.TryGetValue(type, out result)) { result = clonningMetaTypes.GetOrAdd(type, key => new ClonningMetaType(MetaModel.GetMetaType(key))); } return result; } private class ClonningMetaType { private readonly MetaType metaType; private readonly IReadOnlyCollection keys; public ClonningMetaType(MetaType metaType) { this.metaType = metaType; keys = metaType.DataMembers .Where(dataMember => dataMember.IsPrimaryKey) .ToArray(); Fields = metaType.DataMembers .Where(dataMember => dataMember.IsPersistent) .Where(dataMember => !dataMember.IsAssociation) .ToArray(); EntityRefs = metaType.DataMembers .Where(dataMember => dataMember.IsPersistent) .Where(dataMember => dataMember.IsAssociation) .Where(dataMember => !dataMember.Association.IsMany) .Where(dataMember => !dataMember.Member.HasCustomAttribute()) .ToArray(); EntitySets = metaType.DataMembers .Where(dataMember => dataMember.IsPersistent) .Where(dataMember => dataMember.IsAssociation) .Where(dataMember => dataMember.Association.IsMany) .Where(dataMember => !dataMember.Member.HasCustomAttribute()) .ToArray(); } public IReadOnlyCollection Fields { get; private set; } public IReadOnlyCollection EntityRefs { get; private set; } public IReadOnlyCollection EntitySets { get; private set; } public ItcEntityKey GetKey(object entity) { return new ItcEntityKey( metaType, keys.Select(dataMember => dataMember.StorageAccessor.GetBoxedValue(entity)).ToArray()); } }
Для того, чтобы при клонировании проходясь по EntityRef’ам и EntitySet’ам не выгрузить половину БД, я помечаю часть EntityRef’ов и EntitySet’ов с помощью специального атрибута NonCacheableAssociationAttribute. Я больше склонялся к тому, чтобы наоборот помечать особым атрибутом CacheableAssociationAttribute те ассоциации, которые надо клонировать, но в итоге коллеги меня уговорили на текущий вариант, та как так меньше полей придется помечать, да и ошибки, при которых лишнее выгружается, легче заметить.
Так как кэш загружается в отдельном DataContext’е и клонирование происходит уже после его Dispose, мне также пришлось написать метод который подгружает все кэшируемые ассоциации у загружаемой сущности:
public void LoadEntityForClone(TEntity entity) { // Проверка, устраняющая проблемы с циклическими ссылками if (loadedEntities.Contains(entity)) { return; } loadedEntities.Add(entity); var entityType = entity.GetType(); var entityMetaType = GetClonningMetaType(entityType); // Загружаем все EntityRef'ы foreach (var dataMember in entityMetaType.EntityRefs) { var repository = Repositories.GetRepositoryCheckingBaseTypeFor(dataMember.Type); var value = dataMember.MemberAccessor.GetBoxedValue(entity); if (value != null) { repository.LoadEntityForClone(value); } } // Загружаем все EntitySet'ы foreach (var dataMember in entityMetaType.EntitySets) { var repository = Repositories .GetRepositoryCheckingBaseTypeFor(dataMember.Type.GenericTypeArguments[0]); var entitySet = (IList)dataMember.MemberAccessor.GetBoxedValue(entity); foreach (var item in entitySet) { repository.LoadEntityForClone(item); } } }
Конечно же, если использовать DataLoadOptions, то понадобится меньше запросов для загрузки EntityRef’ов. Но я даже не пытался заморачиваться с динамическим построением DataLoadOptions, потому что в Linq to SQL есть бага, из-за которой при использовании DataLoadOptions для загрузки EntitySet’ов некорректно загружается тип сущности (дискриминатор типа игнорируется и вместо дочернего типа загружается родительский).
Так как доступ к записям в БД у нас происходит через репозитории, обращения к кэшу я также вынес туда. При попытке получить кампанию или элемент кампании происходит примерно следующее:
Проверяем, была ли кампания уже добавлена в текущий DataContext из кэша. Если да то возращаем объект, который уже был склонирован и зааттачен.
Смотрим есть ли кампания в кэше. Если нет, загружаем. Если есть, сравниваем дату кампании в БД с датой кампании в кэше. Если не равны, перезагружаем кампанию.
Клонируем и аттачим кампанию и все ее элементы к текущему DataContext’у
При таком подходе мы аттачим только те кампании, которые нам нужны и проверяем актуальность кэша (дату изменения камании) только один раз за транзакцию (один раз за время жизни DataContext’а). Так как в репозиториях есть множество методов для получения элементов кампаний (по Id, по кампании, по уникальному строковому идентифкатору) струкутра данных в кэша получилась довольно таки сложной, но с этим можно было жить.
Новая архитектура кэширования прошла все интеграционные тесты и была успешно выложена на Production на одном из проектов. Все стабильно работало и ничто не предвещало беды. Но потом что-то пошло не так (об этом в следующей статье).








