在将所有导航属性(无论是惰性加载还是急切加载)加载到内存之前,请过滤它们。

28

对于未来的访问者:如果您使用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; }
}

当我从数据库中获取健身房会员列表时,我可以通过以下方式确保不会获取到任何“已删除”的健身房会员:

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

然而,当我迭代这些健身房会员时,它们的Workouts从数据库中加载,而不考虑它们的Deleted标志。虽然我不能责怪实体框架没有注意到这一点,但我希望能够以某种方式配置或拦截延迟属性加载,以便永远不会加载已删除的导航属性。
我已经研究了我的选择,但它们似乎很少: 简单地说,这不是一个选项,因为这将是太多的手动工作。(我们的应用程序非常庞大,并且每天都在增长)。我们也不想放弃使用Code First的优势(有很多) 同样,这也不是一个选项。此配置仅适用于每个实体。始终急切地加载实体还将对性能造成严重影响。
  • 应用表达式访问者模式,自动将.Where(e => !e.Deleted)注入到任何发现的IQueryable<Entity>中,如herehere所述。

我在概念验证应用程序中实际测试了这一点,效果非常好。 这是一个非常有趣的选择,但遗憾的是,它无法对惰性加载的导航属性应用过滤。显然,这些惰性属性不会出现在表达式/查询中,因此无法被替换。我想知道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); }
     }

}

虽然这种方法并不差,但我仍然有一些问题:
  • 在属性赋值器被触发时,它仍会将所有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记录已更新到数据库中,但事实并非如此!由于ChangeTracker检查gymMember是否有任何更改,因此属性gymMember.Workouts将突然返回1个较少的训练。这是因为CustomCollection自动过滤了删除的实例,记住了吗?所以现在Entity Framework认为需要删除该训练,并且EF将尝试将FK设置为null,或者实际上删除该记录(取决于您的DB配置方式)。这正是我们一开始尝试使用软删除模式避免的情况!!!
我偶然发现了一篇interesting blog文章,覆盖了DbContext默认的SaveChanges方法,以便将任何具有EntityState.Deleted的条目更改回EntityState.Modified,但这似乎又是'hacky'和相当不安全的。但是,如果它可以解决问题而没有任何意外的副作用,我愿意尝试它。
我在这里向StackOverflow求助。我已经进行了广泛的选项研究,如果我可以这么说的话,但是我已经到了无路可走的地步。现在我转向你们。你们如何在企业应用程序中实现软删除?
重申一下,以下是我要寻找的要求:
  • 在数据库层面上,查询应自动排除已删除的实体
  • 删除实体并调用“SaveChanges”应仅更新相应记录,没有其他副作用。
  • 当加载导航属性时,无论是惰性还是渴望的,都应自动排除“Deleted”的实体。
期待着任何建议,提前感谢。

1
你尝试过在初始加载时调用 .ToList() 以强制 EF 加载到内存中吗? - Botonomous
2
恐怕你的评论没有多少意义。你真的在不到1分钟的时间内读完了我的整个问题吗?那太令人印象深刻了! - Moeri
2
谢谢你在别人试图帮助你时使用讽刺的语气。 - Botonomous
非常抱歉,我并不是有意冒犯你。在健身房会员上调用ToList确实会将健身房会员加载到内存中,但我真的看不出这样做如何解决我的问题? - Moeri
当然,它不能包括那些记录,这就是我想要实现的!我的问题是:如何实现?此外,在此时禁用延迟加载真的不是一个选项,因为我们的应用程序已经在各个地方广泛使用它。我也不想放弃延迟加载的优势。 - Moeri
显示剩余3条评论
3个回答

10
经过大量研究,我终于找到了实现想要的功能的方法。简而言之,我通过在对象上下文中使用事件处理程序截取物化实体,并注入自定义集合类到可以找到的每个集合属性中(借助反射)。
最重要的部分是拦截“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
}
如果您仔细浏览,会发现最重要的部分是“实体”属性,它将延迟加载实际值。在FilterCollection的构造函数中,我传递了一个可选的ICollection,用于已经急切地加载集合的场景。
当然,我们仍然需要配置Entity Framework,以便我们的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 =>!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

一个可能的方法是使用规范与基础规范一起检查所有查询的软删除标志,并采用包含策略。

我将演示一个调整过的规范模式版本,该版本在项目中使用(其起源可以追溯到这个博客文章

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

如果有什么不清楚的,请告诉我。我试图让这篇文章不至于太长,因此可能会遗漏一些细节。

编辑:我意识到我没有完全回答你的问题;导航属性。如果将导航属性设置为 internal(使用 此帖子配置它 并创建非映射公共属性,使其成为 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:删除。
如果您正在使用通用存储库,您可以简单地在删除方法中添加一些进一步的功能:
    public void Delete(T entity)
    {
        var castedEntity = entity as Entity;
        if (castedEntity != null)
        {
            castedEntity.IsDeleted = true;
        }
        else
        {
            _context.Remove(entity);
        }            
    }

这看起来很有前途,但我现在时间有点紧,等我有机会仔细阅读你的答案并测试它时,我会再联系你。谢谢! - Moeri
当然!让我知道进展如何。经过一些思考,我意识到导航属性建议需要更多的工作(在FK上进行过滤等)。 - The Heatherleaf
1
我已经阅读了你的帖子并测试了一些东西,我注意到大部分内容只是将查询逻辑封装在你的“规范”对象中。这实际上与我们在项目中使用的EntityFilter和EntitySorter非常相似(请参见http://www.cuttingedge.it/blogs/steven/pivot/entry.php?id=66)。至于导航属性,我发现两个不可原谅的缺陷:
  1. 由于导航集合属性不再映射,因此我无法查询它!
  2. 如果我没有另一端的导航属性,则无法向集合添加/删除任何项。
- Moeri
总之(在我之前的评论中,我已经达到了SO评论长度限制):恐怕你的解决方案行不通。 :-( 尽管如此,我还是要感谢你非常认真和彻底地尝试! - Moeri
是的,我也有这种感觉。让我知道进展如何。很有趣听到你是如何解决它的 :) 我会补充一些额外的想法。 - The Heatherleaf

1

您是否考虑使用视图来从数据库中加载不包含已删除项的问题实体?

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


我已经考虑过这个问题,但是我非常犹豫采取这种方法,因为它有点违背了首先使用ORM的目的。其次,已经有大量的表格,这意味着我需要编写很多存储过程,最后,维护将是纯粹的恐怖。每当更改或添加某些字段时,我都必须更新存储过程。现在,使用Code First,这是一种乐趣:运行“Add-Migration”和“Update-Database”,一切都准备就绪。 - Moeri
所有这些代码(视图、存储过程)看起来都一样,每当表定义被更改/检查时都可以轻松生成。并不是说这是一个好的解决方案,但无论如何。 - Tommy Grovnes
回应您的每一个反对意见: 1)我不明白为什么使用视图会破坏ORM的目的。在像EF这样的ORM中,使用视图是常见的,可以应用查询提示,否则需要重新编译源代码。 2)为什么需要编写存储过程? 3)我不使用EF迁移,所以那可能是您真正的瓶颈。我使用RoundhousE进行迁移。 - John Zabroski

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接