实现整洁架构项目分层

这个案例分为Users.Domain、Users.Infrastructure、Users.WebAPI这三个项目。

Users.Domain是领域层项目,主要包含实体类、值对象、领域事件数据类、领域服务、仓储接口、防腐层接口等;

Users.Infrastructure是基础设施项目,主要包含实体类的配置、上下文类的定义、仓储服务、防腐层接口的实现、基础工具类等;

Users.WebAPI是ASP.NET Web API项目,主要包含应用服务、Controller类、领域事件处理者、数据校验、权限校验、工作单元、事务处理等代码。

Users.Domain项目对应的是整洁架构中的领域模型和领域服务,Users.Infrastructure项目对应的是整洁架构中的基础设施、数据库、外部服务,Users.WebAPI项目对应的是整洁架构中的用户界面和应用服务。

Users.Domain不依赖任何项目,Users.Infrastructure依赖于Users.Domain,而Users.WebAPI同时依赖于Users.Domain和Users.Infrastructure。因此Users.WebAPI既可以调用Users.Domain中的领域服务,也可以直接调用Users.Infrastucture。因此Users.WebAPI既可以调用Users.Domain中的领域服务,也可以直接调用Users.Infrastructure中上下文的代码,而Users.Domain则不可以直接调用Users.Infrastructure中上下文的代码,而Users.Domain则不可以直接访问Users.Infrastructure,但是Users.Domain可以通过访问IUsersDomainRepository接口来间接调用Users.Infrastructure中的服务。

领域模型的实现

我们将实现实体类、值对象等基础的领域模型,并且识别和定义出聚合及聚合根,这是DDD的战术起点。这些代码都位于Users.Domain项目中。

作为一个用户管理系统,“用户(User)”是我们识别出的第一个实体类;“用户登录失败次数过多则锁定”这个需求并不属于“用户”这个实体类中的一个常用的特征,我们应当把它拆分到一个单独的实体类中,因此我们识别出一个单独的“用户登录失败”(UserAccessFail)实体类:“用户登陆记录”(UserLoginHistory)也应该识别为一个单独的实体类。User和UserAccessFail的关系是非常紧密的,UserAccessFail,因此该User和UserAccessFail不会独立于User存在,而且我们只有访问到User的时候才会访问UserAccessFail,因此该User和UserAccessFail设计为同一个聚合,并且把User设置为聚合根;由于我没有可能单独查询一段时间内的登录记录等独立与某个用户的需求,因此我们把UserLoginHistory设计为一个单独的聚合。

PhoneNumber

我们的系统中需要保存手机号,由于该系统可能被海外用户访问,而海外用户的手机号还需要包含“国家/地区码”,因此我们设计了用来表示手机号的值对象PhoneNumber。

1
2
3
4
{
//海外手机号
public record PhoneNumber(int RegionCode, int Number);
}

Zack.Commons

为了区别聚合根实体类和普通实体类,我们定义了不包含任何成员的标识接口IAggregateRoot,并且让所有的聚合根实体类实现这个接口。

1
2
3
public interface IAggregateRoot
{
}

编写UserAccessFail类

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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
namespace Users.Domain.Entities
{
// 用户访问失败记录类,用于管理用户登录失败次数和锁定状态
internal class UserAccessFail
{
// 主键Id
public Guid Id { get; init; }
// 关联的用户Id
public Guid UserId { get; init; }
// 关联的用户对象
public User User { get; init; }
// 是否被锁定
public bool lockOut;
// 锁定结束时间
public DateTime? LockoutEnd { get; private set; }
// 连续失败次数
public int AccessFailCount { get; private set; }

// 私有构造函数,防止外部直接创建
private UserAccessFail()
{
}

// 通过用户对象创建访问失败记录
public UserAccessFail(User user)
{
Id = Guid.NewGuid();
UserId = user.Id;
}

// 重置失败次数和锁定状态
public void Reset()
{
lockOut = false;
LockoutEnd = null;
AccessFailCount = 0;
}

// 判断当前是否处于锁定状态
public bool IsLockOut()
{
if (lockOut)
{
// 如果锁定还未到期,返回true
if (LockoutEnd >= DateTime.Now)
{
return true;
}
else
{
// 锁定已过期,重置状态
AccessFailCount = 0;
LockoutEnd = null;
return false;
}
}
else
{
return false;
}
}

// 登录失败时调用,增加失败次数,超过5次则锁定5分钟
public void Fail()
{
AccessFailCount++;
if (AccessFailCount >= 5)
{
lockOut = true;
LockoutEnd = DateTime.Now.AddMinutes(5);
}
}
}
}

UserAccessFail类同样是充血模型,当用户登陆失败一次时,我们就调用Fail方法来记录因此登录失败,如果发现登陆失败超过3次,我们就锁定这个用户5min;我们通过IsLockOut方法判断这个账户是否已经被锁定;一旦登陆成功一次,我们就调用Reset方法来重置登录失败信息。由于实体类中进行的都是抽象操作,并不会直接进行数据库操作,因此我们编写的实体类中的Fail、Reset等方法都只是修改实体类的属性,并没有写入数据库的操作。

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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// User聚合根,表示系统中的用户实体
internal class User : IAggregateRoot
{
// 用户唯一标识
public Guid Id { get; set; }

// 用户手机号(值对象),只允许内部修改
public PhoneNumber PhoneNumber { get; private set; }

// 用户密码的哈希值,私有字段
private string? passwordHash;

// 用户访问失败信息(如登录失败次数、锁定状态等)
public UserAccessFail AccessFail { get; private set; }

// 无参构造函数,供ORM或序列化使用
public User() { }

// 通过手机号创建用户,同时初始化访问失败信息
public User(PhoneNumber phoneNumber)
{
Id = Guid.NewGuid();
PhoneNumber = phoneNumber;
AccessFail = new UserAccessFail(this);
}

// 判断用户是否设置了密码
public bool HasPassword()
{
return !string.IsNullOrEmpty(passwordHash);
}

// 修改用户密码,密码长度必须大于3位,内部存储为MD5哈希
public void ChangePassword(string value)
{
if (value.Length <= 3)
{
throw new ArgumentException("密码长度不能小于等于3");
}
passwordHash = HashHelper.ComputeMd5Hash(value);
}

// 检查输入的密码是否正确(与存储的哈希值比对)
public bool CheckPassword(string password)
{
return passwordHash == HashHelper.ComputeMd5Hash(password);
}

// 修改用户手机号
public void ChangePhoneNumber(PhoneNumber phoneNumber)
{
PhoneNumber = phoneNumber;
}
}

可以看到,作为聚合根,User类实现了IAggregateRoot接口,而且它是一个充血模型。密码的哈希值不应该被外界访问到,因此passwordHash被定义为私有的成员变量。由于用户可以不设置密码,而使用手机号加短信验证码登录,因此passwordHash被定义为可空的string类型,并且提供了HasPassword方法用于判断用户是否设置了密码。ChangePassword、CheckPassword两个方法分别用于修改密码和检查用户输入的密码是否正确,由于“密码采用哈希值保存”属于User类的内部实现细节,因此计算明文密码的哈希值的操作在ChangePassword、CheckPasswrod两个方法中完成。

UserAccessFail

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
41
42
43
44
45
46
47
48
49
50
51
52
53
/// <summary>
/// 用户登录历史记录实体,表示用户每次登录的相关信息。
/// </summary>
internal class UserLoginHistory : IAggregateRoot
{
/// <summary>
/// 主键,自增ID。
/// </summary>
public long Id { get; init; }

/// <summary>
/// 用户ID,可能为null(如未注册用户)。
/// </summary>
public Guid? UserId { get; init; }

/// <summary>
/// 用户登录时使用的手机号。
/// </summary>
public PhoneNumber PhoneNumber { get; init; }

/// <summary>
/// 登录时间。
/// </summary>
public DateTime LoginTime { get; init; }

/// <summary>
/// 登录相关消息或描述。
/// </summary>
public string Messsage { get; init; }

/// <summary>
/// ORM或序列化使用的无参构造函数。
/// </summary>
private UserLoginHistory()
{
// ORM或序列化使用的无参构造函数
}

/// <summary>
/// 创建用户登录历史记录的新实例。
/// </summary>
/// <param name="userId">用户ID</param>
/// <param name="phoneNumber">手机号</param>
/// <param name="loginTime">登录时间</param>
/// <param name="message">登录消息</param>
public UserLoginHistory(Guid? userId, PhoneNumber phoneNumber, DateTime loginTime, string message)
{
UserId = userId;
PhoneNumber = phoneNumber;
LoginTime = loginTime;
Messsage = message;
}
}

如果用户输入一个系统中不存在的手机号,我们也要把他记录到日志中,因此UserId属性为可空的Guid类型。由于UserLoginHistory类是独立的聚合,而在DDD中,聚合之间只通过聚合根实体类的标识来引用,因此UserLoginHistory类中只定义UserId属性,而不是定义User属性,这样我们就把聚合之间的耦合度降低了。从逻辑上来讲,UserLoginHistory类的UserId属性是一个指向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
/// <summary>
/// 表示用户访问结果的枚举。
/// </summary>
public enum UserAccessResult
{
/// <summary>
/// 访问成功。
/// </summary>
OK,
/// <summary>
/// 未找到手机号。
/// </summary>
PhoneNumberNotFound,
/// <summary>
/// 用户被锁定。
/// </summary>
Lockout,
/// <summary>
/// 未设置密码。
/// </summary>
NoPassword,
/// <summary>
/// 密码错误。
/// </summary>
PasswordError
}
1
2
3
4
5
6
7
/// <summary>
/// 用户访问结果事件。
/// 用于在用户访问(如登录、注册等)后,发布访问结果信息。
/// </summary>
/// <param name="PhoneNumber">用户手机号。</param>
/// <param name="Result">访问结果。</param>
public record class UserAccessResultEvent(PhoneNumber PhoneNumber, UserAccessResult Result) : INotification;

IUserDomainRepository

领域服务需要使用仓储接口来通过持久层读写数据,因此我们需要在Users.Domain项目中编写仓储接口IUserDomainRepository,这些代码都位于Users.Domain项目中。

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
41
42
43
44
45
46
47
48
49
namespace Users.Domain
{
/// <summary>
/// 用户领域仓储接口,定义用户相关的数据访问操作。
/// </summary>
public interface IUserDomainRepository
{
/// <summary>
/// 根据手机号查找用户。
/// </summary>
/// <param name="phoneNumber">用户手机号。</param>
/// <returns>找到的用户对象,找不到返回null。</returns>
Task<User?> FindOneAsync(PhoneNumber phoneNumber);

/// <summary>
/// 根据用户唯一标识查找用户。
/// </summary>
/// <param name="userId">用户唯一标识(Guid)。</param>
/// <returns>找到的用户对象,找不到返回null。</returns>
Task<User?> FindOneAsync(Guid userId);

/// <summary>
/// 新增一条用户登录历史记录。
/// </summary>
/// <param name="phoneNumber">用户手机号。</param>
/// <param name="msg">登录相关信息(如登录结果、时间等)。</param>
Task AddNewLoginHistoryAsync(PhoneNumber phoneNumber, string msg);

/// <summary>
/// 发布用户访问结果领域事件(如登录成功、失败等),用于领域事件通知。
/// </summary>
/// <param name="eventData">用户访问结果事件数据。</param>
Task PublishEventAsync(UserAccessResultEvent eventData);

/// <summary>
/// 保存手机验证码(如短信验证码),用于后续校验。
/// </summary>
/// <param name="phoneNumber">用户手机号。</param>
/// <param name="code">验证码内容。</param>
Task SavePhoneCodeAsync(PhoneNumber phoneNumber, string code);

/// <summary>
/// 获取指定手机号最近保存的验证码。
/// </summary>
/// <param name="phoneNumber">用户手机号。</param>
/// <returns>验证码内容。</returns>
Task<string> RetrievePhoneCodeAsync(PhoneNumber phoneNumber);
}
}

两个FindOneAsync方法分别用于根据手机号和用户ID查找用户;AddNewLoginHistoryAsync方法用于记录一次登录操作;PublishEventAsync方法用于发布领域事件;SavePhoneCodeAsync方法用于保存短信验证码,而RetrievePhoneCodeAsync方法用于获取保存的短信验证码。

ISmsCodeSender

ISmsCodeSender是用于发送短信验证码的防腐层接口。

1
2
3
4
5
6
7
8
9
10
11
12
13
/// <summary>
/// 定义用于发送短信验证码的接口。
/// </summary>
public interface ISmsCodeSender
{
/// <summary>
/// 异步发送短信验证码到指定手机号。
/// </summary>
/// <param name="phoneNumber">接收验证码的手机号。</param>
/// <param name="code">要发送的验证码内容。</param>
/// <returns>表示异步操作的任务。</returns>
Task SendCodeAsync(PhoneNumber phoneNumber, string code);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/// <summary>
/// 表示校验验证码的结果。
/// </summary>
public enum CheckCodeResult
{
/// <summary>
/// 校验成功。
/// </summary>
OK,
/// <summary>
/// 未找到对应的手机号。
/// </summary>
PhoneNumberNotFound,
/// <summary>
/// 用户已被锁定。
/// </summary>
Lockout,
/// <summary>
/// 验证码错误。
/// </summary>
CodeError
}

UserDomainService

实体类中定义的方法只是和特定实体类相关的业务逻辑代码,而跨实体类、跨聚合的代码需要定义在领域服务或者应用服务中。因此我们编写领域服务UserDomainService。

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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
namespace Users.Domain
{
/// <summary>
/// 用户领域服务,封装用户相关的业务逻辑,如登录、验证码发送与校验、访问失败处理等。
/// </summary>
public class UserDomainService
{
// 用户仓储接口,用于数据持久化和查询
private readonly IUserDomainRepository repository;
// 短信验证码发送器接口
private readonly ISmsCodeSender smsSender;

/// <summary>
/// 构造函数,注入仓储和短信发送器
/// </summary>
public UserDomainService(IUserDomainRepository repository, ISmsCodeSender smsSender)
{
this.repository = repository;
this.smsSender = smsSender;
}

/// <summary>
/// 重置用户的访问失败次数和锁定状态
/// </summary>
public void ResetAccessFail(User user)
{
user.AccessFail.Reset();
}

/// <summary>
/// 判断用户是否被锁定
/// </summary>
public bool IsLockOut(User user)
{
return user.AccessFail.IsLockOut();
}

/// <summary>
/// 记录一次用户访问失败(如登录失败),并根据失败次数自动锁定
/// </summary>
public void AccessFail(User user)
{
user.AccessFail.Fail();
}

/// <summary>
/// 检查用户登录,返回登录结果(如成功、密码错误、被锁定等)
/// </summary>
/// <param name="phoneNum">用户手机号</param>
/// <param name="password">用户输入的密码</param>
/// <returns>登录结果枚举</returns>
public async Task<UserAccessResult> CheckLoginAsync(PhoneNumber phoneNum, string password)
{
// 根据手机号查找用户
User? user = await repository.FindOneAsync(phoneNum);
UserAccessResult result;
if (user == null)
{
// 用户不存在
result = UserAccessResult.PhoneNumberNotFound;
}
else if (IsLockOut(user))
{
// 用户被锁定
result = UserAccessResult.Lockout;
}
else if (user.HasPassword() == false)
{
// 用户未设置密码
result = UserAccessResult.NoPassword;
}
else if (user.CheckPassword(password))
{
// 密码正确
result = UserAccessResult.OK;
}
else
{
// 密码错误
result = UserAccessResult.PasswordError;
}
if (user != null)
{
if (result == UserAccessResult.OK)
{
// 登录成功,重置失败次数
ResetAccessFail(user);
}
else
{
// 登录失败,记录失败
AccessFail(user);
}
}
// 发布登录结果事件
UserAccessResultEvent eventItem = new(phoneNum, result);
await repository.PublishEventAsync(eventItem);
return result;
}

/// <summary>
/// 发送短信验证码到指定手机号
/// </summary>
/// <param name="phoneNum">用户手机号</param>
/// <returns>发送结果</returns>
public async Task<UserAccessResult> SendCodeAsync(PhoneNumber phoneNum)
{
var user = await repository.FindOneAsync(phoneNum);
if (user == null)
{
// 用户不存在
return UserAccessResult.PhoneNumberNotFound;
}
if (IsLockOut(user))
{
// 用户被锁定
return UserAccessResult.Lockout;
}
// 生成4位随机验证码
string code = Random.Shared.Next(1000, 9999).ToString();
// 保存验证码到仓储
await repository.SavePhoneCodeAsync(phoneNum, code);
// 发送验证码短信
await smsSender.SendCodeAsync(phoneNum, code);
return UserAccessResult.OK;
}

/// <summary>
/// 校验用户输入的短信验证码是否正确
/// </summary>
/// <param name="phoneNum">用户手机号</param>
/// <param name="code">用户输入的验证码</param>
/// <returns>校验结果</returns>
public async Task<CheckCodeResult> CheckCodeAsync(PhoneNumber phoneNum, string code)
{
var user = await repository.FindOneAsync(phoneNum);
if (user == null)
{
// 用户不存在
return CheckCodeResult.PhoneNumberNotFound;
}
if (IsLockOut(user))
{
// 用户被锁定
return CheckCodeResult.Lockout;
}
// 获取服务器端保存的验证码
string? codeInServer = await repository.RetrievePhoneCodeAsync(phoneNum);
if (string.IsNullOrEmpty(codeInServer))
{
// 没有验证码或已过期
return CheckCodeResult.CodeError;
}
if (code == codeInServer)
{
// 验证码正确
return CheckCodeResult.OK;
}
else
{
// 验证码错误,记录一次失败
AccessFail(user);
return CheckCodeResult.CodeError;
}
}
}
}

因为在实现领域服务的时候,我们需要调用仓储服务和短信发送服务,所以我们通过构造方法注入IUserDomainRepository和ISmsCodeSender,我们再调用这两个服务的方法的时候,并不需要关心它们是由哪个类实现的,以及是如何实现的,这就是“依赖于接口,而非依赖于现实”的依赖反转带来给系统架构的好处。

由于User是聚合根,所以对UserAccessFail的操作都通过User进行,因此我们在UserDomainService中定义的AccessFail等方法都是通过User进行的。
User等实体类中都是和实体类相关的代码,而UserDomainService领域服务中都是跨实体类操作的代码。

基础设施的实现

领域模型、领域服务中只定义了抽象的实体类、防腐层和仓储,我们需要在基础设施中对它们进行落地和实现。这些代码都位于Users.Infrastructure项目中。

UserConfig\UserAccessFailConfig\UserLoginHistoryConfig

实体类、值对象的定义是和持久机制无关的,它们需要通过EF Core的配置、上下文等建立和数据库的关系,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
namespace Users.Infrastructure.Configs
{
/// <summary>
/// 用户实体的EF Core配置类,定义表结构及相关映射规则
/// </summary>
internal class UserConfig : IEntityTypeConfiguration<User>
{
/// <summary>
/// 配置User实体的数据库映射
/// </summary>
/// <param name="builder">实体类型构建器</param>
public void Configure(EntityTypeBuilder<User> builder)
{
// 映射到表T_Users
builder.ToTable("T_Users");

// 配置PhoneNumber值对象的属性映射
builder.OwnsOne(x => x.PhoneNumber, nb =>
{
// 区号最大长度5,非Unicode
nb.Property(x => x.RegionCode).HasMaxLength(5).IsUnicode(false);
// 手机号最大长度20,非Unicode
nb.Property(x => x.Number).HasMaxLength(20).IsUnicode(false);
});

// 配置私有字段passwordHash的映射,最大长度100,非Unicode
builder.Property("passwordHash").HasMaxLength(100).IsUnicode(false);

// 配置一对一关系:User有一个AccessFail,UserAccessFail有一个User
builder.HasOne(x => x.AccessFail).WithOne(x => x.User)
// 配置UserAccessFail的外键,指定UserId为外键字段
.HasForeignKey<UserAccessFail>(x => x.UserId);
}
}
}

我们对值对象PhoneNumber进行了配置;由于passwordHash是一个私有成员变量,因此我们对他进行特殊的配置。

1
2
3
4
5
6
7
8
9
10
11
namespace Users.Infrastructure.Configs
{
internal class UserAccessFailConfig : IEntityTypeConfiguration<UserAccessFail>
{
public void Configure(Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<UserAccessFail> builder)
{
builder.ToTable("T_UserAccessFails");
builder.Property("lockOut");
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
namespace Users.Infrastructure.Configs
{
internal class UserLoginHistoryConfig : IEntityTypeConfiguration<UserLoginHistory>
{
public void Configure(EntityTypeBuilder<UserLoginHistory> builder)
{
builder.ToTable("T_UserLoginHistories");
builder.OwnsOne(x=>x.PhoneNumber, nb =>
{
// 区号最大长度5,非Unicode
nb.Property(x => x.RegionCode).HasMaxLength(5).IsUnicode(false);
// 手机号最大长度20,非Unicode
nb.Property(x => x.Number).HasMaxLength(20).IsUnicode(false);
});
}
}
}

UserDbContext

UserDbContext定义在Users.Infrastructure项目中,并且只为User、UserLoginHistory两个聚合根实体类声明DbSet属性,而不为User聚合中的UserAccessFail实体类定义DbSet属性,这样就约束开发人员尽量通过聚合根来操作聚合内的实体类。

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
namespace Users.Infrastructure
{
// UserDbContext 是用户模块的数据库上下文,负责与数据库进行交互
// 继承自 Entity Framework Core 的 DbContext
internal class UserDbContext : DbContext
{
// Users 表示用户实体集合,对应数据库中的用户表
public DbSet<User> Users { get; private set; }
// LoginHistories 表示用户登录历史记录集合,对应数据库中的登录历史表
public DbSet<UserLoginHistory> LoginHistories { get; private set; }

// 构造函数,接收数据库上下文配置参数
public UserDbContext(DbContextOptions<UserDbContext> options) : base(options)
{
}

// 配置实体模型的映射关系
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// 调用基类方法,保证基础配置被应用
base.OnModelCreating(modelBuilder);
// 自动应用当前程序集中的所有实体配置(IEntityTypeConfiguration实现类)
modelBuilder.ApplyConfigurationsFromAssembly(this.GetType().Assembly);
}
}
}

UserDomainRepository/ExpressionHelper

仓储接口IUserDomainRepository的实体类UserDomainRepository

我们把创建的UserLoginHistory对象添加到上下文中以后,并没有立即把数据保存到数据库中,因为到底什么时候保存工作单元中的修改是由应用服务层来决定的,仓储和领域层中都不能执行SaveChangesAsync操作。
文买把短信验证码保存在分布式缓存中,当然我们可以把短信验证码保存到数据库、Redis等地方。验证码保存到什么地方是由UserDomainRepository类来决定的,IUserDomainRepository服务的使用者并不需要知道这些细节。这就是整洁架构的内层定义和使用接口,以及外层实现接口所带来的好处。

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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
namespace Users.Infrastructure
{
/// <summary>
/// 用户领域仓储实现类,负责用户相关的数据访问和操作。
/// </summary>
public class UserDomainRepository : IUserDomainRepository
{
// 用户数据库上下文,用于操作数据库
private readonly UserDbContext dbCtx;
// 分布式缓存,用于存储验证码等临时数据
private readonly IDistributedCache distCache;
// MediatR中介者,用于发布领域事件
private readonly IMediator mediator;

/// <summary>
/// 构造函数,注入数据库上下文、分布式缓存和中介者。
/// </summary>
public UserDomainRepository(UserDbContext dbCtx, IDistributedCache distCache, IMediator mediator)
{
this.dbCtx = dbCtx;
this.distCache = distCache;
this.mediator = mediator;
}

/// <summary>
/// 新增一条用户登录历史记录。
/// </summary>
/// <param name="phoneNumber">用户手机号</param>
/// <param name="msg">登录相关信息</param>
public async Task AddNewLoginHistoryAsync(PhoneNumber phoneNumber, string msg)
{
// 先查找用户
var user = await FindOneAsync(phoneNumber);
// 创建登录历史记录(用户ID可能为null)
UserLoginHistory history = new UserLoginHistory(user?.Id, phoneNumber, msg);
// 添加到数据库上下文
dbCtx.LoginHistories.Add(history);
}

/// <summary>
/// 根据手机号查找用户。
/// </summary>
/// <param name="phoneNumber">用户手机号</param>
/// <returns>找到的用户对象,找不到返回null</returns>
public Task<User?> FindOneAsync(PhoneNumber phoneNumber)
{
// 包含访问失败信息,一起查出来
return dbCtx.Users.Include(u => u.AccessFail).SingleOrDefaultAsync(MakeEqual((User u) => u.PhoneNumber, phoneNumber));
}

/// <summary>
/// 根据用户ID查找用户。
/// </summary>
/// <param name="userId">用户唯一标识</param>
/// <returns>找到的用户对象,找不到返回null</returns>
public Task<User?> FindOneAsync(Guid userId)
{
// 包含访问失败信息,一起查出来
return dbCtx.Users.Include(u => u.AccessFail)
.SingleOrDefaultAsync(u => u.Id == userId);
}

/// <summary>
/// 发布用户访问结果领域事件(如登录成功、失败等)。
/// </summary>
/// <param name="eventData">事件数据</param>
public Task PublishEventAsync(UserAccessResultEvent eventData)
{
// 通过MediatR发布事件
return mediator.Publish(eventData);
}

/// <summary>
/// 获取指定手机号最近保存的验证码,并从缓存中移除。
/// </summary>
/// <param name="phoneNumber">用户手机号</param>
/// <returns>验证码内容</returns>
public Task<string> RetrievePhoneCodeAsync(PhoneNumber phoneNumber)
{
// 拼接完整手机号作为缓存Key
string fullNumber = phoneNumber.RegionCode + phoneNumber.Number;
string cacheKey = $"LoginByPhoneCode_Code_{fullNumber}";
// 从缓存获取验证码
string? code = distCache.GetString(cacheKey);
// 获取后立即移除,防止重复使用
distCache.Remove(cacheKey);
return Task.FromResult(code);
}

/// <summary>
/// 保存手机验证码到分布式缓存,有效期5分钟。
/// </summary>
/// <param name="phoneNumber">用户手机号</param>
/// <param name="code">验证码内容</param>
public Task SavePhoneCodeAsync(PhoneNumber phoneNumber, string code)
{
// 拼接完整手机号作为缓存Key
string fullNumber = phoneNumber.RegionCode + phoneNumber.Number;
// 创建分布式缓存项配置对象,用于设置缓存项的过期策略
var options = new DistributedCacheEntryOptions();
// 设置5分钟过期
options.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(300);
// 保存到缓存
distCache.SetString($"LoginByPhoneCode_Code_{fullNumber}", code, options);
return Task.CompletedTask;
}
}
}

封装MakeEqual方法进行手机号的相等性比较。

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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
namespace Users.Infrastructure
{
public class ExpressionHelper
{
/// <summary>
/// 构建一个用于比较对象属性是否相等的表达式。
/// 例如:可用于LINQ查询中,判断TItem类型对象的某个属性(TProp类型)是否与other对象的每个属性值都相等。
/// </summary>
/// <typeparam name="TItem">要比较的对象类型</typeparam>
/// <typeparam name="TProp">要比较的属性类型(必须是引用类型)</typeparam>
/// <param name="propAccessor">属性访问表达式,如 x => x.SomeProp</param>
/// <param name="other">用于比较的属性对象</param>
/// <returns>一个表达式,表示TItem对象的指定属性与other对象的所有属性值都相等</returns>
public static Expression<Func<TItem, bool>> MakeEqual<TItem, TProp>(Expression<Func<TItem, TProp>> propAccessor, TProp? other)
where TItem : class
where TProp : class
{
// 获取表达式参数(如 x => ... 中的 x)
var e1 = propAccessor.Parameters.Single();
BinaryExpression? conditionalExpr = null;
// 遍历TProp类型的所有属性
foreach (var prop in typeof(TProp).GetProperties())
{
BinaryExpression equalExpr;
object? otherValue = null;
// 获取other对象对应属性的值
if (other != null)
{
otherValue = prop.GetValue(other);
}
Type propType = prop.PropertyType;
// 构建左侧表达式(如 x.SomeProp.属性)
var leftExpr = MakeMemberAccess(
propAccessor.Body,
prop
);
// 构建右侧表达式(other对象的属性值)
Expression rightExpr = Convert(Constant(otherValue), propType);
// 如果属性是值类型(如int、bool),用Equal比较
if (propType.IsPrimitive)
{
equalExpr = Expression.Equal(leftExpr, rightExpr);
}
else
{
// 否则尝试用op_Equality方法比较(如string等)
equalExpr = MakeBinary(ExpressionType.Equal,
leftExpr, rightExpr, false,
prop.PropertyType.GetMethod("op_Equality")
);
}
// 多个属性用AndAlso连接(全部相等才为true)
if (conditionalExpr == null)
{
conditionalExpr = equalExpr;
}
else
{
conditionalExpr = Expression.AndAlso(conditionalExpr, equalExpr);
}
}
// 如果没有属性,抛出异常
if (conditionalExpr == null)
{
throw new ArgumentException("propAccessor must have at least one property");
}
// 返回最终的lambda表达式
return Lambda<Func<TItem, bool>>(
conditionalExpr, e1
);
}
}
}

MockSmsCodeSender

模拟短信验证码发送器

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
namespace Users.Infrastructure
{
/// <summary>
/// 模拟短信验证码发送器,实现 <see cref="ISmsCodeSender"/> 接口,仅用于测试或开发环境。
/// </summary>
internal class MockSmsCodeSender : ISmsCodeSender
{
private readonly ILogger<MockSmsCodeSender> logger;

/// <summary>
/// 构造函数,注入日志记录器。
/// </summary>
/// <param name="logger">日志记录器实例。</param>
public MockSmsCodeSender(ILogger<MockSmsCodeSender> logger)
{
this.logger = logger;
}

/// <summary>
/// 模拟异步发送短信验证码,将验证码信息写入日志。
/// </summary>
/// <param name="phoneNumber">接收验证码的手机号。</param>
/// <param name="code">要发送的验证码内容。</param>
/// <returns>表示异步操作的任务。</returns>
public Task SendCodeAsync(PhoneNumber phoneNumber, string code)
{
logger.LogInformation($"向{phoneNumber}发送验证码:{code}");
return Task.CompletedTask;
}
}
}

工作单元的实现

工作单元由应用服务层来确定,其他层不应该调用SaveChangesAsync方法保存对数据的修改。我们把Web API的控制器当成应用服务,而且对于大部分应用场景来讲,一次对控制器中方法的调用就对应一个工作单元,因此我们可以开发一个在控制器的方法调用结束后自动调用SaveChangesAsync的机制。这样就能大大简化应用服务层代码的编写,从而避免对SaveChangesAsync方法的重复调用。当然,对于特殊的应用服务层代码,我们可能仍然需要手动决定调用SaveChangesAsync方法的时机。

UnitOfWorkAttribute

我们定义一个Attribute,将其添加到需要重启自动提交工作单元的操作方法上。

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
namespace Users.WebAPI
{
// UnitOfWorkAttribute 是一个自定义特性(Attribute),用于标记需要启用工作单元(Unit of Work)模式的类或方法。
// 该特性可以应用于类或方法上,且不允许重复应用,支持继承。
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)]
public class UnitOfWorkAttribute : Attribute
{
// DbContextTypes 用于指定需要参与工作单元的 DbContext 类型数组。
public Type[] DbContextTypes { get; init; }

// 构造函数,接收一个或多个 DbContext 类型作为参数。
// 会检查传入的类型是否都继承自 DbContext,如果不是则抛出异常。
public UnitOfWorkAttribute(params Type[] dbContextTypes)
{
this.DbContextTypes = dbContextTypes;
foreach (var type in dbContextTypes)
{
// 判断类型是否继承自 DbContext,如果不是则抛出异常。
if (!typeof(DbContext).IsAssignableFrom(type))
{
throw new ArgumentException($"{type} 必须继承自 DbContext");
}
}
}
}
}

如果一个控制器上标注了UnitOfWorkAttibute,那么这个控制器中所有的方法都会在执行结束后自动提交工作单元,我们也可以把UnitOfWorkAttribute添加到控制器的方法上。因为一个微服务中可能有多个上下文,所以我们通过DbContextTypes来指定工作单元结束后程序自动调用哪些上下文的SaveChangesAsync方法,DbContextTypes属性用来指定上下文的类型。

UnitOfWorkFillter

我们实现一个全局的ActionFillter,来实现在控制器的操作方法执行结束后自动提交工作单元的功能。

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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
namespace Users.WebAPI
{
// 单元工作过滤器,用于自动处理数据库事务
public class UnitOfWorkFilter : IAsyncActionFilter
{
// 获取控制器或方法上的 UnitOfWorkAttribute 特性
private static UnitOfWorkAttribute? GetUoWAttr(ActionDescriptor actionDesc)
{
// 尝试将 ActionDescriptor 转换为 ControllerActionDescriptor
var caDesc = actionDesc as ControllerActionDescriptor;
if (caDesc == null)
{
// 如果转换失败,返回 null
return null;
}
// 先从控制器类型上获取 UnitOfWorkAttribute 特性
var uowAttr = caDesc.ControllerTypeInfo.GetCustomAttribute<UnitOfWorkAttribute>();
if (uowAttr != null)
{
// 如果控制器上有特性,直接返回
return uowAttr;
}
else
{
// 否则从方法上获取 UnitOfWorkAttribute 特性
return caDesc.MethodInfo.GetCustomAttribute<UnitOfWorkAttribute>();
}
}

// 拦截 Action 执行,处理数据库上下文的保存
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
// 获取当前 Action 上的 UnitOfWorkAttribute 特性
var uowAttr = GetUoWAttr(context.ActionDescriptor);
if (uowAttr == null)
{
// 如果没有特性,直接执行下一个中间件
await next();
return;
}
// 用于存放所有需要处理的 DbContext 实例
List<DbContext> dbCtxs = new List<DbContext>();
// 遍历特性中声明的所有 DbContext 类型
foreach (var dbCtxType in uowAttr.DbContextTypes)
{
// 通过依赖注入容器获取 DbContext 实例
var sp = context.HttpContext.RequestServices;
DbContext dbCtx = (DbContext)sp.GetRequiredService(dbCtxType);
// 添加到列表中
dbCtxs.Add(dbCtx);
}
// 执行 Action 方法
var result = await next();
// 如果没有异常发生
if (result.Exception == null)
{
// 遍历所有 DbContext,保存更改
foreach (var dbCtx in dbCtxs)
{
await dbCtx.SaveChangesAsync();
}
}
}
}
}

最后只要把UnitOfWorkFilter注册为ASP.NET Core的全局筛选器,所有添加了[UnitOfWork]的控制器或者操作方法就都能自动进行工作单元的提交了。

应用服务层的实现

ASP.NET Core Web API中的控制器就是应用服务层,因此我们在ASP.NET Core的项目Users.WebAPI中编写的代码就是应用服务层代码。

1
2
3
4
5
6
7
8
9
10
11
12
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"Default": "Server=localhost;Database=YOUXIANYU;Trusted_Connection=True;MultipleActiveResultSets=true;TrustServerCertificate=True;Encrypt=True;"
}
}
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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Users.Domain;
using Users.Infrastructure;
using Users.WebAPI;
using MediatR;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.

builder.Services.AddControllers();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// 配置数据库上下文,使用SQL Server数据库,连接字符串从配置文件读取
builder.Services.AddDbContext<UserDbContext>(b =>
{
string connStr = builder.Configuration.GetConnectionString("Default");
b.UseSqlServer(connStr);
});
// 配置Redis缓存,设置Redis服务器地址和实例名称
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = "localhost";
options.InstanceName = "UsersCache";
});
// 配置MVC选项,添加工作单元过滤器(UnitOfWorkFilter)用于自动管理事务
builder.Services.Configure<MvcOptions>(opt =>
{
opt.Filters.Add<UnitOfWorkFilter>();
});
// 注册领域服务UserDomainService为依赖注入服务
builder.Services.AddScoped<UserDomainService>();
// 注册短信验证码发送服务接口和实现(MockSmsCodeSender为模拟实现)
builder.Services.AddScoped<ISmsCodeSender, MockSmsCodeSender>();
// 注册用户领域仓储接口和实现
builder.Services.AddScoped<IUserDomainRepository, UserDomainRepository>();
// 注册MediatR库,用于实现领域事件和命令的中介者模式
builder.Services.AddMediatR(cfg =>
{
cfg.RegisterServicesFromAssembly(typeof(Program).Assembly);
});
var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}

app.UseHttpsRedirection();

app.UseAuthorization();

app.MapControllers();

app.Run();