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部分

服务器程序的Xamarin-Java.Interop体验(二)

原本以为会比较容易跑起来demo,但其实还是我太单纯了。

那么今天来介绍一下单纯的在C#中调用Java代码段的一些解读。这样,意味着我们在本文中会直接调用Java的类,而不会在C#中进行继承、重写等。

此时需要考虑用到两个工具:class-parse和generator。

class-parse通过读取jar包字节码,推导出每个类的public、protected方法、字段,并以XML的格式输出。此工具基本上没有太大问题,可以直接使用;当然了,你不会在C#里用java的Stream API吧,所以可以考虑改一下源码来手动去掉stream api。

generator通过读取上述工具生成的XML和部分引用程序集来生成对应的.cs文件。这个工具似乎官方的进度还不够快,有很多老旧的类名称、方法都没有修改(例如JNIEnv、RegisterAttribute、JniHandleOwnership等)需要魔改后才能正式用起来。https://github.com/yang-er/java-interop 这里提供了我自己魔改的结果,不保证运行正确性、与最终发布时的设计的一致性啊~

上述程序运行完了以后,你会获得一个一串.cs文件,然后编译之后就可以在你的C#程序里运行了。注意由于截止目前还没有支持coreclr,请使用TargetFramework = net472编译,并在linux/macos上用mono运行。另外直接根据rt.jar编译出来的文件需要进行一些修改(例如让Java.Lang.Object继承于Java.Interop.JavaObject,让Java.Lang.Throwable继承于Java.Interop.JavaException)

互操作基本方法

generator将对应类的字段、函数,生成对应的JNI调用代码,C#运行时调用这个函数就会通过JNI访问Java的对应功能。

  • 每个函数都会翻译出来四个部分:
    • 一个cb_XXXX的Delegate,用于缓存互操作的时候Java的callback,在继承和重写中需要使用。
    • 一个GetXXXXHandler,用于获取或创建上述callback的委托。
    • 一个n_XXXXX_函数,是提供上述回调类似于C++的方式访问(函数签名都是IntPtr、int等基础值类型),在C#中获取对应对象并进行调用。
    • 一个对应的函数,会将传参列表转换成jvalue*数组,然后通过JniPeerMember缓存的方法信息进行调用。
  • 普通的字段会被生成成为具有getter和setter的属性

  • 具有getXXX(),setXXX(value)的一对函数也会被翻译成属性

  • Listener、Observer之类的东西则会被翻译成事件、EventArgs等

  • 抽象类、接口会生成对应的Invoker,如果C#中没有注册返回对象实际对应类型,则会使用这些Invoker来提供一个假的C#实现,否则哪来的类来调用Java方法呢(雾)

一些细节和讨论

设计是否正确?

是否有必要将get和set对翻译成属性?我个人的观点是:只翻译成对应的函数,然后提供一个属性来访问对应函数。显然这些get和set也可能被virtual override,而重写属性的话代码就会长得比较丑了。

另外对有些类型的返回处理是否有必要?例如java.lang.String和System.String之间是否有必要每次调用都转换?数组直接返回JavaArray不也挺好?有必要将java.util.Collection,java.util.Set等翻译成System.Collections.ICollection吗?虽然生成的代码更C#了,但是实际上似乎会比较影响GC和性能吧?我个人持怀疑态度。

IJavaPeerable

目前与Xamarin.Android一个很大的变化是,他们决定废弃JNIEnv这个不伦不类的类,改为使用JniEnvironment这个进行良好的整理的类。所以类的生成内容都有变化。原来的JniEnv中提供了直接对IntPtr操作的类,现在由JniObjectReference提供对应的方法来复制,整理的更加“干净”。

在Xamarin团队决定将互操作支持带到桌面上的时候,他们一开始使用了SafeHandle来代替原来的IntPtr,但是发现性能下降明显,所以后期他们全部改成了JniObjectReference。目前的generator大部分还都返回IntPtr+JniHandleOwnership,你需要改成ref JniObjectReference+JniObjectReferenceOptions。

除此之外,与初代实现的不同一点是,

类型系统相容性

显然Java中,Throwable是继承于Object的,但是如果想在C#中强类型处理Java异常,Throwable就不能再继承于Object了,除非之后CLR规范修改(雾)

另外目前的Generator生成出来的并没有泛型,全部都是平铺直叙的类。如果想支持C#那样的泛型,需要后期他们继续增加支持,目前你需要自己写一些胶水代码(继承、重写、cast)来“支持”。

另外Java还支持重写某函数以后返回比父类更具体的子类类型,这一点C#是不支持的,所以你可能需要修改生成的胶水代码才能编译。

性能

这套框架走JNI,所以其实性能其实不会太差?但是需要注意的是,这套框架目前翻译Java数组、CharSequence的时候,会有Java数组内容复制到C#数组,和C#数组内容复制到Java数组里,这两个过程,你需要非常小心,尽量在胶水中少使用数组,多使用ArrayList等。

完成进度

我怎么总觉得按他们的速度,这个功能会跳票啊?(大雾)

服务器程序的Xamarin-Java.Interop体验(一)

这几天需要写一个用到Java模块的程序,但是Java是不可能写的,这辈子都不可能写的,只能搞搞interop了。

目前市面上已有的基本上是IKVM.NET和JNBridgePro,后者没太了解技术细节,前者看起来是只有单向的互操作(JVM是跑在CLR上的,或者将Java字节码翻译到MSIL)。

想起来之前好像说.NET 5.0要支持Java互操作,但是翻了翻dotnet/runtime库,丝毫看不出来仓库内在搞支持。后来就想了想,换了xamarin/java.interop库研究看看。

按照之前Xamarin.Android的做法的话,互操作应该是双向的。C#这边可以继承Java的类,然后Java那边也会生成访问对应C#代码的代码。

然后发现……他们正在支持.NET Core 3.1,但是其JNI库引用的头文件还是mono的,而且用到了pthread和dlfcn的头文件,也就是说……现在必须在Linux/macOS和mono下运行。

那么先来build一下吧~

此处以Ubuntu 18.04为例。首先需要准备一些系统依赖。编译要很久,还是选择apt安装吧。

sudo apt install gnupg ca-certificates
sudo apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys 3FA7E0328081BFF6A14DA29AA6A19B38D3D831EF
echo "deb https://download.mono-project.com/repo/ubuntu stable-bionic main" | sudo tee /etc/apt/sources.list.d/mono-official-stable.list
sudo apt update
sudo apt install openjdk-8-jdk mono-devel nuget dotnet-sdk-3.1
sudo ln -s /usr/include/mono-2.0/mono /usr/include/mono

编译的时候TargetFrameworks要用到netcoreapp3.1,所以得安装上。然后就是编译内容了。

先clone一下。

git clone https://github.com/xamarin/java.interop --depth=1
cd java.interop

然后先简单修改一下几个文件。

diff --git a/Directory.Build.props b/Directory.Build.props
index 521e68a..1da7d44 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -43,6 +43,8 @@
     <XamarinAndroidToolsDirectory   Condition=" '$(XamarinAndroidToolsDirectory)' == '' ">$(MSBuildThisFileDirectory)external\xamarin-android-tools</XamarinAndroidToolsDirectory>
   </PropertyGroup>
   <PropertyGroup>
+    <JavaCPath>/usr/lib/jvm/java-8-openjdk-amd64/bin/javac</JavaCPath>
+    <JarPath>/usr/lib/jvm/java-8-openjdk-amd64/bin/jar</JarPath>
     <JavacSourceVersion Condition=" '$(JavacSourceVersion)' == '' ">1.8</JavacSourceVersion>
     <JavacTargetVersion Condition=" '$(JavacTargetVersion)' == '' ">1.8</JavacTargetVersion>
     <_BootClassPath Condition=" '$(JreRtJarPath)' != '' ">-bootclasspath "$(JreRtJarPath)"</_BootClassPath>
diff --git a/samples/Hello/Program.cs b/samples/Hello/Program.cs
index 6ffacbb..9f45fac 100644
--- a/samples/Hello/Program.cs
+++ b/samples/Hello/Program.cs
@@ -10,6 +10,7 @@ namespace Hello
                public static unsafe void Main (string[] args)
                {
                        Console.WriteLine ("Hello World!");
+                       JreRuntime.Initialize("/usr/lib/jvm/java-8-openjdk-amd64/jre/lib/amd64/server/libjvm.so");
                        try {
                                var ignore = JniRuntime.CurrentRuntime;
                        } catch (InvalidOperationException e) {
diff --git a/src/Java.Interop/Java.Interop/JniRuntime.cs b/src/Java.Interop/Java.Interop/JniRuntime.cs
index 6de9021..f9fa0de 100644
--- a/src/Java.Interop/Java.Interop/JniRuntime.cs
+++ b/src/Java.Interop/Java.Interop/JniRuntime.cs
@@ -149,7 +149,8 @@ namespace Java.Interop
                                Debug.Assert (count == 0);
                                var available   = GetAvailableInvocationPointers ().FirstOrDefault ();
                                if (available == IntPtr.Zero)
-                                       throw new NotSupportedException ("No available Java runtime to attach to. Please create one.");
+                                       return null;
+                                       //throw new NotSupportedException ("No available Java runtime to attach to. Please create one.");
                                var options     = new CreationOptions () {
                                        DestroyRuntimeOnDispose = false,
                                        InvocationPointer       = available,
diff --git a/src/Java.Runtime.Environment/Java.Interop/JreRuntime.cs b/src/Java.Runtime.Environment/Java.Interop/JreRuntime.cs
index ea1489f..9ca06b0 100644
--- a/src/Java.Runtime.Environment/Java.Interop/JreRuntime.cs
+++ b/src/Java.Runtime.Environment/Java.Interop/JreRuntime.cs
@@ -72,6 +72,14 @@ namespace Java.Interop {

        public class JreRuntime : JniRuntime
        {
+               public static void Initialize(string path)
+               {
+                       int r = NativeMethods.java_interop_jvm_load (path);
+                       if (r != 0) {
+                               throw new Exception ($"Could not load JVM path `{path}` ({r})!");
+                       }
+               }
+
                static int CreateJavaVM (out IntPtr javavm, out IntPtr jnienv, ref JavaVMInitArgs args)
                {
                        return NativeMethods.java_interop_jvm_create (out javavm, out jnienv, ref args);

另外,OpenJDK11应该也是可用的,不过得注意JavacSourceVersion和JavacTargetVersion=11,由于使用的部分代码还是java8标准所以建议继续JavacSourceVersion=1.8。暂时还没实验jdk11。

文件差不多编辑完了,来编译。

make src/Java.Runtime.Environment/Java.Runtime.Environment.dll.config
make
mono bin/TestDebug/Hello.exe

此时会显示运行成功的样子。如果没成功,那就是我忘了哪个步骤没写(逃)

Hello World!
Part 2!
# JniEnvironment.EnvironmentPointer=94212541059552
vm.SafeHandle=140206052962432
java.lang.Object=0x55af91090e50/L
hashcode=1735600054
WITHIN: GetCreatedJavaVMs: 140206052962432
POST: GetCreatedJavaVMs: 140206052962432

接下来的文章将大致介绍如何在C#中直接调用Java代码,而不是JniType一顿操作。

EFCore查询语句生成流程、让EFCore支持批量Update/Delete/MergeInto

引子

之前发现了一款叫 EFCore.BulkExtensions 的 nuget 包。里面提供了大量的 BulkInsertOrUpdateOrDelete 和 BatchUpdate 的拓展,可以很方便的解决批量更新和删除的问题,不用让 EFCore 一条一条的删除和更新。

其中几个比较有用的函数签名是

Task<int> BatchDeleteAsync(this IQueryable<T> queryable);
Task<int> BatchUpdateAsync(this IQueryable<T> queryable, Expression<Func<T, T>> updateExpression);

但是在升级到 ASP.NET Core 3.1 的时候,所有 Where 中的 someArray.Contains(i.Key) 全部挂掉了。而我的程序里用这一语句比较多,遂下载了其源代码并合并了当时作者几个月都没合并的一个PR。

研究代码,总结了该程序的基本运行过程:

  1. 通过反射获取各种私有变量来访问到 DbContext
  2. updateExpression 由这个包自己访问表达式树获得
  3. 让 IQueryable 执行 GetEnumerator 让 EFCore 生成对应的 Select 语句,进行字符串拼接
  4. 由 DbContext.Database.ExecuteSqlRaw 来完成语句执行

但是这过程有几个问题:

  1. 有几种句式 updateExpression 会翻译不了
  2. 由其原来实现的 updateExpression 翻译后的某些参数的 SQL 类型不对
  3. 我需要一个 INSERT INTO SELECT FROM 的句式,它不支持
  4. 我需要一个 upsert 功能,但是原来的 BulkInsertOrUpdate 不能在原表基础上操作

遂研究 IQueryable.Provider.Execute<T> 是什么执行流程。

语句生成过程

我觉得在翻代码的过程中,有这么一首歌比较符合我的心情:如果你愿意一层一层一层一层的拨开我的心,你会发现,你会讶异,你是我最压抑最深处的秘密。

  1. 调用 QueryCompiler.ExtractParameters,将其中的闭包捕捉变量参数化
  2. 检查是否已经缓存了这个查询表达式,如果没有则转入 QueryCompilationContext 处理,否则转到8
  3. QueryTranslationPreprocessor 处理,在原来的表达式树上先跳舞
  4. QueryableMethodTranslatingExpressionVisitor 将原来的表达式树翻译成一个 ShapedQueryExpression,而这一个表达式则包含了几个部分:SelectExpressionShaperExpressionResultCardinality。其中前者是可以翻译成 SQL 语句的表达式,中间的是将查询出来的元组映射到实体类型,最后一个是查询的维度(Enumerable、Single、SingleOrDefault)
  5. QueryTranslationPostprocessor 处理,其中比较重要的是将查询的字段加入 SELECT 的 Projection 列表
  6. ShapedQueryCompilingExpressionVisitorShapedQueryExpression 缓存,并转换成为 IRelationcalCommandCache,然后构造一个 QueryableEnumerable 的 NewExpression。前者包含了该查询语句需要的参数、查询语法树、查询字符串,后者是进行语句执行的类
  7. 将上述 NewExpression 和将 QueryCompilationContext 中的查询参数加到 QueryContext 中的语句合并成为一个代码块,然后 Lambda Compile
  8. 生成 DbCommandIRelationcalCommandCache 获取字符串并加入各种参数进行查询

翻译结束了,查询到这里也就可以开始了。

支持批量操作?

IRelationalCommandCache 是怎么生成字符串的呢?没错,就是 QuerySqlGenerator 啦。

那么,也就是说,我们能过拿到 Select Expression 的话,一切都好说。

上述过程中,最后的 IRelationalCommandCache 中会包含这个 SelectExpression。我们可以魔改这个啊!

DELETE 语句的生成比较简单。我们构建一个 DeleteExpression 类,将要删除的 Table、删除中的 Predicate、删除个数限制 Limit、原来的一些 Join 全部获取出来,就好了。然后在我们自己继承的 SqlServerQuerySqlGenerator 中实现这个部分。

INSERT INTO SELECT 也比较简单,只要构建一个 InsertIntoSelectExpression 类,将要插入的表 Table 和 SelectExpression 保存起来,就好了。

UPDATE SET 可能比较麻烦。但是我们可以骚操作啊!将那个 updateExpression 变成 Select 的字段,然后再读取 SelectExpression 中的 ProjectionExpression 不就好了吗~我真是个小天才。

MERGE INTO 是最烦的,因为结构过于复杂,涉及到 Target、Source、JoinPredicate、Limit、Matched、NotMatchedByTarget、NotMatchedBySource。过程中还要实现一些表的更名之类的。目前我只是实现了这些,但是想做出 Matched When 功能以后再发布到 nuget 上,这个实现实在是过于复杂,不知道有没有人帮帮我啊 TAT。

由于翻译 SqlExpression 最方便还是基于 QuerySqlGenerator 操作,所以就写一个 EnhancedQuerySqlGenerator 类来满足我们的需求,并在 DbContextOptionsBuilder 那边将这个 Factory 替换掉。

实现了这些,GitHub 地址:Microsoft.EntityFrameworkCore.Bulk,可以在 github packages 上下载目前版本的 nuget 包。

另外 src/Internal/TranslationGoThrough.cs 中有上述语句生成过程的一个缩影,和系统版本几乎一致,唯一不同的是修改了 ExtractParameters 函数。

因为原来的 Extract 过程有一个事情很诡异:在生成参数的时候,我们可以进行一些本地执行,但是如果不阻止某些本地执行程的话,可能会导致 UPDATE 语句的字段全部空。例如 updateExpression 中没有利用到原表的参数并且不捕捉闭包变量的时候,那么不会被本地执行,但是如果没有利用到原表的参数还捕捉闭包变量的时候,它就会被直接本地执行,字段空啦~(确实不懂他们这段代码逻辑怎么写的,你生成查询的时候优化这个的话,怎么不把前面一个也优化掉啊……

ASP.NET Core 修改 EndpointRouting 的链接生成行为

微软在 ASP.NET Core 2.2 时期引入了 EndpointRouting,并且在之后的 3.0 / 3.1 进行了很多的升级改造。我前段时间刚刚把网站升级到了 3.1,之前一直在使用 2.1 的兼容模式,并且内部有很多的属性路由的配置,而没有使用 DefaultRoute 那条规则。按照官方的文档将 UseMvc 那套替换成了 EndpointRouting 那一套。然后发现,很多的链接生成出了问题。

由于之前略微读过 AnchorTagHelper 的源码,大约知道问题出在了 UrlHelper 身上,将。随后发现, 在使用终结点路由后,UrlHelper 的实现类型变成了 EndpointRoutingUrlHelper。而后者则在内部调用了 LinkGenerator,其默认实现为 DefaultLinkGenerator

LinkGenerator 的实现使用到了几个比较重要的对象:EndpointDataSource 是所有终结点的集合,TemplateBinderFactory 是根据终结点的 RoutePattern 生成 TemplateBinder 的工厂,TemplateBinder 是一个保存了 RoutePattern 内部信息(例如默认RouteValues、链接模板)并提供实际链接生成的工具。由于微软在这方面的代码编写中全都 internal 了,且代码中注释较少,在接下来我将介绍他们的基本工作流程,并给出一个能够基本做到“兼容”、不修改太多代码的方法。此处的解释仅仅针对属性路由。

寻找终结点

请关注 GetEndpoints<TAddress>(TAddress address) 函数。此处,TAddress 取值有两种:string、RouteValuesAddress。

当 TAddress 为 string 时,请回顾 RouteAttribute 中的 Name 属性。没错就是这个,在 anchor 链接使用 asp-route=”something” 的时候,路由的查找是根据此处进行的。在生成终结点时,程序已经确保了所有的 RouteName 都唯一。查找直接找字典就好了。

当 TAddress 为 RouteValuesAddress 时,是使用 asp-area、asp-controller、asp-page、asp-action、asp-route-xxx 时进行的寻址方式。此 Address 由三个部分组成,其中包括 RouteName、ExplicitValues、AmbientValues。根据 RoutePattern 中的 RequiredValues 来检查 ExplicitValues 和 AmbientValues 的值。

结束后返回一个终结点列表,其中包含可能匹配的结果。我们针对每个可能的终结点,尝试匹配路由模板。

尝试匹配路由模板

生成一个 TemplateBinder,这个对象提供三个函数:GetValues、TryProcessConstraints、TryBindValues。

GetValues 会根据 RoutePattern 和两种 RouteValue 生成一个最终匹配列表,使用了 Ambient Values Invalidation Algorithm,在接下来介绍。

TryProcessConstraints 是根据上方匹配,检查是否满足路由值的限制。

TryBindValues 是将获取的 RouteValues 生成最后的终结点链接。

隐式路由值失效算法

首先介绍几个字段,在接下来会被算法使用到。

  • _slots 是一个 KVP<string, object>,其 Key 为路由值的名称,Value 为 null。其中前几个字段依次是 pattern 的参数,后几个字段是filter默认值。

  • _requiredKeys 是一个 string 数组,是以 RoutePattern 的字段中提取的。

  • _defaults 是默认路由值的字典。

  • _pattern 是 RoutePattern。

  • ambientValues 是目前页面的路由值字典。

  • values 是即将导航的页面的路由值字典,一般由asp-action等等计算得到。

最开始会将“需要填的空格”复制一份,以供本次计算使用。

  • 首先将所有需要填的空在 values 中寻找一遍,决定使用该值或者 explicit null 值。

  • 考虑针对每个 requiredKeys 是否继续复制隐式路由值。

    • 如果 ambientValues 为空引用,那么不复制。
    • 对于每一个在上一步中确定的显式值,检查和隐式路由值是否相同。检查过程会考虑值相等、any值匹配、显式null值。
  • 对于每一个路由参数,检查是否存在显式值、隐式值。
    • 如果还可以复制隐式值,那么检查此处的显式和隐式值是否匹配。如果不匹配,那么不再复制后面的隐式值。
    • 如果不能再复制隐式值,并且没有显式值,但它确实是路由需要的值,而且存在隐式值,并且和要求的值相等,那么使用它。
    • 如果在以上两种情况中匹配成功,那么将它加入 accepted 匹配成功的路由字典。
    • 如果是可选参数或者通配参数,那么将它从路由字典中忽略。
    • 如果没有匹配但是有默认值,那么使用默认值。
    • 以上过程均没有对应的话,认为此Endpoint匹配失败,不适用于此结果。
  • 对每个filter字段进行检查。
    • 如果存在显式值,检查两者是否相等,如果不相等那么就是匹配失败
    • 如果不存在显式值,那么不加入 accepted 匹配成功的路由字典。
  • 如果存在没有处理的显式值,加入 combined 合并后的路由字典。

  • 对于一些非参数的隐式值,需要加入到 combined 字典,这样可以对路由限制可见。

破坏的部分

以上对于 /[area]/{area_id}/[controller]/[action] 非常的不友好。

可以通过将该 TemplateBinder 的 _requiredValues 清空来达到这一点操作。但是这是 readonly 的 private 字段,所以使用反射解决。

using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing.Template;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;

namespace Microsoft.AspNetCore.Routing
{
    public sealed class OrderLinkGenerator : LinkGenerator, IDisposable
    {
        private readonly LinkGenerator inner;
        private readonly TemplateBinderFactory _binderFactory;
        private readonly Func<RouteEndpoint, TemplateBinder> _createTemplateBinder;
        private readonly FieldInfo _requiredKeys;
        const string typeName = "Microsoft.AspNetCore.Routing.DefaultLinkGenerator";
        internal static Type typeInner;

        public OrderLinkGenerator(
            ParameterPolicyFactory parameterPolicyFactory,
            TemplateBinderFactory binderFactory,
            EndpointDataSource dataSource,
            IOptions<RouteOptions> routeOptions,
            IServiceProvider serviceProvider)
        {
            if (typeInner.FullName != typeName)
                throw new NotImplementedException();
            var logger = serviceProvider.GetService(typeof(ILogger<>).MakeGenericType(typeInner));
            var autoFlag = BindingFlags.NonPublic | BindingFlags.Instance;

            var args = new object[]
            {
                parameterPolicyFactory,
                binderFactory,
                dataSource,
                routeOptions,
                logger,
                serviceProvider
            };

            var ctorInfo = typeInner.GetConstructors()[0];
            var newExp = Expression.New(ctorInfo, args.Select(o => Expression.Constant(o)));
            var ctor = Expression.Lambda<Func<LinkGenerator>>(newExp).Compile();
            inner = ctor();

            _binderFactory = binderFactory;
            _createTemplateBinder = CreateTemplateBinder;
            var fieldInfo = typeInner.GetField(nameof(_createTemplateBinder), autoFlag);
            fieldInfo.SetValue(inner, _createTemplateBinder);

            _requiredKeys = typeof(TemplateBinder).GetField(nameof(_requiredKeys), autoFlag);
        }

        private TemplateBinder CreateTemplateBinder(RouteEndpoint endpoint)
        {
            /*
             * The following code section is disabled
             * for its change to RoutePattern may cause
             * errors.
             * 
             * var rawText = endpoint.RoutePattern.RawText;
             * var rv = endpoint.RoutePattern.RequiredValues as RouteValueDictionary;
             *
             * if (rawText != null)
             * {
             *     var m = Regex.Matches(rawText, "\\{(\\w+)\\}");
             *     for (int i = 0; i < m.Count; i++)
             *         rv.Add(m[i].Value.TrimStart('{').TrimEnd('}'), RoutePattern.RequiredValueAny);
             * }
             * 
             * A better solution is to disable the _requiredKeys.
             */

            var binder = _binderFactory.Create(endpoint.RoutePattern);
            _requiredKeys.SetValue(binder, Array.Empty<string>());
            return binder;
        }

        public override string GetPathByAddress<TAddress>(HttpContext httpContext, TAddress address, RouteValueDictionary values, RouteValueDictionary ambientValues = null, PathString? pathBase = null, FragmentString fragment = default, LinkOptions options = null) =>
            inner.GetPathByAddress(httpContext, address, values, ambientValues, pathBase, fragment, options);
        public override string GetPathByAddress<TAddress>(TAddress address, RouteValueDictionary values, PathString pathBase = default, FragmentString fragment = default, LinkOptions options = null) =>
            inner.GetPathByAddress(address, values, pathBase, fragment, options);
        public override string GetUriByAddress<TAddress>(HttpContext httpContext, TAddress address, RouteValueDictionary values, RouteValueDictionary ambientValues = null, string scheme = null, HostString? host = null, PathString? pathBase = null, FragmentString fragment = default, LinkOptions options = null) =>
            inner.GetUriByAddress(httpContext, address, values, ambientValues, scheme, host, pathBase, fragment, options);
        public override string GetUriByAddress<TAddress>(TAddress address, RouteValueDictionary values, string scheme, HostString host, PathString pathBase = default, FragmentString fragment = default, LinkOptions options = null) =>
            inner.GetUriByAddress(address, values, scheme, host, pathBase, fragment, options);
        public void Dispose() => ((IDisposable)inner).Dispose();
    }
}

最后在依赖注入容器中替换即可。

public static IMvcBuilder ReplaceLinkGenerator(this IMvcBuilder mvc)
{
    var old = mvc.Services.FirstOrDefault(s => s.ServiceType == typeof(LinkGenerator));
    OrderLinkGenerator.typeInner = old.ImplementationType;
    mvc.Services.Replace(ServiceDescriptor.Singleton<LinkGenerator, OrderLinkGenerator>());
    return mvc;
}

ASP.NET Core中使用Basic Authentication

前段时间需要给某个项目接入Web API,并且使用的是基于HTTP Header的Basic Authorzation。

最开始的写法是在appsettings.json里开辟字段存储用户名和密码,然后给对应的Controller加IActionFilter,后来随着项目的升级,想着接入ASP.NET Core自带的用户系统。

翻了翻Microsoft Docs,并没有找到一些科学的内容。后来在NuGet上找到了idunno.Authentication.Basic这样的一个NuGet包。

这是其GitHub Repo上提供的代码示例。

public void ConfigureServices(IServiceCollection services)
{
    services.AddAuthentication(BasicAuthenticationDefaults.AuthenticationScheme)
            .AddBasic(options =>
            {
                options.Realm = "idunno";
                options.Events = new BasicAuthenticationEvents
                {
                    OnValidateCredentials = context =>
                    {
                        if (context.Username == context.Password)
                        {
                            var claims = new[]
                            {
                                new Claim(
                                    ClaimTypes.NameIdentifier, 
                                    context.Username, 
                                    ClaimValueTypes.String, 
                                    context.Options.ClaimsIssuer),
                                new Claim(
                                    ClaimTypes.Name, 
                                    context.Username, 
                                    ClaimValueTypes.String, 
                                    context.Options.ClaimsIssuer)
                            };

                            context.Principal = new ClaimsPrincipal(
                                new ClaimsIdentity(claims, context.Scheme.Name));
                            context.Success();
                        }

                        return Task.CompletedTask;
                    }
                };
            });

    // All the other service configuration.
}

在此之后,给需要使用Basic的Controller添加

[Authorize(AuthenticationSchemes = "Basic")]

即可。


众所周知,ASP.NET Core使用的是基于Claim的认证与授权。

如果想使用自带的用户系统(services.AddIdentity()那套),则直接使用SignInManager就好啦。

那么大致的验证代码(OnValidateCredentials)就是

private static async Task ValidateAsyncOld(ValidateCredentialsContext context)
{
    var signInManager = context.HttpContext.RequestServices
        .GetRequiredService<SignInManager<TUser>>();
    var userManager = signInManager.UserManager;
    var user = await userManager.FindByNameAsync(context.Username);

    if (user == null)
    {
        context.Fail("User not found.");
        return;
    }

    var checkPassword = await signInManager.CheckPasswordSignInAsync(user, context.Password, false);
    if (!checkPassword.Succeeded)
    {
        context.Fail("Login failed, password not match.");
        return;
    }

    context.Principal = await signInManager.CreateUserPrincipalAsync(user);
    context.Success();
}

此后发现验证的性能问题。

显然每次访问API接口都会进行四次数据库查询!(Users一次,UserClaims一次,Roles一次,RoleClaims一次)

在轮询的接口中出现了非常严重的性能问题,毕竟主要程序代码只有一个查询,而用户信息就要查询四次。


不过不要紧,我们还有MemoryCache。

将用户信息缓存在内存中,并且定期清除,让几百次登录同一账户查询才生成一次ClaimsIdentity就会将查询用户信息的时间均摊的更低了。

同时由于我的这个项目不使用UserClaims和RoleClaims,也可以在ClaimsIdentity生成过程中忽略这两个步骤。在用户信息的查询中也可以省略掉一堆一堆一堆一堆不需要的字段。

根据SignInManager、UserManager等的源码,最后挖掘出来了四个接口需要使用

  • IPasswordHasher<TUser>
  • IOptions<IdentityOptions>
  • IdentityDbContext<...>
  • IUserClaimsPrincipalFactory<TUser> (已经展开到最后代码中)

这样就不需要使用UserManager、SignInManager而底层的直接生成ClaimsIdentity和ClaimsPrincipal。

private readonly static IMemoryCache _cache =
    new MemoryCache(new MemoryCacheOptions()
    {
        Clock = new Microsoft.Extensions.Internal.SystemClock()
    });

private static async Task ValidateAsync(ValidateCredentialsContext context)
{
    var dbContext = context.HttpContext.RequestServices
        .GetRequiredService<TContext>();
    var normusername = context.Username.ToUpper();

    var user = await _cache.GetOrCreateAsync("`" + normusername.ToLower(), async entry =>
    {
        var value = await dbContext.Users
            .Where(u => u.NormalizedUserName == normusername)
            .Select(u => new { u.Id, u.UserName, u.PasswordHash, u.SecurityStamp })
            .FirstOrDefaultAsync();
        entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
        return value;
    });

    if (user == null)
    {
        context.Fail("User not found.");
        return;
    }

    var passwordHasher = context.HttpContext.RequestServices
        .GetRequiredService<IPasswordHasher<TUser>>();

    var attempt = passwordHasher.VerifyHashedPassword(
        user: default, // assert that hasher don't need TUser
        hashedPassword: user.PasswordHash,
        providedPassword: context.Password);

    if (attempt == PasswordVerificationResult.Failed)
    {
        context.Fail("Login failed, password not match.");
        return;
    }

    var principal = await _cache.GetOrCreateAsync(normusername, async entry =>
    {
        var uid = user.Id;
        var ur = await dbContext.UserRoles
            .Where(r => r.UserId.Equals(uid))
            .Join(dbContext.Roles, r => r.RoleId, r => r.Id, (_, r) => r.Name)
            .ToListAsync();

        var options = context.HttpContext.RequestServices
            .GetRequiredService<IOptions<IdentityOptions>>().Value;

        // REVIEW: Used to match Application scheme
        var id = new ClaimsIdentity("Identity.Application",
            options.ClaimsIdentity.UserNameClaimType,
            options.ClaimsIdentity.RoleClaimType);
        id.AddClaim(new Claim(options.ClaimsIdentity.UserIdClaimType, $"{user.Id}"));
        id.AddClaim(new Claim(options.ClaimsIdentity.UserNameClaimType, user.UserName));
        id.AddClaim(new Claim(options.ClaimsIdentity.SecurityStampClaimType, user.SecurityStamp));
        foreach (var roleName in ur)
            id.AddClaim(new Claim(options.ClaimsIdentity.RoleClaimType, roleName));
        var value = new ClaimsPrincipal(id);

        entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(20);
        return value;
    });

    context.Principal = principal;
    context.Success();
}

缓存的5分钟/20分钟可以根据需求来修改。

这样就有一个似乎“完美”一点的Basic Authentication啦!

Xamarin.Forms 安卓沉浸式状态栏

一开始用MasterDetailPage做DrawerLayout的时候发现没办法透过状态栏,记录踩坑全过程。

values/style.xmlMainTheme.Base 里面加入:

<item name="android:windowTranslucentStatus">true</item>

然后状态栏是透过去了,但是Toolbar也跟着上去了……

values/style.xmlMainTheme 里面加入:

<item name="actionBarSize">@dimen/action_bar_default_height_material_overlay</item>

action_bar_default_height_material_overlay竖屏76.0dip,横屏68.0dip

layout/Toolbar.axml里加入两个属性

<android.support.v7.widget.Toolbar
...
android:layout_height="?attr/actionBarSize"
android:minHeight="?attr/actionBarSize"
android:paddingTop="12dp"
app:titleMarginTop="24dp"
...
/>

大致是完成了。

后期发现ActionMode的状态栏会变宽,后来这样解决

values/style.xml里面加入:

<style name="MainTheme.ActionMode" parent="Widget.AppCompat.ActionMode">
<item name="height">@dimen/action_mode_default_height_material_overlay</item>
<item name="actionBarSize">@dimen/action_mode_default_height_material_overlay</item>
</style>

values/style.xmlMainTheme.Base里面加入:

<item name="actionModeStyle">@style/MainTheme.ActionMode</item>

action_mode_default_height_material_overlay竖屏56.0dip,横屏48.0dip

业界毒瘤(误)OpenLitespeed

:confused: 前段时间发现伪静态设置一直都不对,后来发现OLS根本没有读取伪静态规则,后台INFO级别没有记录任何。先挂在这里。

但是突然又莫名其妙可以伪静态了,可能是要把日志级别调整为9。后来发现要restart才行,reload还是没有用的。

–2017/7/17更新–

再带点今晚读OLS的admin网站源码的感受吧。

OLS的配置文件是一种很奇特的格式,

继续阅读“业界毒瘤(误)OpenLitespeed”