检查和变异可查询表达式树

目录

提供程序

表达式转换器

查询拦截提供程序

查询快照

查询“护栏”

拦截二进制评估

捕获实体

Lambda表达式

包装二进制表达式

结论


在本文中,我将创建一个混合提供程序,该提供程序将拦截该过程,以提供一个检查和/或变异表达式树的钩子。然后,它将修改后的表达式传递给原始提供程序。然后,我们看一下自定义提供程序。

我使用一个特殊的查询主机,演示了如何迭代表达式树,甚至对其进行修改,从而使查询的执行方式有所不同。问题在于该方法受到限制,因为检查/修改仅在您控制端到端查询时才起作用。当查询被传递,扩展和/或再次运行时,您将失去控制和见识。像EF Core这样的产品如何让您编写所需的查询,然后成功拦截它们以运行SQL命令?秘密在提供程序中。

为了帮助解释这些概念,我构建了一个示例项目,您可以在此处浏览和下载:

查询主机实际上只是IQueryable的实现。接口上的基本属性包括:

  1. 查询要查询的ElementType
  2. 一个代表查询的定义/结构的Expression
  3. 实现表达式和实现结果在Provider

将查询主机视为构建查询的容器。这就是定义IQueryProvider是实现。接口上的主要方法应该可以证明这一点:

  1. CreateQuery负责产生IQueryable表达式代表
  2. Execute 负责运行查询

只要您正在定义主机的过滤器、投影、排序和查询的其他部分(作为启发,您可以查看各种Queryable方法)。您开始迭代查询结果的那一刻,提供程序就起作用了。对于默认的“LINQ to Objects”,这涉及对内存中的对象进行排序和过滤。对于EF Core,这涉及生成对所使用的提供程序有意义的数据库语句,例如SQL

一般流程如下所示:

  1. 构建查询:对IQueryable主机应用扩展方法
  2. 执行查询,即开始迭代或调用 ToList()
    1. 为了提供结果,在查询主机上调用GetEnumerator()
    2. 这导致对提供程序的CreateQuery()调用
    3. 最终,执行表达式树以返回结果
  3. 每当遇到新类型时(即,当您有子查询或将项目结果转换为匿名类型时),都会使用该新类型进行另一个CreateQuery调用。

提供程序

我还没有准备建立一个完整的提供程序。相反,我创建了一个混合提供程序,该提供程序拦截该过程以提供一个检查和/或变异表达式树的钩子。然后,它将修改后的表达式传递给原始提供程序。这样一来,它就可以与LINQ to Objects等内置提供程序以及EF Core等外部提供程序一样良好地工作。基本提供程序的接口如下所示:

public interface ICustomQueryProvider<T> : IQueryProvider
{
    IEnumerable<T> ExecuteEnumerable(Expression expression);
}

这只是主机使用表达式调用的自定义方法。这就是拦截的地方。

抽象基类负责默认实现:

public abstract class CustomQueryProvider<T> : ICustomQueryProvider<T>
{
    public CustomQueryProvider(IQueryable sourceQuery)
    {
        Source = sourceQuery;
    }

    protected IQueryable Source { get; }

    public abstract IQueryable CreateQuery(Expression expression);

    public abstract IQueryable<TElement> CreateQuery<TElement>(Expression expression);

    public virtual object Execute(Expression expression)
    {
        return Source.Provider.Execute(expression);
    }

    public virtual TResult Execute<TResult>(Expression expression)
    {
        object result = (this as IQueryProvider).Execute(expression);
        return (TResult)result;
    }

    public virtual IEnumerable<T> ExecuteEnumerable(Expression expression)
    {
        return Source.Provider.CreateQuery<T>(expression);
    }
}

定制提供程序有两个关键功能:它捕获原始查询(因此是原始提供程序),并且在调用execute方法时,它将表达式传递给原始提供程序。

查询主机和提供程序齐头并进。这是准备使用新提供程序的QueryHost定义(尚未定义):

public class QueryHost<T> : IQueryHost<T, IQueryInterceptingProvider<T>>
{
    public QueryHost(
        IQueryable<T> source)
    {
        Expression = source.Expression;
        CustomProvider = new QueryInterceptingProvider<T>(source);
    }

    public QueryHost(
        Expression expression,
        QueryInterceptingProvider<T> provider)
    {
        Expression = expression;
        CustomProvider = provider;
    }

    public virtual Type ElementType => typeof(T);

    public virtual Expression Expression { get; }

    public IQueryProvider Provider => CustomProvider;

    public IQueryInterceptingProvider<T> CustomProvider { get; protected set; }

    public virtual IEnumerator<T> GetEnumerator() =>
        CustomProvider.ExecuteEnumerable(Expression).GetEnumerator();
}

要注意的主要功能是在构造函数调用中捕获自定义提供程序,并将其GetEnumerator实现的请求传递给自定义提供程序。定制提供程序是什么样的?

表达式转换器

要拦截和修改查询,需要一个函数,该函数接受原始表达式并返回变异的表达式。这是转换的定义:

public delegate Expression ExpressionTransformer(Expression source);

提供程序需要注意转换。这是定义如何注册的接口:

public interface IQueryInterceptor
{
    void RegisterInterceptor(ExpressionTransformer transformation);
}

现在进入拦截提供程序

查询拦截提供程序

这是拦截提供程序的代码:

public class QueryInterceptingProvider<T> :
    CustomQueryProvider<T>, IQueryInterceptingProvider<T>
{
    private ExpressionTransformer transformation = null;

    public QueryInterceptingProvider(IQueryable sourceQuery)
        : base(sourceQuery)
    {
    }

    public override IQueryable CreateQuery(Expression expression)
    {
        return new QueryHost<T>(expression, this);
    }

    public override IQueryable<TElement> CreateQuery<TElement>(Expression expression)
    {
        if (typeof(TElement) == typeof(T))
        {
            return CreateQuery(expression) as IQueryable<TElement>;
        }

        var childProvider = new QueryInterceptingProvider<TElement>(Source);

        return new QueryHost<TElement>(
            expression, childProvider);
    }

    public void RegisterInterceptor(ExpressionTransformer transformation)
    {
        if (this.transformation != null)
        {
            throw new InvalidOperationException();
        }

        this.transformation = transformation;
    }

    public override object Execute(Expression expression)
    {
        return Source.Provider.Execute(TransformExpression(expression));
    }

    public override IEnumerable<T> ExecuteEnumerable(Expression expression)
    {
        return base.ExecuteEnumerable(TransformExpression(expression));
    }

    private Expression TransformExpression(Expression source) =>
        transformation == null ? source :
        transformation(source);
}

发生什么了?简而言之,定制提供程序将执行三项操作。

  1. CreateQuery被调用时,它确保新的查询也使用自定义提供。如果类型相同,则使用相同的提供程序实例,否则将创建一个新的提供程序实例。
  2. 它实现了接口来注册转换并将其存储为属性。这样可以确保仅注册一次转换。
  3. 调用execute方法时,它将转换表达式,然后再将其传递给源提供程序。

就是这样!查询主机使用自定义提供程序。定制提供程序可确保创建的所有新查询主机也使用定制提供程序。它还在将表达式传递给原始提供程序之前先对其进行转换。

查询快照

基本的构建块已经就绪,我们已经准备好测试实现。我正在使用Thing生成随机属性和半真相来创建一个不错的价差进行查询。

public class Thing
{
    private static readonly Random Random = new Random();
    public string Id { get; private set; } = Guid.NewGuid().ToString();
    public int Value { get; private set; } = Random.Next(int.MinValue, int.MaxValue);
    public DateTime Created { get; private set; } = DateTime.Now;
    public DateTime Expires { get; private set; } = DateTime.Now.AddDays(Random.Next(1, 999));
    public bool IsTrue { get; private set; } = Random.NextDouble() < 0.5;
}

静态方法会生成10,000things。接下来,我设置了拦截器。第一次拦截不会更改表达式。它只是将其写入控制台(ToString()默认覆盖提供了整个表达式树的非常清晰的表示)。

static Expression ExpressionTransformer(Expression e)
{
    Console.WriteLine(e);
    return e;
}

接下来,API设置要使用的查询。它将其包装在查询主机中并注册转换。现在可以使用查询了。

var query = new QueryHost<Thing>(ThingDbQuery);
query.CustomProvider.RegisterInterceptor(ExpressionTransformer);

只需向消费者显示IQueryable<Thing>即可构建查询(通过QueryHost来实现)。

这是一个立即解析为列表的查询:

var list = query.Where(t => t.IsTrue &&
    t.Id.Contains("aa") &&
    t.Expires < DateTime.Now.AddDays(100))
    .OrderBy(t => t.Id).ToList();

Console.WriteLine($"Retrieved {list.Count()} items.");

运行此命令将产生以下输出:

如果您进入调试模式,您将看到提供程序已被调用并立即转换表达式,从而将结果输出到控制台。就是拦截了。接下来,那么变异呢?

查询护栏

暴露IQueryable的挑战是事实,实际上可以应用任何类型的表达链。当滥用查询时,例如返回大型记录集或执行复杂的联接会对性能产生负面影响时,这可能会成为问题。如果您可以执行一个简单的规则,例如此查询可能永远不会返回十个以上的项目,该怎么办?

这是一个没有限制的查询——在上一个示例中,该查询返回了53个结果。

var results = query.Where(t => t.IsTrue &&
    t.Id.Contains("aa") &&
    t.Expires < DateTime.Now.AddDays(100))
    .OrderBy(t => t.Id);

这是注册:

static Expression ExpressionTransformer(Expression e)
{
    Console.WriteLine($"Before: {e}");

    var newExpression = new GuardRailsExpressionVisitor().Visit(e);

    Console.WriteLine($"After: {newExpression}");
    return newExpression;
}

// wrap and intercept
var query = new QueryHost<Thing>(ThingDbQuery);
query.CustomProvider.RegisterInterceptor(ExpressionTransformer);

请注意,该表达式已传递到GuardRailsExpressionVisitorExpressionVisitor类提供了一种遍历和修改表达式树的简便方法。该树由基类递归迭代,然后调用可以重写以检查树上节点的方法。被覆盖的方法将返回原始表达式或修改后的表达式,从而导致树的转换。要实现护栏,有以下三种可能性:

  1. “take”存在但少于十(无变化)
  2. “take”存在但大于十(“take”修改为10
  3. “take”不存在(添加了“take of 10”

此实现很简单,并且假定正在使用常量。请记住,可靠的实现必须处理边缘情况,例如内部查询中的嵌套“take”语句。其他边缘情况包括使用lambda表达式引用属性并使用方法调用结果而不是常量的语句。

当您在查询上指定“take”时,实际发生的是调用Queryable类上的扩展方法IQueryable。这样的查询:

var results = query.Take(10);

使用如下扩展方法:

public static IQueryable<TSource> Take<TSource>(this IQueryable<TSource> source, int count)
{
    if (source == null)
    {
        throw Error.ArgumentNull(nameof(source));
    }

    return source.Provider.CreateQuery<TSource>(
        Expression.Call(
            null,
            IQueryable<object>(Queryable.Take).GetMethodInfo()
                .GetGenericMethodDefinition().MakeGenericMethod(TSource),
            source.Expression,
            Expression.Constant(count)));
}

这是拦截方法调用并强制执行“take”规则的代码:

protected override Expression VisitMethodCall(MethodCallExpression node)
{
    if (node.Method.Name == nameof(Queryable.Take))
    {
        TakeFound = true;

        if (node.Arguments[1] is ConstantExpression constant)
        {
            if (constant.Value is int valueInt)
            {
                if (valueInt > 10)
                {
                    var expression = node.Update(
                        node.Object,
                        new[] { node.Arguments[0] }
                        .Append(Expression.Constant(10)));
                    return expression;
                }
            }
        }
    }

    return base.VisitMethodCall(node);
}

仅当值超出范围时,才覆盖该表达式。该Update方法(适用于大多数表达式)无需从头开始重建原始表达式,而是使您可以轻松地创建表达式的副本并覆盖所需的参数。第一个参数指向父查询,因此第二个参数是我们覆盖到新值的参数。运行此操作将导致以下结果:

请注意,修改了较高的50,而修改较低的5则保持不变。这对于替换现有工单非常有效,但是如果没有指定任何工单怎么办?

解决方案在这里:

public override Expression Visit(Expression node)
{
    if (first)
    {
        first = false;
        var expr = base.Visit(node);

        if (TakeFound)
        {
            return expr;
        }

        var existing = expr as MethodCallExpression;

        var newExpression = Expression.Call(
            typeof(Queryable),
            nameof(Queryable.Take),
            existing.Method.ReturnType.GetGenericArguments(),
            existing,
            Expression.Constant(10));
        return newExpression;
    }

    return base.Visit(node);
}

请记住,这Visit是递归调用的,因此first标志确保顶级逻辑仅运行一次。解析表达式树,如果遇到了take表达式,则仅返回修改后的表达式。否则,它将创建take表达式。顶级表达式始终是返回结果的调用。要实现take,我们只需创建自己的方法即可,将take应用于包装的方法并返回结果。故事通过Expression.Call参数讲述。

  1. typeof(Queryable)是定义Takestatic
  2. nameof(Queryable.Take)是调用(Take<T>())的方法的名称
  3. existing.Method.ReturnType.GetGenericArguments()关闭泛型类型,因为Take需要类型参数。该查询是一个IQueryable<T>查询,因此现有方法必须实现T为泛型参数(在本例中T Thing)。
  4. existing是第一个参数,或扩展方法适用于Take的参数。这是原始表达式,因此该表达式的结果就是将要应用的Take表达式。
  5. Expression.Constant(10)是要发送到Take的项目数。

您可以在此处看到Take添加的内容,并确认结果集是否有限。

该表达式已被截取并转换!对于下一个(也是最后一个)示例,我解决了一个实际问题。

拦截二进制评估

我们的一个客户问,是否有一种方法可以理解为什么在执行查询时为什么选择了某些项目而将其他项目过滤掉了。换句话说,对于每个项目,查询失败的部分是什么?解决方案很棘手。例如,如果将查询发送到数据库,则实际上没有任何拦截的决定:EF Core提供程序将创建SQL语法并将其传递给数据库。对于内存中选择,可以捕获决策。首先,我创建了一个BinaryInterceptorVisitor<T>,其用于转换给定类型的表达式树。

使用过滤器构建查询时,where子句将成为一组二进制表达式。例如,以下查询:

var list = query.Where(
    t => t.IsTrue &&
        t.Id.Contains("aa") &&
        t.Expires < DateTime.Now.AddDays(100))
    .OrderBy(t => t.Id).ToList();

最终看起来像这样的过滤器:

AndAlso(t.IsTrue,
    AndAlso(
        t.Id.Contains("aa"),
        IsLessThan(
            t.Expires,
            DateTime.Now.AddDays(500))))

首先,让我们捕获t的值。

捕获实体

我添加了一个为捕获要评估的实体的名为instance的字段。为了简单起见,我只将其声明为一个对象:

private static object instance;

我创建了一个设置实例的方法:

private static void SetInstance(object instance)
{
    BinaryInterceptorVisitor<T>.instance = instance;
}

为了使构建调用该方法的表达式更加容易,我添加了一些帮助程序方法,这些方法可以解决方法信息的数据结构,称为MethodInfo这些是找到static方法所需的标志:

private static readonly BindingFlags GetStatic =
    BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static;

这是获取MethodInfo数据的方法:

private static MethodInfo GetMethod(string methodName) =>
    typeof(BinaryInterceptorVisitor<T>).GetMethod(methodName, GetStatic);

这是该方法的参考:

private static readonly MethodInfo SetInstanceMethod = GetMethod(nameof(SetInstance));

Lambda表达式

查询的where子句使用以下签名构建为LambdaExpression

Func<T, bool> Where;

每个实体都传递到筛选器中,最终筛选器是否通过。这是拦截lambda表达式的重载:

protected override Expression VisitLambda<TValue>(Expression<TValue> node)

要拦截的表达式具有特定的签名。它将只有一个类型的参数T和一个类型的返回值bool

if (node.Parameters.Count == 1 &&
    node.Parameters[0].Type == typeof(T) &&
    node.ReturnType == typeof(bool))

截取正确的lambda表达式后,我们的代码会潜入以获取实体:

var returnTarget = Expression.Label(typeof(bool));

var lambda = node.Update(
    Visit(node.Body),
    node.Parameters.Select(p => Visit(p)).Cast<ParameterExpression>());

var innerInvoke = Expression.Return(
    returnTarget, Expression.Invoke(lambda, lambda.Parameters));

var expr = Expression.Block(
    Expression.Call(SetInstanceMethod, node.Parameters),
    innerInvoke,
    Expression.Label(returnTarget, Expression.Constant(false)));

return Expression.Lambda<Func<T, bool>>(
    expr,
    node.Parameters);

拦截类型的关键是使用BlockExpression。此表达式使您可以组合顺序运行的多个表达式。结果只使用最终表达式的结果(尽管您可以使用变量捕获其他表达式的结果)。代码中的步骤如下所示:

  1. 创建一个返回目标。将其视为捕获原始lambda表达式的返回值的变量。用表达的话来说,这是一个标签
  2. 通过使用Update和访问其部分(表达式的主体和参数)来转换lambda 。这很重要,因为主体包含也会被转换的二进制表达式,因此必须替换lambda表达式本身。
  3. 创建一个调用原始lambda表达式并捕获其返回值的调用。
  4. 创建一个表达式块:
    1. 第一个表达式从lambda获取参数,并使用它们来调用SetInstance方法。参数是要评估的实体,因此可以有效地捕获该实体以进行检查。
    2. 第二个表达式是原始lambda表达式的调用。
    3. 最终参数是对返回值的绑定,默认值为false。这始终会被原始表达式的实际结果覆盖。
  5. 最后,返回一个lambda表达式,其签名与原始签名相同。

该代码采用以下方式:

Func<T, bool> lambda = t => Where(t);

并将其转换为:

Func<T, bool> lambda = t => 
{
    BinaryInterceptorVisitor<T>.SetInstance(t);
    return Where(t);
};

现在已经捕获了该值,下一步是了解哪些二进制表达式成功或失败。

包装二进制表达式

理解如何捕获结果的关键是合乎逻辑的。很合逻辑。如逻辑门。现在,我们有一个Func<T,bool>可以是truefalse。诀窍是注入我们自己的代码,该代码将在评估规则之前捕获该规则,然后捕获它是true还是false的结果。必须仅使用二进制表达式来完成此操作。这是一些伪代码:

  1. 抓取规则
  2. 如果规则成功,则捕获成功
  3. 否则,如果规则失败,请捕获失败

逻辑提供了一种以保留原始值的方式包装表达式的方法。让我们回顾一下基本的逻辑语句:

逻辑OR left OR right=OrElse(left, right)

  1. If left
    1. Then true
  2. Else
    1. If right
      1. Then true
      2. Else false

请注意,只有leftfalse时,right才运行。本质上,将其视为leftsfalse时,穿过到right这是我们在调用表达式之前对其进行拦截的方式:

OrElse([our code => return false], [existing code]);

……以及另一条逻辑语句:

逻辑AND left AND right =AndAlso(left, right)

If left

  1. Then
    1. If right
      1. Then true
      2. Else false
  2. Else false

基本上,您可以将其视为OR翻转,因为leftstrue时,穿过到right

这使获得成功变得容易:

AndAlso([existing code], [our code => return true]);

但是我们如何捕捉失败?我们需要的是在先前的逻辑返回false时调用代码以进行失败:

OrElse(AndAlso..., [our code => return false]);

因此,完全拦截需要四个部分:

  1. binary ——截取的原始规则
  2. orLeft ——规则运行前的快照
  3. andRight ——捕获成功情况
  4. orRight ——捕获失败情况

这是替换现有表达式的实际代码:

return Expression.OrElse(
    orLeft,
    Expression.OrElse(
        Expression.AndAlso(binary, andRight),
        orRight));

那么,orLeft和其他拦截器的实现是什么样的呢?定义了两个字段来捕获嵌套级别:

private static int evalLevel = 0;
private int binaryLevel = 0;

binaryLevel解析表达式时跟踪级别。在定义阶段,随着结构的更新,将使用它。evalLevel在运行时或实现阶段跟踪嵌套的级别。

BeforeEval方法检查是否在顶层。如果是这样,它将写入由lambda表达式覆盖捕获的当前实体的值,并重置评估级别。否则,它将增加级别。然后,它编写规则。为便于阅读,会根据当前级别缩进文本。

public static void BeforeEval(int binaryLevel, string node)
{
    if (binaryLevel == 1)
    {
        Console.WriteLine($"with {instance} => {{");
        evalLevel = 0;
    }
    else
    {
        evalLevel++;
    }

    Console.WriteLine($"{Indent}[Eval {node}: ");
}

AfterEval方法写出结果(成功或失败)并降低级别。如果求值在顶部表达式之后,它也会写一个右括号。

public static void AfterEval(int binaryLevel, bool success)
{
    var result = success ? "SUCCESS" : "FAILED";

    Console.WriteLine($"{Indent}{result}]");

    evalLevel--;

    if (binaryLevel == 1)
    {
        Console.WriteLine("}");
    }
}

BinaryExpression重写方法中,使用适当的参数构建方法调用:

binaryLevel++;

var before = Expression.Call(
    BeforeEvalMethod,
    Expression.Constant(binaryLevel),
    Expression.Constant($"{node}"));

var afterSuccess = Expression.Call(
    AfterEvalMethod,
    Expression.Constant(binaryLevel),
    Expression.Constant(true));

var afterFailure = Expression.Call(
    AfterEvalMethod,
    Expression.Constant(binaryLevel),
    Expression.Constant(false));

接下来,创建二进制表达式输入。这些使用表达式块调用所需方法,然后返回适当的bool

var orLeft = Expression.Block(
    before,
    Expression.Constant(false));

var andRight = Expression.Block(
    afterSuccess,
    Expression.Constant(true));

var orRight = Expression.Block(
    afterFailure,
    Expression.Constant(false));

现有的二进制表达式被访问leftright节点的调用替换。这确保了下游表达式也被拦截。

var binary = node.Update(
    Visit(node.Left),
    node.Conversion,
    Visit(node.Right));

binaryLevel--;

最后,整个表达式将替换为前面显示的逻辑。

从逻辑上讲,二进制表达式被替换为该树:

这是运行的结果:

在第一个示例中,IsTruetrue,因此评估继续遵循内部规则。到期日期是20221210日,它是将来的500天以上,因此失败了。这导致整个表达式失败。

在第二个示例中,IsTruefalse,因此表达式立即失败。

最后,IsTruetrue93日的截止日期,2020在未来不到500天,因此整个表达式成功。

💡 提示:该方法可以比二元表达式处理更多的增强。例如,表达式AndAlso的每一侧都需要一个bool,因此您可以覆盖成员访问权限(获得IsTrue的表达式的值)并将结果显示为嵌套规则。您还可以截取表达式的求值,例如GreaterThan显示要比较的实际值。

结论

此博客文章中涵盖的代码可在此处获得:

我希望对表达式的这些探索有助于阐明.NET工具箱中一个功能强大的工具。为了使表达式更易于访问,我一直在研究Expression Power Tools库,以简化表达式的使用。例如,此代码:

var query = new QueryHost<Thing>(ThingDbQuery);
query.CustomProvider.RegisterInterceptor(ExpressionTransformer);

简化为:

var query = ThingDbQuery.CreateInterceptedQueryable(ExpressionTranformer);

如果您对表达式感兴趣,请看一下!

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值