贫血模型与充血模型

在面向对象的设计中有贫血模型与充血模型两种风格。所谓的贫血模型指的是一个类中只有属性或者成员变量,没有方法,而充血模型指的是一个类中既有属性、成员变量,也有方法。

假设我们需要定义一个类,这个类中可以保存用户的用户名、密码就、积分;用户必须具有用户名;为了保证安全,密码采用密码的哈希值保存;用户的初始积分为10;每次登录成功奖励5个积分,每次登录失败扣3个积分(这样的需求肯定是不合理的)

贫血模型

逻辑代码:

1
2
3
4
5
6
class User
{
public string UserName{get;set;}//用户名
public string PasswordHash {get;set;}//密码的散列值
public int Credit {get;set;}//积分
}

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
User u1=new User();
u1.UserName="YOUXIANYU";
u1.Credit=10;
u1.PasswordHash=HashHelper.Hash("123456");//计算密码的散列值
string pwd = Console.RedLine();
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.WiteLine("登录失败")
}
}

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

  1. 一个User对象必须具有用户名,但是在第一行代码中创建的User类的对象的UserName属性为null。虽然我们很快在第二行代码中给UserName赋值了,但是如果User类使用不当,User类的对象有可能处于非法状态。
  2. “用户的初始积分为10”这样的领域知识是由使用者在第三行代码中设定的,而不是由User类内化的行为。
  3. “保存用户密码的哈希值”这样的User类内部的领域知识需要类的使用者了解,这样类的使用者才能在第四行代码和第6行代码完成设置密码及判断用户输入的密码是否正确。
  4. 用户的积分余额很显然不能为负值,因此我们在地13~21行代码中进行积分扣减的时候进行了判断,可是这样的行为应该封装到User类中,而不应该由User类的使用者进行判断。

充血模型

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

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
class User
{
public string? UserName { get; init; }
public int Credit { get; private set; }
public 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 = newValue;
}
public bool CheckPassword(string password)
{
string hash = password;
return password == hash;
}
public void DeductCredits(int delta)
{
if (Credit <3)
{
throw new ArgumentException("额度不足");
}
if (delta <= 0)
{
throw new ArgumentException("额度不能为负值");
}
this.Credit-= delta;
}
public void AddCredits(int delta)
{
this.Credit += delta;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
User u1 = new User("YOUXIANYU");
u1.ChangePassword("123456");
string? pwd = Console.ReadLine();
if (u1.CheckPassword(pwd))
{
u1.AddCredits(5);
Console.WriteLine("登陆成功");
}
else
{
u1.DeductCredits(3);
Console.WriteLine("登录失败");
}

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

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

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

EF Core对实体类属性操作的密码

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

1、Why?为EF Core实现充血模型做准备。

2、EF Core是通过实体对象的属性的get、set来进行属性的读写吗?

3、答案:基于性能和对特殊功能支持的考虑,EF Core在读写属性的时候,如果可能,它会直接跳过get、set,而直接操作真正存储属性值的成员变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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;
}
}
}
1
2
3
4
5
6
7
8
9
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的成员变量名

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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()来配置忽略这个属性。

EF Core中实现充血模型

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
class User
{
public int Id { get; init; } //特征一
public DateTime CreatedDateTime { get; init; } //特征一
public string UserName { get; private set; }//特征一
public int Credit { get; private set; }
private string? passwordHash;//特征三
private string? remark;
public string? Remark//特征四
{
get { return remark; }
}
public string? Tag { get; set; }//特征五
private User()//给EFCore从数据库中加载数据然后生成User对象返回用的
{
//特征二
}
public User(string yhm)//特征二
{
this.UserName = yhm;
this.CreatedDateTime = DateTime.Now;
this.Credit = 10;
}
public void ChangeUserName(string newValue)
{
this.UserName = newValue;
}
public void ChangePasswordHash(string newValue)
{
if (newValue.Length > 6)
{
throw new ArgumentException("密码太短");
}
this.passwordHash = HashHelper.ComputeMd5Hash(newValue);
}
}

配置User实体类

1
2
3
4
5
6
7
8
9
class UserConfig : IEntityTypeConfiguration<User>
{
public void Configure(EntityTypeBuilder<User> builder)
{
builder.Property("passwordHash");//特征三
builder.Property(u => u.Remark).HasField("remark");//特征四
builder.Ignore(u=>u.Tag);//特征五
}
}

配置EF Core

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class MyDbContext:DbContext
{
public DbSet<User> Users { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
base.OnConfiguring(optionsBuilder);
optionsBuilder.UseSqlServer("Server=localhost;Database=YourDatabaseName;Trusted_Connection=True;MultipleActiveResultSets=true;TrustServerCertificate=True;Encrypt=True;");
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
new UserConfig().Configure(modelBuilder.Entity<User>());
}
}
1
2
3
4
5
6
using MyDbContext ctx = new MyDbContext();
User u1 = new User("YOUXIANYU");
u1.Tag = "MyTag";
u1.ChangePasswordHash("123456");
ctx.Users.Add(u1);
ctx.SaveChanges();

因为User类的无参构造方法为私有的,所以我们只能调用设定初始用户名的构造方法,这样就保证了对象的合法性。上面的代码会在数据库中插入一条记录,我们修改数据库中Remark列的值为“you”,然后执行代码。

1
2
3
using MyDbContext ctx = new MyDbContext();
User u1 = ctx.Users.First(u => u.UserName == "YOUXIANYU");
Console.WriteLine(u1.Remark);