自有类型的延迟加载

Lazy Loading for Owned Types

我正在使用 Entity Framework Core 迈向领域驱动设计的第一步。我有一个 User 实体,在简化版本中,它只有 IdProfilePhoto。但是,我想将个人资料照片存储在不同的 table 中,这就是为什么我创建了一个包含个人资料照片的 Owned Type 并以这种方式配置的原因:

用户:

public class User
{
    private int id;
    public int Id => this.id;

    //private UserProfilePhoto userProfilePhoto;
    public virtual UserProfilePhoto UserProfilePhoto { get; set; }

    private User()
    {
    }

    public static User Create(byte[] profilePhoto)
    {
        var user = new User();
        user.UserProfilePhoto = new UserProfilePhoto(profilePhoto);

        return user;
    }

    public void SetProfilePhoto(byte[] profilePhoto)
    {
        this.UserProfilePhoto = new UserProfilePhoto(profilePhoto);
    }
}

用户个人资料照片:

public class UserProfilePhoto
{
    public byte[] ProfilePhoto { get; private set; }

    public UserProfilePhoto(byte[] profilePhoto)
    {
        this.ProfilePhoto = profilePhoto;
    }
}

DbContext 配置:

public class ModelContext : DbContext
{
    public ModelContext(DbContextOptions<ModelContext> options) : base(options)
    {
    }

    public DbSet<User> Users { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);

        OnUserModelCreating(modelBuilder);
    }

    protected void OnUserModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<User>()
            .HasKey(u => u.Id);

        modelBuilder.Entity<User>()
            .Property(u => u.Id)
            .HasField("id");

        modelBuilder.Entity<User>()
            .OwnsOne(u => u.UserProfilePhoto, builder =>
            {
                builder.ToTable("UserProfilePhoto");

                builder.Property(u => u.ProfilePhoto)
                    .IsRequired();
            });
    }
}

我选择使用拥有的类型,因为我希望个人资料照片只能从用户实体访问。通过一对一的映射,我仍然可以使用 context.Set<UserProfilePhoto>() 访问 UserProfilePhoto table 例如,对于我读到的 DDD 聚合,这可能意味着跳过 User 商业逻辑。 所以,我迁移了,数据库模型就像我预期的那样:UserProfilePhoto table 带有 User.Id.

的主键和外键

显然在我的查询中我不想每次都加载整个 User 实体,所以我启用了延迟加载,但没有成功。这是我在单元测试中尝试的代码:

protected ModelContext GetModelContext(DbContextOptionsBuilder<ModelContext> builder)
{
    builder
        .UseLoggerFactory(loggerFactory)
        .UseLazyLoadingProxies()
        .EnableDetailedErrors();

    var ctx = new ModelContext(builder.Options);
    ctx.Database.EnsureCreated();

    return ctx;
}

[TestMethod]
public async Task TestMethod1()
{
    var builder = new DbContextOptionsBuilder<ModelContext>()
        .UseSqlServer(...);
    var ctx = this.GetModelContext(builder);
    var user = User.Create(new byte[] { });

    try
    {
        await ctx.Users.AddAsync(user);
        await ctx.SaveChangesAsync();

        var users = ctx.Users;

        foreach (var u in users)
        {
            Console.WriteLine(u.Id);
        }
    }
    finally
    {
        ctx.Users.Remove(user);
        await ctx.SaveChangesAsync();
        ctx.Database.EnsureDeleted();
    }
}

这里 SQL 生成:

SELECT [u].[Id], [u0].[UserId], [u0].[ProfilePhoto]
FROM [Users] AS [u]
LEFT JOIN [UserProfilePhoto] AS [u0] ON [u].[Id] = [u0].[UserId]

我不太清楚它是否有效,但是注入 ILazyLoader 对我来说不是解决方案,另一方面,感觉就像弄脏了模型。

我怀疑拥有的类型不会通过实际的导航属性绑定到主体实体,因此不支持为它们创建代理。

我的方法有什么问题?是DDD吗?如果是这样,我如何延迟加载拥有的实体?

我找到了一个与此相关的 issue on Github,虽然它没有回答我的问题。


编辑

我的目标是阻止从 EF api 访问 UserProfilePhoto table(见评论)。如果我设法做到这一点,那么保护我的 UserProfilePhoto class 并将其封装在 User class 中会很容易,就像这样:

User

...
protected virtual UserProfilePhoto UserProfilePhoto { get; set; }

public void SetProfilePhoto(byte[] profilePhoto)
{
    this.UserProfilePhoto.SetProfilePhoto(profilePhoto);
}

public byte[] GetProfilePhoto()
{
    return this.UserProfilePhoto.ProfilePhoto;
}
...

我用一对一的映射尝试了这段代码并且工作完美,即使是延迟加载。我怎么能只使用自有类型来做到这一点?还有其他方法吗?

我找到了一个临时解决方案:

modelBuilder.Entity<User>()
    .OwnsOne(u => u.UserProfilePhoto, builder =>
    {
        builder.Metadata.IsOwnership = false;
        builder.Metadata.IsRequired = false;
        builder.Metadata.PrincipalToDependent.SetIsEagerLoaded(false);

        builder.ToTable("UserProfilePhoto");

        builder.Property(u => u.ProfilePhoto)
            .IsRequired();
    });

我不喜欢它,我想 EF 允许您以其他更清晰的方式进行配置。我不接受这个答案,希望其他人能指出我正确的方向。


编辑:代理以这种方式工作,但是当 User 被删除时,与 UserProfilePhoto 的关联被切断:

The association between entities 'User' and 'UserProfilePhoto' with the key value '{UserId: 1}' has been severed but the relationship is either marked as 'Required' or is implicitly required because the foreign key is not nullable. If the dependent/child entity should be deleted when a required relationship is severed, then setup the relationship to use cascade deletes.

我什至尝试通过元数据指定 DeleteBehaviour.Cascade 选项,但它可能会破坏内部约束。

此外,现在可以通过 DbContext.Set<UserProfilephoto>() 访问它,这不是我想要的。

字节真的应该是域的一部分吗?您实际上 运行 在用户配置文件上下文中的那些字节上有任何业务逻辑吗?是否真的存在您想从 User AR 中访问字节的用例?

如果不是,那么将字节存储与照片的元数据分离并引入 ProfilePhoto VO 和 storageUrl/storageId 属性 来定位字节可能更有意义。

不要忘记您的域模型应该针对命令而不是查询和表示层设计。

当然,现在在数据库中存储字节和 AR 数据时,您不能轻易拥有 ACID 属性,但通常可以通过清理过程轻松应对。

如果您不需要 User 中的个人资料照片元数据来执行业务规则,那么您也可以考虑制作 ProfilePhoto 它自己的 AR。

最后,我认为没有必要尝试防止 ORM 滥用。 ORM 应被视为 low-level API,不应直接用于更改 AR 状态。我认为可以安全地假设开发人员将有足够的严谨性来遵守该规则,就像他们应该尊重整个系统的架构一样。如果他们不这样做,你就会遇到更大的问题。如果它就像向成员添加私有修饰符一样简单,那么当然可以,但它似乎需要很多努力,所以我只是采用务实的方式......

EF Core 在加载所有者时自动加载拥有的类型(来自 Owned Entity Types: Querying owned types

When querying the owner the owned types will be included by default. It is not necessary to use the Include method, even if the owned types are stored in a separate table.

因此使用自有类型不能满足您仅按需加载的要求。

(您可以修改 Metadata.PrincipalToDependent.SetIsEagerLoaded(false) 等,但这在很大程度上不受支持,不太可能在所有情况下都有效,并且随时可能中断。)

不使用自有类型的选项(按推荐顺序)

  • 覆盖 DbContext.Set<>()DbContext.Find() 等,如果调用不当则抛出
  • 实施传统的自定义 Unit-of-Work 和存储库模式,使您可以完全控制公开的 API(以灵活性换取控制)
  • 尽早向查询管道添加一个表达式访问者(注册 IQueryTranslationPreprocessorFactory 并从 RelationalQueryTranslationPreprocessorFactory 派生),如果在查询中的任何地方使用 DbSet<UserProfilePhoto> 就会抛出
  • 提供您自己的 IDbSetSource(和 InternalDbSet)实现(均为内部实现),如果调用不当则抛出

重写 DbContext 方法

一般来说,覆盖DbContext.Set<>()DbContext.Find()等应该是最简单的解决方案。您可以直接使用自定义属性修饰您不想查询的类型,然后只需检查 TEntity 等是否未使用此自定义属性进行修饰。

为了更容易维护,所有被覆盖的方法都可以移动到一个基础 class,它也可以执行一些运行时检查以确保所有有问题的方法都已被覆盖(当然这些检查也可以通过单元测试完成)。

下面是演示此方法的示例:

using System;
using System.Diagnostics;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;

namespace IssueConsoleTemplate
{
    [AttributeUsage(AttributeTargets.Class)]
    public sealed class DontRootQueryMeAttribute : Attribute
    {
    }
    
    public class User
    {
        public int Id { get; private set; }

        public virtual UserProfilePhoto UserProfilePhoto { get; set; }

        public static User Create(byte[] profilePhoto)
        {
            var user = new User
            {
                UserProfilePhoto = new UserProfilePhoto(profilePhoto)
            };

            return user;
        }
    }

    [DontRootQueryMeAttribute]
    public class UserProfilePhoto
    {
        public int Id { get; set; }
        public byte[] ProfilePhoto { get; private set; }

        public UserProfilePhoto(byte[] profilePhoto)
        {
            ProfilePhoto = profilePhoto;
        }
    }
    
    public abstract class ModelContextBase : DbContext
    {
        private static readonly string[] OverriddenMethodNames =
        {
            nameof(DbContext.Set),
            nameof(DbContext.Query),
            nameof(DbContext.Find),
            nameof(DbContext.FindAsync),
        };

        static ModelContextBase()
        {
            var type = typeof(ModelContextBase);

            var overriddenMethods = type
                .GetRuntimeMethods()
                .Where(
                    m => m.IsPublic &&
                         !m.IsStatic &&
                         OverriddenMethodNames.Contains(m.Name) &&
                         m.GetRuntimeBaseDefinition() != null)
                .Select(m => m.GetRuntimeBaseDefinition())
                .ToArray();

            var missingOverrides = type.BaseType
                .GetRuntimeMethods()
                .Where(
                    m => m.IsPublic &&
                         !m.IsStatic &&
                         OverriddenMethodNames.Contains(m.Name) &&
                         !overriddenMethods.Contains(m))
                .ToArray();

            if (missingOverrides.Length > 0)
            {
                throw new InvalidOperationException(
                    $"The '{nameof(ModelContextBase)}' class is missing overrides for {string.Join(", ", missingOverrides.Select(m => m.Name))}.");
            }
        }
        
        private void EnsureRootQueryAllowed<TEntity>()
            => EnsureRootQueryAllowed(typeof(TEntity));

        private void EnsureRootQueryAllowed(Type type)
        {
            var rootQueriesAllowed = type.GetCustomAttribute(typeof(DontRootQueryMeAttribute)) == null;
            
            if (!rootQueriesAllowed)
                throw new InvalidOperationException($"Directly querying for '{type.Name}' is prohibited.");
        }

        public override DbSet<TEntity> Set<TEntity>()
        {
            EnsureRootQueryAllowed<TEntity>();
            return base.Set<TEntity>();
        }

        public override DbQuery<TQuery> Query<TQuery>()
        {
            EnsureRootQueryAllowed<TQuery>();
            return base.Query<TQuery>();
        }

        public override object Find(Type entityType, params object[] keyValues)
        {
            EnsureRootQueryAllowed(entityType);
            return base.Find(entityType, keyValues);
        }

        public override ValueTask<object> FindAsync(Type entityType, params object[] keyValues)
        {
            EnsureRootQueryAllowed(entityType);
            return base.FindAsync(entityType, keyValues);
        }

        public override ValueTask<object> FindAsync(Type entityType, object[] keyValues, CancellationToken cancellationToken)
        {
            EnsureRootQueryAllowed(entityType);
            return base.FindAsync(entityType, keyValues, cancellationToken);
        }

        public override TEntity Find<TEntity>(params object[] keyValues)
        {
            EnsureRootQueryAllowed<TEntity>();
            return base.Find<TEntity>(keyValues);
        }

        public override ValueTask<TEntity> FindAsync<TEntity>(params object[] keyValues)
        {
            EnsureRootQueryAllowed<TEntity>();
            return base.FindAsync<TEntity>(keyValues);
        }

        public override ValueTask<TEntity> FindAsync<TEntity>(object[] keyValues, CancellationToken cancellationToken)
        {
            EnsureRootQueryAllowed<TEntity>();
            return base.FindAsync<TEntity>(keyValues, cancellationToken);
        }

        // Add other overrides as needed...
    }
    
    public class ModelContext : ModelContextBase
    {
        public DbSet<User> Users { get; set; }

        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        {
            optionsBuilder
                .UseSqlServer(
                    @"Data Source=.\MSSQL14;Integrated Security=SSPI;Initial Catalog=So63887500_01")
                .UseLoggerFactory(LoggerFactory.Create(b => b
                    .AddConsole()
                    .AddFilter(level => level >= LogLevel.Information)))
                .EnableSensitiveDataLogging()
                .EnableDetailedErrors();
        }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            base.OnModelCreating(modelBuilder);

            OnUserModelCreating(modelBuilder);
        }

        protected void OnUserModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<User>(
                entity =>
                {
                    entity.HasOne(e => e.UserProfilePhoto)
                        .WithOne()
                        .HasForeignKey<UserProfilePhoto>(e => e.Id);
                });
        }
    }

    internal static class Program
    {
        private static void Main()
        {
            var accessingSetThrows = false;

            using (var ctx = new ModelContext())
            {
                ctx.Database.EnsureDeleted();
                ctx.Database.EnsureCreated();

                var user = User.Create(new byte[] { });

                ctx.Users.Add(user);
                ctx.SaveChanges();
                
                // Make sure, that UserProfilePhoto cannot be queried directly.
                try
                {
                    ctx.Set<UserProfilePhoto>()
                        .ToList();
                }
                catch (InvalidOperationException)
                {
                    accessingSetThrows = true;
                }
                
                Debug.Assert(accessingSetThrows);
            }

            // No eager loading by default with owned type here.
            using (var ctx = new ModelContext())
            {
                var users = ctx.Users.ToList();

                Debug.Assert(users.Count == 1);
                Debug.Assert(users[0].UserProfilePhoto == null);
            }

            // Explicitly load profile photo.
            using (var ctx = new ModelContext())
            {
                var users = ctx.Users.ToList();
                ctx.Entry(users[0]).Reference(u => u.UserProfilePhoto).Load();
                
                Debug.Assert(users.Count == 1);
                Debug.Assert(users[0].UserProfilePhoto != null);
            }
        }
    }
}

提供IQueryTranslationPreprocessorFactory实现

表达式访问者可用于解决问题,方法是使用 IQueryTranslationPreprocessorFactory 实现来搜索特定表达式的查询,该表达式仅在调用新的 InternalQuery() 扩展方法时添加,并且抛出,如果它丢失并且正在查询 non-root 实体。在实践中,这应该足以确保团队中没有人不小心查询 non-root 个对象。

(您还可以将内部 class 实例作为常量参数添加到方法调用表达式中,然后稍后在表达式访问者中对其进行评估以确保调用者确实具有 internal访问 InternalQuery() 方法。但这只是锦上添花,在实践中是不必要的,因为开发人员无论如何都可以使用反射来绕过任何访问限制。所以我不会费心去实现它。)

这里是实现(使用自定义接口而不是自定义属性来标记不应直接查询的实体):

using System;
using System.Diagnostics;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.EntityFrameworkCore.Query;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

namespace IssueConsoleTemplate
{
    #region Models
    
    public class User
    {
        public int Id { get; private set; }

        public virtual UserProfilePhoto UserProfilePhoto { get; set; }

        public static User Create(byte[] profilePhoto)
        {
            var user = new User
            {
                UserProfilePhoto = new UserProfilePhoto(profilePhoto)
            };

            return user;
        }
    }
    
    public class UserProfilePhoto : INonRootQueryable
    {
        public int Id { get; set; }
        public byte[] ProfilePhoto { get; private set; }

        public UserProfilePhoto(byte[] profilePhoto)
        {
            ProfilePhoto = profilePhoto;
        }
    }

    #endregion

    #region Custom implementations

    public interface INonRootQueryable
    {
    }
    
    public class CustomQueryTranslationPreprocessorFactory : IQueryTranslationPreprocessorFactory
    {
        private readonly QueryTranslationPreprocessorDependencies _dependencies;
        private readonly RelationalQueryTranslationPreprocessorDependencies _relationalDependencies;

        public CustomQueryTranslationPreprocessorFactory(
            QueryTranslationPreprocessorDependencies dependencies,
            RelationalQueryTranslationPreprocessorDependencies relationalDependencies)
        {
            _dependencies = dependencies;
            _relationalDependencies = relationalDependencies;
        }

        public virtual QueryTranslationPreprocessor Create(QueryCompilationContext queryCompilationContext)
            => new CustomQueryTranslationPreprocessor(
                _dependencies,
                _relationalDependencies,
                queryCompilationContext);
    }

    public class CustomQueryTranslationPreprocessor : RelationalQueryTranslationPreprocessor
    {
        public CustomQueryTranslationPreprocessor(
            QueryTranslationPreprocessorDependencies dependencies,
            RelationalQueryTranslationPreprocessorDependencies relationalDependencies,
            QueryCompilationContext queryCompilationContext)
            : base(dependencies, relationalDependencies, queryCompilationContext)
        {
        }

        public override Expression Process(Expression query)
        {
            query = new ThrowOnNoneRootQueryableViolationExpressionVisitor().Visit(query);
            return base.Process(query);
        }
    }
    
    public class ThrowOnNoneRootQueryableViolationExpressionVisitor : ExpressionVisitor
    {
        private bool _isInternalQuery;

        protected override Expression VisitMethodCall(MethodCallExpression node)
        {
            if (node.Method.GetGenericMethodDefinition() == CustomQueryableExtensions.InternalQueryMethodInfo)
            {
                _isInternalQuery = true;
                return node.Arguments[0];
            }
            
            return base.VisitMethodCall(node);
        }

        protected override Expression VisitConstant(ConstantExpression node)
        {
            var expression = base.VisitConstant(node);
            
            // Throws if SomeEntity in a DbSet<SomeEntity> implements INonRootQueryable and the query was not chained
            // to the `InternalQuery()` extension method.
            return !_isInternalQuery &&
                   node.Type.IsGenericType &&
                   node.Type.GetGenericTypeDefinition() == typeof(Microsoft.EntityFrameworkCore.Query.Internal.EntityQueryable<>) &&
                   node.Type.GenericTypeArguments.Length == 1 &&
                   typeof(INonRootQueryable).IsAssignableFrom(node.Type.GenericTypeArguments[0])
                ? throw new InvalidOperationException($"Directly querying for '{node.Type.Name}' is prohibited.")
                : expression;
        }
    }
    
    internal static class CustomQueryableExtensions
    {
        internal static readonly MethodInfo InternalQueryMethodInfo
            = typeof(CustomQueryableExtensions)
                .GetTypeInfo()
                .GetDeclaredMethods(nameof(InternalQuery))
                .Single(m => m.GetParameters().Length == 1 &&
                             m.GetParameters()[0].ParameterType.Namespace == $"{nameof(System)}.{nameof(System.Linq)}" &&
                             m.GetParameters()[0].ParameterType.Name.StartsWith(nameof(IQueryable)) &&
                             m.GetParameters()[0].ParameterType.GenericTypeArguments.Length == 1);

        internal static IQueryable<TSource> InternalQuery<TSource>(this IQueryable<TSource> source)
            => source.Provider.CreateQuery<TSource>(
                Expression.Call(
                    null,
                    InternalQueryMethodInfo.MakeGenericMethod(typeof(TSource)),
                    source.Expression));

        internal static IQueryable<TProperty> InternalQuery<TEntity, TProperty>(this ReferenceEntry<TEntity, TProperty> source)
            where TEntity : class
            where TProperty : class
            => source.Query()
                .InternalQuery();
    }
    
    #endregion
    
    public class ModelContext : DbContext
    {
        public DbSet<User> Users { get; set; }

        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        {
            // Register the custom type IQueryTranslationPreprocessorFactory.
            // Since this is a console program, we need to create our own ServiceCollection
            // for this.
            // In an ASP.NET Core application, the AddSingleton call can just be added to
            // the general service configuration method.
            var serviceProvider = new ServiceCollection()
                .AddEntityFrameworkSqlServer()
                .AddSingleton<IQueryTranslationPreprocessorFactory, CustomQueryTranslationPreprocessorFactory>()
                .AddScoped(
                    s => LoggerFactory.Create(
                        b => b
                            .AddConsole()
                            .AddFilter(level => level >= LogLevel.Information)))
                .BuildServiceProvider();

            optionsBuilder
                .UseInternalServiceProvider(serviceProvider) // <-- use our ServiceProvider
                .UseSqlServer(@"Data Source=.\MSSQL14;Integrated Security=SSPI;Initial Catalog=So63887500_05")
                .EnableSensitiveDataLogging()
                .EnableDetailedErrors();
        }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            base.OnModelCreating(modelBuilder);
            OnUserModelCreating(modelBuilder);
        }

        protected void OnUserModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<User>(
                entity =>
                {
                    entity.HasOne(e => e.UserProfilePhoto)
                        .WithOne()
                        .HasForeignKey<UserProfilePhoto>(e => e.Id);
                });
        }
    }

    internal static class Program
    {
        private static void Main()
        {
            var accessingSetThrows = false;

            using (var ctx = new ModelContext())
            {
                ctx.Database.EnsureDeleted();
                ctx.Database.EnsureCreated();

                var user = User.Create(new byte[] { });

                ctx.Users.Add(user);
                ctx.SaveChanges();
            }

            // Make sure, that UserProfilePhoto cannot be queried directly by default.
            using (var ctx = new ModelContext())
            {
                try
                {
                    ctx.Set<UserProfilePhoto>()
                        .ToList();
                }
                catch (InvalidOperationException)
                {
                    accessingSetThrows = true;
                }
                
                Debug.Assert(accessingSetThrows);
            }

            // Make sure, that UserProfilePhoto can be queried directly, when using the `InternalQuery()` extension
            // method.
            using (var ctx = new ModelContext())
            {
                var userProfilePhotos = ctx.Set<UserProfilePhoto>()
                    .InternalQuery()
                    .ToList();
                
                Debug.Assert(userProfilePhotos.Count == 1);
            }

            // No eager loading of referenced types by default.
            using (var ctx = new ModelContext())
            {
                var users = ctx.Users.ToList();

                Debug.Assert(users.Count == 1);
                Debug.Assert(users[0].UserProfilePhoto == null);
            }

            // Eager loading of referenced types is allowed, when using the `InternalQuery()` extension method.
            using (var ctx = new ModelContext())
            {
                var users = ctx.Users
                    .Include(u => u.UserProfilePhoto)
                    .InternalQuery()
                    .ToList();

                Debug.Assert(users.Count == 1);
                Debug.Assert(users[0].UserProfilePhoto != null);
            }

            // Explicitly load profile photo, when using the `InternalQuery()` extension method.
            using (var ctx = new ModelContext())
            {
                var users = ctx.Users.ToList();
                ctx.Entry(users[0])
                    .Reference(u => u.UserProfilePhoto)
                    .InternalQuery()
                    .Load();

                Debug.Assert(users.Count == 1);
                Debug.Assert(users[0].UserProfilePhoto != null);
            }
        }
    }
}