EFCore查缺补漏(二):查询

第 20 轮 TechEmpower 评测结果出炉了,ASP.NET Core 的 Plaintext 成绩名列前茅,带着 EFCore 的测试却在 Single query / Multiple queries / Fortunes 中落了下风,成绩远不如 dapper,更不如直接 ado.net。

人人都说 EFCore 性能差,人人都在写性能低的代码……

EFCore 是如何进行查询的?除了查询语句本身的合理性,EFCore 本身的性能瓶颈又会出现在哪里呢?如何让 EFCore 的查询变得更快呢?

今天,先从 IQueryable 这个接口说起。

IQueryable 与 IEnumerable

IEnumerable<> 的核心作用是提供一些基础数据通过 GetEnumerator 函数来创建一个 IEnumerator<>

IEnumerator<> 是一个非常单纯的单向迭代器,你可以像系统自带的集合类那样手动实现一个,也可以你可以通过自己编写 yield return / yield break 语句,让编译器将你的程序控制流和变量状态保存在编译器翻译设计的专有 Enumerator 中。

除此之外,System.Linq 这个命名空间提供了大量针对 IEnumerable<> 的拓展。这些拓展将 IEnumerator<> 们通过类似于责任链模式的方法组合起来,提供了很多神奇的 LINQ 功能。

IQueryable<> 是什么呢?

IQueryable<> 接口除了实现 IEnumerable<> 以外,还有三个成员

  • Expression:保存了一个表达式树
  • ElementType:这个 IQueryable<> 的返回类型
  • Provider:一个 IQueryProvider 实例对象

IQueryProvider 则有这样几个成员函数

  • IQueryable<> CreateQuery(Expression expression) 根据传入的表达式树构建一个 IQueryable 对象
  • TResult Execute(Expression expression) 执行这个表达式,获得对应结果

再参考 System.Linq.QueryableIQueryable<> 的拓展函数的实现

// System.Linq.Queryable
public static int Count<TSource>(this IQueryable<TSource> source)
{
    if (source == null)
        throw Error.ArgumentNull(nameof(source));
    return source.Provider.Execute<int>(
        Expression.Call(
            null,
            CachedReflectionInfo.Count_TSource_1(typeof(TSource)),
            source.Expression
        ));
}

public static IQueryable<TSource> Where<TSource>(this IQueryable<TSource> source, Expression<Func<TSource, bool>> predicate)
{
    if (source == null)
        throw Error.ArgumentNull(nameof(source));
    if (predicate == null)
        throw Error.ArgumentNull(nameof(predicate));
    return source.Provider.CreateQuery<TSource>(
        Expression.Call(
            null,
            CachedReflectionInfo.Where_TSource_2(typeof(TSource)),
            source.Expression, Expression.Quote(predicate)
        ));
}

那么我们有如下结论

  • IQueryable<> 保存着一个查询表达式树和一个 IQueryProvider
  • IQueryProvider 支撑着 IQueryable<> 的创建和查询执行
  • IQueryable<> 的拓展函数们仅仅是将表达式树拼接成与函数调用相同形态的表达式

而在 EFCore 中,完成这样功能的类则是 EntityQueryable<>EntityQueryProvider。后者在 EFCore 的依赖注入容器中是 Scoped 服务 IAsyncQueryProvider 的实现,完成所有的 IQueryable<> 的创建,并将所有的 ExecuteExecuteAsync 的请求转发给 IQueryCompiler 这一服务。

IQueryCompiler 中的执行代码大约是这样的

// Microsoft.EntityFrameworkCore.Query.Internal.QueryCompiler
public virtual TResult Execute<TResult>(Expression query)
{
    Check.NotNull(query, nameof(query));

    var queryContext = _queryContextFactory.Create();

    query = ExtractParameters(query, queryContext, _logger);

    var compiledQuery
        = _compiledQueryCache
            .GetOrAddQuery(
                _compiledQueryCacheKeyGenerator.GenerateCacheKey(query, async: false),
                () => CompileQueryCore<TResult>(_database, query, _model, false));

    return compiledQuery(queryContext);
}

其中

  • _compiledQueryCache 是一个由 IMemoryCache 驱动的缓存
  • _compiledQueryCacheKeyGenerator 是将该表达式与数据库模型、数据库驱动等信息的 Hash 值合并产生一个对应的 QueryCacheKey
  • _queryContextFactory 用于生成一个 QueryContext 实例
  • ExtractParameters 将查询表达式中的闭包变量等计算完毕并加入 queryContext 实例
  • compiledQuery 是一个 Func<QueryContext, TResult> 实例,又称为 QueryExecutor

而其中 QueryContext 支持和提供

  • 并行检测(也就是 EFCore 一个上下文实例只能有一个正在执行的查询语句的基石)
  • 执行逻辑(例如失败重试)
  • 异步任务的 CancellationToken
  • 各种日志输出
  • 查询参数的添加和读取
  • 实体跟踪和状态管理

在关系型数据库驱动中,RelationalQueryContext 另外附加

  • 数据库连接
  • 生成 SQL 片段的工厂类

我们每次要执行一个查询,就要先在这个内存缓存中查找是否已经有编译好的执行语句;而这个缓存的键需要利用表达式树来生成。如果我们的查询过于复杂,则会对缓存带来一定的性能负担。

Expression 树与 EF.CompileQuery

我们稍后讨论 CompileQueryCore 的作用。先来讨论一下表达式树吧。

众所周知,EFCore 的强类型特性是由表达式树这个玩意带来的。

编译器为了减少人为构建表达式树的负担,提供了语法糖,让我们可以像写 Lambda 函数一样书写表达式。然而编译器并没有开洞,而是实打实的进行了表达式树的构建。

可以在图上看到,我们经常执行的根据 ID 查找一个实体的操作会产生如此之多的中间代码。甚至,蓝框内还有闭包变量捕捉的步骤。

如果我们的查询很简单,那似乎也没什么……如果我们要执行一个超级复杂的查询,又要 join 好几个表又要 concat 还要 group 呢?

表达式树毕竟是表达式树,为了创建表达式树,这么多中间代码总是需要执行的。

有没有办法直接跳过这么多表达式树的构建呢?有的。看 EF.CompileQuery。其中的一个典型函数

public static Func<TContext, IEnumerable<TResult>> CompileQuery<TContext, TResult>(
    [NotNull] Expression<Func<TContext, IQueryable<TResult>>> queryExpression)
    where TContext : DbContext
    => new CompiledQuery<TContext, IEnumerable<TResult>>(queryExpression).Execute;

这里实际上是构建了一个 CompileQuery<,> 类型的对象,并且将他的 Execute 函数打包成委托返回。

你可能会问,这不是还需要表达式树吗?

那么请回顾官方文档中这个函数的使用场景:将该函数返回的委托放在 静态字段 或者 单例的成员变量 中。也就是说,对于某个参数设计好了的函数,这个 EF.CompileQuery 函数理应只执行一次。

CompiledQuery 对象在第一次执行时,通过接下来的代码创建一个 Func<QueryContext, TResult> 委托;在这个委托中,本次执行产生的 SQL 语句表达式树、SQL 语句文本会被缓存下来;在第二次执行的时候,就跳过对表达式树的处理,直接执行上面那个委托,甚至在特定情况下快进到直接执行 SQL 语句文本了。

// Microsoft.EntityFrameworkCore.Query.Internal.QueryCompiler
public virtual Func<QueryContext, TResult> CreateCompiledQuery<TResult>(Expression query)
{
    Check.NotNull(query, nameof(query));

    query = ExtractParameters(query, _queryContextFactory.Create(), _logger, parameterize: false);

    return CompileQueryCore<TResult>(_database, query, _model, false);
}

是不是感觉会快上很多?我们可以直接获得这个委托,而不是通过字典查找,可以节省很多时间;而且不涉及查询语句缓存,不会存在系统内其他查询太多、某些不常用查询被定期清理掉的情况。

个人认为,在需要比较高性能的同时,又不是直接执行 SQL 语句文本的情况下,这样两种情况可以尝试使用 EF.CompileQuery

  • 查询表达式树太过复杂
  • 高频访问路径上的查询

另外,如果查询是只读,不涉及到实体的增删改,此时完全可以考虑到使用 AsNoTracking 这类拓展,将实体更改追踪关掉,在此基础上可以再提高一点性能,大约能与 Dapper 和 ADO.NET 直接读取数据的性能比肩(没有测试数据)。

笔者在阅读网上现存的对 EF.CompileQuery 的介绍中,有读到过一篇说这个函数不接受 ToListToListAsync 之类的函数,说这个功能支持不完全。

ToListIEnumerable<> 的拓展方法,并不是 IQueryable<> 的拓展方法。也就是说,ToList 实际上是将之前的 IQueryable<> 进行了 foreach 枚举,并手动构建 List<> 对象,所以说不支持似乎情有可原。

ToListAsync 是 EFCore 的拓展方法。实际上,他的代码是这样的:

public static async Task<List<TSource>> ToListAsync<TSource>(
    [NotNull] this IQueryable<TSource> source,
    CancellationToken cancellationToken = default)
{
    var list = new List<TSource>();
    await foreach (var element in source.AsAsyncEnumerable().WithCancellation(cancellationToken))
    {
        list.Add(element);
    }

    return list;
}

对,利用了 IAsyncEnumerableawait foreach 来达到异步查询的目的。所以,当我们需要使用 ToListToArrayToDictionary 类似功能的时候,使用那个 Func<TContext, IAsyncEnumerable<TResult>> 然后手动构架集合就好了。

这里再简单给几个使用这个函数使用的例子吧。

private static readonly Func<MyContext, int, Task<User>> _findUser =
    EF.CompileAsyncQuery(
        (MyContext context, int id) => context.Users.Where(u => u.Id == id).FirstOrDefault());

private static readonly Func<MyContext, IAsyncEnumerable<UserDto>> _listUsers =
    EF.CompileAsyncQuery(
        (MyContext context) => context.Users.Select(u => new UserDto(u.Id, u.Name, true)));

private static readonly Func<MyContext, DateTimeOffset, CancellationToken, Task<int>> _countUsers =
    EF.CompileAsyncQuery(
        (MyContext context, DateTimeOffset time, CancellationToken _) => context.Users.Where(u => u.RegisterTime < time).Count());

public async Task DoAsync(CancellationToken cancellationToken = default)
{
    using var context = CreateContext();

    var user = await _findUser(context, 233);

    var list = new List<UserDto>();
    await foreach (var item in _listUsers(context).WithCancellation(cancellationToken))
    {
        list.Add(item);
    }

    var count = await _countUsers(context, DateTimeOffset.Now.AddDays(-1), cancellationToken);
}

可以创建带有 CancellationToken 的异步版本。同步版本就不用 CancellationToken 了。

真正的查询编译与执行

我们需要结合 SqlServer 这个关系型数据库解释所谓的 QueryExecutor。其他关系型数据库的大致构建过程其实差不多,非关系型的 InMemory 和 Cosmos 的驱动用的少就不解释了哈。

context.Set<User>().Where(u => u.Id != id).ToList() 这一查询的翻译为例。

我们可以发现,这个查询的 QueryExecutorFunc<QueryContext, IEnumerable<User>>,传入 queryContext 会返回一个 IEnumerable 对象。

在 EFCore 3.1 中,返回的是 QueryingEnumerable;在 EFCore 5.0 中,返回 SingleQueryingEnumerable 或者 SplitQueryingEnumerable 或者 FromSqlQueryingEnumerable。5.0 的改动是因为带来了 AsSplitQuery 这个拓展,避免笛卡尔爆炸的问题。

先跳过 QueryExecutor 函数体,看看返回值。

SingleQueryingEnumerable 为例,我们看到它实现了 IEnumerableIAsyncEnumerable。以下是 GetEnumerator 结果的 MoveNextInitializeReader 的实现。

private bool InitializeReader(DbContext _, bool result)
{
    EntityFrameworkEventSource.Log.QueryExecuting();

    var relationalCommand = _relationalCommandCache.GetRelationalCommand(_relationalQueryContext.ParameterValues);

    _dataReader = relationalCommand.ExecuteReader(
        new RelationalCommandParameterObject(
            _relationalQueryContext.Connection,
            _relationalQueryContext.ParameterValues,
            _relationalCommandCache.ReaderColumns,
            _relationalQueryContext.Context,
            _relationalQueryContext.CommandLogger,
            _detailedErrorsEnabled));

    _resultCoordinator = new SingleQueryResultCoordinator();

    _relationalQueryContext.InitializeStateManager(_standAloneStateManager);

    return result;
}

public bool MoveNext()
{
    try
    {
        using (_relationalQueryContext.ConcurrencyDetector.EnterCriticalSection())
        {
            if (_dataReader == null)
            {
                _relationalQueryContext.ExecutionStrategyFactory.Create()
                    .Execute(true, InitializeReader, null);
            }

            var hasNext = _resultCoordinator.HasNext ?? _dataReader.Read();
            Current = default;

            if (hasNext)
            {
                while (true)
                {
                    _resultCoordinator.ResultReady = true;
                    _resultCoordinator.HasNext = null;
                    Current = _shaper(
                        _relationalQueryContext, _dataReader.DbDataReader, _resultCoordinator.ResultContext,
                        _resultCoordinator);
                    if (_resultCoordinator.ResultReady)
                    {
                        // We generated a result so null out previously stored values
                        _resultCoordinator.ResultContext.Values = null;
                        break;
                    }

                    if (!_dataReader.Read())
                    {
                        _resultCoordinator.HasNext = false;
                        // Enumeration has ended, materialize last element
                        _resultCoordinator.ResultReady = true;
                        Current = _shaper(
                            _relationalQueryContext, _dataReader.DbDataReader, _resultCoordinator.ResultContext,
                            _resultCoordinator);

                        break;
                    }
                }
            }

            return hasNext;
        }
    }
    catch (Exception exception)
    {
        _queryLogger.QueryIterationFailed(_contextType, exception);

        throw;
    }
}

其中

  • ExecutionStrategy 处理查询重试之类的执行逻辑
  • _relationalCommandCache 保存了翻译的 SQL 表达式和语句文本
  • _dataReader 保存着 ADO.NET 中常用的 DbCommandDbDataReaderDbConnection 等工具,是的,底层读取库就是 ADO.NET
  • _resultCoordinator 将 ADO.NET 读取的一行结果存入 ResultContext,然后当一条记录完整读取以后(如果有集合 Include,则是集合整个读取完成),由 _shaper 转换成最终实体

这个 QueryingEnumerable 是如何构建出来的?终于到了喜闻乐见的 CompileQueryCore 函数讲解了。

// Microsoft.EntityFrameworkCore.Query.Internal.QueryCompiler
public virtual Func<QueryContext, TResult> CompileQueryCore<TResult>(
    [NotNull] IDatabase database,
    [NotNull] Expression query,
    [NotNull] IModel model,
    bool async)
    => database.CompileQuery<TResult>(query, async);


// Microsoft.EntityFrameworkCore.Storage.Database
public virtual Func<QueryContext, TResult> CompileQuery<TResult>(Expression query, bool async)
    => Dependencies.QueryCompilationContextFactory
        .Create(async)
        .CreateQueryExecutor<TResult>(query);


// Microsoft.EntityFrameworkCore.Query.QueryCompilationContext
public virtual Func<QueryContext, TResult> CreateQueryExecutor<TResult>(Expression query)
{
    query = _queryTranslationPreprocessorFactory.Create(this).Process(query);
    // Convert EntityQueryable to ShapedQueryExpression
    query = _queryableMethodTranslatingExpressionVisitorFactory.Create(this).Visit(query);
    query = _queryTranslationPostprocessorFactory.Create(this).Process(query);

    // Inject actual entity materializer
    // Inject tracking
    query = _shapedQueryCompilingExpressionVisitorFactory.Create(this).Visit(query);

    // If any additional parameters were added during the compilation phase (e.g. entity equality ID expression),
    // wrap the query with code adding those parameters to the query context
    query = InsertRuntimeParameters(query);

    var queryExecutorExpression = Expression.Lambda<Func<QueryContext, TResult>>(
        query,
        QueryContextParameter);

    try
    {
        return queryExecutorExpression.Compile();
    }
    finally
    {
        Logger.QueryExecutionPlanned(new ExpressionPrinter(), queryExecutorExpression);
    }
}

嗯,这玩意……老套娃人了。

这里的代码其实挺抽象的。另外不要被那个 .Compile() 吓到,那个只是利用表达式树把动态构建的函数体生成为真正的委托对象而已,我们需要在它 Compile 之前看到这个函数体内部究竟是什么。

至于查询翻译过程本身的设计,这次先不介绍。

依然是上面那个例子,调试 EFCore 源代码设断点,可以看到,此时 queryExecutor 是这样的:

return new SingleQueryingEnumerable<User>(
    relationalQueryContext: (RelationalQueryContext)queryContext,
    relationalCommandCache: value(RelationalCommandCache),
    shaper: value(Func<QueryContext, DbDataReader, ResultContext, SingleQueryResultCoordinator, User>),
    contextType: typeof(MyContext),
    standAloneStateManager: False,
    detailedErrorsEnabled: False);

在刚才的过程中, InsertRuntimeParameters 并没有实际发挥作用,因为闭包捕捉的 id 变量早在 QueryCompiler.ExtractParameters 时就变为了 ParameterExpression 并被加入 QueryContext,而并没有在查询翻译过程中使用任何“运行时参数”。

在不使用导航属性的情况下,试了几个常见的例子,基本上都不会触发“运行时参数”。笔者找到了这样的两个例子:

public class User
{
    public int Id { get; set; }
}

public class Tenant
{
    public int Id { get; set; }
    public ICollection<User> Users { get; set; }
}

modelBuilder.Entity<Tenant>(entity => entity.HasMany(e => e.Users).WithOne());

var uu = new User { /* .. */ };
context.Users.Where(u => u.Equals(uu)).ToList();
context.Tenants.Where(t => t.Users.Contains(uu)).ToList();

此时 queryExecutor 是这样的:

queryContext.AddParameter(
    "__entity_equality_uu_0_Id",
    new Func<QueryContext, int?>(
        queryContext => ParameterValueExtractor(queryContext, "__uu_0", IProperty)
    ).Invoke(queryContext));

return new SingleQueryingEnumerable<User>(
    relationalQueryContext: (RelationalQueryContext)queryContext,
    relationalCommandCache: value(RelationalCommandCache),
    shaper: value(Func<QueryContext, DbDataReader, ResultContext, SingleQueryResultCoordinator, User>),
    contextType: typeof(MyContext),
    standAloneStateManager: False,
    detailedErrorsEnabled: False);

也就是说,所谓“运行时参数”是 SQL 语句执行时的参数,但是,要想读取它的值,就需要反过来从 QueryContext 中读取。为什么会在 QueryContext 中呢?因为之前 QueryCompiler.ExtractParameters 的时候,这个对象的整体被加入了 QueryContext 中,而不是被直接计算好。对于需要判断实体包含和实体相等的情况,就需要用到这种奇怪的方法。

现在问题来了:relationalCommandCacheshaper 是在何时构建好的?

前者保存着翻译完成的 SQL 表达式树的对象,他会在创建 DbCommand 对象的时候,调用 QuerySqlGenerator 将表达式树拍平成为 SQL 语句文本。

这里我们来研究一下后者这个 shaper 委托。

这个委托也是由 EFCore 动态创建的,但是这个委托的具体实现是和数据库类型有关系的。在关系型数据库中,由 RelationalShapedQueryCompilingExpressionVisitor 进行创建。

对于刚才直接拿到实体的情况,它产生的代码是这样的

// SELECT [u].[Id], [u].[Name], [u].[RegisterTime], [u].[TenantId]
// FROM [Users] AS [u]

User Shape(QueryContext queryContext, DbDataReader dataReader, ResultContext resultContext, SingleQueryResultCoordinator resultCoordinator)
{
    User var1 =
    {
        IEntityType entityType1;

        var materializationContext1 = new MaterializationContext(
            valueBuffer: ValueBuffer.Empty,
            context: queryContext.Context);

        User instance1 = null;

        InternalEntityEntry entry1 = queryContext.TryGetEntry(
            key: value(IKey: "Key: User.Id PK"),
            keyValues: new object[] { dataReader.GetInt32(0) },
            throwOnNullKey: True,
            out bool hasNullKey1));

        if (!hasNullKey1)
        {
            if (entry1 != default(InternalEntityEntry))
            {
                entityType1 = entry1.EntityType;
                instance1 = (User)entry1.Entity;
            }
            else
            {
                ValueBuffer shadowValueBuffer1 = ValueBuffer.Empty;
                entityType1 = value("EntityType: User");
                instance1 = entityType1 switch
                {
                    value("EntityType: User") =>
                    {
                        // EFCore生成的shadow property,此处为 int? TenantId
                        shadowValueBuffer1 = new ValueBuffer(new[]
                        {
                            dataReader.IsDBNull(3) ? default(object) : dataReader.GetInt32(3)
                        });

                        User instance = new User();
                        instance.<Id>k__BackingField = dataReader.GetInt32(0);
                        instance.<Name>k__BackingField = dataReader.IsDBNull(1)
                            ? default(string)
                            : dataReader.GetString(1);
                        instance.<RegisterTime>k__BackingField = dataReader.GetFieldValue(2);

                        block-return instance;
                    },
                    _ => null,
                };

                entry1 = entityType1 == default(IEntityType)
                    ? default(InternalEntityEntry)
                    : queryContext.StartTracking(entityType1, instance1, shadowValueBuffer1);
            }
        }

        block-return instance1;
    };

    return var1;
}

注意此处摆出的代码是从 EFCore 生成的表达式树改写而来,与原来的表达式树并不完全相同,原来的一些写法在 C# 中无法直接表达(例如 kotlin 那样,一对花括号最后一个值作为整个花括号的值,此处用 block-return 表示;以及 default(void) 作为三元表达式值的使用),所以稍有改写。

扔代码出来不是让大家看懂,而是让大家体会一下。Don’t try to understand it, feel it.

可以看到大致的实体生成过程,以及实体跟踪的流程:先看上下文是否已经追踪了这样的实体,有则直接使用,无则跳过。

switch 则是给实体继承关系做出的设计。在有实体继承的情况下,entityType1 的值是通过读取查询结果某个 Shadow Property 字段来确定的。

如果使用 AsNoTracking 标记查询呢?

// SELECT [u].[Id], [u].[Name], [u].[RegisterTime], [u].[TenantId]
// FROM [Users] AS [u]

User Shape(QueryContext queryContext, DbDataReader dataReader, ResultContext resultContext, SingleQueryResultCoordinator resultCoordinator)
{
    User var1 =
    {
        IEntityType entityType1;

        var materializationContext1 = new MaterializationContext(
            valueBuffer: ValueBuffer.Empty,
            context: queryContext.Context);

        User instance1 = null;

        if (((object)dataReader.GetInt32(0)) != null)
        {
            ValueBuffer shadowValueBuffer1 = ValueBuffer.Empty;
            entityType1 = value("EntityType: User");
            instance1 = entityType1 switch
            {
                value("EntityType: User") =>
                {
                    User instance = new User();
                    instance.<Id>k__BackingField = dataReader.GetInt32(0);
                    instance.<Name>k__BackingField = dataReader.IsDBNull(1)
                        ? default(string)
                        : dataReader.GetString(1);
                    instance.<RegisterTime>k__BackingField = dataReader.GetFieldValue(2);

                    block-return instance;
                },
                _ => null,
            };
        }

        block-return instance1;
    };

    return var1;
}

可以看到,实体跟踪相关的代码没了,Shadow Property 相关的也没了,毕竟上下文不追踪这个实体,怎么会知道有哪些虚拟属性呢。上下文能有什么坏心思呢。

如果是查询中 Select 创建了一个非实体类型呢?(这里其实和 .Count().Sum() 之类的函数效果差不多)

例如 context.Users.Select(u => new UserDto(u.Id, u.Name, false)).ToList();

// SELECT [u].[Id], [u].[Name]
// FROM [Users] AS [u]

UserDto Shape(QueryContext queryContext, DbDataReader dataReader, ResultContext resultContext, SingleQueryResultCoordinator resultCoordinator)
{
    var param0 = (int?)dataReader.GetInt32(0);
    var param1 = dataReader.IsDBNull(1) ? default(string) : dataReader.GetString(1);
    return new UserDto((int)param0, param1, false);
}

嗯,甚至直接跳过了 IEntityType 的检查……不过也正常,毕竟这里没有一个实体对应多种 CLR 类型的状况。

再来一个使用了单个实体 Include 的吧。以 context.Users.Include(u => u.Tenant).ToListAsync() 为例

// SELECT [u].[Id], [u].[Name], [u].[RegisterTime], [u].[TenantId], [t].[Id]
// FROM [Users] AS [u]
// LEFT JOIN [Tenants] AS [t] ON [u].[TenantId] = [t].[Id]

User Shape(QueryContext queryContext, DbDataReader dataReader, ResultContext resultContext, SingleQueryResultCoordinator resultCoordinator)
{
    User var1 =
    {
        IEntityType entityType1;

        var materializationContext1 = new MaterializationContext(
            valueBuffer: ValueBuffer.Empty,
            context: queryContext.Context);

        User instance1 = null;

        InternalEntityEntry entry1 = queryContext.TryGetEntry(
            key: value(IKey: "Key: User.Id PK"),
            keyValues: new object[] { dataReader.GetInt32(0) },
            throwOnNullKey: True,
            out bool hasNullKey1));

        if (!hasNullKey1) { ... } // 此处与上述带 Tracking 的类似
        block-return instance1;
    };

    Tenant var2 =
    {
        IEntityType entityType2;

        var materializationContext2 = new MaterializationContext(
            valueBuffer: ValueBuffer.Empty,
            context: queryContext.Context);

        Tenant instance2 = null;

        InternalEntityEntry entry2 = queryContext.TryGetEntry(
            key: value(IKey: "Key: Tenant.Id PK"),
            keyValues: new object[] { dataReader.IsDBNull(4) ? default(object) : dataReader.GetInt32(4) },
            throwOnNullKey: False,
            out bool hasNullKey2));

        if (!hasNullKey2) { ... } // 此处与上述带 Tracking 的类似
        block-return instance2;
    };

    IncludeReference(
        queryContext: queryContext,
        entity: var1,
        relatedEntity: var2,
        navigation: value("Navigation: User.Tenant (Tenant) ToPrincipal Tenant Inverse: Users"),
        inverseNavigation: value("Navigation: Tenant.Users (ICollection<User>) Collection ToDependent User Inverse: Tenant"),
        fixup: (entity, relatedEntity) =>
        {
            entity.<Tenant>k__BackingField = relatedEntity;
            // value(ClrICollectionAccessor<Tenant, ICollection<User>, User>) = inverseNavigation.GetCollectionAccessor()
            value(IClrICollectionAccessor).Add(relatedEntity, entity, forMaterialization: True);
        },
        trackingQuery: True);

    return var1;
}


// Microsoft.EntityFrameworkCore.Query.RelationalShapedQueryCompilingExpressionVisitor+ShaperProcessingExpressionVisitor
private static void IncludeReference<TEntity, TIncludingEntity, TIncludedEntity>(
    QueryContext queryContext,
    TEntity entity,
    TIncludedEntity relatedEntity,
    INavigationBase navigation,
    INavigationBase inverseNavigation,
    Action<TIncludingEntity, TIncludedEntity> fixup,
    bool trackingQuery)
    where TEntity : class
    where TIncludingEntity : class, TEntity
    where TIncludedEntity : class
{
    if (entity is TIncludingEntity includingEntity)
    {
        if (trackingQuery
            && navigation.DeclaringEntityType.FindPrimaryKey() != null)
        {
            // For non-null relatedEntity StateManager will set the flag
            if (relatedEntity == null)
            {
                queryContext.SetNavigationIsLoaded(includingEntity, navigation);
            }
        }
        else
        {
            navigation.SetIsLoadedWhenNoTracking(includingEntity);
            if (relatedEntity != null)
            {
                fixup(includingEntity, relatedEntity);
                if (inverseNavigation != null
                    && !inverseNavigation.IsCollection)
                {
                    inverseNavigation.SetIsLoadedWhenNoTracking(relatedEntity);
                }
            }
        }
    }
}

再来一个集合 Include 的吧。集合的特别复杂。

context.Tenants.Include(t => t.Users).ToListAsync() 为例

// SELECT [t].[Id], [u].[Id], [u].[Name], [u].[RegisterTime], [u].[TenantId]
// FROM [Tenants] AS [t]
// LEFT JOIN [Users] AS [u] ON [t].[Id] = [u].[TenantId]
// ORDER BY [t].[Id], [u].[Id]

Tenant Shape(QueryContext queryContext, DbDataReader dataReader, ResultContext resultContext, SingleQueryResultCoordinator resultCoordinator)
{
    if (resultContext.Values == null)
    {
        Tenant var1 =
        {
            var materializationContext1 = new MaterializationContext(
                valueBuffer: ValueBuffer.Empty,
                context: queryContext.Context);

            Tenant instance1 = null;

            InternalEntityEntry entry1 = queryContext.TryGetEntry(
                key: value(IKey: "Key: Tenant.Id PK"),
                keyValues: new object[] { dataReader.GetInt32(0) },
                throwOnNullKey: True,
                out bool hasNullKey1));

            if (!hasNullKey1) { ... } // 此处与上述带 Tracking 的类似
            block-return instance1;
        };

        resultContext.Values = new[] { var1 };

        InitializeIncludeCollection(
            collectionId: 0,
            queryContext: queryContext,
            dbDataReader: dataReader,
            resultCoordinator: resultCoordinator,
            entity: (Tenant)resultContext.Values[0],
            parentIdentifier: (queryContext, dataReader) => new object[] { (int?)dataReader.GetInt32(0) },
            outerIdentifier: (queryContext, dataReader) => new object[] { (int?)dataReader.GetInt32(0) },
            navigation: value("Navigation: Tenant.Users (ICollection<User>) Collection ToDependent User Inverse: Tenant"),
            clrCollectionAccessor: value(ClrCollectionAccessor),
            trackingQuery: True);
    }

    PopulateIncludeCollection(
        collectionId: 0,
        queryContext: queryContext,
        dbDataReader: dataReader,
        resultCoordinator: resultCoordinator,
        parentIdentifier: (queryContext, dataReader) => new object[] { (int?)dataReader.GetInt32(0) },
        outerIdentifier: (queryContext, dataReader) => new object[] { (int?)dataReader.GetInt32(0) },
        selfIdentifier: (queryContext, dataReader) => new object[] { dataReader.IsDBNull(1) ? default(int?) : (int?)dataReader.GetInt32(1) },
        parentIdentifierValueComparers: value(IReadOnlyList<ValueComparer>),
        outerIdentifierValueComparers: value(IReadOnlyList<ValueComparer>),
        selfIdentifierValueComparers: value(IReadOnlyList<ValueComparer>),
        innerShaper: (queryContext, dataReader, resultContext, resultCoordinator) =>
        {
            User var1 =
            {
                var materializationContext2 = new MaterializationContext(
                    valueBuffer: ValueBuffer.Empty,
                    context: queryContext.Context);

                User instance2 = null;

                InternalEntityEntry entry2 = queryContext.TryGetEntry(
                    key: value(IKey: "Key: User.Id PK"),
                    keyValues: new[] { dataReader.IsDBNull(1) ? default(object) : dataReader.GetInt32(1) },
                    throwOnNullKey: False,
                    out bool hasNullKey2));

                if (!hasNullKey2) { ... } // 此处与上述带 Tracking 的类似
                block-return instance2;
            };

            return var1;
        },
        inverseNavigation: value("Navigation: User.Tenant (Tenant) ToPrincipal Tenant Inverse: Users"),
        fixup: (Tenant including, User included) =>
        {
            value(IClrICollectionAccessor).Add(including, included, True);
            included.<Tenant>k__BackingField = including;
        },
        trackingQuery: True);

    return resultCoordinator.ResultReady
        ? (Tenant)resultContext.Values[0]
        : default(Tenant);
}


// Microsoft.EntityFrameworkCore.Query.RelationalShapedQueryCompilingExpressionVisitor+ShaperProcessingExpressionVisitor
private static void InitializeIncludeCollection<TParent, TNavigationEntity>(
    int collectionId,
    QueryContext queryContext,
    DbDataReader dbDataReader,
    SingleQueryResultCoordinator resultCoordinator,
    TParent entity,
    Func<QueryContext, DbDataReader, object[]> parentIdentifier,
    Func<QueryContext, DbDataReader, object[]> outerIdentifier,
    INavigationBase navigation,
    IClrCollectionAccessor clrCollectionAccessor,
    bool trackingQuery)
    where TParent : class
    where TNavigationEntity : class, TParent
{
    object collection = null;
    if (entity is TNavigationEntity)
    {
        if (trackingQuery)
        {
            queryContext.SetNavigationIsLoaded(entity, navigation);
        }
        else
        {
            navigation.SetIsLoadedWhenNoTracking(entity);
        }

        collection = clrCollectionAccessor.GetOrCreate(entity, forMaterialization: true);
    }

    var parentKey = parentIdentifier(queryContext, dbDataReader);
    var outerKey = outerIdentifier(queryContext, dbDataReader);

    var collectionMaterializationContext = new SingleQueryCollectionContext(entity, collection, parentKey, outerKey);

    resultCoordinator.SetSingleQueryCollectionContext(collectionId, collectionMaterializationContext);
}

// Microsoft.EntityFrameworkCore.Query.RelationalShapedQueryCompilingExpressionVisitor+ShaperProcessingExpressionVisitor
private static void PopulateIncludeCollection<TIncludingEntity, TIncludedEntity>(
    int collectionId,
    QueryContext queryContext,
    DbDataReader dbDataReader,
    SingleQueryResultCoordinator resultCoordinator,
    Func<QueryContext, DbDataReader, object[]> parentIdentifier,
    Func<QueryContext, DbDataReader, object[]> outerIdentifier,
    Func<QueryContext, DbDataReader, object[]> selfIdentifier,
    IReadOnlyList<ValueComparer> parentIdentifierValueComparers,
    IReadOnlyList<ValueComparer> outerIdentifierValueComparers,
    IReadOnlyList<ValueComparer> selfIdentifierValueComparers,
    Func<QueryContext, DbDataReader, ResultContext, SingleQueryResultCoordinator, TIncludedEntity> innerShaper,
    INavigationBase inverseNavigation,
    Action<TIncludingEntity, TIncludedEntity> fixup,
    bool trackingQuery)
    where TIncludingEntity : class
    where TIncludedEntity : class
{
    var collectionMaterializationContext = resultCoordinator.Collections[collectionId];
    if (collectionMaterializationContext.Parent is TIncludingEntity entity)
    {
        if (resultCoordinator.HasNext == false)
        {
            // Outer Enumerator has ended
            GenerateCurrentElementIfPending();
            return;
        }

        if (!CompareIdentifiers(
            outerIdentifierValueComparers,
            outerIdentifier(queryContext, dbDataReader), collectionMaterializationContext.OuterIdentifier))
        {
            // Outer changed so collection has ended. Materialize last element.
            GenerateCurrentElementIfPending();
            // If parent also changed then this row is now pointing to element of next collection
            if (!CompareIdentifiers(
                parentIdentifierValueComparers,
                parentIdentifier(queryContext, dbDataReader), collectionMaterializationContext.ParentIdentifier))
            {
                resultCoordinator.HasNext = true;
            }

            return;
        }

        var innerKey = selfIdentifier(queryContext, dbDataReader);
        if (innerKey.All(e => e == null))
        {
            // No correlated element
            return;
        }

        if (collectionMaterializationContext.SelfIdentifier != null)
        {
            if (CompareIdentifiers(selfIdentifierValueComparers, innerKey, collectionMaterializationContext.SelfIdentifier))
            {
                // repeated row for current element
                // If it is pending materialization then it may have nested elements
                if (collectionMaterializationContext.ResultContext.Values != null)
                {
                    ProcessCurrentElementRow();
                }

                resultCoordinator.ResultReady = false;
                return;
            }

            // Row for new element which is not first element
            // So materialize the element
            GenerateCurrentElementIfPending();
            resultCoordinator.HasNext = null;
            collectionMaterializationContext.UpdateSelfIdentifier(innerKey);
        }
        else
        {
            // First row for current element
            collectionMaterializationContext.UpdateSelfIdentifier(innerKey);
        }

        ProcessCurrentElementRow();
        resultCoordinator.ResultReady = false;
    }

    void ProcessCurrentElementRow()
    {
        var previousResultReady = resultCoordinator.ResultReady;
        resultCoordinator.ResultReady = true;
        var relatedEntity = innerShaper(
            queryContext, dbDataReader, collectionMaterializationContext.ResultContext, resultCoordinator);
        if (resultCoordinator.ResultReady)
        {
            // related entity is materialized
            collectionMaterializationContext.ResultContext.Values = null;
            if (!trackingQuery)
            {
                fixup(entity, relatedEntity);
                if (inverseNavigation != null)
                {
                    inverseNavigation.SetIsLoadedWhenNoTracking(relatedEntity);
                }
            }
        }

        resultCoordinator.ResultReady &= previousResultReady;
    }

    void GenerateCurrentElementIfPending()
    {
        if (collectionMaterializationContext.ResultContext.Values != null)
        {
            resultCoordinator.HasNext = false;
            ProcessCurrentElementRow();
        }

        collectionMaterializationContext.UpdateSelfIdentifier(null);
    }
}

嗯,这件事情很神奇。

理论上,在不带过滤的情况下,One Include Many 和 Many Include One 应该是一致的?

为何代码为什么差别这么大?实际上,这就是“查询跟踪”的神秘之处了。

不知道各位朋友是否在网上看见过这样的文章,说使用 AsNoTracking 可以提高查询性能,并且还建议大家直接 optionsBuilder.UseQueryTrackingBehavior(NoTracking)

实际上,这样有一种隐藏的坑:

如果你使用了一对多的 SQL JOIN,并且还保持着原来的实体形状,如以下代码所示:

var results = context.Tenants
    .AsNoTracking()
    .Join(
        inner: context.Users.AsNoTracking(),
        outerKeySelector: t => t.Id,
        innerKeySelector: u => u.TenantId,
        resultSelector: (t, u) => new { t, u })
    .ToList();

假设你的 results[0].t.Id == results[1].t.Id,也就是前两条在数据库中是同一个 t 实例,当你拉取到本地时,你会发现 object.ReferenceEquals(results[0].t, results[1].t) == false,或者说在本地不是同一个实例,而两者仅仅是值相等。

如果上述代码使用的都是 AsTracking,那么 object.ReferenceEquals(results[0].t, results[1].t) == true,从头到尾只会创建一个这样的实体。

为了保证 One Include Many 在未开启实体追踪的时候也正常工作,不得不使用一些奇怪的代码来保证结果正确性。

这种情况是在笔者两年前做 JOIN 以后拉到本地 GroupBy 的时候发现的。当年在已经写了很多代码以后才开启了 NoTracking,结果导致很多原有功能失效。从那之后,笔者都是老老实实开 Tracking 的。

对,确实有很多“不开启实体跟踪”还要“生成出来的实体不重复”的情况,这种情况下要怎么做呢?

EFCore 5.0 新推出了 NoTrackingWithIdentityResolution。用这个就能保证上述的引用相等,但是实体不会最终被上下文追踪。

另外,所谓“不开启实体跟踪”最大的影响场景,是查询大量数据以后,对上下文进行多次保存操作。如果你觉得频繁保存时处理大量实体变得异常缓慢,你应该考虑修改 DbContext.ChangeTracker.AutoDetectChangesEnabledfalse,然后对所有修改过的实体手动调用 context.Update。这样即使上下文追踪上万个实体,保存都是几乎一瞬间的事情。

另外,实体追踪还有什么用呢?

记得 IQueryable<> 们的一个拓展 .LoadAsync() 吗?这个 .LoadAsync() 的注释说,它的功能约等于 .ToListAsync() 并立马将这个列表扔掉。那查询到的东西怎么利用?使用 context.Set<MyEntity>().FindAsync(id) 即可。它仅在 id 不存在于本地的时候才到数据库查找结果。

所以笔者给出的建议是

  • 在性能要求高的关键路径上尽量少使用导航属性
  • 尽量不依赖实体更改自动检测

当然了,导航属性还是很好的,编写 Where 的时候可以少编写很多 Join……

今天关于查询编译和查询跟踪的讨论就到这里啦。

EFCore查缺补漏(一):依赖注入

前段时间,在群里潜水的时候,看见有个群友的报错日志是这样的:

An unhandled exception was thrown by the application. System.OutOfMemoryException: Exception of type 'System.OutOfMemoryException' was thrown.
   at System.Threading.Thread.StartInternal()
   at Microsoft.Extensions.Logging.Console.ConsoleLoggerProvider..ctor(IOptionsMonitor`1 options)
   at …
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitCache(ServiceCallSite callSite, RuntimeResolverContext context, ServiceProviderEngineScope serviceProviderEngine, RuntimeResolverLock lockType)
   at …
   at Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions.GetService[T](IServiceProvider provider)
   at Microsoft.Extensions.Logging.LoggerFactory.Create(Action`1 configure)
   at xxxxxxx. <>c__DisplayClass2_0.<AddXxxDbContext>b__0(DbContextOptionsBuilder builder)
   at Microsoft.Extensions.DependencyInjection.EntityFrameworkServiceCollectionExtensions.CreateDbContextOptions[TContext](IServiceProvider applicationServiceProvider, Action`2 optionsAction)
   at …

嗯……内存满了?是在构建 ConsoleLoggerProvider 的时候报的异常?是由依赖注入容器产生的?再上层是 AddXxxDbContext?

好吧,看来一定是位没研究过 EFCore 源码也没看过与本文类似内容的仁兄……我甚至能反推出他写的代码:

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddDbContext<MyDbContext>(options =>
        {
            // ...
            options.UseLoggerFactory(LoggerFactory.Create(b => b.AddConsole().AddDebug()));
        });

        // ...
    }

    // ...
}

看,这个调用堆栈是不是对上味儿了。

接下来我将介绍这个bug产生的原因,并带各位看官一窥 DbContext、DbContextOptions、EFCore内部类的大致生命周期。

本文所有知识均基于 EFCore 3.1 版本,EFCore 5.0 对这部分几乎没有改动。

另外,如果有兴趣调试 EFCore 的源码,可以 clone 下来某个 release 版本,然后保留 EFCore/Abstractions/Analyzers/Relational/SqlServer 这几个项目,然后开一个自己的命令行或者单元测试项目,就可以尽情遨游 EFCore 的源码了。

读代码前,请储备一定量的英文知识和自信。很多代码的意思都写在变量名和函数名上了,大部分源代码读起来并不是什么很难的事情:)

谁实例化了 DbContext?

常见有两种方式来构建 DbContext,一种是直接拿来 new 一个,构造函数传入 DbContextOptions 或者什么都不传入;一种是在 ASP.NET Core 中常用的 services.AddDbContext<...>(...),然后通过某个服务的构造函数或者 IServiceProvider 取得该 DbContext 实例。后者要求该 DbContext 只实现一个构造函数,该构造函数只接受一个参数 DbContextOptions<MyDbContext>

关于后一种构造方式,我们将父依赖注入容器称为 Application ServiceProvider。

首先需要明确的一点是,DbContext 的构造是由父依赖注入容器实现的。而构造函数要求检测仅仅是 EFCore 那个拓展函数进行的检查。

我们先来看各个 AddDbContext 的核心操作函数吧。

public static IServiceCollection AddDbContext<TContextService, TContextImplementation>(
    [NotNull] this IServiceCollection serviceCollection,
    [CanBeNull] Action<IServiceProvider, DbContextOptionsBuilder> optionsAction,
    ServiceLifetime contextLifetime = ServiceLifetime.Scoped,
    ServiceLifetime optionsLifetime = ServiceLifetime.Scoped)
    where TContextImplementation : DbContext, TContextService
{
    Check.NotNull(serviceCollection, nameof(serviceCollection));

    if (contextLifetime == ServiceLifetime.Singleton)
    {
        optionsLifetime = ServiceLifetime.Singleton;
    }

    if (optionsAction != null)
    {
        CheckContextConstructors<TContextImplementation>();
    }

    AddCoreServices<TContextImplementation>(serviceCollection, optionsAction, optionsLifetime);

    serviceCollection.TryAdd(new ServiceDescriptor(typeof(TContextService), typeof(TContextImplementation), contextLifetime));

    return serviceCollection;
}

在这里可以看到:

  • 我们可以修改 DbContextOptionsDbContext 的生命周期为 Singleton 或者 Transient,而不是默认的 Scoped
  • 当检测到对 DbContextOptionsBuilder 的调用时,会检查构造函数是否符合要求
  • TContextImplementation 是被构造的 DbContext 实例类型,直接由该依赖注入容器构造

AddCoreServices 函数则是将 DbContextOptions 实例注入容器。

private static void AddCoreServices<TContextImplementation>(
    IServiceCollection serviceCollection,
    Action<IServiceProvider, DbContextOptionsBuilder> optionsAction,
    ServiceLifetime optionsLifetime)
    where TContextImplementation : DbContext
{
    serviceCollection.TryAdd(
        new ServiceDescriptor(
            typeof(DbContextOptions<TContextImplementation>),
            p => CreateDbContextOptions<TContextImplementation>(p, optionsAction),
            optionsLifetime));

    serviceCollection.Add(
        new ServiceDescriptor(
            typeof(DbContextOptions),
            p => p.GetRequiredService<DbContextOptions<TContextImplementation>>(),
            optionsLifetime));
}

在这里可以看到:

  • 容器中可能具有很多个 DbContextOptions 实例,可以通过 IEnumerable<DbContextOptions> 拿到全部;这一设计是由于一个依赖注入容器中可以加入多个 DbContext 类型
  • 对于每一个特性类型的 DbContext (以下写为 MyDbContext),都会有一个 DbContextOptions<MyDbContext> 与之对应
  • 我们在构造函数处用到的 DbContextOptionsBuilderMicrosoft.Extensions.Options 其实没什么关系,不能用 IOptions<TOptions> 拿到,只是恰巧都叫 XxxxxxOptions 而已
  • 每次新构造 DbContextOptions 实例时,都会使用传入的 Action<IServiceProvider, DbContextOptionsBuilder> 函数;此时第一个参数显然是当前的依赖注入容器,例如发生 HTTP 请求时 HttpContext.RequestService 的容器 Scope;或者 DbContextOptions 单例注入时, IHost.Services 这种容器根
  • 实际构建结果是由 CreateDbContextOptions 函数创造的

那么再来看看 CreateDbContextOptions 的实现。

private static DbContextOptions<TContext> CreateDbContextOptions<TContext>(
    [NotNull] IServiceProvider applicationServiceProvider,
    [CanBeNull] Action<IServiceProvider, DbContextOptionsBuilder> optionsAction)
    where TContext : DbContext
{
    var builder = new DbContextOptionsBuilder<TContext>(
        new DbContextOptions<TContext>(new Dictionary<Type, IDbContextOptionsExtension>()));

    builder.UseApplicationServiceProvider(applicationServiceProvider);

    optionsAction?.Invoke(applicationServiceProvider, builder);

    return builder.Options;
}

可以看到,DbContextOptionsBuilder.UseApplicationServiceProvider 实际上是被执行过的,并且恰好指向父依赖注入容器。

此时会发现,我们在单元测试时,不创建依赖注入容器而直接实例化 DbContext 的时候,是没有这一步的。这就是为什么两者有时表现不同,例如直接实例化 Builder 拿到 Options,并且没有 UseLoggerFactoryUseApplicationServiceProvider 时,它不会有日志输出。至于日志那部分是怎么构建的呢,暂且按下不表。

而我们会看到网上有些文章说,因为某某原因,选择 services.AddEntityFrameworkSqlServer() 然后 options.UseInternalServiceProvider(..) 的,其实是将两个依赖注入容器合二为一了。具体好坏,还是使用者自行定夺。

DbContext 实例化时做了些什么?

看到上面那个图了吗。我们会发现,原来 EFCore 的内部容器也是分 Singleton 和 Scoped 的。

先来看看 DbContext 的这样一个 private 成员属性 InternalServiceProvider。

private IServiceProvider InternalServiceProvider
{
    get
    {
        CheckDisposed();

        if (_contextServices != null)
        {
            return _contextServices.InternalServiceProvider;
        }

        if (_initializing)
        {
            throw new InvalidOperationException(CoreStrings.RecursiveOnConfiguring);
        }

        try
        {
            _initializing = true;

            var optionsBuilder = new DbContextOptionsBuilder(_options);

            OnConfiguring(optionsBuilder);

            if (_options.IsFrozen
                && !ReferenceEquals(_options, optionsBuilder.Options))
            {
                throw new InvalidOperationException(CoreStrings.PoolingOptionsModified);
            }

            var options = optionsBuilder.Options;

            _serviceScope = ServiceProviderCache.Instance.GetOrAdd(options, providerRequired: true)
                .GetRequiredService<IServiceScopeFactory>()
                .CreateScope();

            var scopedServiceProvider = _serviceScope.ServiceProvider;

            var contextServices = scopedServiceProvider.GetService<IDbContextServices>();

            contextServices.Initialize(scopedServiceProvider, options, this);

            _contextServices = contextServices;

            DbContextDependencies.InfrastructureLogger.ContextInitialized(this, options);
        }
        finally
        {
            _initializing = false;
        }

        return _contextServices.InternalServiceProvider;
    }
}

可以观察到如下事实:

  • 除了外部的 DbContextOptions 实例,内部可能也会用 OnConfiguring 函数修改这个 Options,这样保证了两者的配置都会被应用;当使用 DbContextPool 时,内部函数是不能修改配置的
  • DbContext 的每个执行指令都是在内部容器的一个 Service Scope 中执行
  • 每次创建 Service Scope 之后,会取出其中 Scoped 服务 IDbContextServices,并将这个 DbContext 实例和 DbContextOptions 保存进这个 Service Scope
  • 内部容器的获取是由 ServiceProviderCache.Instance.GetOrAdd(options, providerRequired: true) 操作的;此时拿到的一般都是内部容器的根容器

这个 ServiceProviderCache 的源码处于 src\EFCore\Internal\ServiceProviderCache.cs

在解析 GetOrAdd 函数之前,我们需要了解这样一个结构:IDbContextOptionsExtension。这个结构具有几个基本功能:

  • 向依赖注入容器注册依赖服务
  • 验证当前 IDbContextOptions 是否正确配置,是否具有冲突
  • 告诉 EFCore 该拓展是否提供数据库底层功能(即 Database Provider,例如提供 SQL Server 相关依赖、数据库连接信息等)
  • 提供调试信息、日志片段(就是初始化 DbContext 时出现的类似 initialized 'MyDbContext' using provider 'Microsoft.EntityFrameworkCore.SqlServer' with options:... 的地方添加的)
  • 实现函数 long GetServiceProviderHashCode(),当这个 EFCore 插件包括某些不太方便通过 Scoped 服务修改的 Singleton 信息时(例如 SensitiveDataLoggingEnabled),这里应该返回一个与这些配置有关的值,同时保证:对于相同的配置,返回相同的值;对于不同的配置,返回不同的值。

例如 DbContextOptionsBuilder 中很多函数都是修改 CoreOptionsExtension 完成的。

再看看 EFCore 的内部容器中有哪些类,其对应生命周期是什么样的。此处建议参考 src/EFCore/Infrastructure/EntityFrameworkServicesBuilder.cs。这个代码文件中规定了每个类的生命周期,以及是否可以注册多个。

可以注意到,有这样一些类有着对应的生命周期:

Singleton:
- IDatabaseProvider
- IDbSetFinder
- IModelCustomizer
- ILoggingOptions
- IMemoryCache

Scoped:
- IInterceptors
- ILoggerFactory
- IModel
- IDbContextServices
- IChangeTrackerFactory
- IDiagnosticsLogger<>
- IQueryCompiler
- IQueryContextFactory
- IAsyncQueryProvider
- ICurrentDbContext
- IDbContextOptions

接下来看拿到内部容器的逻辑。

public virtual IServiceProvider GetOrAdd([NotNull] IDbContextOptions options, bool providerRequired)
{
    var coreOptionsExtension = options.FindExtension<CoreOptionsExtension>();
    var internalServiceProvider = coreOptionsExtension?.InternalServiceProvider;
    if (internalServiceProvider != null)
    {
        ValidateOptions(options);

        var optionsInitializer = internalServiceProvider.GetService<ISingletonOptionsInitializer>();
        if (optionsInitializer == null)
        {
            throw new InvalidOperationException(CoreStrings.NoEfServices);
        }

        if (providerRequired)
        {
            optionsInitializer.EnsureInitialized(internalServiceProvider, options);
        }

        return internalServiceProvider;
    }

    if (coreOptionsExtension?.ServiceProviderCachingEnabled == false)
    {
        return BuildServiceProvider().ServiceProvider;
    }

    var key = options.Extensions
        .OrderBy(e => e.GetType().Name)
        .Aggregate(0L, (t, e) => (t * 397) ^ ((long)e.GetType().GetHashCode() * 397) ^ e.Info.GetServiceProviderHashCode());

    return _configurations.GetOrAdd(key, k => BuildServiceProvider()).ServiceProvider;

    (IServiceProvider ServiceProvider, IDictionary<string, string> DebugInfo) BuildServiceProvider()
    {
        ... 此处省略
    }
}

嗯,这个逻辑很好盘,而且 99.99% 的情况下大家都只使用了默认配置,即:通过 GetServiceProviderHashCode 函数来计算哈希值,然后从 ServiceProviderCache 内部的一个缓存表中取得之前创建的容器,或者构建一个新的容器。

我们可能会发现,第一次使用 DbContext 的时候,加载时间很长;经过两三秒才能实例化完成;第二次使用的时候,基本上就是瞬间实例化成功了。但我们通过在上层依赖注入容器的 AddDbContext 处做手脚,或者通过重写 OnConfiguring 函数,更改了 DbContextOptions 之后,或者实例化另一个不同类型的 DbContext,又会花很久时间才能实例化成功。应证了上面的说法。

如果每次构建 DbContext 实例时都创建一个全新的内部容器,这样会有大量的性能浪费。

那么我们再来观察一下 DbContextOptionsBuilder 有哪些方法。

- UseSqlServer / UseNpgSql / UseInMemoryDatabase
- Use第三方插件1/2/3
- EnableDetailedErrors
- UseInternalServiceProvider
- EnableSensitiveDataLogging
- EnableServiceProviderCaching
- ConfigureWarnings
- UseMemoryCache
- ReplaceService
--- 一条朴实无华的分割线 ---
- UseModel
- UseLoggerFactory
- UseApplicationServiceProvider
- UseQueryTrackingBehavior
- AddInterceptors

CoreOptionsExtensionlong GetServiceProviderHashCode() 会包括 IMemoryCacheSensitiveDataLoggingEnabledDetailedErrorsEnabledWarningsConfiguration、通过 ReplaceService 修改的那些服务。

可以注意到,其中有些控制的是 Singleton 服务或者决定了实例化的结果,例如 UseMemoryCacheUseSqlServerReplaceService,如果每次拿到的 DbContextOptions 实例中的 IMemoryCache 或者数据库类型不一样,那么此时肯定需要构建一个新的依赖注入容器。而有些东西控制的是 Scoped 服务,例如 UseLoggerFactoryUseModel、数据库连接字符串,在一般场景下是不需要重新构建容器的。

也就是说,如果不动态改变分割线上方的那些状态,并且你使用的第三方插件编写很科学,是不会每次都构建新的内部容器的。

内部容器如何取得 ILoggerFactory?

内部的服务当然是从内部容器构建的了。

先以 ILoggerFactory 为例,看看为什么 EFCore 能拿到父容器的 ILoggerFactory

回到上面 EntityFrameworkServicesBuilder,我们可以看到一行

TryAdd<ILoggerFactory>(p => ScopedLoggerFactory.Create(p, null));

转到这个函数,我们可以看到

public static ScopedLoggerFactory Create(
    [NotNull] IServiceProvider internalServiceProvider,
    [CanBeNull] IDbContextOptions contextOptions)
{
    var coreOptions
        = (contextOptions ?? internalServiceProvider.GetService<IDbContextOptions>())
        ?.FindExtension<CoreOptionsExtension>();

    if (coreOptions != null)
    {
        if (coreOptions.LoggerFactory != null)
        {
            return new ScopedLoggerFactory(coreOptions.LoggerFactory, dispose: false);
        }

        var applicationServiceProvider = coreOptions.ApplicationServiceProvider;
        if (applicationServiceProvider != null
            && applicationServiceProvider != internalServiceProvider)
        {
            var loggerFactory = applicationServiceProvider.GetService<ILoggerFactory>();
            if (loggerFactory != null)
            {
                return new ScopedLoggerFactory(loggerFactory, dispose: false);
            }
        }
    }

    return new ScopedLoggerFactory(new LoggerFactory(), dispose: true);
}

即:先看 CoreOptionsExtension 中是否有之前 optionsBuilder.UseLoggerFactory 指定的;如果没有,再到 ApplicationServiceProvider 中找一个 ILoggerFactory;再如果真的没有,就不用了。

回顾开头的内存溢出问题:为什么呢?

DbContextOptions 未经修改的默认生命周期是 Scoped,也就是在父容器中每次实例化一个 DbContextOptions,就会调用一次 LoggerFactory.Create(b => b.AddConsole()),并且并没有照顾到它的 Dispose。而 ConsoleLoggerProvider 每次会建立一个新的线程去输出日志,没有被回收,于是……内存就在一次又一次请求中消耗殆尽了。

再回过来想想,既然能调用到父容器的 ILoggerFactory,他又为什么会用 LoggerFactory.Create 呢?……一定是 Microsoft.EntityFrameworkCore 开头的日志被父容器的设置禁用了,所以没有输出。

如何把玩其他内部服务?

观察到 DbContext 实现了 IInfrastructure<IServiceProvider> 这一接口,这个接口要求保存一个 IServiceProvider 的实例,而其实现直接指向了 InternalServiceProvider 这一私有属性。

那先谈谈这个 IInfrastructure<IServiceProvider> 接口的作用吧。这个接口同时在 DbSet<T>DatabaseFacade 中也有实现。在 Microsoft.EntityFrameworkCore.Infrastructure.AccessorExtensions 中,我们有一个针对这个接口的拓展函数 TService GetService<TService>([NotNull] this IInfrastructure<IServiceProvider> accessor)

也就是说,我们在引入 Microsoft.EntityFrameworkCore.Infrastructure 命名空间之后,可以通过 DbContext.GetService<T>() 来拿到一部分服务。

其进一步的查找逻辑为:先在 EFCore 内部直接使用的依赖注入容器(即 InternalServiceProvider)中查找,再去上一层依赖注入容器中查找。

这个函数在 EFCore 中用的很少,基本上只用于静态函数,或者非静态函数中传入 DbContext 实例时需要拿到某个服务时才会用到。

例如,如果是在写某个 EFCore 的拓展函数,传入只有 DbSet<T> 的实例,但我们想拿到这个 DbContext,不用反射之类的奇怪功能,要如何拿到呢?通常可以用 dbSetInstance.GetService<ICurrentDbContext>().Context 拿到实例。

好了,容器都拿到了,该咋玩咋玩吧……

课后习题

已知数据库模型是通过 IModelCustomizer 进行构建的,需要达到这样的效果:

  • 一个模块化的应用
  • 每个模块可以向父容器注册很多个功能类似于 Action<ModelBuilder> 的东西
  • 希望在构建数据库的 IModel 时,对着 ModelBuilder 执行这些操作

这样可以不修改 DbContext 本身的代码,而将所需的实体信息加载到 DbContext 的 Model 里。

参考答案:IDbModelSupplier设计 + AddDbContext部分