自有类型的延迟加载

Mar*_*ara 1 c# lazy-loading entity-framework-core ef-core-3.1

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

用户:

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);
    }
}
Run Code Online (Sandbox Code Playgroud)

用户资料照片:

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

    public UserProfilePhoto(byte[] profilePhoto)
    {
        this.ProfilePhoto = profilePhoto;
    }
}
Run Code Online (Sandbox Code Playgroud)

数据库上下文配置:

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();
            });
    }
}
Run Code Online (Sandbox Code Playgroud)

我选择使用“拥有”类型,因为我希望只能从用户实体访问个人资料照片。通过一对一映射,我仍然可以UserProfilePhoto使用例如访问表context.Set<UserProfilePhoto>(),并且对于我读到的有关 DDD 聚合的内容,这可能意味着跳过User业务逻辑。因此,我迁移了,数据库模型就像我预期的那样:UserProfilePhoto主键和外键为 的表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();
    }
}
Run Code Online (Sandbox Code Playgroud)

这里生成的 SQL 是:

SELECT [u].[Id], [u0].[UserId], [u0].[ProfilePhoto]
FROM [Users] AS [u]
LEFT JOIN [UserProfilePhoto] AS [u0] ON [u].[Id] = [u0].[UserId]
Run Code Online (Sandbox Code Playgroud)

我不完全知道它是否有效,但是注入 anILazyLoader对我来说不是一个解决方案,另一方面,它感觉就像弄脏了模型。

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

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

在 Github 上发现了一个与此相关的问题,尽管它没有回答我的问题。


编辑

我的目标是阻止UserProfilePhoto从 EF api 访问表(请参阅评论)。如果我设法做到这一点,那么保护我的UserProfilePhoto类并将其封装在User类中就会很容易,如下所示:

User

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

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

public byte[] GetProfilePhoto()
{
    return this.UserProfilePhoto.ProfilePhoto;
}
...
Run Code Online (Sandbox Code Playgroud)

我用一对一映射尝试了这段代码,即使在延迟加载的情况下也能完美地工作。我怎样才能仅使用自有类型来做到这一点?还有其他方法吗?

lau*_*jpn 5

加载所有者时,EF Core 会自动加载拥有的类型(来自拥有的实体类型:查询拥有的类型

查询所有者时,默认情况下将包含拥有的类型。即使拥有的类型存储在单独的表中,也没有必要使用 Include 方法。

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

(您可以修改Metadata.PrincipalToDependent.SetIsEagerLoaded(false)等等,但这非常不受支持,不太可能在所有情况下都有效,并且随时可能会崩溃。)

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

  • Override等DbContext.Set<>()DbContext.Find()如果调用不当则抛出
  • 实施传统的自定义工作单元和存储库模式,使您可以完全控制公开的 API(以灵活性换取控制)
  • 尽早将表达式访问者添加到查询管道(注册IQueryTranslationPreprocessorFactory并派生自RelationalQueryTranslationPreprocessorFactory),如果DbSet<UserProfilePhoto>在查询中的任何位置使用a ,则会抛出异常
  • 提供您自己的IDbSetSource(和InternalDbSet)实现(都是内部的),如果调用不当则抛出异常

重写DbContext方法

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

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

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

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);
            }
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

提供IQueryTranslationPreprocessorFactory实施方案

表达式访问者可以通过使用实现IQueryTranslationPreprocessorFactory来搜索特定表达式的查询来解决该问题,该表达式仅在InternalQuery()调用新的扩展方法时添加,如果缺少并且正在查询非根实体,则抛出异常。实际上,这应该足以确保团队中没有人意外查询非根对象。

(您还可以将内部类实例作为常量参数添加到方法调用表达式中,然后稍后在表达式访问者中对其进行求值,以确保调用者确实可以访问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);
            }
        }
    }
}
Run Code Online (Sandbox Code Playgroud)