黑魔法——LINQ 表达式重用






4.94/5 (16投票s)
在开发复杂的业务系统查询时,复用是经常需要的。本文提供了一些关于 LINQ 表达式复用的指南和工具。
- 下载 LinqExpressionsReuse_v1.0.zip - 1.7 MB
- 该项目是开源的,可以在 GitHub 上找到。
- 如果您只想使用代码,请使用 nuget 包。
介绍
在开发复杂的业务系统查询时,复用是经常需要的。本文提供了一些关于 LINQ 表达式复用的指南,以及一个支持表达式复用的实用工具。
当寻找一种方法来复用 LINQ 表达式进行投影(LINQ Select()
调用)时,我遇到了 Marc Gravell 的 这个回复。我喜欢他将表达式复用称为“黑色艺术”,所以我也在这里沿用了这个说法……
如果您只对在投影中使用表达式(LINQ Select()
调用)感兴趣,请 点击此处。
必备组件
本文假定您对 LINQ 有一定的了解。
问题领域
为了说明本文的目标,让我们假设有一个包含项目和子项目的模型,由相应的类表示。
public class Project
{
public int ID { get; set; }
public virtual ICollection<Subproject> Subprojects { get; set; }
}
public class Subproject
{
public int ID { get; set; }
public int Area { get; set; }
public Project Project { get; set; }
}
复用选择器表达式
现在,让我们还假设存在一些逻辑来确定每个项目被认为是“主要”子项目的子项目。让我们假设这不是一个简单的逻辑,并且它在整个应用程序中被使用。显然,我们希望保持 DRY(Don't Repeat Yourself)原则,并将此逻辑写在一个地方。出于性能原因,我们希望能够以一种支持我们在数据库上使用此逻辑的方式来提供此逻辑,我们希望在只需要主子项目时避免加载所有子项目。
使用 LINQ,我们可以以类型安全的方式,并根据我们的业务对象来编写该逻辑。
假设主项目逻辑是面积最大的项目,前提是其面积不超过 1,000。首先,我们将选择所有主子项目,此时我们将把选择逻辑包含在查询中(请忽略非复杂情况的处理)。
var mainsSbprojects = ctx.Subprojects
.Where(sp =>
sp.Area ==
sp.Project.Subprojects.Where(spi => spi.Area < 1000)
.Max(spii => spii.Area)).ToArray();
我们希望将 Where 子句(lambda 表达式)中的逻辑提取出来,并在应用程序中复用。如果使用 IntelliSense,我们可以了解到该表达式的类型是 Expression<Func<Subproject, bool>>
。
Func<Subproject, bool>
意味着预期的参数是一个方法,该方法接受一个 Subproject
并返回一个 boolean
。将其视为一个对每个子项目运行的循环,并返回一个指示它是否应包含在结果中的值。Expression
部分意味着它不是一个真正的方法,而是一个表达式树,可以被编译成一个方法。然而,这个树可能会被翻译成 SQL(或其他数据检索等价物),具体取决于数据源(如果您对此不清楚,请查看 这个)。
让我们将这段 lambda 表达式提取出来并放入一个变量中。
private static Expression<Func<Subproject, bool>> mainSubprojectSelector = sp =>
sp.Area ==
sp.Project.Subprojects.Where(spi => spi.Area < 1000)
.Max(spii => spii.Area);
现在让我们重写我们的查询。
var mainsSbprojectsBySelector =
ctx.Subprojects.Where(mainSubprojectSelector).ToArray();
现在,假设我们只想要项目 1 的主子项目。
var proj1MainSubProj = ctx.Subprojects.Where(
mainSubprojectSelector).Single(sp => sp.Project.ID == 1);
好的,这很好,我们已经复用了我们的逻辑。
从项目出发
请注意,此表达式从子项目开始选择。当处理一个项目时,回答“这个项目的主子项目是什么?”会更有意义。我们仍然希望使用原始逻辑,所以我们可能想要一个接受项目并返回子项目的新表达式,同时使用原始逻辑。也许是这样的:
private static Expression<Func<Project, Subproject>> projectMainSubprojSelector =
proj => proj.Subprojects.AsQueryable().Single(mainSubprojectSelector);
现在我们可以这样做:
var proj1MainSubprojByProj = ctx.Projects.Where(p => p.ID == 1).Select(projectMainSubprojSelector).Single();
(这在 LINQ to Objects 中可以正常运行,但是,如果您尝试使用 LINQ to Entities 运行它,您会遇到一个错误,声称 Single()
方法只能是链中的最后一个方法。这对于 SingleOrDefault()
和 First
来说是真的,但对于 FirstOrDefault()
则不适用。)
让我们再看看那个选择表达式。
var proj1MainSubprojByProj = ctx.Projects.Where(p => p.ID == 1).Select(projectMainSubprojSelector).Single();
请注意,您可能会想到使用 DbSet.Find()
或 DbSet.Single()
来获取 ID==1
的 Project,但您将无法在其上调用 Select()
,因此,不可能使用主子项目选择逻辑。我们必须保持一个 IQueryable<Project>
在链中向下传递,以便复用逻辑。
投影
让我们引入一个新的需求 – 我们现在有一个检索项目平均有效面积 (AEA) 的逻辑。这将是所有子项目面积的平均值,不包括面积大于 1,000 的项目。这里有一个表达式供您使用(在 DRY 环境中复用):
private static Expression<Func<Project, double>> projectAverageEffectiveAreaSelector =
proj => proj.Subprojects.Where(sp => sp.Area < 1000).Average(sp => sp.Area);
以下是如何获取项目的 AEA:
var proj1Aea =
ctx.Projects.Where(p => p.ID == 1)
.Select(projectAverageEffectiveAreaSelector)
.Single();
现在,假设我们想检索项目 ID 和它的 AEA。由于 AEA 选择器是一个表达式,我们想这样做:
var proj1AndMainSubprojByProj =
ctx.Projects.Where(p => p.ID == 1)
.Select(p => new
{
ProjID = p.ID,
AEA = projectAverageEffectiveAreaSelector
})
.Single();
嗯,不行。这是编译器处理 lambda 表达式的一种非常有趣的体现。变量 projectAverageEffectiveAreaSelector
的类型是 Expression<Func<Project, double>>
。当您在匿名类型初始化中进行赋值时,编译器会创建一个您正在赋值到的类型的属性。我们希望 AEA
属性的类型是 double
,但它将是 Expression<Func<Project, double>>
类型。编译器不知道我们想将表达式引入并合并到 LINQ 查询中,这样我们就得到了一个 double(使用预定义类型而不是匿名类型也不会更好 – 赋值将无法构建)。
引入 LinqExpressionProjection
LinqExpressionProjection 库的唯一目的是允许您从 LINQ 查询之外的表达式进行投影。
使用代码
您需要做的就是:
- 添加对 LinqExpressionProjection.dll 的引用(最好使用 nugget 包)。
- 在查询的集合上调用
AsExpressionProjectable()
。 - 在检索表达式的代码元素(变量、属性、方法等)上调用
Project<T>()
,其中类型参数是预期结果的类型。请记住,表达式的类型必须是Expression<Func<TIn, TOut>>
,其中TIn
是选择 lambda 参数的类型,TOut
是您要设置的属性的类型,也是Project<T>()
调用的类型参数。
对于上面的示例,可以这样完成:
var proj1AndAea =
ctx.Projects.AsExpressionProjectable()
.Where(p => p.ID == 1)
.Select(p => new
{
Project = p,
AEA = Utilities.projectAverageEffectiveAreaSelector.Project<double>()
}
).Single();
就是这样。
您可以在此处下载,或者(可能更好)使用 nuget 包。
工作原理
如果您“只想让它工作”,可以安全地跳过本节。但是,请注意,在文章 末尾 有更多关于表达式复用的讨论。
第一部分 – 替换 Project<T>()
调用
对 AsExpressionProjectable()
的调用将 IQueryable
包装在另一个 IQueryable
中,该 IQueryable
负责访问表达式树并替换对 Project<T>()
的调用。
当访问到对 Project<T>()
的调用时,方法调用的参数会被编译并执行。由于这是一个扩展方法,这就是您检索类型为 Expression<Func<TIn, TOut>>
的可复用表达式的部分。参数被编译并执行,而不是被分析或解释。这意味着任何具有正确返回类型的代码都可以适应,包括带参数的方法调用。
然后,生成的 lambda 表达式的体会被访问并插入到表达式树中,替换掉 Project<T>()
调用及其参数。
LinqExpressionProjection 中的表达式树访问基于流行的表达式树重写模式,并基于 LinqKit – 由伟大的 Joseph Elbahary 创建(如果您没有 LINQPad,您真的应该拥有一个),而 LinqKit 又基于 Tomas Petricek 的工作。以上所有内容,包括本文,都使用了行业标准的 ExpressionVisitor
,由 Matt Warren 创建(Matt 有一系列博客文章名为“LINQ: Building an IQueryable Provider”,他在其中构建了“IQToolkit”。如果您想一睹真正的天才风采,请 阅读此文)。
第二部分 – 重新绑定参数
这里有两个参数在起作用。一个是投影 lambda 的参数(Select()
方法调用),另一个是可复用表达式的 lambda 参数。这两个参数预期类型相同(代码会进行验证),但为了操作成功,投影参数应该替换可复用表达式参数。
参数的重新绑定是通过访问树的相关部分并替换出现的实例来实现的。此访问器 归功于 Colin Meek,同样基于上面提到的 Matt Warren 的工作。
该项目是开源的,可以在 CodePlex 上找到。
值得关注的点
查询风格 – 说明
LINQ 有两种风格——查询语法和方法链式调用。如果您熟悉查询语法,请考虑方法链式调用比查询语法更适合表达式复用。实际上,使用本文描述的工具,您可能会让它在查询语法中工作,但这会使您的查询可读性大大降低,从而失去查询语法的大部分优势。您也无法获得 IntelliSense 的帮助来理解表达式树和您需要提供的选择器表达式。如果您计划进行大量的 LINQ 复用,请选择方法语法或混合语法。
LINQ 表达式复用注意事项
有些事情可以通过检索数据并进行处理来实现,或者通过创建更复杂的数据检索来包含一些处理。在 LINQ 的世界里,查询本身是类型安全的、可测试的,并且用问题域术语来描述,因此自然会倾向于在查询中做更多的事情。虽然这通常是好的,并且表达式复用是支持这种做法的工具,但请考虑 LINQ 与过程式代码相比更难理解。调试它通常也更难,当您不是在查询对象而是使用 ORM 来组合存储查询时,则更难。
在规划方法时,请提前思考。
历史
- 2012 年 6 月:首次发布。