在 EFCore 中,通过 Linq 如 Where 方法来查询会返回一个 IQueryable。从代码上看,IQueryable 是查询后返回的,但事实并非如此。下面这段代码执行时,EFCore 并未执行任何语句。只有在迭代 IQueryable 对象时,才会进行查询。这就是 IQueryable 的延迟查询特点。

using (MyDbContext ctx = new MyDbContext())
{
    var books = ctx.Books.Where(e => e.Price > 10);
}

在执行 Where 方法时,EFCore 只是将查询条件记录下来,而并没有进行查询,这么设计的好处在于我们可以分步设定条件,最终在迭代时,EFCore 会将之前设定的所有查询条件综合并翻译为 SQL 语句,然后开始查询。

using (MyDbContext ctx = new MyDbContext())
{
    var books = ctx.Books.Where(e => e.Price > 10);
    books = books.Where(e => e.Price < 100);
    foreach (var book in books) //此处开始查询
    {
        Console.WriteLine(book.Title);
    }
}

这里需要注意,Where 方法并不会改变调用的 IQueryable 对象,而是会返回一个新的 IQueryable 对象。除了 Where 方法,还有哪些方法并不会进行查询,以及还有哪些方法会导致 IQueryable 立即查询呢?一般来说,如果这个方法最终需要获取数据,就一定会执行查询,这类方法称为终结方法,例如 ToArray、ToList、Min、Max、Count。相反,如果这个方法最终并不返回任何数据,且通常返回 IQueryable,则它不会执行查询,这类方法称为非终结方法,例如 GroupBy、OrderBy、Include、Take、Skip。

IQueryable 的延迟执行特点,有利于我们实现对于确定实体的动态查询,以及对查询条件的复用,下面这段代码实现了特定过滤下的分页查询:

static async Task<List<T>> PartialQuery<T>(int numPerPage, int pageIndex, 
    IQueryable<T> query) 
    where T : class
{
    int count = (int)Math.Ceiling(query.Count() * 1.0 / numPerPage);
    if (pageIndex > count) return new List<T>();
    var partialQuery = query.Skip((pageIndex - 1) * numPerPage).Take(numPerPage);
    return await partialQuery.ToListAsync();
}

需要注意的是,即便使用 AsEnumerable 将 IQueryable 转换为 IEnumerable,延迟查询的特点依旧存在。事实上,由于 IQueryable 继承自 IEnumerable,AsEnumerable 内部并没有做什么,只是将类型改变。只能期望IEnumerable 在查询时是在本机执行的。

IQueryable 的另一个读取特点在于它是分批加载,而非一次性加载的。当你对一个 IQueryable 迭代时,它是边迭代边加载的,而不是先加载完再迭代的,这种行为类似于 ADO.NET 的 DataReader,它的好处在于内存占用比较小,坏处在于它会占据一个数据库连接很长时间。如果在迭代时与数据库失去连接,那么就会出现异常。

var books = ctx.Books.Where(e => e.Price > 5);
foreach (var book in books)
{
    Console.WriteLine(book.Title);
    Thread.Sleep(500);
}

在上面的这个代码中加了延时,所以迭代会比较慢,这是为了模拟数据量较大的情况,如果在迭代过程中中断数据库服务,那么 EFCore 会抛出 Microsoft.Data.SqlClient.SqlException 异常。怎么让 IQueryable 一次性将所有数据都加载进内存呢?只需要使用 ToList、ToListAsync、ToArray、ToArrayAsync 即可。

var books = ctx.Books.Where(e => e.Price > 5);
foreach (var book in await books.ToListAsync())
{
    Console.WriteLine(book.Title);
}

在迭代时,如果在本地对数据进行处理的时间过长,可能会导致长时间占用数据库连接,这个时候就可以使用一次性加载。另一种情况是,如果迭代过程中,DbContext 类有可能被销毁的话,也应该使用一次性加载。如果一个用于查询的方法返回一个 IQueryable,就必须保证所使用的 DbContext 不能被销毁,否则接收方无法进行迭代,更好的办法是在查询时使用 ToList 或者 ToArray 保存数据,然后销毁 DbContext,之后返回 List 或者 Array。此外,EFCore 中,很多数据库不支持对 IQueryable 进行嵌套迭代,这个时候也需要使用一次性加载。