DDD的技术落地 — EF Core中实现值对象

在定义实体类的时候,实体类的一些属性之间有着紧密的联系。

比如我们要在表示城市的实体类City中定义地理位置的属性,因为地理位置包含”经度”和”纬度”两个值,所以我们可以为City类增加Longitude、Latitude两个属性。

这也是大部分人的做法,这样做没什么太大的问题。

不过,从逻辑上来讲,这样定义的经纬度和主键、名字等属性之间是平等的关系,体现不出来经度和纬度的紧密关系。

如果我们能定义一个包含Longitude、Latitude两个属性的Geo类型,然后把City的”地理位置”属性定义为Geo类型,这样经度、纬度的关系就更紧密了。

Geo类型的Longitude、Latitude两个属性通常不会被单独修改,因此Geo被定义成不可变类,也就是值对象。

在定义实体类的时候,实体类中有的属性为数值类型,比如”商品”实体类中的质量属性。

我们如果把质量定义为double类型,那么其实隐含了一个”质量单位”的领域知识,使用这个实体类的开发人员就需要知道这个领域知识,而且我们还要通过文档等形式把这个领域知识记录下来,这又面临一个文档和代码修改同步的问题。

在DDD中,我们要尽量减少文档中不必要的领域知识。

如果我们定义一个包含Value、Unit的Weight类型,然后把”商品”的质量属性设置为Weight类型,这样的代码中天然包含了数值和质量单位信息。

在定义实体类的时候,很多数值类型的属性其实都是隐含了单位的,比如金额隐含了币种信息。

理想情况下,这些数值类型的属性都应该定义为包含了计量单位信息的类型。

这些包含数值和计量单位的类也一般被定义为不可变的值对象。

我们在编写实体类的时候,有一些属性的可选值范围是固定的,比如”员工”中用来定义职位级别的属性为int类型,可选范围为1~3,它们分别表示”初级””中级””高级”。

我们用int类型表示级别,因此我们同样需要在文档中说明不同数值的含义。

如果我们用C#中的枚举类型来表示这些固定可选值范围的属性,就可以让代码的可读性更强,也就更加符合DDD的思想。

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

在EF Core中,实体类的属性可以定义为枚举类型,枚举类型的属性在数据库中默认是以int类型来保存的。

对于直接操作数据库的人员来讲,0、1、2这样的值没有”CNY”、”USD”、”NZD”等这样的string类型的值可读性强。

EF Core中可以在Fluent API中用HasConversion<string>把枚举类型的值配置成字符串。

我们通过”地区”实体类Region来举例。

一个省、一个市等都可以表示为一个地区,Region类含有Name、Area、Level、Population、Location等属性;

Name中既包含中文名字,也包含英文名字,因此我们把它们定义到MultilingualString值对象中;

Area定义为包含Value、Unit两个属性的Area类型;

Location定义为包含Longitude、Latitude两个属性的Geo类型;

Level定义为包含Province、City、County、Town几个可选值的枚举类型RegionLevel。

Region类型如下代码所示:

C#
 public record Region
 {

     private Region() { }

     public Region(MultilingualString name, Area area, RegionLevel level, Geo location)
     {
         
         Name = name;
         Area = area;
         Level = level;
         Location = location;
     }


     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; }


     public void ChangePopulation(long value)
     {
         Population = value;
     }

     public void ChangeLevel(RegionLevel level)
     {
         Level = level;
     }


 }

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

Level和Population是可变的,因此Level和Population属性设置为Private set。

MultilingualString、Geo、Area等类以及RegionLevel、AreaType等枚举类型如以下代码所示:

C#
 public record MultilingualString(string? Chinese, string? English);

 public record Area(double Value,AreaType Unit);

 public 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 ArgumentOutOfRangeException(nameof(longitude), "Longitude must be between -180 and 180 degrees.");

         if (latitude < -90 || latitude > 90)
             throw new ArgumentOutOfRangeException(nameof(latitude), "Latitude must be between -90 and 90 degrees.");


         Longitude = longitude;
         Latitude = latitude;
     }

 }

 public enum RegionLevel
 {
     Province,
     City,
     Country,
     Town
 }

 public enum AreaType
 {
     SquareKM,//平方公里
     Hectare, //公顷
     CnMu //市亩
 }

我们还需要通过Fluent API来对Region中的枚举类型以及从属实体类型进行配置,如以下代码所示:

C#
public class RegionConfig : IEntityTypeConfiguration<Region>
{
    public void Configure(EntityTypeBuilder<Region> builder)
    {

        //用OwnsOne对从属实体类型属性进行配置。

        //对实体类Region以及从属实体类型Area中的枚举类型属性用HasConversion方法进行映射
        builder.OwnsOne(c => c.Area, ab => {

            ab.Property(e => e.Unit).HasMaxLength(20).IsUnicode(false).HasConversion<string>();

        });

        //对从属实体类型属性进行自定义配置
        builder.OwnsOne(c => c.Location);

        //让枚举类型在数据库中映射为string类型而不是默认的int类型
        builder.Property(c => c.Level).HasMaxLength(20).IsUnicode(false).HasConversion<string>();

        builder.OwnsOne(c => c.Name, ab => {

            ab.Property(e => e.English).HasMaxLength(20).IsUnicode(false);

            ab.Property(e => e.Chinese).HasMaxLength(20).IsUnicode(true);

        });
    }
}

接下来,我们编写上下文类并执行数据库迁移,数据库中就会生成如下图所示的数据库表:

从数据库表中我们可以看出,这些从属实体类型的属性在数据库中是保存在和实体类同样的表中的,列名以”实体类中的属性名_”开头,比如Name属性对应Name_Chinese、Name_English两列。

编写代码向数据库插入数据,如以下代码所示:

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

上面的程序执行完成后,数据库表中的数据如下图所示:

我们除了可以用从属实体类来保存值对象,也可以通过自定义ValueConverter把值对象以JSON字符串的形式保存到文本类型的列中。

不过,这种方式对于DBA(Database Administrator)以及通过SQL语句来操作数据库并不友好,因此一般不这样做。

在DDD理论中,实体类的标识符也也应该是强类型的,而不是long、Guid等看不出具体实体类型的标识符。

比如书籍类应该是BookId类型的,用户类的标识符应该是UserId类型的,这样的标识符被称为”强类型标识符”。

强类型标识符的语义更加清晰、代码的可读性、可维护性也更强,比如看到Find(BookId id)这样的方法我们就知道它是根据书籍的ID来查询数据的,而看到Find(long id)这样的方法我们就知道需要借助于注释、文档等才能了解id参数的意义。

EF Core中我们可以实现部分强类型标识符效果,但是EF Core还无法完美地支持强类型标识符。

订阅评论
提醒
0 评论
最旧
最新 最多投票
内联反馈
查看所有评论
滚动至顶部