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

MrW*_*rus 2 c# reflection expression entity-framework-core .net-core

我有一个 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);
}
Run Code Online (Sandbox Code Playgroud)

代码正在运行并产生了预期的结果,但是我担心使用表达式和一些反射会影响性能。所以我使用内存数据库和 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);
        }
    }
Run Code Online (Sandbox Code Playgroud)

就像我说的,我期待使用反射的性能损失。但基准测试表明,由我的扩展方法构建的查询比直接在 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 |

Run Code Online (Sandbox Code Playgroud)

任何人都可以对此做出解释吗?

Iva*_*oev 5

简而言之,不同之处在于pattern参数的不同表达式EF.Functions.Like,以及 LINQ to Objects(由 EF Core InMemory 提供程序使用)处理IQueryable表达式树的方式。

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

二、关于

我担心我会因为使用表达式和一些反思而受到影响

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

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

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

query.Where(x => EF.Functions.Like(x.Value, $"%{SearchValue1}%"))
Run Code Online (Sandbox Code Playgroud)

SearchValue1变量被转换成闭合,但由于$"%{SearchValue1}%"是的一部分表达,这不是在该点进行评价,但被记录为MethodCallExpressionstring.Format

这两者在 LINQ to Objects 中提供了很大的性能差异,因为它通过首先将表达式编译为委托,然后运行它来执行查询表达式树。所以最后你的代码传递常量值,编译器生成查询代码调用string.Format。并且两者在编译/执行时间上存在很大差异。在您的测试中乘以 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)
});
Run Code Online (Sandbox Code Playgroud)

请注意,我Property从方法名称中删除了和 的要求MemberExpression,因为它不需要 - 该方法将适用于任何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();
}

Run Code Online (Sandbox Code Playgroud)

最后,为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();
}

Run Code Online (Sandbox Code Playgroud)

结果:

方法 意思 错误 标准差
过滤器使用扩展 51.84 美元 0.089 美元 0.079 美元
FilterUsingExtensionOptimizedUsingConstant 48.95 美元 0.061 美元 0.054 美元
FilterUsingExtensionOptimizedUsingVariable 58.40 美元 0.354 美元 0.331 美元
传统过滤 625.40 美元 1.269 美元 1.187 美元
过滤器传统优化 60.09 美元 0.491 美元 0.435 美元

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

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

优化后的“传统”方法比前两种慢一点,这有点令人惊讶,但差异可以忽略不计。

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