You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

仓储模式下EF Core外键导航属性返回空的问题及方案探讨

EF Core导航属性加载问题:Select投影与Include的区别及最优方案

问题描述

我基于EF Core实现了泛型仓储基类RepositoryBase,实体Localitate包含外键JudetId和导航属性judet,但查询时导航属性始终返回空。于是我添加了GetLocalitatiBasicAsync方法,通过Select显式指定获取导航属性judet,想请教:

  1. 这种方案是否为最优解?
  2. 该方式与使用Include加载导航属性的区别是什么?

现有代码实现

仓储基类RepositoryBase<T>

using mediere_API.DataLayer.Entities;
using mediere_API.DataLayer.Repository.Interfaces;
using Microsoft.EntityFrameworkCore;

namespace mediere_API.DataLayer.Repository.Implementations
{
    public class RepositoryBase<T> : IDisposable, IRepositoryBase<T> where T : BaseEntity, new()
    {
        private readonly DbContext _db;
        private readonly DbSet<T> _dbSet;

        protected RepositoryBase(EfDbContext context)
        {
            _db = context;
            _dbSet = context.Set<T>();
        }

        public async Task<IList<T>> GetAll(bool asNoTracking = false, bool includeDeleted = false)
        {
            var query = includeDeleted
               ? _dbSet
               : _dbSet.Where(entity => entity.DeletedAt == null);

            return asNoTracking
                ? await query.AsNoTracking().ToListAsync()
                : await query.ToListAsync();
        }

        public async Task<T> GetById(int id, bool asNoTracking = false, bool includeDeleted = false)
        {
            return includeDeleted
                ? await GetRecords(asNoTracking).FirstOrDefaultAsync(e => e.Id == id)
                : await GetRecords(asNoTracking).FirstOrDefaultAsync(e => e.Id == id && e.DeletedAt == null);
        }

        public virtual void Insert(T record)
        {
            if (_db.Entry(record).State == EntityState.Detached)
            {
                _db.Attach(record);
                _db.Entry(record).State = EntityState.Added;
            }
        }

        public virtual void Update(T record)
        {
            if (_db.Entry(record).State == EntityState.Detached)
                _db.Attach(record);

            _db.Entry(record).State = EntityState.Modified;
        }

        public void Delete(T record)
        {
            if (record != null)
            {
                record.DeletedAt = DateTime.UtcNow;
                Update(record);
            }
        }

        public void Dispose()
        {
            _db?.Dispose();
        }

        protected IQueryable<T> GetRecords(bool asNoTracking = false, bool includeDeleted = false)
        {
            var result = includeDeleted ?
                _dbSet
                :
                _dbSet.Where(e => e.DeletedAt == null);

            return asNoTracking ? result.AsNoTracking() : result;
        }
    }
}

实体Localitate

using mediere_API.Dtos;
using System.ComponentModel.DataAnnotations;

namespace mediere_API.DataLayer.Entities
{
    public class Localitate : BaseEntity
    {
        [Required]
        public string Nume { get; set; }
        [Required]
        public int JudetId { get; set; }
        public virtual Judet judet { get; set; }

        public Localitate() { }
    }
}

自定义查询方法GetLocalitatiBasicAsync

public async Task<List<Localitate>> GetLocalitatiBasicAsync()
{
    return await base.GetRecords(true).Select(l => new Localitate()
    {
        Id = l.Id,
        Nume = l.Nume,
        judet = l.judet
    }).ToListAsync();
}

问题解答

1. 当前方案是否为最优解?

不是。你当前的Select投影方式虽然能拿到导航属性数据,但存在以下问题:

  • 写法繁琐:每次需要加载导航属性时,都要手动指定所有需要的实体字段,扩展性差;
  • 实体跟踪失效:手动new出来的Localitate对象不会被EF Core上下文跟踪,后续如果要修改实体并保存,需要额外处理;
  • 维护成本高:如果Localitate或Judet实体添加新字段,需要同步修改Select中的赋值逻辑。

更优的方案是结合泛型仓储特性,使用Include进行贪婪加载,或者在泛型仓储中添加支持多Include的重载方法,让代码更简洁、可维护。

2. Select显式导航与Include的区别

(1)加载本质不同

  • Include:属于贪婪加载(Eager Loading),会生成SQL JOIN语句,一次性查询主实体和关联实体的所有数据,返回完整的实体对象,导航属性直接被填充。
  • Select投影:属于按需加载,只查询你指定的字段,生成的SQL仅包含Select中列出的列,返回的是你构造的对象(即使是实体类型,也只是部分字段被赋值)。

(2)SQL生成差异

  • 使用Include的SQL示例:

    SELECT [l].[Id], [l].[Nume], [l].[JudetId], [l].[DeletedAt], [j].[Id], [j].[Nume], ...
    FROM [Localitates] AS [l]
    LEFT JOIN [Judets] AS [j] ON [l].[JudetId] = [j].[Id]
    WHERE [l].[DeletedAt] IS NULL
    

    会查询Localitate和Judet的所有字段。

  • 使用Select投影的SQL示例:

    SELECT [l].[Id], [l].[Nume], [j].[Id], [j].[Nume], ...
    FROM [Localitates] AS [l]
    LEFT JOIN [Judets] AS [j] ON [l].[JudetId] = [j].[Id]
    WHERE [l].[DeletedAt] IS NULL
    

    仅查询你在Select中指定的字段,减少数据传输量。

(3)实体状态与跟踪

  • Include:返回的实体默认被上下文跟踪(除非添加AsNoTracking),可以直接修改并调用SaveChanges保存。
  • Select投影:手动构造的实体对象不会被上下文跟踪,因为EF Core没有参与实体的创建过程,仅将查询结果映射到对象上。

(4)使用场景不同

  • Include:适合需要完整实体对象、后续要进行修改操作,或者关联实体需要被完整使用的场景。
  • Select投影:适合只需要部分数据的场景(比如给前端返回DTO),能提升查询性能,减少不必要的数据传输。

优化建议

如果需要获取完整的Localitate实体并包含judet导航属性,推荐直接在自定义方法中使用Include:

public async Task<List<Localitate>> GetLocalitatiBasicAsync()
{
    return await base.GetRecords(true)
        .Include(l => l.judet)
        .ToListAsync();
}

如果需要在泛型仓储中支持多导航属性加载,可以添加如下重载方法:

// 在RepositoryBase<T>中添加
public async Task<IList<T>> GetAllWithIncludes(bool asNoTracking = false, bool includeDeleted = false, params Expression<Func<T, object>>[] includes)
{
    var query = includeDeleted
       ? _dbSet
       : _dbSet.Where(entity => entity.DeletedAt == null);

    foreach (var include in includes)
    {
        query = query.Include(include);
    }

    return asNoTracking
        ? await query.AsNoTracking().ToListAsync()
        : await query.ToListAsync();
}

然后在自定义仓储中调用:

public async Task<List<Localitate>> GetLocalitatiBasicAsync()
{
    return await base.GetAllWithIncludes(true, false, l => l.judet) as List<Localitate>;
}

内容的提问来源于stack exchange,提问作者Octavian Niculescu

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.22 10:18:19