DDD的技术落地 — 贫血模型与充血模型

在面向对象的设计中有贫血模型和充血模型两种风格。

所谓的贫血模型指的是一个类中只有属性或成员变量,没有方法。

而充血模型指的是一个类中既有属性、成员变量,也有方法。

下面用一个用户的例子来说明它们的区别:

假设我们需要定义一个类,这个类中可以保存用户的用户名、密码、积分;

用户必须具有用户名;

为了保证安全,密码采用密码的哈希值保存;

用户的初始积分为10;

每次登录成功奖励5个积分,每次登录失败扣3个积分。

如果采用贫血模式,我们就会如以下代码所示定义User类

C#
class User
{

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

这是一个典型的只包含属性、不包含逻辑方法的类,这样的类通常被叫做POCO类,这就是典型的”贫血模型”。

使用这样的类,我们编写代码来进行用户创建、登录、积分变动操作如下代码所示:

C#
User u1 = new();

u1.UserName = "张三";

u1.Credit = 100;

//调用HashHelper.Hash方法来计算字符串的哈希值;
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("登录失败");
}

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

第一:

一个User对象必须具有用户名,但是在第1行代码中创建的User类的对象的UserName属性为null。

虽然我们很快在第3行代码中为UserName属性赋值了,但是如果User类使用不当,User类的对象有可能处于非法状态。

第二:

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

第三:

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

第四:

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

面向对象编程的基本特征是”封装性”:

把类的内部实现细节封装起来,对外提供可供安全调用的方法,从而让类的使用者无须关心类的内部实现。

一个类中核心的元素是数据和行为,数据指的是类的属性或者成员变量,而行为指的是类的方法。

而我们设计的User类只包含数据,不包含行为,我们用心设计的类只能利用面向对象编程的一部分能力。

如果我们按照面向对象的原则来重新设计User类,应该如以下代码所示:

C#
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;
	}
    /// <summary>
    /// 更改密码
    /// </summary>
    /// <param name="newValue"></param>
    /// <exception cref="ArgumentException"></exception>
    public void ChangePassword(string newValue)
	{
		if (newValue.Length < 6)
		{
			throw new ArgumentException("密码太短");
		}
		this.passwordHash = HashHelper.Hash(newValue);
	}
    /// <summary>
    /// 验证密码
    /// </summary>
    /// <param name="password"></param>
    /// <returns></returns>
    public bool CheckPassword(string password)
	{
		string hash = HashHelper.Hash(password);
		return passwordHash == hash;
	}

    /// <summary>
    /// 扣除积分
    /// </summary>
    /// <param name="delta"></param>
    /// <exception cref="ArgumentException"></exception>
    public void DeductCredits(int delta)
	{
		if (delta <= 0)
		{
			throw new ArgumentException("额度不能为负值");
		}
		this.Credit -= delta;
	}

    /// <summary>
    /// 增加积分
    /// </summary>
    /// <param name="delta"></param>
    public void AddCredits(int delta)
	{
		this.Credit += delta;
	}

}

User类中,UserName属性设置为只读并且只能在初始化时被赋值,Credit属性设置为只读并且只能在User类内部被修改。

通过合理设置User类的属性的访问修饰符,我们有效地避免了外部访问者对类内部数据的随意修改,这样的类就是典型的”充血模型”。

经过User类的封装,我们把应该封装到User类中的行为都隐藏到了User类中,类的使用者需要了解的领域知识非常少。

下面是调用新版User类的代码:

C#
User u1 = new User("yzk");
u1.ChangePassword("123456");
string pwd = Console.ReadLine();
if (u1.CheckPassword(pwd))
{
    u1.AddCredits(5);
    Console.WriteLine("登录成功");
}
else
{
    Console.WriteLine("登录失败");
}

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

大家可能会认为,无论是贫血模型还是充血模型,只不过是逻辑代码放置的位置不同而已,本质上没什么区别

这样的观点是错误的

首先,从代码的角度来讲,把本应该属于User类的行为封装到User类中,这是符合”单一职责原则”的,当系统中其他地方需要调用User类的时候就可以复用User中的方法。

其次,贫血模型是站在开发人员的角度思考问题的,而充血模型是站在业务的角度思考问题的。

领域专家不明白上面是”把用户输入的密码进行哈希运算,然后把哈希值保存起来”,但是他们明白”修改密码、检查密码成功”等充血模型反映出来的概念,因此领域模型中的所有行为应该有业务价值,而不应该只是反映数据属性。

尽管充血模型带来的好处更明显,但是贫血模型依然很流行,其根本原因就在于早期的很多持久性框架(比如ORM等)要求实体类的所有属性必须是可读可写的。

而且我们可以很简单地把数据库中的表按照字段逐个映射为一个贫血模型的POCO类,这样”数据库驱动”的思维方法更简单直接,因此我们就见到”到处都是贫血模型”的情况了。

值得欣慰的是,目前大部分主流的持久性框架都已经支持充血模型的写法了,比如EF Core对充血模型的支持就非常好,因此我们就没有再继续编写贫血模型的理由了。

采用充血模型编写代码,我们能更好地实现DDD和模型驱动编程。

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