【问题标题】:Filter all navigation properties before they are loaded (lazy or eager) into memory在将所有导航属性加载(惰性或急切)到内存之前过滤所有导航属性
【发布时间】:2013-09-08 13:20:41
【问题描述】:

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

在我们正在构建的应用程序中,我们应用“软删除”模式,其中每个类都有一个“已删除”布尔值。实际上,每个类都简单地继承自这个基类:

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

    public virtual bool Deleted { get; set; }
}

举个简单的例子,假设我有 GymMember 和 Workout 类:

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; }
}

当我从数据库中获取健身房成员列表时,我可以确保没有获取任何“已删除”的健身房成员,如下所示:

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

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

我一直在考虑我的选择,但它们似乎很少:

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

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

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

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

  • 编写一个自定义类来实现 ICollection 但会自动过滤 Deleted 实体。

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

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); }
     }

}

虽然这种方法实际上还不错,但我仍然有一些问题:

  • 它仍然将所有Workouts 加载到内存中,并在属性设置器被命中时过滤Deleteds。在我看来,这太迟了。

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

想象一个场景,我想要一份自上周以来进行锻炼的健身房成员的列表:

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 记录,但您错了!由于ChangeTracker 正在检查gymMember 是否有任何更改,属性gymMember.Workouts 将突然返回少1 个锻炼。那是因为 CustomCollection 会自动过滤已删除的实例,记得吗?所以现在Entity Framework认为需要删除锻炼,EF会尝试将FK设置为​​null,或者实际删除记录。 (取决于您的数据库的配置方式)。这就是我们一开始就试图避免的软删除模式!!!

我偶然发现了一个interesting blog 帖子,它覆盖了DbContext 的默认SaveChanges 方法,因此任何带有EntityState.Deleted 的条目都改回EntityState.Modified,但这又让人感觉“hacky”而且相当不安全.但是,如果它解决了没有任何意外副作用的问题,我愿意尝试一下。


所以我在这里是 StackOverflow。如果我自己可以这么说的话,我已经非常广泛地研究了我的选择,而且我已经束手无策了。所以现在我转向你。您是如何在企业应用程序中实现软删除的?

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

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

我期待任何和所有的建议,提前谢谢你。

【问题讨论】:

  • 您是否尝试过在初始加载时调用 .ToList() 以强制 EF 加载到内存中?
  • 恐怕你的评论没有多大意义。您真的在不到 1 分钟的时间内阅读了我的整个问题吗?这令人印象深刻!
  • 感谢有人试图帮助你时的讽刺。
  • 抱歉,我不是故意冒犯你的。在健身房成员上调用 ToList 确实会将健身房成员加载到内存中,但我真的不明白这将如何解决我的问题?
  • 我认为你需要回归基础。您所解释的不是我对 EF 的经验。 “但是,当我遍历这些健身房成员时,他们的锻炼是从数据库中加载的,而不考虑他们的已删除标志”……这里有问题。它不得包括这些记录。检查你的谓词。 2. 在您的上下文中禁用延迟加载。完成排序后,在 for 循环中显式加载每个健身房成员的锻炼。使用他 IQueryable 返回类型。

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


【解决方案1】:

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

最重要的部分是拦截“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 可以在任何有集合属性的地方使用。这可以通过挂钩到实体框架底层 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 类的“添加”方法中被过滤掉了。这是一个可以接受的缺点,尽管我还没有对这如何影响 SaveChanges() 方法进行一些测试。

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

这是通过 ExpressionVisitor 实现的,该表达式自动将 '.Where(e => !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

但是,仍然可能存在一些错误,因为这是一项正在进行中的工作。不过,这个概念性的想法是合理的,我希望它能够在我整齐地重构所有内容并找到时间为此编写一些测试后很快就能完全发挥作用。

【讨论】:

    【解决方案2】:

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

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

    【讨论】:

    • 我已经考虑过这一点,但我很犹豫要不要走这条路,因为它首先违背了使用 ORM 的目的。其次,已经有大量的表,这意味着我需要编写大量的存储过程,最后,维护将是非常可怕的。每次更改或添加某些字段时,我都必须更新存储过程。现在,使用 Code First,这是一种享受:运行“Add-Migration”和“Update-Database”,一切顺利。
    • 所有这些代码(视图、存储过程)看起来都一样,并且可以在更改/签入表定义时轻松生成。并不是说这是一个好的解决方案,但无论如何。
    • 回应您的每一个反对意见:1)我不明白为什么使用视图会破坏 ORM 的目的。视图对于像 EF 这样的 ORM 很常见,用于应用查询提示,否则需要重新编译源代码。 2)为什么需要编写存储过程? 3)我不使用 EF 迁移,所以这可能是你真正的交易破坏者。我使用 RoundhouseE 进行迁移。
    【解决方案3】:

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

    我将说明我在一个项目中使用的规范模式的调整版本(起源于 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 的一部分。

    关于包含/延迟加载属性,可以使用关于包含的进一步属性扩展规范。规范基础可以添加基础包含,然后子规范添加它们的包含。然后,存储库可以在从数据库中获取之前应用规范中的包含。

        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);
            }            
        }
    

    【讨论】:

    • 这看起来很有希望,我现在的时间有点短,但是当我有机会正确阅读您的答案并进行测试时,我会回复您。谢谢!
    • 当然!让我知道事情的后续。经过一番思考,我意识到导航属性建议需要更多的工作(过滤 FK 等)。
    • 我浏览了您的帖子并测试了一些东西,我注意到其中大部分只是将查询逻辑封装在您的“规范”对象中。这实际上与我们在项目中使用的 EntityFilter 和 EntitySorter 惊人地相似(参见cuttingedge.it/blogs/steven/pivot/entry.php?id=66 )至于导航属性,我发现了两个不可原谅的缺陷: 1. 由于导航集合属性不再映射,我可以不要查询它! 2. 如果我在另一端没有导航属性,我无法向集合中添加/删除任何项目。
    • 总之(我在之前的评论中已经达到了 SO 评论长度限制):恐怕你的解决方案行不通。 :-( 我要感谢你给它一个非常定性和彻底的尝试!
    • 是的,我也意识到了这一点。让我知道它是怎么回事。很高兴听到你是如何解决它的:)我会添加一些额外的想法。
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多