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 { 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 () { } 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 { internal record MultilingualString (string Chinese, string ? English ) ; record Area (double Value , AreaType Unit ); enum AreaType{SquareKM, Hectare, CnMu}; enum RegionLevel { Province,City,County,Town}; 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 { internal class RegionConfig : IEntityTypeConfiguration <Region > { 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();