EF Core中实现值对象

在定义实体类的时候,实体类中的一些属性之间有着紧密的联系,比如我们要在表示城市的实体类City中定义表示地理位置的属性,因为地理位置包含“经度”(longitude)和“纬度”(latitude)两个值,所以我们可以为City类增加Longitude、Latitude两个属性。这也是大部分人的做法,这样做没什么太大的问题。不过,从逻辑上来讲,这样定义的经纬度和主键、名字等属性之间是平等的关系,体现不出来经度和纬度的紧密关系。如果我们能定义一个包含Longitude、Latitude两个属性的Geo类型,然后把City的“地理位置”属性定义为Geo类型,这样经度、纬度的关系就更紧密了。Geo类型的Longitude、Latitude两个属性通常不会被单独修改,因此Geo被定义成不可变类,也就是值对象。

在定义实体类的时候,实体类中有的属性为数值类型,比加“商品”实体类中的质量属性。我们如果把质量定义为double类型,
那么其实隐含了一个“质量单位”的领域知识,使用这个实体类的开发人员就需要知道这个领域知识,而且我们还要通过文档形式把这个领域知识记录下来,这又面临一个文档和代码修改同步的问题、在DDD中,我们要尽减少文档中不必要的领域知识。如果我们定义一个包含value(数值)、Unit(质量单位)的Weight类型,然后把“商品”的质量属性设置为weipht类型,这样的代码中天然包含了数值和质量单位信息。在定义实体类的时候,很多数值类型的属性其实都是隐含了单位的,比如金额隐含了币种信息。理想情况下,这些数值类型的属性都应该定义为包含了计量单位信息的类型。这些包数值和计量单位的类也一般被定义为不可变的值对象。

我们在编写实体类的时候,有一些属性的可选值范围是固定的,比如“员工”中用来定义职位级别的属性为int类型,可选范围为1~3,它们分别表示“初级”“中级”“高级”。我们用int类型表示级别,因此我们同样需要在文档中说明不同数值的含义。如果我们用C#中的枚举类型来表示这些固定可选值范围的属性,就可以让代码的可读性更强,也就更加符合DDD的思想。

EF Core 中提供了对于没有标识符的值对象进行映射的功能,那就是“从属实体类”(ownedentities)类型,我们只要在主实体类中声明从属实体类型的属性,然后使用FluentAP中的OwnsOne 等方法来配置。

在EFCore中,实体类的属性可以定义为枚举类型,枚举类型的属性在数据库中默认是以int类型来保存的。对于直接操作数据库的人员来讲,0、1、2这样的值没有“CNY”(人民币)、“USD”(美元)、“NZD”(新西兰元)等这样的 string 类型的值可读性强。EFCore 中可以在FluentAPI中用HasConversion把枚举类型的值配置成字符串。

我们通过“地区”实体类Region来举例。一个省、一个市等都可以表示为一个地区,Region类含有Name(名字)、Area(面积)、Level(级别)、Population(总人口)、Location(地理行置)等属性:Name 中既包含中文名字,也包含英文名字,因此我们把它们定义到MultilingualSting 值对象中;Area 定义为包含Value(数值)、Unit(计量单位)两个属性的Area 类型:Location 定义为包含 Longitude(经度)、Latitude(纬度)两个属性的 Geo 类型.Level定义为包含 Province(省)、City(市)、County(县)、Town(镇)几个可选值的枚举类型RegionLevel。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
{
/// <summary>
/// 表示具有相关元数据(如名称、面积、人口和位置)的地理区域。
/// </summary>
/// <remarks>这个 <see cref="Region"/> 类提供了一种建模地理区域的方法
///对其名称、定义区域、人口数据和特定位置的多语言支持。它还包括
///分层级别对区域进行分类。此类的实例对于大多数属性都是不可变的,除了
/// <see cref="Population"/><see cref="Level"/>, 其可以使用所提供的方法进行更新。</remarks>
internal class Region
{
public long Id { get; init; }
public MultilingualString Name{ get; init; }
public Area Area{ get; init; }
public RegionLevel Level{ get; private set; }
public long? Population { get; private set; }
public Geo Location{ get; init; }
private Region() { }//给EFCore从数据库中加载数据然后生成User对象返回用的
public Region(MultilingualString name, Area area,Geo location,RegionLevel level)
{
this.Name= name;
this.Area= area;
this.Level= level;
this.Location= location;
}
public void ChangePopulation(long value)
{
this.Population= value;
}
public void ChangeLevel(RegionLevel value)
{
this.Level= value;
}
}
}

Region类Name、Area、Location都是不变的,因此我们把Id、Name、Area、Location属性设置为init;Level和Population是不变的,因此Level和Population属性设置为private set。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
{
/// <summary>
/// 实现多语言
/// </summary>
/// <param name="Chinese">汉语</param>
/// <param name="English">英语</param>
internal record MultilingualString(string Chinese, string? English);
/// <summary>
/// 表示具有指定值和单位的面积测量。
/// </summary>
/// <remarks>该记录封装了一个面积测量,包括其数值和单位测量。它是不变的,可以用来表示不同单位的面积,如平方米或平方英尺。</remarks>
/// <param name="Value"></param>
/// <param name="Unit"></param>
record Area(double Value, AreaType Unit);
enum AreaType{SquareKM, Hectare, CnMu}; //平方公里、公顷、市亩
enum RegionLevel { Province,City,County,Town};//省、市、县、镇
/// <summary>
/// 代表具有指定纬度和经度的地理坐标。
/// </summary>
/// <remarks>这个 <see cref="Geo"/> 记录是不可变的,并确保纬度和经度值在有效范围内:
/// <list type="bullet"> <item><description>经度必须介于-180和180之间度。</description></item>
/// <item><description>纬度必须介于-90和90之间度。</description></item> </list></remarks>
record Geo
{
public double Longitude { get; init; }
public double Latitude { get; init; }
public Geo(double Longitude, double Latitude)
{
if (Longitude < -180 || Longitude > 180)
{
throw new ArgumentException("longitude invalid");
}
if(Latitude < -90|| Latitude > 90)
{
throw new ArgumentException("longitude invalid");
}
this.Longitude = Longitude;
this.Latitude = Latitude;
}
}
}

MultilingualString、AreaType都是以整体出现的,因此我们把它们通过record定义为不可变类型。从属实体类型并不要求必须为不可变类型,不过我们一般都把它们定义为不可变类型,这样能够提供更清晰的语义。

我们还需要通过Fluent API来对Region中的枚举类型以及从属实体类型进行配置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
{
/// <summary>
/// 区域实体的EF Core配置类,配置了Area、Location、Level和Name等值对象属性的映射规则。
/// </summary>
internal class RegionConfig : IEntityTypeConfiguration<Region>
{
/// <summary>
/// 为配置实体类型<see cref="Region"/> 类.
/// </summary>
/// <remarks>此方法定义了<see cref="Region"/> 实体,包括
///拥有类型和属性约束。它指定了 <c>Area</c>,
/// <c>Location</c>, <c>Level</c>, and <c>Name</c> 属性 <see cref="Region"/> 实体.</remarks>
/// <param name="builder">这个 <see cref="EntityTypeBuilder{TEntity}"/> 用于配置 <see cref="Region"/> 实体.</param>
public void Configure(EntityTypeBuilder<Region> builder)
{
builder.OwnsOne(c => c.Area, nb =>
{
nb.Property(e => e.Unit).HasMaxLength(20)
.IsRequired(true).HasConversion<string>();
});
builder.OwnsOne(c => c.Location);
builder.Property(c=>c.Level).HasMaxLength(20)
.IsRequired(true).HasConversion<string>();
builder.OwnsOne(c => c.Name, nb =>
{
nb.Property(e => e.English).HasMaxLength(20).IsRequired(true);
nb.Property(e => e.Chinese).HasMaxLength(20).IsRequired(true);
});
}
}
}

我们用 wns0ne对从属实体类型属性进行配置。默认情况下,EFCore 将会按照约定对从属实体类型属性进行配置,如第9行代码所示;如果我们需要对从属实体类型属性进行自定义配置,可以像第58行代码以及第1215 行代码那样在 OwnsOne 方法的第二个参数中进个配置。为了让枚举类型在数据库中映射为sting类型而不是默认的int类型,我们在第7行代码和第11行代码分别对于实体类Region以及从属实体类型Area中的枚举类型性HasConversion方法进行配置。

1
2
3
4
5
6
7
8
9
using MyDbContext ctx = new MyDbContext();

MultilingualString name1 = new MultilingualString("北京", "BeiJing");
Area areal = new Area(16410, AreaType.SquareKM);
Geo loc = new Geo(116.4074, 39.9042);
Region c1 = new Region(name1, areal, loc, RegionLevel.Province);
c1.ChangePopulation(21893100);
ctx.Regions.Add(c1);
ctx.SaveChanges();