在将所有导航属性加载(懒惰或渴望)到内存之前对其进行过滤

时间:2013-09-04 21:15:58

标签: c# ef-code-first entity-framework-5 soft-delete

对于未来的访问者:对于EF6,您最好使用过滤器,例如通过此项目:https://github.com/jbogard/EntityFramework.Filters

在我们正在构建的应用程序中,我们应用"软删除"模式,每个班级都有一个'删除'布尔。实际上,每个类都只是继承自这个基类:

public abstract class Entity
{
    public virtual int Id { get; set; }

    public virtual bool Deleted { get; set; }
}

举一个简短的例子,假设我有班级GymMemberWorkout

public class GymMember: Entity
{
    public string Name { get; set; }

    public virtual ICollection<Workout> Workouts { get; set; }
}

public class Workout: Entity
{
    public virtual DateTime Date { get; set; }
}

当我从数据库中获取健身房成员列表时,我可以确保没有删除任何&#39;健身房成员就像这样:

var gymMembers = context.GymMembers.Where(g => !g.Deleted);

然而,当我遍历这些健身房成员时,他们的Workouts从数据库加载而不考虑他们的Deleted标志。虽然我不能责怪实体框架没有理解这一点,但我想以某种方式配置或拦截延迟属性加载,以便永远不会加载已删除的导航属性。

我一直在考虑我的选择,但看起来很稀缺:

这根本不是一种选择,因为手动工作太多了。 (我们的应用程序非常庞大,每天都变得越来越大)。我们也不想放弃使用Code First(其中有很多)的优势

同样,不是一个选择。此配置仅适用于每个实体。总是急切地加载实体也会造成严重的性能损失。

  • 应用表达式访问者模式,该模式会在找到.Where(e => !e.Deleted)的任何位置自动注入IQueryable<Entity>,如herehere所述。

我实际上在概念验证应用程序中对此进行了测试,并且它运行得非常好。 这是一个非常有趣的选项,但是,它无法对延迟加载的导航属性应用过滤。这很明显,因为这些惰性属性不会出现在表达式/查询中,因此无法替换。我想知道实体框架是否允许在DynamicProxy类中的某个位置加载延迟属性的注入点。 我也担心会产生其他后果,例如可能会破坏EF中的Include机制。

  • 编写实现ICollection但自动过滤Deleted实体的自定义类。

这实际上是我的第一个方法。我们的想法是为内部使用自定义Collection类的每个集合属性使用backing属性:

public class GymMember: Entity
{
    public string Name { get; set; }

    private ICollection<Workout> _workouts;
    public virtual ICollection<Workout> Workouts 
    { 
        get { return _workouts ?? (_workouts = new CustomCollection()); }
        set { _workouts = new CustomCollection(value); }
     }

}

虽然这种做法实际上并不差,但我仍然遇到一些问题:

  • 它仍会将所有Workout加载到内存中,并在命中属性设置器时过滤Deleted个。以我的拙见,这太晚了。

  • 执行的查询与加载的数据之间存在逻辑不匹配。

想象一下自上周以来我想要一个健身房成员列表的情景:

var gymMembers = context.GymMembers.Where(g => g.Workouts.Any(w => w.Date >= DateTime.Now.AddDays(-7).Date));

此查询可能会返回一个健身房成员,该成员只有已删除但仍满足谓词的锻炼。一旦将它们加载到内存中,就好像这个健身房成员根本没有锻炼! 您可以说开发人员应该知道Deleted并始终将其包含在他的查询中,但这是我真正想要避免的事情。也许ExpressionVisitor可以再次提供答案。

  • 使用CustomCollection时,实际上无法将导航属性标记为Deleted

想象一下这种情况:

var gymMember = context.GymMembers.First();
gymMember.Workouts.First().Deleted = true;
context.SaveChanges();`

您可能希望在数据库中更新相应的Workout记录,这样你就错了!由于gymMember正在检查ChangeTracker是否有任何更改,因此属性gymMember.Workouts将突然减少1次锻炼。这是因为CustomCollection会自动过滤已删除的实例,还记得吗?所以现在Entity Framework认为需要删除锻炼,EF会尝试将FK设置为​​null,或者实际删除记录。 (取决于数据库的配置方式)。这就是我们试图避免使用软删除模式开始!!!

我偶然发现了interesting blog帖子,该帖子会覆盖SaveChanges的默认DbContext方法,以便将EntityState.Deleted的任何条目更改回EntityState.Modified但这再一次感觉“hacky&#39;而且相当不安全。但是,如果能解决问题而没有任何意外的副作用,我愿意尝试一下。


所以我在这里是StackOverflow。我已经非常广泛地研究了我的选择,如果我可以这样说的话,我会在我的智慧结束时。所以现在我转向你。您是如何在企业应用程序中实现软删除的?

重申一下,这些是我正在寻找的要求:

  • 查询应自动排除数据库级别的Deleted实体
  • 删除实体并调用&#39; SaveChanges&#39;应该只是更新相应的记录,没有其他副作用。
  • 加载导航属性时,无论是懒惰还是急切,都应自动排除Deleted导航属性。

我期待着所有的建议,谢谢你们。

3 个答案:

答案 0 :(得分:9)

经过大量研究,我终于找到了实现我想要的方法。 它的要点是我在对象上下文中使用事件处理程序拦截物化实体,然后在我可以找到的每个集合属性中注入我的自定义集合类(使用反射)。

最重要的部分是拦截“DbCollectionEntry”,这是负责加载相关集合属性的类。通过在实体和DbCollectionEntry之间摆动自己,我可以完全控制何时以及如何加载。唯一的缺点是这个DbCollectionEntry类几乎没有公共成员,这要求我使用反射来操纵它。

这是我的自定义集合类,它实现ICollection并包含对相应DbCollectionEntry的引用:

public class FilteredCollection <TEntity> : ICollection<TEntity> where TEntity : Entity
{
    private readonly DbCollectionEntry _dbCollectionEntry;
    private readonly Func<TEntity, Boolean> _compiledFilter;
    private readonly Expression<Func<TEntity, Boolean>> _filter;
    private ICollection<TEntity> _collection;
    private int? _cachedCount;

    public FilteredCollection(ICollection<TEntity> collection, DbCollectionEntry dbCollectionEntry)
    {
        _filter = entity => !entity.Deleted;
        _dbCollectionEntry = dbCollectionEntry;
        _compiledFilter = _filter.Compile();
        _collection = collection != null ? collection.Where(_compiledFilter).ToList() : null;
    }

    private ICollection<TEntity> Entities
    {
        get
        {
            if (_dbCollectionEntry.IsLoaded == false && _collection == null)
            {
                IQueryable<TEntity> query = _dbCollectionEntry.Query().Cast<TEntity>().Where(_filter);
                _dbCollectionEntry.CurrentValue = this;
                _collection = query.ToList();

                object internalCollectionEntry =
                    _dbCollectionEntry.GetType()
                        .GetField("_internalCollectionEntry", BindingFlags.NonPublic | BindingFlags.Instance)
                        .GetValue(_dbCollectionEntry);
                object relatedEnd =
                    internalCollectionEntry.GetType()
                        .BaseType.GetField("_relatedEnd", BindingFlags.NonPublic | BindingFlags.Instance)
                        .GetValue(internalCollectionEntry);
                relatedEnd.GetType()
                    .GetField("_isLoaded", BindingFlags.NonPublic | BindingFlags.Instance)
                    .SetValue(relatedEnd, true);
            }
            return _collection;
        }
    }

    #region ICollection<T> Members

    void ICollection<TEntity>.Add(TEntity item)
    {
        if(_compiledFilter(item))
            Entities.Add(item);
    }

    void ICollection<TEntity>.Clear()
    {
        Entities.Clear();
    }

    Boolean ICollection<TEntity>.Contains(TEntity item)
    {
        return Entities.Contains(item);
    }

    void ICollection<TEntity>.CopyTo(TEntity[] array, Int32 arrayIndex)
    {
        Entities.CopyTo(array, arrayIndex);
    }

    Int32 ICollection<TEntity>.Count
    {
        get
        {
            if (_dbCollectionEntry.IsLoaded)
                return _collection.Count;
            return _dbCollectionEntry.Query().Cast<TEntity>().Count(_filter);
        }
    }

    Boolean ICollection<TEntity>.IsReadOnly
    {
        get
        {
            return Entities.IsReadOnly;
        }
    }

    Boolean ICollection<TEntity>.Remove(TEntity item)
    {
        return Entities.Remove(item);
    }

    #endregion

    #region IEnumerable<T> Members

    IEnumerator<TEntity> IEnumerable<TEntity>.GetEnumerator()
    {
        return Entities.GetEnumerator();
    }

    #endregion

    #region IEnumerable Members

    IEnumerator IEnumerable.GetEnumerator()
    {
        return ( ( this as IEnumerable<TEntity> ).GetEnumerator() );
    }

    #endregion
}

如果你浏览它,你会发现最重要的部分是“实体”属性,它将延迟加载实际值。在FilteredCollection的构造函数中,我传递了一个可选的ICollection,用于已经急切加载集合的场景。

当然,我们仍然需要配置实体框架,以便我们的FilteredCollection可用于有集合属性的任何地方。这可以通过挂钩到Entity Framework的底层ObjectContext的ObjectMaterialized事件来实现:

(this as IObjectContextAdapter).ObjectContext.ObjectMaterialized +=
    delegate(Object sender, ObjectMaterializedEventArgs e)
    {
        if (e.Entity is Entity)
        {
            var entityType = e.Entity.GetType();
            IEnumerable<PropertyInfo> collectionProperties;
            if (!CollectionPropertiesPerType.TryGetValue(entityType, out collectionProperties))
            {
                CollectionPropertiesPerType[entityType] = (collectionProperties = entityType.GetProperties()
                    .Where(p => p.PropertyType.IsGenericType && typeof(ICollection<>) == p.PropertyType.GetGenericTypeDefinition()));
            }
            foreach (var collectionProperty in collectionProperties)
            {
                var collectionType = typeof(FilteredCollection<>).MakeGenericType(collectionProperty.PropertyType.GetGenericArguments());
                DbCollectionEntry dbCollectionEntry = Entry(e.Entity).Collection(collectionProperty.Name);
                dbCollectionEntry.CurrentValue = Activator.CreateInstance(collectionType, new[] { dbCollectionEntry.CurrentValue, dbCollectionEntry });
            }
        }
    };

这看起来相当复杂,但它本质上是扫描物化类型的集合属性并将值更改为过滤集合。它还将DbCollectionEntry传递给过滤后的集合,以便它可以发挥其魔力。

这涵盖了整个“装载实体”部分。到目前为止唯一的缺点是,急切加载的集合属性仍将包含已删除的实体,但它们会在FilterCollection类的“Add”方法中被过滤掉。这是一个可以接受的缺点,虽然我还没有对它如何影响SaveChanges()方法进行一些测试。

当然,这仍然存在一个问题:查询没有自动过滤。如果您想要获取过去一周进行锻炼的健身会员,您希望自动排除已删除的锻炼。

这是通过ExpressionVisitor实现的,它自动将'.Where(e =&gt;!e.Deleted)'过滤器应用于它在给定表达式中可以找到的每个IQueryable。

以下是代码:

public class DeletedFilterInterceptor: ExpressionVisitor
{
    public Expression<Func<Entity, bool>> Filter { get; set; }

    public DeletedFilterInterceptor()
    {
        Filter = entity => !entity.Deleted;
    }

    protected override Expression VisitMember(MemberExpression ex)
    {
        return !ex.Type.IsGenericType ? base.VisitMember(ex) : CreateWhereExpression(Filter, ex) ?? base.VisitMember(ex);
    }

    private Expression CreateWhereExpression(Expression<Func<Entity, bool>> filter, Expression ex)
    {
        var type = ex.Type;//.GetGenericArguments().First();
        var test = CreateExpression(filter, type);
        if (test == null)
            return null;
        var listType = typeof(IQueryable<>).MakeGenericType(type);
        return Expression.Convert(Expression.Call(typeof(Enumerable), "Where", new Type[] { type }, (Expression)ex, test), listType);
    }

    private LambdaExpression CreateExpression(Expression<Func<Entity, bool>> condition, Type type)
    {
        var lambda = (LambdaExpression) condition;
        if (!typeof(Entity).IsAssignableFrom(type))
            return null;

        var newParams = new[] { Expression.Parameter(type, "entity") };
        var paramMap = lambda.Parameters.Select((original, i) => new { original, replacement = newParams[i] }).ToDictionary(p => p.original, p => p.replacement);
        var fixedBody = ParameterRebinder.ReplaceParameters(paramMap, lambda.Body);
        lambda = Expression.Lambda(fixedBody, newParams);

        return lambda;
    }
}

public class ParameterRebinder : ExpressionVisitor
{
    private readonly Dictionary<ParameterExpression, ParameterExpression> _map;

    public ParameterRebinder(Dictionary<ParameterExpression, ParameterExpression> map)
    {
        _map = map ?? new Dictionary<ParameterExpression, ParameterExpression>();
    }

    public static Expression ReplaceParameters(Dictionary<ParameterExpression, ParameterExpression> map, Expression exp)
    {
        return new ParameterRebinder(map).Visit(exp);
    }

    protected override Expression VisitParameter(ParameterExpression node)
    {
        ParameterExpression replacement;

        if (_map.TryGetValue(node, out replacement))
            node = replacement;

        return base.VisitParameter(node);
    }
}

我的运行时间有点短暂,所以我稍后会回到这篇文章中了解更多细节,但是它的要点是写下来的,对于那些渴望尝试一切的人来说;我在这里发布了完整的测试应用程序:https://github.com/amoerie/TestingGround

但是,可能仍然存在一些错误,因为这是一项非常重要的工作。虽然概念性的想法是合理的,但是一旦我整齐地重构了所有内容并且找到时间为此编写一些测试,我希望它能够很快完成。

答案 1 :(得分:1)

可能的方法可能是使用具有基本规范的规范,该规范检查所有查询的软删除标志以及包含策略。

我将说明我在项目中使用的规范模式的调整版本(其来源于此blog post

public abstract class SpecificationBase<T> : ISpecification<T>
    where T : Entity
{
    private readonly IPredicateBuilderFactory _builderFactory;
    private IPredicateBuilder<T> _predicateBuilder;

    protected SpecificationBase(IPredicateBuilderFactory builderFactory)
    {
        _builderFactory = builderFactory;            
    }

    public IPredicateBuilder<T> PredicateBuilder
    {
        get
        {
            return _predicateBuilder ?? (_predicateBuilder = BuildPredicate());
        }
    }

    protected abstract void AddSatisfactionCriterion(IPredicateBuilder<T> predicateBuilder);        

    private IPredicateBuilder<T> BuildPredicate()
    {
        var predicateBuilder = _builderFactory.Make<T>();

        predicateBuilder.Check(candidate => !candidate.IsDeleted)

        AddSatisfactionCriterion(predicateBuilder);

        return predicateBuilder;
    }
}

IPredicateBuilder是LINQKit.dll中包含的谓词构建器的包装器。

规范基类负责创建谓词构建器。创建后,可以添加应该应用于所有查询的条件。然后可以将谓词构建器传递给继承的规范以添加更多条件。例如:

public class IdSpecification<T> : SpecificationBase<T> 
    where T : Entity
{
    private readonly int _id;

    public IdSpecification(int id, IPredicateBuilderFactory builderFactory)
        : base(builderFactory)
    {
        _id = id;            
    }

    protected override void AddSatisfactionCriterion(IPredicateBuilder<T> predicateBuilder)
    {
        predicateBuilder.And(entity => entity.Id == _id);
    }
}

IdSpecification的完整谓词将是:

entity => !entity.IsDeleted && entity.Id == _id

然后可以将规范传递给使用PredicateBuilder属性构建where子句的存储库:

    public IQueryable<T> FindAll(ISpecification<T> spec)
    {
        return context.AsExpandable().Where(spec.PredicateBuilder.Complete()).AsQueryable();
    }

AsExpandable()是LINQKit.dll的一部分。

关于包含/延迟加载属性,可以使用包含的其他属性扩展规范。规范库可以添加基础包含然后子规范添加其包含。然后,存储库可以在从db中获取之前应用规范中的包含。

    public IQueryable<T> Apply<T>(IDbSet<T> context, ISpecification<T> specification) 
    {
        if (specification.IncludePaths == null)
            return context;

        return specification.IncludePaths.Aggregate<string, IQueryable<T>>(context, (current, path) => current.Include(path));
    } 

如果不清楚,请告诉我。我试图不把它变成一个怪物帖子,所以可能会遗漏一些细节。

编辑:我意识到我没有完全回答你的问题;导航属性。如果您将导航属性设置为内部(使用this post to configure it并创建IQueryable的非映射公共属性,该怎么办?非映射属性可以具有自定义属性,并且存储库将基本规范的谓词添加到where,而不会急切地加载当有人确实应用了一个急切的操作时,过滤器就会适用。例如:

    public T Find(int id)
    {
        var entity = Context.SingleOrDefault(x => x.Id == id);
        if (entity != null)
        {
            foreach(var property in entity.GetType()
                .GetProperties()
                .Where(info => info.CustomAttributes.OfType<FilteredNavigationProperty>().Any()))
            {
                var collection = (property.GetValue(property) as IQueryable<IEntity>);
                collection = collection.Where(spec.PredicateBuilder.Complete());
            }
        }

        return entity;
    }

我没有测试过上面的代码,但它可以用一些调整:)

编辑2:删除。

如果您使用的是通用/通用存储库,则只需在delete方法中添加一些其他功能:

    public void Delete(T entity)
    {
        var castedEntity = entity as Entity;
        if (castedEntity != null)
        {
            castedEntity.IsDeleted = true;
        }
        else
        {
            _context.Remove(entity);
        }            
    }

答案 2 :(得分:0)

您是否考虑过使用数据库中的视图加载已排除已删除项目的问题实体?

这确实意味着您需要使用存储过程来映射INSERT / UPDATE / DELETE功能,但如果Workout映射到a,它肯定会解决您的问题删除已删除的行的视图。此外 - 在代码优先方法中,这可能不起作用......