DDD实战(二)

目录

一、贫血模型与充血模型

二、EF Core 中实现充血模型

三、 EF Core 中实现值对象


一、贫血模型与充血模型

        在面向对象的设计中有贫血模型和充血模型两种风格。所谓的贫血模型指的是一个类中只有属性或者成员变量,没有方法,而充血模型指的是一个类中既有属性、成员变量,也有方法。下面就用一个用户的例子来说明它们的区别。假设我们需要定义一个类,这个类中可以保存用户的用户名、密码、积分:用户必须具有用户名;为了保证安全,密码采用密码的哈希值保存;用户的初始积分为10;每次登录成功奖励5个积分,每次登录失败扣3个积分(这样的需求肯定是不合理的,这里只是为了方便演示而已)。用贫血模型定义一下User类。

class User
{
public string UserName { get; set; }  //用户名
public string PasswordHash { get; set; }  //密码的哈希值
public string Credit{ get; set; }  //积分
}

这是一个典型的只包含属性、不包含逻辑方法的类,这样的类通常被叫做POCO类,这就是典型的“贫血模型”。使用这样的类,我们编写代码来进行用户创建、登录、积分变动操作。

​
User ul = new User();
u1.UserName = "yzk";
u1.Credit = 10;
u1.PasswordHash = HashHelper.Hash("123456"); //计算密码的哈希值
string pwd = Console.ReadLine();
if(HashHelper.Hash(pwd)==u1.PasswordHash)
{
ul.Credit += 5; //登录成功,奖励5个积分
Console.WriteLine("登录成功");
}
else
{
if (u1.Credit<3)
{
Console.WriteLine("积分不足,无法扣减");
}
else
{
u1.Credit -= 3;  //登录失败,则扣3个积分
Console.WriteLine("登录成功");
}
Console.WriteLine("登录失败");
}

​

 第4行代码中,调用HashHelper.Hash 方法来计算字符串的哈希值;第5行代码中,等待用户输入一个密码,以便进行密码正确性的检查。上面的代码可以正常地实现需求,但有如下问题。

第一,一个User 对象必须具有用户名,但是在第 1 行代码中创建的 User 类的对象的UserName属性为nul。虽然我们很快在第2行代码中为UserName 属性赋值了,但是如果User类使用不当,User 类的对象有可能处于非法状态。

第二,"用户的初始积分为10"这样的领域知识是由使用者在第3行代码中设定的,而不是由 User 类内化的行为。

第三,“保存用户密码的哈希值”这样的 User 类内部的领域知识需要类的使用者了解。这样类的使用者才能在第4行代码和第6行代码完成设置密码及判断用户输入的密码是否正确。

第四,用户的积分余额很显然不能为负值,因此我们在第 13~21 行代码中进行积分扣减的时候进行了判断,可是这样的行为应该被封装到 User类中,而不应该由User 类的使用者进行判断。

面向对象编程的基本特征是“封装性”:把类的内部实现细节封装起来,对外提供可供安全调用的方法,从而让类的使用者无须关心类的内部实现。一个类中核心的元素是数据和行为,数据指的是类的属性或者成员变量,而行为指的是类的方法。而我们设计的User类只包含数据,不包含行为,我们用心设计的类只能利用面向对象编程的一部分能力。

按照面向对象的原则来重新设计 User 类

class User
{
public string UserName { get; init; }
public int Credit { get; private set; }
private string? passwordHash;
public User(string userName)
{
this.UserName = userName;
this.Credit =10;
}
public void ChangePassword(string newValue)
{
if(newValue.Length<6)
{
throw new ArgumentException("密码太短");
}
this.passwordHash = HashHelper.Hash(newValue);
}
public bool CheckPassword(string password)
{
string hash = HashHelper.Hash(password);
return passwordHash== hash;
}
public void DeductCredits(int delta)
{
if(delta<=0)
{
throw new ArgumentException("额度不能为负值");
}
this.Credit -= delta;
}
public void Addcredits(int delta)
{
this.Credit += delta;
}
}

        User类中,UserName 属性设置为只读并且只能在初始化时被赋值,Credit 属性设置为只读并且只能在 User 类内部被修改。通过合理设置 User 类的属性的访问修饰符,我们有效地避免了外部访问者对类内部数据的随意修改,这样的类就是典型的“充血模型”。

       在第6行代码中,我们为User类提供了构造方法,确保了User类的对象在创建出来的时候就处于合法的状态,而且我们把“初始积分为10”这样的行为内化在了User 类中。

        在第5行代码中,我们把密码的哈希值设置为一个私有的成员变量,因为密码的哈希值是不应该被外部系统访问的。为了让外部系统能够发送修改密码请求以及检查用户输入的密码是否正确,我们在第 11~23行代码中提供了 ChangePassword、CheckPassword方法,它们把保存密码和校验用户密码的工作封装了起来。

        为了能够提供扣减积分和奖励积分的能力,我们分别在第 24~35 行代码中封装了DeductCredits、AddCredits 两个方法,并且在方法中提供了数据合法性的检查。经过User类的封装,我们把应该封装到 User 类中的行为都隐藏到了User类中,类的使用者需要了解的领域知识非常少。

使用充血模型的新版 User类

User ul = new User("yzk");
ul.ChangePassword("123456");
string pwd =Console.readLine();
if ful.CheckPassword(pwd))
{
u1.AddCredits(5);
Console.writeLine("登录成功");
}
else
{
ul.DeductCredits(3);
Console.WriteLine("登录失败");
}

        可以看到,User类的使用者的工作量减少了很多,他们需要了解的领域知识也少了很多。有的读者可能会认为,无论是贫血模型还是充血模型,只不过是逻辑代码放置的位置不同而已,本质上没什么区别。这样的观点是错误的。首先,从代码的角度来讲,把本应该属于 User 类的行为封装到 User 类中,这是符合“单一职责原则”的,当系统中其他地方需要调用 User 类的时候就可以复用 User 中的方法。其次,贫血模型是站在开发人员的角度思考问题的,而充血模型是站在业务的角度思考问题的。领域专家不明白什么是“把用户输入的密码进行哈希运算,然后把哈希值保存起来”,但是他们明白“修改密码、检查密码成功”等充血模型反映出来的概念,因此领域模型中的所有行为应该有业务价值,而不应该只是反映数据属性。

        尽管充血模型带来的好处更明显,但是贫血模型依然很流行,其根本原因就在于早期的很多持久性框架(比如ORM 等)要求实体类的所有属性必须是可读可写的,而且我们可以很简单地把数据库中的表按照字段逐个映射为一个贫血模型的 POCO 类,这样“数据库驱动”的思维方法更简单直接,因此我们就见到“到处都是贫血模型”的情况了。

二、EF Core 中实现充血模型

      EF Core 中对充血模型提供了比较好的支持,本小节我们来学习如何在 EF Core 中把充血模型风格的实体类映射到数据库表中。充血模型中的实体类和 POCO类相比,有如下的特征
特征一:属性是只读的或者只能被类内部的代码修改。
特征二:定义了有参构造方法。
特征三:有的成员变量没有对应属性,但是这些成员变量需要映射为数据库表中的列,也就是我们                 需要把私有成员变量映射到数据库表中的列。
特征四:有的属性是只读的,也就是它的值是从数据库中读取出来的,但是我们不能修改属性值。
特征五:有的属性不需要映射到数据列,仅在运行时被使用。

三、 EF Core 中实现值对象

        在定义实体类的时候,实体类中的一些属性之间有着紧密的联系,比如我们要在表示城市
的实体类City中定义表示地理位置的属性,因为地理位置包含“经度”(longitude)和“纬度”
latitude)两个值,所以我们可以为City类增加 Longitude、Latitude 两个属性。这也是大部分
人的做法,这样做没什么太大的问题。不过,从逻辑上来讲,这样定义的经纬度和主键、名字
等属性之间是平等的关系,体现不出来经度和纬度的紧密关系。如果我们能定义一个包含
Longitude、Latitude 两个属性的 Geo 类型,然后把City的“地理位置”属性定义为Geo类型,
这样经度、纬度的关系就更紧密了。Geo类型的Longitude、Latitude 两个属性通常不会被单独修改,因此 Geo 被定义成不可变类,也就是值对象。
       在定义实体类的时候,很多数值类型的属性其实都是隐含了单位的,比如金额隐含了币种
信息。理想情况下,这些数值类型的属性都应该定义为包含了计量单位信息的类型。这些包含
我们在编写实体类的时候,有一些属性的可选值范围是固定的。如果我们用 C#中的枚
举类型来表示这些固定可选值范围的属性,就可以让代码的可读性更强,也就更加符合 DDD
EF Core 中提供了对于没有标识符的值对象进行映射的功能,那就是“从属实体类”(owned
entities)类型,我们只要在主实体类中声明从属实体类型的属性,然后使用 Fluent API中的
在 EF Core 中,实体类的属性可以定义为枚举类型,枚举类型的属性在数据库中默认是以
im 类型来保存的。

你可能感兴趣的:(.NET,.netcore)