使用反射构建 EF Core 查询比使用反射更快

Using Reflection to build EF Core query is faster than to using reflection

我有一个 IQueryable 扩展方法,用于减少在 EF Core DbContext 模型中搜索多个字段所需的样板代码量:

public static IQueryable<TEntity> WherePropertyIsLikeIfStringIsNotEmpty<TEntity>(this IQueryable<TEntity> query,
    string searchValue, Expression<Func<TEntity, string>> propertySelectorExpression)
{
    if (string.IsNullOrEmpty(searchValue) || !(propertySelectorExpression.Body is MemberExpression memberExpression))
    {
        return query;
    }
    
    // get method info for EF.Functions.Like
    var likeMethod = typeof(DbFunctionsExtensions).GetMethod(nameof(DbFunctionsExtensions.Like), new []
    {
        typeof(DbFunctions),
        typeof(string),
        typeof(string)
    });
    var searchValueConstant = Expression.Constant($"%{searchValue}%");
    var dbFunctionsConstant = Expression.Constant(EF.Functions);
    var propertyInfo = typeof(TEntity).GetProperty(memberExpression.Member.Name);
    var parameterExpression = Expression.Parameter(typeof(TEntity));
    var propertyExpression = Expression.Property(parameterExpression, propertyInfo);
    
    
    var callLikeExpression = Expression.Call(likeMethod, dbFunctionsConstant, propertyExpression, searchValueConstant);
    var lambda = Expression.Lambda<Func<TEntity, bool>>(callLikeExpression, parameterExpression);
    return query.Where(lambda);
}

代码正在运行并产生了预期的结果,但是我担心我会因为使用表达式和一些反射而受到性能影响。因此,我使用内存数据库和 BenchmarkDotNet nuget 包设置了基准测试。这是基准:

using System;
using System.Collections.Generic;
using System.Linq;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using Microsoft.EntityFrameworkCore;

class Program
    {
        static void Main(string[] args)
        {
            BenchmarkRunner.Run<Benchmark>();
        }
    }

    public class Benchmark
    {
        private Context _context;
        private string SearchValue1 = "BCD";
        private string SearchValue2 = "FG";
        private string SearchValue3 = "IJ";
        
        [GlobalSetup]
        public void Setup()
        {
            _context = new Context(new DbContextOptionsBuilder<Context>().UseInMemoryDatabase(Guid.NewGuid().ToString())
                .Options);

            _context.TestModels.Add(new TestModel(1, "ABCD", "EFGH", "HIJK"));
            _context.SaveChanges();
        }

        [GlobalCleanup]
        public void Cleanup()
        {
            _context.Dispose();
        }
        
        [Benchmark]
        public void FilterUsingExtension()
        {
            var _ = _context.TestModels
                .WherePropertyIsLikeIfStringIsNotEmpty(SearchValue1, testModel => testModel.Value)
                .WherePropertyIsLikeIfStringIsNotEmpty(SearchValue2, testModel => testModel.OtherValue)
                .WherePropertyIsLikeIfStringIsNotEmpty(SearchValue3, testModel => testModel.ThirdValue)
                .ToList();
        }

        [Benchmark]
        public void FilterTraditionally()
        {
            var query = _context.TestModels.AsQueryable();
            if (!string.IsNullOrEmpty(SearchValue1))
            {
                query = query.Where(x => EF.Functions.Like(x.Value, $"%{SearchValue1}%"));
            }
            if (!string.IsNullOrEmpty(SearchValue2))
            {
                query = query.Where(x => EF.Functions.Like(x.OtherValue, $"%{SearchValue2}%"));
            }
            if (!string.IsNullOrEmpty(SearchValue3))
            {
                query = query.Where(x => EF.Functions.Like(x.ThirdValue, $"%{SearchValue3}%"));
            }
        
            var _ = query.ToList();
        }
    }

    public class TestModel
    {
        public int Id { get; }
        public string Value { get; }
        public string OtherValue { get; }
        public string ThirdValue { get; }

        public TestModel(int id, string value, string otherValue, string thirdValue)
        {
            Id = id;
            Value = value;
            OtherValue = otherValue;
            ThirdValue = thirdValue;
        }
    }
    
    public class Context : DbContext
    {

        public Context(DbContextOptions<Context> options)
            : base(options)
        {
            
        }
        
        // ReSharper disable once UnusedAutoPropertyAccessor.Global
        public DbSet<TestModel> TestModels { get; set; }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            base.OnModelCreating(modelBuilder);

            modelBuilder.Entity<TestModel>().ToTable("test_class", "test");
            modelBuilder.Entity<TestModel>().Property(x => x.Id).HasColumnName("id").HasColumnType("int");
            modelBuilder.Entity<TestModel>().Property(x => x.Value).HasColumnName("value").HasColumnType("varchar")
                .ValueGeneratedNever();
            modelBuilder.Entity<TestModel>().Property(x => x.OtherValue).HasColumnName("other_value").HasColumnType("varchar")
                .ValueGeneratedNever();
            modelBuilder.Entity<TestModel>().Property(x => x.ThirdValue).HasColumnName("third_value").HasColumnType("varchar")
                .ValueGeneratedNever();
            modelBuilder.Entity<TestModel>().HasKey(x => x.Id);
        }
    }

就像我说的,我预计使用反射会导致性能下降。但是基准测试表明,通过我的扩展方法构建的查询比直接在 Where 方法中直接编写表达式快 10 倍以上:

|               Method |        Mean |     Error |    StdDev |      Median |
|--------------------- |------------:|----------:|----------:|------------:|
| FilterUsingExtension |    73.73 us |  1.381 us |  3.310 us |    72.36 us |
|  FilterTraditionally | 1,036.60 us | 20.494 us | 22.779 us | 1,032.69 us |

谁能解释一下?

很快,差异来自 EF.Functions.Likepattern 参数的不同表达式,以及 LINQ to Objects(由 EF Core InMemory 提供程序使用)处理 IQueryable 表达式的方式树.

首先,使用 EF Core InMemory 提供程序针对小型数据集进行的性能测试是无关紧要的,因为它基本上是在测量查询表达式树的构建,而在真实数据库的情况下,大部分时间是在执行生成的SQL查询、返回并具体化结果数据集。

其次,关于

I was worried that I would get a performance hit for using Expressions and a bit of reflection

两种方法都使用 Expression class 方法在运行时 构建查询表达式树。唯一的区别是 C# 编译器在编译时为您生成该代码,因此没有反射调用。但是您的代码也可以很容易地修改以避免反射,从而使生成完全等效。

更重要的区别是您的代码发出 ConstantExpression,而当前 C# 编译器无法从变量生成常量表达式,因此它总是发出闭包,而闭包又被绑定为查询参数EF Core 查询转换器。通常建议将其用于 SQL 查询,因此您最好在您的方法中执行相同的操作,或者可以选择这样做。

因此,简要回顾一下,您的方法绑定了常量表达式,而编译器方法绑定了闭包。但不仅如此。看这里

query.Where(x => EF.Functions.Like(x.Value, $"%{SearchValue1}%"))

SearchValue1 变量被转换为闭包,但由于 $"%{SearchValue1}%" 表达式 的一部分,它不会在此时被计算,但被记录为 MethodCallExpressionstring.Format.

这两个在 LINQ to Objects 中给出了很大的性能差异,因为它通过首先编译表达式来委托来执行查询表达式树,然后 运行 它。所以最后你的代码传递常量值,编译器生成查询代码调用 string.Format。而且两者在compilation/execution时间上相差很大。在你的测试中乘以 3。


话虽如此,让我们看看实际效果。

一、优化后的扩展方法,一次性静态反射信息缓存,常量或变量使用选项:

public static IQueryable<TEntity> WhereIsLikeIfStringIsNotEmpty<TEntity>(
    this IQueryable<TEntity> query,
    string searchValue,
    Expression<Func<TEntity, string>> selector,
    bool useVariable = false)
{
    if (string.IsNullOrEmpty(searchValue)) return query;
    var parameter = selector.Parameters[0];
    var pattern = Value($"%{searchValue}%", useVariable);
    var body = Expression.Call(LikeMethod, DbFunctionsArg, selector.Body, pattern);
    var predicate = Expression.Lambda<Func<TEntity, bool>>(body, parameter);
    return query.Where(predicate);
}

static Expression Value(string value, bool variable)
{
    if (!variable) return Expression.Constant(value);
    return Expression.Property(
        Expression.Constant(new StringVar { Value = value }),
        StringVar.ValueProperty);
}

class StringVar
{
    public string Value { get; set; }
    public static PropertyInfo ValueProperty { get; } = typeof(StringVar).GetProperty(nameof(Value));
}

static Expression DbFunctionsArg { get; } = Expression.Constant(EF.Functions);

static MethodInfo LikeMethod { get; } = typeof(DbFunctionsExtensions).GetMethod(nameof(DbFunctionsExtensions.Like), new[]
{
    typeof(DbFunctions),
    typeof(string),
    typeof(string)
});

请注意,我从方法名称中删除了 PropertyMemberExpression 的要求,因为不需要它 - 该方法将适用于任何 string 返回表达式。

其次,为其添加两个新的基准测试方法:


[Benchmark]
public void FilterUsingExtensionOptimizedUsingConstant()
{
    var _ = _context.TestModels
        .WhereIsLikeIfStringIsNotEmpty(SearchValue1, testModel => testModel.Value, false)
        .WhereIsLikeIfStringIsNotEmpty(SearchValue2, testModel => testModel.OtherValue, false)
        .WhereIsLikeIfStringIsNotEmpty(SearchValue3, testModel => testModel.ThirdValue, false)
        .ToList();
}

[Benchmark]
public void FilterUsingExtensionOptimizedUsingVariable()
{
    var _ = _context.TestModels
        .WhereIsLikeIfStringIsNotEmpty(SearchValue1, testModel => testModel.Value, true)
        .WhereIsLikeIfStringIsNotEmpty(SearchValue2, testModel => testModel.OtherValue, true)
        .WhereIsLikeIfStringIsNotEmpty(SearchValue3, testModel => testModel.ThirdValue, true)
        .ToList();
}

最后,为“传统方式”的优化版本添加基准,它避免了表达式树中的string.Format(但仍然绑定变量):

[Benchmark]
public void FilterTraditionallyOptimized()
{
    var query = _context.TestModels.AsQueryable();
    if (!string.IsNullOrEmpty(SearchValue1))
    {
        var pattern = $"%{SearchValue1}%";
        query = query.Where(x => EF.Functions.Like(x.Value, pattern));
    }
    if (!string.IsNullOrEmpty(SearchValue2))
    {
        var pattern = $"%{SearchValue2}%";
        query = query.Where(x => EF.Functions.Like(x.OtherValue, pattern));
    }
    if (!string.IsNullOrEmpty(SearchValue3))
    {
        var pattern = $"%{SearchValue3}%";
        query = query.Where(x => EF.Functions.Like(x.ThirdValue, pattern));
    }

    var _ = query.ToList();
}

结果:

Method Mean Error StdDev
FilterUsingExtension 51.84 us 0.089 us 0.079 us
FilterUsingExtensionOptimizedUsingConstant 48.95 us 0.061 us 0.054 us
FilterUsingExtensionOptimizedUsingVariable 58.40 us 0.354 us 0.331 us
FilterTraditionally 625.40 us 1.269 us 1.187 us
FilterTraditionallyOptimized 60.09 us 0.491 us 0.435 us

正如我们所见,使用常量的优化扩展方法是最快的,但与您的原始方法非常接近(这意味着反射不是必需的)。

带有变量的变体有点慢,但在用于真实数据库时通常会更好。

优化后的“传统”方法比前两种慢了点,有点意外,但差别可以忽略不计。

由于上述原因,原来的“传统”方法比以前的所有方法都慢。但是对于真实的数据库来说,它在整个查询执行中的作用可以忽略不计。