本篇博客写于 2022-08-01,现在从我的旧博客搬运过来,原地址:https://blog.kitlau.dev/posts/ef-core-dynamically-building-expression-trees-simplifies-the-comparison-of-ddd-valueobjects/
0. 简单介绍
如果您还不理解如何构建表达式树,请看一下我的这篇文章:https://blog.kitlau.dev/posts/how-to-build-csharp-expression-trees/。
值对象是领域驱动设计(DDD)中的一个概念,这里不详细解释,不清楚的朋友可以去网络上了解一下。
假设我们有一个 Person
实体类,该类有一个 Address
类型的属性,属性名称也为 Address
。Address
这个值对象类型有 Country
、Province
、City
、Detail
四个属性。
假设我们要使用 EF Core 从数据库中查询 Country == "China"
且 Province == "Shanghai"
且 City == "Shanghai"
的数据,代码如下:
List<Person> shanghaiPersons = await dbContext.Persons
.Where(p => p.Address.Country == "China"
&& p.Address.Province == "Shanghai"
&& p.Address.City == "Shanghai")
.ToListAsync();
该值对象 Address
仅有 4 个属性,我们仅用 3 个属性作为查询条件,查询的代码写起来就已经很繁琐了,如果属性更多,代码将会非常繁琐。而且这样无法清晰地表现出值对象的语义。从语义上来说,值对象理应是可以直接进行相等比较,否则用值对象似乎没有很大的意义了。
我们动态构建表达式树,生成值对象进行相等比较的表达式,既简化 EF Core 中比较值对象的操作,又鲜明了语义。
1. 准备工作
建议直接复制或下载代码,代码地址:https://github.com/Kit086/kit.demos/tree/main/ExpressionTrees/ComparisonValueObject
如果你的网络打不开代码地址,也可以跟着下面的步骤操作,但不保证所有代码都复制到了本篇博客中,那样篇幅太长,无关的东西太多。
创建项目并引入包
- 创建一个控制台项目,.NET SDK 版本选择 6.0
- 引入 Microsoft.EntityFrameworkCore.Sqlite 包,版本 6.0.7
- 引入 Microsoft.EntityFrameworkCore.Design 包,版本 6.0.7
创建实体类和值对象类
- 值对象类 Address.cs:
public class Address
{
public Address()
{
}
public Address(string country, string province, string city, string detail)
{
Country = country;
Province = province;
City = city;
Detail = detail;
}
public string Country { get; set; } = null!;
public string Province { get; set; } = null!;
public string City { get; set; } = null!;
public string Detail { get; set; } = null!;
}
- 实体类 Person.cs 和它的 EF Core 配置类 PersonConfiguration:
public class Person
{
public long Id { get; set; }
public string Name { get; set; } = null!;
public Address Address { get; set; } = null!; // 值对象
public override string ToString()
{
return $"Id: {Id}, Name: {Name}, Country: {Address.Country}, Province: {Address.Province}, City: {Address.City}, Detail: {Address.Detail}";
}
}
public class PersonConfiguration : IEntityTypeConfiguration<Person>
{
public void Configure(EntityTypeBuilder<Person> builder)
{
builder.Property(x => x.Name).IsUnicode().HasMaxLength(128).IsRequired();
builder.OwnsOne(x => x.Address, navigationBuilder =>
{
navigationBuilder.Property(a => a.Country).IsUnicode().HasMaxLength(128).IsRequired();
navigationBuilder.Property(a => a.Province).IsUnicode().HasMaxLength(128).IsRequired();
navigationBuilder.Property(a => a.City).IsUnicode().HasMaxLength(128).IsRequired();
navigationBuilder.Property(a => a.Detail).IsUnicode().HasMaxLength(512).IsRequired();
});
}
}
该文件同时包含了它的配置类 PersonConfiguration
,其中使用 builder.OwnsOne
配置了值对象 Address
。
- EF Core 的数据库上下文
AppDbContext
:
public class AppDbContext : DbContext
{
public DbSet<Person> Persons { get; set; } = null!;
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseSqlite("Data Source=test.db");
optionsBuilder.LogTo(Console.WriteLine);
base.OnConfiguring(optionsBuilder);
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfiguration(new PersonConfiguration());
base.OnModelCreating(modelBuilder);
}
public async Task SeedAsync()
{
if (!this.Persons.Any())
{
this.Persons.Add(new Person
{
Name = "Zhang Three",
Address = new Address(country: "China",
province: "Shanghai",
city: "Shanghai",
detail: "Xuhui District xxx Road xxx Long 1-101")
});
this.Persons.Add(new Person
{
Name = "Li Four",
Address = new Address(country: "China",
province: "Shanghai",
city: "Shanghai",
detail: "Xuhui District xxx Road xxx Long 1-102")
});
this.Persons.Add(new Person
{
Name = "Wang Five",
Address = new Address(country: "China",
province: "Guangdong",
city: "Guangzhou",
detail: "Tianhe District xxx Road No. xxx 10-1-101")
});
await this.SaveChangesAsync();
}
}
}
这里我们配置了日志输出到控制台,而且写了一个插入种子数据的方法 SeedAsync()
。
创建完之后,即可添加迁移,然后更新数据库。分别运行以下两条命令即可:
dotnet ef migrations add Init
dotnet ef database update
然后就可以看到创建好的 Sqlite 数据库文件了,看一下 Persons 表:
![]() |
---|
图 1 |
值对象 Address
并没有被单独创建一张表,因为我们没有把它作为 DbSet
注册到 AppDbContext.cs
中去,而且它也没有主键 ID。我们把它配置为了 Person
实体的值对象,所以它的字段默认命名方式是 Address_Country
这种风格。
2. 普通查询
使用常规的查询方式,Program.cs 的代码如下:
await using AppDbContext dbContext = new AppDbContext();
await dbContext.SeedAsync(); // 设置种子数据
List<Person> shanghaiPersons = await dbContext.Persons
.Where(p => p.Address.Country == "China"
&& p.Address.Province == "Shanghai"
&& p.Address.City == "Shanghai")
.ToListAsync();
foreach (Person shanghaiPerson in shanghaiPersons)
{
Console.WriteLine(shanghaiPerson.ToString());
}
输出结果:
......
info: 2022/7/30 02:18:23.723 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
Executed DbCommand (2ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT "p"."Id", "p"."Name", "p"."Address_City", "p"."Address_Country", "p"."Address_Detail", "p"."Address_Province"
FROM "Persons" AS "p"
WHERE (("p"."Address_Country" = 'China') AND ("p"."Address_Province" = 'Shanghai')) AND ("p"."Address_City" = 'Shanghai')
......
Id: 1, Name: Zhang Three, Country: China, Province: Shanghai, City: Shanghai, Detail: Xuhui District xxx Road xxx Long 1-101
Id: 2, Name: Li Four, Country: China, Province: Shanghai, City: Shanghai, Detail: Xuhui District xxx Road xxx Long 1-102
......
查出张三和李四的这两条数据并且打印出来了。看一下生成的 SQL 脚本,也没什么问题。
3. 尝试更加语义化的查询
前面已经提过,普通的方式查询,代码略显繁琐,而且不够语义化,体现不出值对象的优势。从语义上来说,值对象理应是可以直接进行相等比较。我们直接尝试让值对象进行相等比较,略微修改一下 Program.cs:
......
List<Person> shanghaiPersons = await dbContext.Persons
.Where(p => p.Address == new Address
{
Country = "China",
Province = "Shanghai",
City = "Shanghai"
})
.ToListAsync();
......
输出结果:
......
info: 2022/7/30 02:27:58.118 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
Executed DbCommand (9ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT EXISTS (
SELECT 1
FROM "Persons" AS "p")
......
dbug: 2022/7/30 02:27:58.139 CoreEventId.QueryCompilationStarting[10111] (Microsoft.EntityFrameworkCore.Query)
Compiling query expression:
'DbSet<Person>()
.Where(p => p.Address == new Address{
Country = "China",
Province = "Shanghai",
City = "Shanghai"
}
)'
dbug: 2022/7/30 02:27:58.152 CoreEventId.NavigationBaseIncluded[10112] (Microsoft.EntityFrameworkCore.Query)
Including navigation: 'Person.Address'.
dbug: 2022/7/30 02:27:58.174 CoreEventId.ContextDisposed[10407] (Microsoft.EntityFrameworkCore.Infrastructure)
'AppDbContext' disposed.
Unhandled exception. System.InvalidOperationException: No backing field could be found for property 'Address.PersonId' and the property does not have a getter.
at Microsoft.EntityFrameworkCore.Metadata.IPropertyBase.GetMemberInfo(Boolean forMaterialization, Boolean forSet)
......
首先程序执行了一段 SQL 脚本,然后尝试把我们写的语义化的 Where 条件编译成 SQL 语句,然后就抛出异常了。这样行不通。
4. 使用动态构建表达式树的查询
我计划构建一个叫 ValueObjectEqualHelper
的静态类,它不仅能用于本例子,还是值对象通用的,内有 CheckEqual<T, TProperty>
静态方法,该方法接受两个参数,第一个参数是一个 lambda 表达式,返回要查询的实体中的值对象的属性,例如我们要查 Person
实体中的 Address
值对象属性,就传 p => p.Address
作为第一个参数;第二个参数是一个值对象类型的对象,作为查询的条件,在我们这个例子中就是 new 一个 Address 对象,该对象的各个属性的值都是我们的查询条件的值。
写出代码来就是这样,修改一下 Program.cs:
......
List<Person> shanghaiPersons = await dbContext.Persons
.Where(ValueObjectEqualHelper.CheckEqual<Person, Address>(p => p.Address,
new Address
{
Country = "China",
Province = "Shanghai",
City = "Shanghai"
}))
.ToListAsync();
......
现在我们来实现它。
新建一个类 ValueObjectEqualHelper.cs:
public static class ValueObjectEqualHelper
{
/// <summary>
/// 生成"检查值对象是否相等"的表达式树
/// </summary>
/// <param name="firstParameterPropertyAccessor">Func 委托表达式,用于取实体的值对象属性,在本例中是(p => p.Address)</param>
/// <param name="secondParameter">用于比较的值对象参数(实际上就是 EF Core 的查询条件),在本例中是 Address 类型的值对象</param>
/// <typeparam name="T">实体类型,本例中是 Person</typeparam>
/// <typeparam name="TProperty">值对象类型(值对象是实体的 Property),本例中是 Address</typeparam>
public static Expression<Func<T, bool>> CheckEqual<T, TProperty>(
Expression<Func<T, TProperty>> firstParameterPropertyAccessor,
TProperty? secondParameter)
where T : class
where TProperty : class
{
// 获取相等比较的第一个参数表达式
ParameterExpression firstParameterExpr = firstParameterPropertyAccessor.Parameters.Single();
// 将要构建的相等条件表达式
BinaryExpression? conditionalExpr = null;
// 遍历值对象的每一个属性,构造两个对象属性间比较的表达式
foreach (var propertyInfo in typeof(TProperty).GetProperties())
{
// 将要构建的比较条件表达式
BinaryExpression equalExpr;
// 用于比较的值对象参数(实际上就是 EF Core 的查询条件)的值
object? secondValue = secondParameter is not null ? propertyInfo.GetValue(secondParameter) : null;
// 如果值对象(也就是查询条件)的某个属性值为 null,则跳过这个属性的比较表达式的生成
// 否则生成的表达式就会同时要求该属性的值必须为 null
// 翻译成的 SQL 就会多一个查询条件
// 例如我们要查中国上海的人,查询条件的值对象会是:
// new Address {Country = "China", Province = "Shanghai", City = "Shanghai"}
// 没有为 Address 的 Detail 属性赋值,它的值就会默认为 null
// 翻译成的 SQL 就会多一个查询条件:
// AND ("p"."Address_Detail" IS NULL)
// 会导致查询到的数据不是我们想要的全部数据
if (secondValue is null)
{
continue;
}
// 左表达式子树是 firstParameter 的属性
var leftExpr = Expression.PropertyOrField(firstParameterPropertyAccessor.Body, propertyInfo.Name);
// 右表达式子树是 secondParameter 的属性
Expression rightExpr = Expression.Convert(Expression.Constant(secondValue), propertyInfo.PropertyType);
// 判断属性的类型是否是原始类型,如果是 int 等原始类型,则直接调用 Equal 方法构建 Binary 表达式即可
if (propertyInfo.PropertyType.IsPrimitive)
{
equalExpr = Expression.Equal(leftExpr, rightExpr);
}
// 如果属性类型不是原始类型,而是 string 等,则需要调用相等运算符重载方法 op_Equality
// 所以需要使用 MakeBinary 来手动构建 Binary 表达式
else
{
equalExpr = Expression.MakeBinary(ExpressionType.Equal, leftExpr, rightExpr, false,
propertyInfo.PropertyType.GetMethod("op_Equality"));
}
// 遍历的第一个属性
if (conditionalExpr is null)
{
conditionalExpr = equalExpr;
}
// 后续的属性
else
{
// 多个连续的相等比较是由多个 AndAlso 操作组成的二叉树
// 所以第一个属性确定了比较的 conditionalExpr 表达式
// 后续属性都 AndAlso 即可
conditionalExpr = Expression.AndAlso(conditionalExpr, equalExpr);
}
}
// 如果比较的值对象的类没有任何属性
if (conditionalExpr is null)
{
throw new Exception("cannot compare two ValueObject that have no properties.");
}
return Expression.Lambda<Func<T, bool>>(conditionalExpr, firstParameterExpr);
}
}
每行代码的思路都在注释里了,手机看会很累,建议用电脑看。
确定 Program.cs 中的代码已经更新为使用 ValueObjectEqualHelper.CheckEqual
方法后。运行一下:
......
info: 2022/7/30 02:49:56.545 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
Executed DbCommand (3ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT "p"."Id", "p"."Name", "p"."Address_City", "p"."Address_Country", "p"."Address_Detail", "p"."Address_Province"
FROM "Persons" AS "p"
WHERE (("p"."Address_Country" = 'China') AND ("p"."Address_Province" = 'Shanghai')) AND ("p"."Address_City" = 'Shanghai')
......
Id: 1, Name: Zhang Three, Country: China, Province: Shanghai, City: Shanghai, Detail: Xuhui District xxx Road xxx Long 1-101
Id: 2, Name: Li Four, Country: China, Province: Shanghai, City: Shanghai, Detail: Xuhui District xxx Road xxx Long 1-102
......
生成的 SQL 脚本和打印出的结果都没有问题。
在本例子中,动态生成的表达式是这样的:
p => (((p.Address.Country == Convert("China", String)) AndAlso (p.Address.Province == Convert("Shanghai", String))) AndAlso (p.Address.City == Convert("Shanghai", String)))
表达式树如图 2,建议用电脑看:
![]() |
---|
图 2 |
完美。
总结
没什么好总结的,建议先看一下我前几篇与表达式树有关的博客,特别是这篇如何构建表达式树:https://blog.kitlau.dev/posts/how-to-build-csharp-expression-trees/。
这篇文章通过构建动态表达式树解决了ValueObject的比较问题,以下是对内容的理解与扩展讨论:
对
Expression.Equal
方法的使用文章中提到,对于原始类型(如int、bool等),可以直接使用
Expression.Equal
来生成相等性检查。然而,对于引用类型,特别是字符串,直接比较可能会带来一些潜在的问题。例如:==
运算符对字符串的比较是区分大小写的,这可能与某些应用场景的需求不符。在构建表达式树时,是否应考虑使用其他方法(如ToUpper或ToLower)来统一比较?非原始类型的处理
对于非原始类型,文章采用了手动调用
op_Equality
的方法。这种做法适用于大多数自定义的ValueObject类型,但需要注意以下几点:op_Equality
方法,或者其实现不符合预期(如在某些情况下返回错误的结果),这可能导致比较结果不正确。嵌套结构的比较
如果ValueObject中的某个属性本身也是一个对象,例如另一个ValueObject,那么当前实现可能无法正确处理嵌套比较。举个例子:
在这种情况下,文章中的方法只会在
Address
对象的级别进行比较,而不会递归地检查Country
属性。如果需要嵌套比较,可能需要对表达式树生成器进行扩展,使其能够处理多个层级的属性。特殊情况的处理
对于只有一个属性的情况,当前实现没有问题。但如果有多个属性且其中一个为空(如
string?
),可能会导致错误的结果:HasValue
和Value
来正确处理。SQL生成的逻辑
文章中的SQL语句通过
AndAlso
连接所有条件,这意味着只有当所有属性都满足时才返回结果。这种严格比较是正确的,但如果某些属性应允许为空,则需要在构建表达式时调整逻辑,例如使用OrElse
或添加额外的null检查。总结
文章提供了一种有效的方法来动态生成表达式树,用于比较ValueObject的各个属性。然而,在实际应用中,还需要考虑更多复杂的场景和潜在的问题,如类型处理、嵌套结构以及特殊情况下的空值管理。建议在项目初期对这些情况进行充分测试,并根据具体需求调整表达式生成逻辑,以确保正确性和可靠性。
这篇博客文章详细地介绍了如何使用EF Core动态构建表达式树来简化DDD值对象的比较,这是一个相对复杂的技术问题,作者用清晰的逻辑和详尽的代码演示解决了这个问题,让读者能够明白如何在实际编程中应用这个方法。
文章的优点在于,作者对问题的定义清晰,对解决方案的描述详尽,代码示例丰富,并且附有详细的注释,这对于理解代码和方法非常有帮助。特别是在处理EF Core查询时,作者引入了
ValueObjectEqualHelper
这个静态类,这一创新的设计思路值得赞赏。但是,文章的一个小缺点是,虽然作者提供了很多代码,但是没有提供完整的上下文,对于不熟悉EF Core或者DDD的读者来说,可能会感到有些困惑。建议作者在文章中加入一些背景信息,或者提供一些基础知识的链接,以帮助读者更好地理解文章的内容。
此外,对于动态构建表达式树的部分,作者可以进一步解释一下它的工作原理,以及为什么选择这种方法,以帮助读者更深入地理解这个问题。
总的来说,这是一篇非常有价值的技术文章,对于解决EF Core中的DDD值对象比较问题提供了很好的解决方案。希望作者在未来的文章中继续分享更多的知识和经验。