65.9K
CodeProject 正在变化。 阅读更多。
Home

优雅地操作您的表达式树

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.80/5 (6投票s)

2015年4月8日

CPOL

7分钟阅读

viewsIcon

20001

downloadIcon

210

无需弄脏双手,即可创建和操作你的表达式树。

引言

表达式树是一个非常有用的概念。特别是使用 lambda 表达式构建它的可能性,可以使你的代码类型安全、清晰、简洁,并且代码中的引用也会得到保留。问题开始出现在当你想要操作你的表达式树时——例如将它们组合在一起。这时你的工作就会变得更加复杂。我提出了一种保持双手清洁的解决方案。它可能不是非常容易理解,但一旦你明白了,我敢打赌你会觉得它很有帮助。

背景

当微软推出表达式树,我第一次遇到它们时,我对 LINQ 语法有点困惑,它允许我们用类似 SQL 的代码来查询数据库。我很惊讶——作为一个年轻人,我被告知程序员和数据库工程师是不同的人,数据层应该与代码分离。那么为什么要像这样混合在一起呢?但后来我意识到了它的优点

  1. 你的代码简短而清晰。 如果你的逻辑很简单,你只需一行 C# 代码就可以运行查询。不再需要在解决方案的其他层进行更改。
  2. 你的代码类型安全。 你不可能意外地将非预期的内容发送到 SQL 查询。如果你更改了数据库列的类型,编译器会为你找出错误。
  3. 你的代码保留了引用。 如果某个列在某个查询中使用,你可以在 Visual Studio .NET 中轻松找到它。结合类型安全,这让你可以在不担心应用程序变得过于错误的情况下更改数据库。
  4. 你可以向数据库查询传递复杂的条件。 我猜每个有经验的程序员都记得这一点:你在存储过程中有一个数据库查询,你想过滤结果。如果你只需要按一个或两个参数进行过滤,那没问题,但如果你需要按未知数量的参数进行过滤呢?你不想最终将 SQL 字符串条件发送到存储过程……尽管有时确实发生了。简而言之,将复杂的查询传递给数据库层并不容易。

到目前为止都很好。但最后一点有点棘手。是的,你可以在 C# 中创建一个复杂的查询,是的,你可以直接用它来查询数据库。但表达式仍然硬编码在你的代码中。如果你需要动态创建表达式怎么办?那么你可能会这样做类似的事情(微软仍然推荐)

IQueryable<String> queryableData = companies.AsQueryable<string>();
ParameterExpression pe = Expression.Parameter(typeof(string), "company");
Expression left = Expression.Call(pe, typeof(string).GetMethod("ToLower", System.Type.EmptyTypes));
Expression right = Expression.Constant("coho winery");
Expression e1 = Expression.Equal(left, right);
left = Expression.Property(pe, typeof(string).GetProperty("Length"));
right = Expression.Constant(16, typeof(int));
Expression e2 = Expression.GreaterThan(left, right);
Expression predicateBody = Expression.OrElse(e1, e2);
MethodCallExpression whereCallExpression = Expression.Call(
	typeof(Queryable),
	"Where",
	new Type[] { queryableData.ElementType },
	queryableData.Expression,
	Expression.Lambda<Func<string, bool>>(predicateBody, new ParameterExpression[] { pe }));
MethodCallExpression orderByCallExpression = Expression.Call(
	typeof(Queryable),
	"OrderBy",
	new Type[] { queryableData.ElementType, queryableData.ElementType },
	whereCallExpression,
	Expression.Lambda<Func<string, string>>(pe, new ParameterExpression[] { pe }));
IQueryable<string> results = queryableData.Provider.CreateQuery<string>(orderByCallExpression);

如你所见,我们突然失去了所有的优点。代码不再简短清晰。它也不再是类型安全的:如果你更改了 Length 属性的数据类型,编译器将不会注意到这一点。如果你重命名这个属性——同样也不会出现编译时错误。

注意:这个例子展示了表达式的构建。对于操作,原理是相同的:你使用的是类型不安全的 Expression 类型后代。微软通过在 .NET Framework 4 中引入(公共)ExpressionVisitor 类使其更加容易,但这仍然不能解决问题。

典型解决方案

那么如何处理这个问题呢?我猜很多程序员不得不解决类似的问题,并决定和我一样:将混乱的代码封装到某个辅助类中。混乱的代码就在这里,它只允许存在于这里,而别无他处。你的混乱类有一个很好的 API,它可能甚至是类型安全的,易于调用,一切都很好。直到……你的 API 不够大,它不涵盖表达式树操作的所有情况……如果你需要更多的类型参数怎么办?如果你需要用其他东西替换其中一个怎么办?如何让我的混乱类拥有一个通用的接口,同时仍然保持类型安全并允许保留代码引用?

提出的解决方案

让我们考虑这个场景

  • 我们有一些由 ORM 类表示的数据库表:UserCompany。每个表都有 IdDeleted 列,以及一个带名称的字段(UserNameCompanyName)。
  • 我们需要将每个(未删除)行写入控制台:它们的字段是 IdName。我们想避免加载其他列从数据库。
  • 对于每个表,逻辑都非常相似,所以我们也想避免复制粘贴,而是创建一些通用的代码。

所以我们要做的就是创建一个实现所述逻辑的方法。我们称之为 WriteAll。因为逻辑对所有 ORM 类都是通用的,并且每个表都有不同的列名,所以它将有一个泛型参数 TIdDeleted 列对每个表都是常见的,所以我们可以使用一个接口来访问这些字段。

interface IMyEntity
{
    int Id { get; }

    bool Deleted { get; }
}

我们方法中棘手的部分是只获取我们需要的那些列。要做到这一点,我们需要调用 IQueryable.Select() 方法,所以我们必须为该方法准备表达式参数。表达式的结果类型将是某个 IdWithName 对象——它只是 IdName 列的容器,用于输出逻辑。但问题是,表达式必须为每个表而变化,因为每个表都有不同的“名称”字段名(UserName vs CompanyName),所以我们不能只写一个 lambda 表达式。相反,我们需要从两个单独的表达式动态创建表达式

  1. 名称获取器 -(变量)用于从数据库项中获取名称的函数,例如
  2. 模板 - 创建一个 IdWithName,其中仅包含 名称获取器 的替换。

所以我们的方法(包括 模板)可能看起来像这样

static void WriteAll<T>(IQueryable<T> table, Func<T, string> nameGetter)
	where T : IMyEntity
{
	var items = table
		.Where(item => !item.Deleted)
		.Select(item => new IdWithName 
		{ 
			Id = item.Id, 
			Name = nameGetter(item) 
		});

	foreach (var item in items)
	{
		Console.WriteLine("{0}: {1}", item.Id, item.Name);
	}
}

方法调用包括特定的 名称获取器

    WriteAll(users, user => user.UserName);

你可以编译代码,然后运行它……正如你可能意识到的那样,你会得到一个错误。我们不能像这样在数据库上运行查询,因为 Entity Framework 无法识别 nameGetter 委托调用。它无法被翻译成 SQL。我们需要传递的不是委托(Func<>),而是表达式(Expression<Func<>>)。如果你使用表达式而不是委托,你就不能直接运行它。如果你想调用一个表达式树,你必须先通过 Compile() 方法编译它。这并不能解决我们的问题——至少现在还不能。解决方案稍后会出现。

static void WriteAll<T>(IQueryable<T> table, Expression<Func<T, string>> nameGetter)
	where T : IMyEntity
{
	var items = table
		.Where(item => !item.Deleted)
		.Select(item => new IdWithName
		{
			Id = item.Id,
			Name = nameGetter.Compile()(item)
		});

	foreach (var item in items)
	{
		Console.WriteLine("{0}: {1}", item.Id, item.Name);
	}
}

实际上这段代码有两个问题

  1. 你不能使用 DeletedId 属性来查询 Entity Framework,因为它们是接口成员。它们必须被替换为特定的 ORM 类成员。
  2. 你不能使用 nameGetter 引用、Compile() 方法调用,以及调用委托。所有这些操作都无法翻译成 SQL。

为了解决这些问题,我们可以使用我的两个方法

  • GetRidOfInterfaceCast - 用 ORM 类上的特定属性替换接口属性
  • GetRidOfCompile - 用替换(见下文)替换 Compile() 方法和委托调用

GetRidOfInterfaceCast

使用接口成员的表达式树只有一个有问题的部分:接口转换。ORM 对象首先被转换为接口,然后调用具有特定名称的属性。GetRidOfInterfaceCast 在树中搜索此类节点,然后删除接口转换。它的工作原理如下

 

GetRidOfCompile

 

带有 Compile() 方法的表达式树稍微复杂一些。我们需要获取 Compile() 方法的源,也就是我们场景中的 nameGetter 变量,并对其进行求值。结果是一个表达式树,用于替换委托调用——如果它有参数(在我们这个场景中有一个),它们会被替换为最初传递给委托调用的子树(如下图的 P1、P2 等)。

 

所以实际的替换过程应该按照以下流程图进行,其中步骤 1 是参数映射(P1、P2 等),步骤 2 是用给定的表达式树替换委托调用。

这个表达式就可以被 Entity Framework 处理了。

使用代码

现在我们需要使用我们的辅助方法来清理使用的表达式。它们被直接写成 lambda 表达式作为 Where()Select() 方法的参数,所以我们不能在那里直接操作它们。我们需要将 lambda 声明移到变量中,然后使用我的辅助方法来清理变量的值

static void WriteAll<T>(IQueryable<T> table, Expression<Func<T, string>> nameGetter)
	where T : IMyEntity
{
	Expression<Func<T, bool>> @where = item => !item.Deleted;
	Expression<Func<T, IdWithName>> @select = 
		item => new IdWithName 
		{ 
			Id = item.Id, 
			Name = nameGetter.Compile()(item) 
		};

	var items = table
		.Where(where.GetRidOfInterfaceCast())
		.Select(select.GetRidOfInterfaceCast().GetRidOfCompile());

	foreach (var item in items)
	{
		Console.WriteLine("{0}: {1}", item.Id, item.Name);
	}
}

附加的 ZIP 文件中有一些例子,展示了在清理过程中表达式树实际会发生什么。

关注点

我开发了这两个方法大约两三年了,从那时起,我就再也没有需要其他东西来操作表达式。我认为这个解决方案非常健壮,因为

  • 你不需要使用任何低级的、非类型安全的 Expression 操作,只需要高级的、强类型的 Expression<Func<>>
  • 你的代码不需要任何类型转换(这可能是不安全的)。
  • 所有代码引用都得到保留并且是类型安全的。
  • 该解决方案对所有表达式类型都是通用的,无论它们可能有多少参数以及它们是什么类型。

历史

  • 1.0 - 初始版本
© . All rights reserved.