在企业项目中,随着业务不断迭代,数据查询往往会越来越复杂。例如,一个 HR 系统最开始可能只有一个需求:根据部门查询员工。仓储类可能长这样:
public IEnumerable<Employee> GetByDepartment(int departmentId)
{
return _dbContext.Employees
.Where(e => e.DepartmentId == departmentId)
.ToList();
}
后来产品又提出新的需求:按年龄查询、按薪资范围查询、按国籍查询、按部门 + 年龄查询、按部门 + 国籍查询、按部门 + 年龄 + 薪资查询……如果继续沿用这种方式,仓储类很快就会变成一堆方法的集合。随着筛选条件越来越多,方法数量几乎呈指数增长,仓储类最终会变得越来越臃肿,也越来越难维护。
那么,有没有一种方式,既能保持查询的灵活性,又不用为每种组合都写一个方法?
第一次尝试:直接返回 IQueryable
很多人在接触 EF Core 后,都会想到一个简单的办法:把 IQueryable<T> 返回出去。
public IQueryable<Employee> Query()
{
return _dbContext.Employees;
}
这里返回的并不是已经查询好的数据,而是一棵尚未执行的查询表达式树。调用方可以根据自己的需要继续拼接条件:
var employee = _repo.Query()
.Where(e => e.Id == id)
.FirstOrDefault();
或者:
var employees = _repo.Query()
.Where(e => e.DepartmentId == departmentId)
.OrderBy(e => e.Name)
.ToList();
这种方式最大的优点就是灵活。无论以后增加多少筛选条件,都不用继续增加新的仓储方法。但是,它也存在一个明显的问题:业务层开始到处写 .Where(...)、.OrderBy(...)、.Include(...)、.Select(...),查询逻辑分散在各个业务模块里。时间久了以后,同样的查询条件可能在多个地方重复出现,维护起来反而越来越困难。
因此,我们通常会进一步把这些公共查询提取出来。
使用查询扩展(Query Extensions)组织查询逻辑
一种比较简单也比较常见的做法,就是把常用查询封装成扩展方法。
public staticclassEmployeeQueryExtensions
{
public static IQueryable<Employee> ById(
this IQueryable<Employee> query,
int id)
{
return query.Where(e => e.Id == id);
}
public static IQueryable<Employee> ByDepartment(
this IQueryable<Employee> query,
int departmentId)
{
return query.Where(e => e.DepartmentId == departmentId);
}
public static IQueryable<Employee> ByAge(
this IQueryable<Employee> query,
int age)
{
return query.Where(e => e.Age == age);
}
public static IQueryable<Employee> BySalaryRange(
this IQueryable<Employee> query,
decimal min,
decimal max)
{
return query.Where(e => e.Salary >= min &&
e.Salary <= max);
}
public static IQueryable<Employee> ByNationality(
this IQueryable<Employee> query,
string nationality)
{
return query.Where(e => e.Nationality == nationality);
}
}
这样以后查询就可以像搭积木一样自由组合:
var result = _repo.Query()
.ByDepartment(2)
.ByAge(30)
.ByNationality("MY")
.ToList();
如果以后新增一个筛选条件,例如学历:
public static IQueryable<Employee> ByEducation(...)
业务代码完全不用修改,只需要继续拼接:
_repo.Query()
.ByDepartment(2)
.ByEducation("Master")
.ToList();
这种方式最大的优点,就是把重复出现的查询条件集中管理。相比业务层到处写 Where(),代码更容易复用,也更容易维护。
★严格来说,这种方式更接近 Query Extensions(查询扩展),而不是经典的 Query Object 模式。不过对于大多数 EF Core 项目来说,这已经能够很好地满足日常开发需求。
为什么可以一直拼接查询?
很多初学者第一次看到这样的代码时,都会有一个疑问:
_repo.Query()
.ByDepartment(2)
.ByAge(30)
.ByNationality("MY")
这里连续调用了三个 Where(),是不是会查询数据库三次?
答案是:不会。原因就在于 IQueryable<T> 的延迟执行(Deferred Execution)。每一次调用 .Where(...),实际上只是把新的查询条件追加到表达式树中,并不会立即访问数据库。直到最后调用 .ToList()、.FirstOrDefault()、.Single()、.Count() 这些终止操作时,EF Core 才会把整个表达式树一次性翻译成 SQL。
例如:
_repo.Query()
.ByDepartment(2)
.ByAge(30)
.ByNationality("MY")
.ToList();
最终生成的大致 SQL 类似于:
SELECT *
FROM Employees
WHERE DepartmentId = 2
AND Age = 30
AND Nationality = 'MY'
整个过程只会发送一条 SQL。需要说明的是,EF Core 的职责是把 LINQ 表达式翻译成 SQL,而真正决定是否使用索引、如何执行查询的是数据库本身的查询优化器(例如 SQL Server、PostgreSQL 或 MySQL)。
构建动态查询
实际项目中,前端通常不会固定传某几个条件,而是传一个筛选对象。
public class EmployeeFilter
{
public int? DepartmentId { get; set; }
public int? Age { get; set; }
public decimal? MinSalary { get; set; }
public decimal? MaxSalary { get; set; }
public string? Nationality { get; set; }
}
这时候,再继续写 ByDepartment()、ByAge()、BySalary() 调用起来反而会比较麻烦。更常见的做法是,根据 Filter 动态拼接查询:
public IQueryable<Employee> BuildQuery(EmployeeFilter filter)
{
var query = _dbContext.Employees.AsNoTracking();
if (filter.DepartmentId.HasValue)
{
query = query.Where(e =>
e.DepartmentId == filter.DepartmentId);
}
if (filter.Age.HasValue)
{
query = query.Where(e =>
e.Age == filter.Age);
}
if (filter.MinSalary.HasValue)
{
query = query.Where(e =>
e.Salary >= filter.MinSalary);
}
if (filter.MaxSalary.HasValue)
{
query = query.Where(e =>
e.Salary <= filter.MaxSalary);
}
if (!string.IsNullOrWhiteSpace(filter.Nationality))
{
query = query.Where(e =>
e.Nationality == filter.Nationality);
}
return query;
}
调用起来就简单很多:
var filter = new EmployeeFilter
{
DepartmentId = 2,
MinSalary = 3000,
Nationality = "MY"
};
var result = _repo.BuildQuery(filter)
.ToList();
随着筛选条件越来越多,只需要修改 EmployeeFilter 和 BuildQuery() 即可,不需要新增大量仓储方法。这种方式在后台管理系统、ERP、CRM 等项目中非常常见。
Repository 是否应该返回 IQueryable?
这里还有一个比较有争议的话题。有些开发者认为:Repository 就是为了屏蔽数据库实现,因此不应该直接暴露 IQueryable。因为一旦返回 IQueryable,业务层就可以继续调用 .Include(...)、.GroupBy(...)、.AsSplitQuery()、.IgnoreQueryFilters(),Repository 对数据访问的封装就会变弱。
但也有很多团队认为,EF Core 本身已经是 Repository + Unit of Work 的实现,再套一层 Repository 意义并不大,直接返回 IQueryable 反而更加灵活。因此,这两种做法都很常见,并没有绝对的对错。如果你的项目主要围绕 EF Core 开发,返回 IQueryable 是一种比较自然的选择;如果希望彻底隔离 ORM,则可以考虑使用 Specification、Query Service 等模式来封装查询。
总结
随着业务不断发展,查询条件几乎都会越来越复杂。相比不断增加 GetByDepartmentAndAge()、GetByDepartmentAndSalary()、GetByDepartmentAndNationality() 等方法,更推荐采用可组合查询的方式,把查询条件封装成可复用的扩展方法,或者根据 Filter 动态构建查询。
这种方式不仅减少了仓储层的方法数量,也让查询逻辑更加集中、更容易维护,同时还能充分利用 IQueryable 的延迟执行能力,在最终执行时生成一条完整的 SQL。对于大多数 EF Core 项目来说,这是一种简单、实用且足够灵活的设计方式。当项目进一步复杂时,也可以逐步演进到 Specification Pattern、Query Service 等更成熟的查询封装方案,而无需推翻现有代码。