DDD(三)DDD实战、贫血模型与充血模型

贫血模型与充血模型

1、贫血模型:一个类中只有属性或者成员变量,没有方法。
2、充血模型:一个类中既有属性、成员变量,也有方法。
需求:定义一个类保存用户的用户名、密码、积分;用户必须具有用户名;为了保证安全,密码采用密码的散列值保存;用户的初始积分为10分;每次登录成功奖励5个积分,每次登录失败扣3个积分。

贫血模型:代码
class User
{
	public string UserName { get; set; }//用户名
	public string PasswordHash { get; set; }//密码的散列值
	public int Credit { get; set; }//积分
}
逻辑代码
User u1 = new User(); 
u1.UserName = "yzk"; 
u1.Credit = 10;
u1.PasswordHash = HashHelper.Hash("123456");//计算密码的散列值
string pwd = Console.ReadLine();
if(HashHelper.Hash(pwd)==u1.PasswordHash)
{
    u1.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行代码中,等待用户输入一个密码,一边进行密码正确性的检查,

上面的代码可以正常的实现需求,但是有如下问题,

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

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

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

4、用户的积分余额很显然不能为负数,因此我们在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 u1 = new User("yzk");
u1.ChangePassword("123456");
string pwd = Console.ReadLine();
if (u1.CheckPassword(pwd))
{
    u1.AddCredits(5);
    Console.WriteLine("登录成功");
}
else
{
    Console.WriteLine("登录失败");
}
Console.Read();

可以看到,User类的使用者的工作量少了很多,他们需要了解的领域知识也少了很多,

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

​ 尽管充血模型带来的好处是明显的,但是贫血模型依旧很流行,其根本原因在于早期的很多持久性框架(比如ORM等)要求实体类的所有属性必须是可读可写,而我们可以很简单的把数据库中的表按照字段逐个映射为一个贫血模型的POCO类,这样“数据库驱动”的思维方式更简单直接,因此我们就见到“到处都是贫血模型”的情况了。值得欣慰的是,目前大部分主流持久性框架都已经支持充血模型的写法了,比如EF Core对充血模型的支持就非常好,因此我们没有理由再继续写贫血模型了。采用充血模型编写代码,我们能更好的实现DDD和模型驱动编程了。

EF Core对实体属性操作的秘密

1、Why?为EF Core实现充血模型做准备。
2、EF Core是通过实体对象的属性的get、set来进行属性的读写吗?
3、答案:基于性能和对特殊功能支持的考虑,EF Core在读写属性的时候,如果可能,它会直接跳过get、set,而直接操作真正存储属性值的成员变量。

class Dog
{
	public long Id { get; set; }
	private string name;
	public string Name 
	{ 
		get
		{
			Console.WriteLine("get被调用");
			return name;
		}
		set 
		{
			Console.WriteLine("set被调用");
			this.name = value; 
		} 
	}
}
Program
Dog d1 = new Dog { Name= "goofy" };
Console.WriteLine("Dog初始化完毕");
ctx.Dogs.Add(d1);
ctx.SaveChanges();
Console.WriteLine("SaveChanges完毕");

Console.WriteLine("准备读取数据");
Dog d2 = ctx.Dogs.First(d=>d.Name== "goofy");
Console.WriteLine("读取数据完毕");
总结:

EF Core在读写实体对象的属性时,会查找属性对应的成员变量,如果能找到,EF Core会直接读写这个成员变量的值,而不是通过set和get代码块来读写。

修改Dog的成员变量名
class Dog
{
	public long Id { get; set; }
	private string xingming;
	public string Name 
	{ 
		get
		{
			Console.WriteLine("get被调用");
			return xingming;
		}
		set 
		{
			Console.WriteLine("set被调用");
			this.xingming = value; 
		} 
	}
}

总结:

1、EF Core会尝试按照命名规则去直接读写属性对应的成员变量,只有无法根据命名规则找到对应成员变量的时候,EF Core才会通过属性的get、set代码块来读写属性值。

2(*)、可以在FluentAPI中通过UsePropertyAccessMode()方法来修改默认的这个行为。

EF Core中充血模型的需求:

充血模型实现的要求

一:属性是只读的或者是只能被类内部的代码修改。
二:定义有参数的构造方法。
三:有的成员变量没有对应属性,但是这些成员变量需要映射为数据表中的列,也就是我们需要把私有成员变量映射到数据表中的列。
四:有的属性是只读的,也就是它的值是从数据库中读取出来的,但是我们不能修改属性值。
五:有的属性不需要映射到数据列,仅在运行时被使用。


在EF Core中如何实现

实现“一”

属性是只读的或者是只能被类内部的代码修改。
实现:把属性的set定义为private或者init,然后通过构造方法为这些属性赋予初始值。

实现“二”

定义有参数的构造方法。
原理: EF Core中的实体类如果没有无参的构造方法,则有参的构造方法中的参数的名字必须和属性的名字一致。
实现方式1:无参构造方法定义为private。
实现方式2:实体类中不定义无参构造方法,只定义有意义的有参构造方法,但是要求构造方法中的参数的名字和属性的名字一致。

实现“三”

不属于属性的成员变量映射为数据列。
实现:
builder.Property(“成员变量名”)

实现“四”

从数据列中读取值的只读属性。
EF Core中提供了“支持字段”(backing field)来支持这种写法:在配置实体类的代码中,使用HasField(“成员变量名”)来配置属性。

实现“五”

有的属性不需要映射到数据列,仅在运行时被使用。
实现:使用Ignore()来配置忽略这个属性。

本文内容大部分都为杨中科老师《ASP.NET Core技术内幕与项目实战》一书中内容,此文只是做学习记录,如有侵权,联系立马删除。