dream

一个菜鸟程序员的成长历程

0%

大家好,我是大头,职高毕业,现在大厂资深开发,前上市公司架构师,管理过10人团队!
我将持续分享成体系的知识以及我自身的转码经验、面试经验、架构技术分享、AI技术分享等!
愿景是带领更多人完成破局、打破信息差!我自身知道走到现在是如何艰难,因此让以后的人少走弯路!
无论你是统本CS专业出身、专科出身、还是我和一样职高毕业等。都可以跟着我学习,一起成长!一起涨工资挣钱!
关注我一起挣大钱!文末有惊喜哦!

关注我发送”MySQL知识图谱”领取完整的MySQL学习路线。
发送”电子书”即可领取价值上千的电子书资源。
发送”大厂内推”即可获取京东、美团等大厂内推信息,祝你获得高薪职位。
发送”AI”即可领取AI学习资料。

DDD记账软件实战四

前情提要

在实战三中,我们完成了登录系统的设计与实现,运用了策略模式、工厂模式等设计模式,实现了一个可扩展的多种登录方式的认证系统。

架构图

我们已经按照DDD划分了目录并创建了项目结构:

  • starter: 接口层,包括HTTP接口、队列的消费者、DTO、启动类
  • api: 接口层,提供RPC接口,包括外部RPC接口需要使用的DTO、枚举等
  • application:应用服务层,放应用服务,负责编排领域服务、聚合根等。
  • domain:领域服务层,放领域相关的一切信息,领域服务负责编排聚合根,聚合根负责完成自身的业务逻辑。
  • infrastructure: 基础设施层,放配置、仓储、工厂、对外部的请求、发送MQ消息等。
  • common: 放一些公共信息。

技术栈版本:

  • Spring Boot 3.5.4
  • Java 21

如何设计一个记账微服务

在完成用户认证系统后,我们要开始实现核心的业务功能了。对于记账软件来说,记账功能是最核心的模块。

记账功能看似简单,但是涉及的业务逻辑其实不少,比如:

  • 账本管理
    • 账本列表
    • 创建账本
    • 更新账本信息
    • 删除账本
    • 更新账本预算
    • 查询账本预算
    • 查询账本信息
    • 邀请用户加入账本
    • 用户加入账本
    • 账本成员管理
  • 收入支出记录管理
    • 查看收支记录
    • 记账
  • 分类管理
    • 用户分类管理
      • 用户分类列表
      • 创建用户分类
      • 删除用户分类
    • 系统分类管理
      • 系统分类列表

面对如此复杂的记账业务,如何设计一个既能满足当前需求,又能支持未来扩展的微服务架构呢?

有办法的,兄弟,DDD来解决。

业务流程

基于DDD的核心思想,我们需要先理解业务,然后识别核心领域,最后通过代码来实现业务逻辑。

而针对复杂的业务场景,在软件设计上已经有了很多前人的智慧,比如领域驱动设计原则、各种企业级设计模式等。

首先,我们应该梳理一下记账的核心业务流程。深入理解业务是做好架构设计的基础。

记账业务流程图

用户进入记账小程序页面后,首先看到的就是一个账本列表,需要从账本列表中选择一个账本,然后点击记账、选择收入/支出类型,接着选择分类(如餐饮、交通等),填写金额和备注,最后保存记录。

从这个流程可以看出,记账涉及三个核心业务概念:

  1. 账本(AccountBook) - 用户可以创建多个账本,比如个人账本、家庭账本
  2. 记录(Record) - 具体的收入支出记录
  3. 分类(Category) - 对收支进行分类管理

因此,我们可以识别出三个核心领域模型。

领域分析

基于DDD的思想,我们需要对记账业务进行领域分析,识别出核心的聚合根、实体和值对象。

账本领域(AccountBook Domain)

账本是用户记账的载体,一个用户可以有多个账本。

账本业务流程图

核心业务逻辑:

  1. 创建账本
  2. 修改账本信息
  3. 删除账本
  4. 查询账本列表
  5. 查询账本信息
  6. 更新账本预算
  7. 加入账本
  8. 查询账本的成员列表

业务规则:

  • 同一用户下的账本名称不能重复
  • 删除账本时需要检查是否还有记录
  • 只有账本管理员可以删除账本

记录领域(Record Domain)

记录是记账的核心,包含收入和支出两种类型。

记录业务流程图

核心业务逻辑:

  1. 添加收支记录
  2. 修改记录信息
  3. 删除记录
  4. 查询记录列表

业务规则:

  • 金额必须大于0
  • 记录必须归属于某个账本
  • 记录必须有分类
  • 记录时间不能超过当前时间
  • 删除记录需要权限验证

分类领域(Category Domain)

分类是记账系统中对收支记录进行归类管理的重要功能,帮助用户更好地分析自己的消费习惯和收入来源。分类分为系统分类用户分类

系统分类是系统自带的一些分类,这些分类是所有用户都可以使用的,一些通用的分类。

用户分类是用户自身维护的一些分类,可以根据用户自己的使用习惯来创建不同的分类,这些分类只能用户自己使用,方便用户自定义自身的需求。

分类业务流程图

核心业务逻辑:

  1. 系统分类管理
    • 查询系统默认分类列表
    • 系统分类不可删除和修改
  2. 用户分类管理
    • 创建用户自定义分类
    • 修改用户分类信息
    • 删除用户分类
    • 查询用户分类列表
  3. 分类层级管理
    • 支持父子分类关系(如:餐饮 -> 早餐/午餐/晚餐)
    • 查询完整分类树结构
    • 分类层级深度限制(建议最多3层)
  4. 分类统计分析
    • 按分类统计收支金额
    • 分类使用频率统计
    • 分类趋势分析

业务规则:

  • 分类层级规则:支持树形结构,最多支持2级分类(一级分类 -> 二级分类)
  • 删除约束:删除分类时需要检查是否被记录引用,如有引用则不能删除
  • 系统分类保护:系统提供的默认分类不可删除和修改,保证基础功能可用
  • 用户分类权限:用户只能管理自己创建的分类,不能修改他人分类
  • 分类名称唯一性:同一层级下的分类名称不能重复
  • 分类图标:每个分类可以设置图标,提升用户体验

常见系统默认分类:
收入分类:

  • 工资收入
  • 投资收益
  • 兼职收入
  • 其他收入

支出分类:

  • 餐饮美食(早餐、午餐、晚餐、夜宵)
  • 交通出行(打车、公交、地铁、加油)
  • 购物消费(服装、数码、日用品)
  • 娱乐休闲(电影、游戏、旅游)
  • 医疗健康(看病、买药、体检)
  • 生活缴费(水电费、房租、话费)
  • 学习教育(培训、书籍、课程)

接下来我们需要分析,这三个领域之间的关系和需要使用哪些能力。

记账业务架构图

从业务架构图可以看出:

  • 账本是聚合根,管理属于它的记录
  • 记录依赖分类进行归类
  • 三个领域之间通过领域事件进行解耦
  • 统计分析等功能可以通过读模型实现

这样,我们就明确了记账微服务的核心业务能力和领域边界。

系统架构

在明确了业务领域和架构边界后,我们需要设计系统架构来支撑这些业务能力。

微服务分层架构

基于DDD的分层架构,记账微服务的系统架构如下:

记账系统架构图

网关层(GateWay Layer)

  • 路由转发
  • 认证鉴权
  • 限流熔断
  • 日志监控

应用服务层(Application Layer)

  • 账本应用服务(LedgerCommandApplicationService)
  • 记录应用服务(TransactionStatementCommandApplicationService)
  • 分类应用服务(CategoryCommandApplicationService)
  • 通知应用服务(NotificationService)

领域层(Domain Layer)

  • 账本聚合(Leger Aggregate)
  • 记录聚合(TransactionStatement Aggregate)
  • 分类聚合(Category Aggregate)
  • 领域服务
  • 领域事件

基础设施层(Infrastructure Layer)

  • 数据库仓储
  • 缓存
  • 消息队列
  • 注册中心
  • 日志分析

数据存储设计

基于领域模型,我们设计了以下数据表结构:

  • ledger: 账本表
  • ledger_budget: 账本预算表
  • leger_members: 账本成员表(支持共享账本)
  • invitation: 邀请码表
  • invitation_usage: 邀请码使用表
  • transaction_statement: 记录表
  • sys_category: 系统分类表
  • user_category: 用户分类表

这样的设计既能保证数据一致性,又能支撑高并发的业务场景。

详细设计

经过上面的架构设计以后,我们明确了下面几点:

  1. 明确了记账微服务的三个核心领域
  2. 明确了各领域的责任边界和交互关系
  3. 明确了系统的技术架构和存储设计

接下来,我们需要进行详细设计,明确具体的实现方案。

用例设计

我们通过用例图来理解记账微服务的核心功能。

记账系统用例图

主要用例:

  1. 账本管理

    • 创建账本
    • 编辑账本
    • 删除账本
    • 账本成员管理
  2. 记录管理

    • 添加收入记录
    • 添加支出记录
    • 编辑记录
    • 删除记录
    • 查询记录
  3. 分类管理

    • 创建分类
    • 编辑分类
    • 删除分类
    • 分类统计
  4. 统计分析

    • 收支统计
    • 分类统计
    • 趋势分析

领域模型UML设计

基于DDD的领域建模,我们设计了以下核心领域模型:

记账领域模型UML图

设计亮点:

  1. 聚合根设计:以AccountBook为聚合根,管理所有相关的Record
  2. 值对象设计:Money、RecordType等作为值对象,封装业务规则
  3. 领域服务:处理复杂的业务逻辑,如统计、校验等
  4. 仓储模式:抽象数据访问,解耦领域和基础设施

架构优势:

  • 高内聚低耦合:各领域职责清晰
  • 易于测试:领域逻辑独立于基础设施
  • 易于扩展:新增功能不影响现有代码
  • 符合业务语言:代码结构反映业务概念

开发

当设计完成以后就可以进入开发阶段了。这里给出记账微服务的核心代码实现。

接口层(Interface Layer)

LedgerController - 账本管理控制器

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
@RestController
@RequestMapping("/ledger")
public class LedgerController {

@Resource
private LedgerCommandApplicationService ledgerCommandApplicationService;

@Resource
private LedgerQueryApplicationService ledgerQueryApplicationService;

@PostMapping("/create")
public Result<String> createLedger(@RequestBody CreateLedgerRequest request) {
String ledgerNo = ledgerCommandApplicationService.createLedger(request);
return Result.success(ledgerNo);
}

@PostMapping("/update")
public Result<String> updateLedger(@RequestBody UpdateLedgerRequest request) {
ledgerCommandApplicationService.updateLedger(request);
return Result.success();
}

@DeleteMapping()
public Result<String> deleteLedger(@RequestBody DeleteLedgerRequest request) {
ledgerCommandApplicationService.deleteLedger(request.getLedgerNo());
return Result.success();
}

@GetMapping("/list")
public Result<PageRes<LedgerListRes>> getLedgerList(QueryLedgerListRequest request) {
return Result.success(ledgerQueryApplicationService.getLedgerList(request));
}

@GetMapping("/detail")
public Result<LedgerDetailRes> getLedgerDetail(QueryLedgerRequest request) {
return Result.success(ledgerQueryApplicationService.getLedger(request));
}

@PostMapping("/update/budget")
public Result<String> updateLedgerBudget(@RequestBody UpdateLedgerBudgetRequest request) {
ledgerCommandApplicationService.updateLedgerBudget(request);
return Result.success();
}

@PostMapping("/join")
public Result<String> joinLedger(@RequestBody JoinLedgerRequest request) {
ledgerCommandApplicationService.joinLedger(request);
return Result.success();
}

@GetMapping("/memberList")
public Result<List<LedgerMemberListRes>> memberList(QueryLedgerMemberListRequest request) {
List<LedgerMemberListRes> memberList = ledgerQueryApplicationService.getMemberList(request);
return Result.success(memberList);
}
}

应用服务层(Application Layer)

整体采用CQRS来实现,所以应用服务分成两种,分别是增删改的command应用服务和只负责查询的query应用服务
LedgerCommandApplicationService - 账本command应用服务

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
@Service
@Slf4j
public class LedgerCommandApplicationService {

@Resource
private ApplicationEventPublisher eventPublisher;

@Resource
private LedgerDomainService ledgerDomainService;

@Resource
private LedgerFactory ledgerFactory;

@Resource
private InvitationDomainService invitationDomainService;

public void deleteLedger(String ledgerNo) {
LedgerAgg ledgerAgg = ledgerDomainService.findByNo(ledgerNo);
if (ledgerAgg == null) {
throw new AggNotExistsException(ResultCode.LEDGER_NOT_FOUND);
}
ledgerAgg.delete();
ledgerDomainService.save(ledgerAgg);
}

public String createLedger(CreateLedgerRequest request) {
// 获取用户ID
String userNo = UserContextHolder.getCurrentUserNo();

// 查询该账本是否已经存在
LedgerAgg ledger = ledgerDomainService.findByNameInUser(request.getLedgerName(), userNo);
if (ledger != null) {
throw new AggNotExistsException(ResultCode.LEDGER_ALREADY_EXISTS);
}
// 账本不存在则创建
LedgerAgg ledgerAgg = ledgerFactory.createLedgerAgg(request.getLedgerName(), userNo, request.getLedgerDesc(),
request.getLedgerImage());
ledgerAgg.create();

// 插入数据库
ledgerDomainService.save(ledgerAgg);

// 获取注册的事件进行发布
List<DomainEvent> domainEventList = ledgerAgg.getDomainEvents();
domainEventList.forEach(event -> eventPublisher.publishEvent(event));

// 返回账本编号
return ledgerAgg.getLedgerNo();
}

public void updateLedger(UpdateLedgerRequest request) {
// 查询该账本是否已经存在
LedgerAgg ledgerAgg = ledgerDomainService.findByNo(request.getLedgerNo());
if (ledgerAgg == null) {
throw new AggNotExistsException(ResultCode.LEDGER_NOT_FOUND);
}
// 账本存在则更新
ledgerAgg.save(request.getLedgerName(), request.getLedgerDesc(), request.getLedgerImage());
ledgerDomainService.save(ledgerAgg);

// 获取注册的事件进行发布
List<DomainEvent> domainEventList = ledgerAgg.getDomainEvents();
eventPublisher.publishEvent(domainEventList);
}

public void updateLedgerBudget(UpdateLedgerBudgetRequest request) {
// 查询该账本是否已经存在
LedgerAgg ledgerAgg = ledgerDomainService.findByNo(request.getLedgerNo());
if (ledgerAgg == null) {
throw new AggNotExistsException(ResultCode.LEDGER_NOT_FOUND);
}
// 账本存在则更新
LedgerBudgetVO ledgerBudget = ledgerFactory.createLedgerBudget(request.getLedgerNo(), request.getBudgetAmount(), request.getBudgetDate());
ledgerAgg.updateBudget(ledgerBudget);
ledgerDomainService.save(ledgerAgg);

// 获取注册的事件进行发布
List<DomainEvent> domainEventList = ledgerAgg.getDomainEvents();
eventPublisher.publishEvent(domainEventList);
}

public void joinLedger(JoinLedgerRequest request) {
String userNo = UserContextHolder.getCurrentUserNo();
// 1. 使用邀请码
invitationDomainService.useInvitationCode(request.getInvitationCode(), userNo);

// 查询账本信息
InvitationAgg invitationAgg = invitationDomainService.loadByCode(
new InvitationCodeVO(request.getInvitationCode()));
LedgerAgg ledgerAgg = ledgerDomainService.findByNo(invitationAgg.getLedgerNo());

// 2. 插入成员信息
LedgerMemberEntity ledgerMember = ledgerFactory.createLedgerMember(
ledgerAgg.getLedgerNo(), userNo, LedgerMemberRoleVO.MEMBER);
ledgerAgg.addMember(ledgerMember);

// 3. 保存账本信息
ledgerDomainService.save(ledgerAgg);

// 获取注册的事件进行发布
List<DomainEvent> domainEventList = ledgerAgg.getDomainEvents();
eventPublisher.publishEvent(domainEventList);
}
}

LedgerQueryApplicationService - 账本query应用服务

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
168
169
@Service
@Slf4j
public class LedgerQueryApplicationService {

@Resource
private LedgerMapper ledgerMapper;

@Resource
private LedgerMemberMapper ledgerMemberMapper;

@Resource
private LedgerDomainService ledgerDomainService;

@Resource
private UserInfoService userInfoService;

private List<LedgerListRes> convertToLedgerListResList(List<LedgerMemberPO> memberList, Map<String, LedgerPO> ledgerMap) {
if (CollectionUtils.isEmpty(memberList)) {
return Collections.emptyList();
}
return memberList.stream().map(member -> {return convertToLedgerListRes(member, ledgerMap.get(member.getLedgerNo()));}).filter(Objects::nonNull).collect(Collectors.toList());
}

private LedgerListRes convertToLedgerListRes(LedgerMemberPO memberPO, LedgerPO ledgerPO) {
if (memberPO == null || ledgerPO == null) {
return null;
}
LedgerListRes res = new LedgerListRes();
res.setLedgerName(ledgerPO.getLedgerName());
res.setLedgerImage(ledgerPO.getLedgerImage());
res.setLedgerNo(ledgerPO.getLedgerNo());
res.setLedgerDesc(ledgerPO.getLedgerDesc());
res.setLedgerStatus(ledgerPO.getLedgerStatus());
res.setCreateTime(LocalDateTimeUtil.format(ledgerPO.getCreateTime()));
res.setUpdateTime(LocalDateTimeUtil.format(ledgerPO.getUpdateTime()));
res.setRole(LedgerMemberRoleVO.of(memberPO.getRole()).getLabel());
res.setJoinTime(LocalDateTimeUtil.format(memberPO.getJoinTime()));
return res;
}

public LedgerDetailRes getLedger(QueryLedgerRequest request) {
// 获取用户ID
String userNo = UserContextHolder.getCurrentUserNo();
// 加载账本聚合
LedgerAgg ledgerAgg = ledgerDomainService.findByNo(request.getLedgerNo());
if (ledgerAgg == null) {
return null;
}
// 判断这个用户是否有查看权限
if (!ledgerAgg.hasViewPermission(userNo)) {
return null;
}
// 有查看权限则返回数据
return convertToLedgerRes(ledgerAgg);
}

private LedgerDetailRes convertToLedgerRes(LedgerAgg ledgerAgg) {
LedgerDetailRes res = new LedgerDetailRes();
res.setLedgerNo(ledgerAgg.getLedgerNo());
res.setLedgerName(ledgerAgg.getLedgerName());
res.setLedgerDesc(ledgerAgg.getLedgerDesc());
res.setLedgerStatus(ledgerAgg.getLedgerStatus().getLabel());
res.setLedgerBudget(buildLedgerBudget(ledgerAgg.getLastLedgerBudget()));
res.setLedgerSummary(buildLedgerSummary(ledgerAgg.getLastLedgerSummary()));
return res;
}

private LedgerSummaryRes buildLedgerSummary(LedgerSummaryVO summaryVO) {
LedgerSummaryRes res = new LedgerSummaryRes();
res.setIncome(summaryVO.getIncome());
res.setExpense(summaryVO.getExpense());
res.setRemained(summaryVO.getRemained());
return res;
}

private LedgerBudgetRes buildLedgerBudget(LedgerBudgetVO budgetPO) {
LedgerBudgetRes res = new LedgerBudgetRes();
res.setAmount(MoneyUtil.fen2Yuan(budgetPO.getBudgetAmount()));
res.setUsed(MoneyUtil.fen2Yuan(budgetPO.getUsedAmount()));
res.setRemained(MoneyUtil.fen2Yuan(budgetPO.getRemainedAmount()));
res.setBudgetDate(LocalDateTimeUtil.format(budgetPO.getBudgetDate()));
return res;
}


public PageRes<LedgerListRes> getLedgerList(QueryLedgerListRequest request) {
// 获取用户ID
String userNo = UserContextHolder.getCurrentUserNo();
// 查询用户有哪些账本
LambdaQueryWrapper<LedgerMemberPO> wrapper1 = new LambdaQueryWrapper<>();
wrapper1.eq(LedgerMemberPO::getUserNo, userNo)
.eq(LedgerMemberPO::getStatus, LedgerMemberStatusVO.NORMAL.getCode())
.eq(LedgerMemberPO::getIsDeleted, false)
.orderByDesc(LedgerMemberPO::getJoinTime);
Page<LedgerMemberPO> pageList = ledgerMemberMapper.selectPage(Page.of(request.getPage(), request.getSize()),
wrapper1);
if (pageList.getTotal() == 0) {
// 组装返回
PageRes<LedgerListRes> pageRes = new PageRes<>();
pageRes.setPageNum(pageList.getCurrent());
pageRes.setPageSize(pageList.getSize());
pageRes.setTotal(pageList.getTotal());
pageRes.setList(Collections.emptyList());
return pageRes;
}
// 查询用户账本信息
List<String> ledgerNoList = pageList.getRecords().stream()
.map(LedgerMemberPO::getLedgerNo).collect(Collectors.toList());
LambdaQueryWrapper<LedgerPO> wrapper = new LambdaQueryWrapper<>();
wrapper.in(LedgerPO::getLedgerNo, ledgerNoList)
.eq(LedgerPO::getIsDeleted, false);
List<LedgerPO> ledgerList = ledgerMapper.selectList(wrapper);
Map<String, LedgerPO> ledgerMap = ledgerList.stream()
.collect(Collectors.toMap(LedgerPO::getLedgerNo, Function.identity()));

// 组装返回
PageRes<LedgerListRes> pageRes = new PageRes<>();
pageRes.setPageNum(pageList.getCurrent());
pageRes.setPageSize(pageList.getSize());
pageRes.setTotal(pageList.getTotal());
pageRes.setList(convertToLedgerListResList(pageList.getRecords(), ledgerMap));
return pageRes;
}

public List<LedgerMemberListRes> getMemberList(QueryLedgerMemberListRequest request) {
// 获取成员列表
LambdaQueryWrapper<LedgerMemberPO> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(LedgerMemberPO::getLedgerNo, request.getLedgerNo())
.eq(LedgerMemberPO::getStatus, LedgerMemberStatusVO.NORMAL.getCode())
.eq(LedgerMemberPO::getIsDeleted, false);
List<LedgerMemberPO> memberList = ledgerMemberMapper.selectList(wrapper);
if (CollectionUtils.isEmpty(memberList)) {
return Collections.emptyList();
}
// 判断权限
String userNo = UserContextHolder.getCurrentUserNo();
if (memberList.stream().noneMatch(member -> member.getUserNo().equals(userNo))) {
// 没有权限
return Collections.emptyList();
}

// 使用dubbo查询用户服务的用户信息
List<String> userNoList = memberList.stream()
.map(LedgerMemberPO::getUserNo)
.collect(Collectors.toList());
List<UserInfoBO> userBOList = userInfoService.batchQueryUserInfo(userNoList);
Map<String, UserInfoBO> userBOMap = userBOList.stream()
.collect(Collectors.toMap(UserInfoBO::getUserNo, Function.identity()));

// 转换返回
return memberList.stream()
.map(member -> convertToMemberListRes(member, userBOMap.get(member.getUserNo())))
.collect(Collectors.toList());
}

private LedgerMemberListRes convertToMemberListRes(LedgerMemberPO memberPO, UserInfoBO userBO) {
LedgerMemberListRes res = new LedgerMemberListRes();
res.setUserNo(memberPO.getUserNo());
res.setRole(LedgerMemberRoleVO.of(memberPO.getRole()).getLabel());
res.setJoinTime(LocalDateTimeUtil.format(memberPO.getJoinTime()));

if (userBO != null) {
res.setUsername(userBO.getUserName());
res.setAvatar(userBO.getUserAvatar());
}

return res;
}
}

领域层(Domain Layer)

LedgerAgg - 账本聚合根

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
@Data
@Builder
public class LedgerAgg extends AbstractAgg {
private Long id;
private String ledgerNo;
private String ledgerName;
private String ownerNo;
private LedgerStatusVO ledgerStatus;
private String ledgerDesc;
private String ledgerImage;
private LocalDateTime createTime;
private LocalDateTime updateTime;
private Boolean isDeleted;

// 最新的一个预算信息
private LedgerBudgetVO lastLedgerBudget;

// 最新的一个汇总信息
private LedgerSummaryVO lastLedgerSummary;

// 账本成员列表
private Set<LedgerMemberEntity> memberSet;

// 插入成员列表
public void addMember(LedgerMemberEntity member) {
if (memberSet == null) {
memberSet = new HashSet<>();
}
memberSet.add(member);
// 创建用户加入账本事件
registerDomainEvent(new UserJoinedLedgerEvent(this));
}

public void delete() {
if (memberSet != null) {
memberSet.forEach(LedgerMemberEntity::delete);
}
if (lastLedgerBudget != null) {
lastLedgerBudget.delete();
}
isDeleted = true;
// 删除账本事件
registerDomainEvent(new LedgerDeletedEvent(this));
}

public void deleteTransaction(Integer amount) {
// 增加预算
lastLedgerBudget.increase(amount);
}

public void transaction(Integer amount, TransactionTypeVO transactionType) {
//判断收入 or 支出
if (transactionType.isExpenditure()) {
// 支出,减少预算
lastLedgerBudget.reduce(amount);
}
}

public void save(String ledgerName, String ledgerDesc, String ledgerImage) {
updateSelf(ledgerName, ledgerDesc, ledgerImage);

// 创建账本更新事件
registerDomainEvent(new LedgerUpdatedEvent(this));
}

public void updateBudget(LedgerBudgetVO ledgerBudget) {
lastLedgerBudget = ledgerBudget;
}

private void updateSelf(String ledgerName, String ledgerDesc, String ledgerImage) {
this.ledgerName = ledgerName;
this.ledgerDesc = ledgerDesc;
this.ledgerImage = ledgerImage;
}

public void create() {
ledgerStatus = LedgerStatusVO.NORMAL;
createTime = LocalDateTime.now();
updateTime = createTime;

// 注册账本已创建事件
registerDomainEvent(new LedgerCreatedEvent(this));
}

public Boolean checkUserPermission(String userNo) {
return ownerNo.equals(userNo);
}

public Boolean hasViewPermission(String userNo) {
return memberSet.stream().anyMatch(member -> member.getUserNo().equals(userNo));
}
}

TransactionStatementAgg - 记录聚合根

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
@Data
@Builder
public class TransactionStatementAgg extends AbstractAgg {
private Long id;
private String transactionStatementNo;
private String ledgerNo;
private Integer amount;
private TransactionTypeVO transactionType;
private LocalDateTime transactionTime;
private String transactionDesc;
private Integer transactionStatus;
// 分类信息快照
private CategorySnapshotVO categorySnapshot;
// 分类信息引用
private CategoryVO category;
private LocalDateTime createTime;
private LocalDateTime updateTime;

private Boolean deleted;

public void create() {
LocalDateTime now = LocalDateTime.now();
this.setCreateTime(now);
this.setUpdateTime(now);
this.setTransactionTime(now);

// 注册交易流水已创建事件
registerDomainEvent(new TransactionStatementCreatedEvent(this));
}

public void delete() {
deleted = true;
updateTime = LocalDateTime.now();
// 注册交易流水已删除事件
registerDomainEvent(new TransactionStatementCreatedEvent(this));
}
}

Money - 金额值对象

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
public class Money {

public static final Money ZERO = new Money(BigDecimal.ZERO);

private final BigDecimal amount;

private Money(BigDecimal amount) {
if (amount == null) {
throw new IllegalArgumentException("金额不能为空");
}
this.amount = amount.setScale(2, RoundingMode.HALF_UP);
}

public static Money of(BigDecimal amount) {
return new Money(amount);
}

public static Money of(double amount) {
return new Money(BigDecimal.valueOf(amount));
}

public Money add(Money other) {
return new Money(this.amount.add(other.amount));
}

public Money subtract(Money other) {
return new Money(this.amount.subtract(other.amount));
}

public boolean lessThanOrEqual(Money other) {
return this.amount.compareTo(other.amount) <= 0;
}

@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (obj == null || getClass() != obj.getClass()) return false;
Money money = (Money) obj;
return amount.equals(money.amount);
}

@Override
public int hashCode() {
return amount.hashCode();
}
}

基础设施层(Infrastructure Layer)

AccountBookRepositoryImpl - 账本仓储实现

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
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
@Repository
public class LedgerRepositoryImpl implements LedgerRepository {

@Resource
private LedgerMapper ledgerMapper;

@Resource
private LedgerBudgetMapper ledgerBudgetMapper;

@Resource
private LedgerMemberMapper ledgerMemberMapper;

@Resource
private TransactionStatementMapper transactionStatementMapper;

@Resource
private LedgerFactory ledgerFactory;

@Override
@Transactional(rollbackFor = Exception.class)
public void insert(LedgerAgg ledgerAgg) {
// 插入账本信息
LedgerPO ledgerPO = this.toPO(ledgerAgg);
ledgerMapper.insert(ledgerPO);

// 插入账本预算信息
LedgerBudgetPO ledgerBudgetPO = toLedgerBudgetPO(ledgerAgg.getLastLedgerBudget());
ledgerBudgetMapper.insert(ledgerBudgetPO);

// 插入成员信息
saveMemberSet(ledgerAgg.getMemberSet());
}

private void insertMember(Set<LedgerMemberEntity> memberSet) {
LedgerMemberEntity memberEntity = memberSet.stream().findFirst().orElse(null);
if (memberEntity == null) {
return;
}
ledgerMemberMapper.insert(toMemberPO(memberEntity));
}

private LedgerPO toPO(LedgerAgg userAgg) {
return LedgerPO.builder()
.id(userAgg.getId())
.ledgerName(userAgg.getLedgerName())
.ledgerNo(userAgg.getLedgerNo())
.ledgerStatus(userAgg.getLedgerStatus().getCode())
.ownerNo(userAgg.getOwnerNo())
.ledgerDesc(userAgg.getLedgerDesc())
.ledgerImage(userAgg.getLedgerImage())
.isDeleted(userAgg.getIsDeleted())
.build();
}

private LedgerBudgetPO toLedgerBudgetPO(LedgerBudgetVO ledgerBudgetVO) {
return LedgerBudgetPO.builder()
.id(ledgerBudgetVO.getId())
.ledgerNo(ledgerBudgetVO.getLedgerNo())
.budgetAmount(ledgerBudgetVO.getBudgetAmount())
.usedAmount(ledgerBudgetVO.getUsedAmount())
.remainedAmount(ledgerBudgetVO.getRemainedAmount())
.budgetDate(ledgerBudgetVO.getBudgetDate())
.isDeleted(ledgerBudgetVO.getIsDeleted())
.build();
}

private LedgerMemberPO toMemberPO(LedgerMemberEntity memberEntity) {
return LedgerMemberPO.builder()
.id(memberEntity.getId())
.ledgerNo(memberEntity.getLedgerNo())
.userNo(memberEntity.getUserNo())
.role(memberEntity.getRole().getCode())
.joinTime(memberEntity.getJoinTime())
.status(memberEntity.getStatus().getCode())
.isDeleted(memberEntity.getIsDeleted())
.build();
}

public LedgerAgg findByNameInUser(String name, String userNo) {
// 查询账本基本信息
LambdaQueryWrapper<LedgerPO> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(LedgerPO::getLedgerName, name)
.eq(LedgerPO::getOwnerNo, userNo);
LedgerPO ledgerPO = ledgerMapper.selectOne(wrapper);
if (ledgerPO == null) {
return null;
}
return toEntity(ledgerPO, null, null, 0L, 0L);
}

private LedgerAgg toEntity(LedgerPO ledgerPO, LedgerBudgetPO ledgerBudgetPO, List<LedgerMemberPO> memberList, Long income, Long expense) {
LedgerAgg ledgerAgg = LedgerAgg.builder()
.id(ledgerPO.getId())
.ledgerName(ledgerPO.getLedgerName())
.ledgerNo(ledgerPO.getLedgerNo())
.ledgerStatus(LedgerStatusVO.of(ledgerPO.getLedgerStatus()))
.ownerNo(ledgerPO.getOwnerNo())
.ledgerDesc(ledgerPO.getLedgerDesc())
.ledgerImage(ledgerPO.getLedgerImage())
.createTime(ledgerPO.getCreateTime())
.updateTime(ledgerPO.getUpdateTime())
.build();
// 预算信息
if (ledgerBudgetPO != null) {
LedgerBudgetVO ledgerBudgetVO = LedgerBudgetVO.builder()
.ledgerNo(ledgerBudgetPO.getLedgerNo())
.budgetAmount(ledgerBudgetPO.getBudgetAmount())
.usedAmount(ledgerBudgetPO.getUsedAmount())
.remainedAmount(ledgerBudgetPO.getRemainedAmount())
.budgetDate(ledgerBudgetPO.getBudgetDate())
.createTime(ledgerBudgetPO.getCreateTime())
.updateTime(ledgerBudgetPO.getUpdateTime())
.build();
ledgerAgg.setLastLedgerBudget(ledgerBudgetVO);
}
if (!CollectionUtils.isEmpty(memberList)) {
Set<LedgerMemberEntity> memberSet = new HashSet<>();
memberList.forEach(member -> {
LedgerMemberEntity memberEntity = LedgerMemberEntity.builder()
.id(member.getId())
.ledgerNo(member.getLedgerNo())
.userNo(member.getUserNo())
.joinTime(member.getJoinTime())
.role(LedgerMemberRoleVO.of(member.getRole()))
.status(LedgerMemberStatusVO.of(member.getStatus()))
.createTime(member.getCreateTime())
.updateTime(member.getUpdateTime())
.build();
memberSet.add(memberEntity);
});
ledgerAgg.setMemberSet(memberSet);
}
LedgerSummaryVO ledgerSummaryVO = ledgerFactory.createLedgerSummary(income, expense);
ledgerAgg.setLastLedgerSummary(ledgerSummaryVO);
return ledgerAgg;
}

@Override
public LedgerAgg load(String ledgerNo) {
// 查询账本基本信息
LambdaQueryWrapper<LedgerPO> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(LedgerPO::getLedgerNo, ledgerNo)
.last("limit 1");
LedgerPO ledgerPO = ledgerMapper.selectOne(wrapper);
if (ledgerPO == null) {
return null;
}
// 查询账本最新的预算信息
LambdaQueryWrapper<LedgerBudgetPO> wrapperBudget = new LambdaQueryWrapper<>();
wrapperBudget.eq(LedgerBudgetPO::getLedgerNo, ledgerPO.getLedgerNo())
.orderByDesc(LedgerBudgetPO::getId)
.last("limit 1");
LedgerBudgetPO ledgerBudgetPO = ledgerBudgetMapper.selectOne(wrapperBudget);

// 加载成员信息
LambdaQueryWrapper<LedgerMemberPO> wrapperMember = new LambdaQueryWrapper<>();
wrapperMember.eq(LedgerMemberPO::getLedgerNo, ledgerPO.getLedgerNo())
.orderByDesc(LedgerMemberPO::getId)
.last("limit 10");
List<LedgerMemberPO> memberList = ledgerMemberMapper.selectList(wrapperMember);

// 加载汇总信息
Long incomeAmount = transactionStatementMapper.getSummaryAmount(ledgerPO.getLedgerNo(), TransactionTypeVO.INCOME.getCode());
Long expenditureAmount = transactionStatementMapper.getSummaryAmount(ledgerPO.getLedgerNo(), TransactionTypeVO.EXPENDITURE.getCode());
return toEntity(ledgerPO, ledgerBudgetPO, memberList, incomeAmount, expenditureAmount);
}

@Override
public void update(LedgerAgg ledgerAgg) {
// 插入账本信息
LedgerPO ledgerPO = this.toPO(ledgerAgg);
ledgerMapper.updateById(ledgerPO);

// 插入账本预算信息
LedgerBudgetPO ledgerBudgetPO = toLedgerBudgetPO(ledgerAgg.getLastLedgerBudget());
// 查询预算是否存在,决定插入还是更新
LambdaQueryWrapper<LedgerBudgetPO> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(LedgerBudgetPO::getLedgerNo, ledgerBudgetPO.getLedgerNo())
.eq(LedgerBudgetPO::getBudgetDate, LocalDateTimeUtil.format(ledgerBudgetPO.getBudgetDate(), LocalDateTimeUtil.DATE_FORMATTER_MONTH_ONE));
LedgerBudgetPO budget = ledgerBudgetMapper.selectOne(wrapper);
if (budget == null) {
ledgerBudgetMapper.insert(ledgerBudgetPO);
} else {
ledgerBudgetPO.setId(budget.getId());
ledgerBudgetPO.setUsedAmount(budget.getUsedAmount());
ledgerBudgetPO.setRemainedAmount(ledgerBudgetPO.getBudgetAmount() - ledgerBudgetPO.getUsedAmount());
ledgerBudgetMapper.updateById(ledgerBudgetPO);
}

// 更新成员信息
saveMemberSet(ledgerAgg.getMemberSet());
}

private void saveMemberSet(Set<LedgerMemberEntity> memberSet) {
if (CollectionUtils.isEmpty(memberSet)) {
return;
}
memberSet.forEach(member -> {
if (member.getId() == null) {
ledgerMemberMapper.insert(toMemberPO(member));
} else {
ledgerMemberMapper.updateById(toMemberPO(member));
}
});
}

public Boolean exists(String ledgerNo) {
LambdaQueryWrapper<LedgerPO> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(LedgerPO::getLedgerNo, ledgerNo);
return ledgerMapper.exists(wrapper);
}

}

总结

本文重点在如何进行设计系统,部分代码如上所示,完整代码可私信领取。

我们从0-1,完整实现了一个可扩展的记账微服务设计,包括:

  1. 领域分析:识别出账本、记录、分类三个核心领域
  2. 架构设计:基于DDD的分层架构,明确各层职责
  3. 详细设计:用例图和UML类图指导开发实现
  4. 代码实现:完整的四层架构代码示例

核心设计亮点:

  • 领域驱动:以业务为核心,代码结构反映业务概念
  • 聚合设计:LedgerAgg作为聚合根,保证数据一致性
  • 值对象:Money等值对象封装业务规则
  • 分层架构:职责清晰,易于维护和扩展
  • 仓储模式:抽象数据访问,解耦领域和基础设施

架构优势:

  • 高内聚低耦合,各模块职责明确
  • 易于单元测试,领域逻辑独立
  • 支持后续功能扩展,如报表分析等
  • 符合企业级开发规范

通过这个设计思路,我们可以继续实现其他微服务模块,构建完整的记账系统。

文末福利

关注我发送“MySQL知识图谱”领取完整的MySQL学习路线。
发送“电子书”即可领取价值上千的电子书资源。
发送“大厂内推”即可获取京东、美团等大厂内推信息,祝你获得高薪职位。
发送“AI”即可领取AI学习资料。
部分电子书如图所示。

概念学习

概念学习

概念学习

概念学习

大家好,我是大头,职高毕业,现在大厂资深开发,前上市公司架构师,管理过10人团队!
我将持续分享成体系的知识以及我自身的转码经验、面试经验、架构技术分享、AI技术分享等!
愿景是带领更多人完成破局、打破信息差!我自身知道走到现在是如何艰难,因此让以后的人少走弯路!
无论你是统本CS专业出身、专科出身、还是我和一样职高毕业等。都可以跟着我学习,一起成长!一起涨工资挣钱!
关注我一起挣大钱!文末有惊喜哦!

关注我发送“MySQL知识图谱”领取完整的MySQL学习路线。
发送“电子书”即可领取价值上千的电子书资源。
发送“大厂内推”即可获取京东、美团等大厂内推信息,祝你获得高薪职位。
发送“AI”即可领取AI学习资料。

MySQL零基础教程

本教程为零基础教程,零基础小白也可以直接学习,有基础的可以跳到后面的原理篇学习。
基础概念和SQL已经更新完成。

接下来是应用篇,应用篇的内容大致如下图所示。

应用学习

零基础MySQL教程之崩溃恢复:从灾难中重生

今天,我们将一起探索如何在数据库崩溃后恢复数据,帮助那些初级开发工程师和测试人员在面对数据库问题时从容应对。准备好迎接挑战了吗?让我们开始吧!

MySQL崩溃恢复基础介绍

在开始实战之前,我们需要了解一些基础知识。MySQL的崩溃恢复主要依赖于以下几个机制:

  • 事务日志(binlog):记录所有更改操作,帮助在崩溃后重建数据。
  • 重做日志(redo log):确保事务的持久性,即使在崩溃后也能恢复。
  • 撤销日志(undo log):用于回滚未完成的事务,保持数据一致性。

实战案例:手把手恢复崩溃数据

  1. 步骤一:检查崩溃原因

首先,我们需要了解数据库崩溃的原因。通常,崩溃可能由于硬件故障、软件错误或人为操作失误导致。检查MySQL错误日志文件(通常位于/var/log/mysql目录)可以帮助我们快速找到问题的根源。

  1. 步骤二:备份数据

在进行任何恢复操作之前,确保数据安全是关键。使用以下命令备份当前数据库:

1
mysqldump -u root -p --all-databases > all_databases_backup.sql
  1. 步骤三:恢复数据库
  • 启动MySQL服务:确保MySQL服务正在运行。
1
sudo systemctl start mysql
  • 应用事务日志:使用mysqlbinlog工具应用事务日志以恢复数据。
1
mysqlbinlog /var/lib/mysql/mysql-bin.000001 | mysql -u root -p
  • 检查数据一致性:使用CHECK TABLE命令验证数据完整性。
1
CHECK TABLE my_table;
  1. 步骤四:测试恢复结果

一旦恢复完成,进行测试以确保所有数据已成功恢复。运行一些查询验证数据的完整性和准确性。

说明与建议

  • 定期备份:养成定期备份数据库的习惯,使用工具如mysqldump或MySQL Enterprise Backup。
  • 监控系统:使用监控工具如Prometheus或Grafana监控数据库健康状态。
  • 升级硬件:确保数据库运行在稳定的硬件环境中,减少崩溃风险。

结论

掌握MySQL崩溃恢复不仅能提高您的数据库管理技能,还能为您的职业发展提供坚实的基础。希望这篇文章能帮助您在面对数据库崩溃时保持冷静,并迅速恢复数据。记住,数据安全是数据库管理的核心,定期备份和监控是确保数据安全的重要手段。

文末福利

关注我发送“MySQL知识图谱”领取完整的MySQL学习路线。
发送“电子书”即可领取价值上千的电子书资源。
发送“大厂内推”即可获取京东、美团等大厂内推信息,祝你获得高薪职位。
发送“AI”即可领取AI学习资料。
部分电子书如图所示。

概念学习

概念学习

概念学习

概念学习

登录系统架构图 登录系统架构图 展示层 前端应用 Web端 移动端 小程序 第三方接入 网关层 API网关 路由转发 认证鉴权 限流熔断 日志监控 应用层 认证服务 登录控制器 LoginController 认证服务接口 AuthenticationService Token管理器 TokenManager 验证码服务 第三方认证适配器 领域层 认证领域 用户聚合根 User UserCredential LoginRecord 认证策略接口 AuthStrategy + authenticate() + validate() 策略工厂 AuthStrategyFactory + getStrategy() + register() 领域服务 AuthDomainService SecurityService TokenService 基础设施层 基础设施 MySQL 用户数据 Redis 缓存/Token SMS服务 短信验证码 微信API OAuth2.0 MQ 事件总线 日志 ELK

大家好,我是大头,职高毕业,现在大厂资深开发,前上市公司架构师,管理过10人团队!
我将持续分享成体系的知识以及我自身的转码经验、面试经验、架构技术分享、AI技术分享等!
愿景是带领更多人完成破局、打破信息差!我自身知道走到现在是如何艰难,因此让以后的人少走弯路!
无论你是统本CS专业出身、专科出身、还是我和一样职高毕业等。都可以跟着我学习,一起成长!一起涨工资挣钱!
关注我一起挣大钱!文末有惊喜哦!

关注我发送“MySQL知识图谱”领取完整的MySQL学习路线。
发送“电子书”即可领取价值上千的电子书资源。
发送“大厂内推”即可获取京东、美团等大厂内推信息,祝你获得高薪职位。
发送“AI”即可领取AI学习资料。

DDD记账软件实战三

前情提要

之前我们已经梳理了整体架构图:

架构图

除了我们一开始划分好的两个服务以外,还有一些支撑服务,属于不管干啥都需要用到的。

并且我们已经按照DDD划分了目录并创建了项目。

  • starter: 接口层,包括HTTP接口、队列的消费者、DTO、启动类
  • api: 接口层,提供RPC接口,包括外部RPC接口需要使用的DTO、枚举等
  • application:应用服务层,放应用服务,负责编排领域服务、聚合根等。
  • domain:领域服务层,放领域相关的一切信息,领域服务负责编排聚合根,聚合根负责完成自身的业务逻辑。
  • infrastructure: 基础设施层,放配置、仓储、工厂、对外部的请求、发送MQ消息等。
  • common: 放一些公共信息。

我们使用的版本如下:

  • spring 3.5.4版本
  • java21

如何设计一个登陆系统

开始一个应用程序当然是从用户注册登陆开始了。

现在的用户登陆一般都有很多种方式,比如:

  • 用户名密码登录
  • 手机号验证码登陆
  • 微信扫码登陆
  • 微信openId登陆
  • 微信unionId登陆
  • 邀请注册登录
  • 其他第三方登录等等

面对如此多的登录方式,难道每次我们都要新加一个接口,并且去写一套登录逻辑吗?

有没有什么解决方案呢?

有的,兄弟,包有的。

业务流程

对于架构设计,我的理解就是抽取通用的扩展不通用的

而针对这些,在代码设计上已经有了很多前人的智慧了,比如开闭原则等,比如各种设计模式。

首先,我们应该梳理一下登陆的业务流程。万里长城始于脚下。不了解业务的开发不是一个好开发。

登陆业务流程图

首先, 用户在登录页面选择登录方式。接下来我们执行登录操作,如果登录成功,就生成Token并返回。如果登录失败,就返回错误信息。

到这里可以发现,除了登录方式不同,其他的流程基本都是一样的。

因此,我们应该把登录方式抽取出来。

业务架构

  1. 侧重点
  • 业务流程:展示业务活动的流动和交互,例如用户注册、订单处理、支付流程等。
  • 业务实体:定义业务领域中的关键实体和它们之间的关系,例如用户、订单、产品等。
  • 业务规则:描述业务逻辑和约束,例如折扣计算、库存校验等。
  1. 目标
  • 业务理解:帮助业务人员和开发团队理解业务需求和流程。
  • 需求分析:作为需求分析和设计的基础,确保技术实现符合业务目标。
  • 沟通协作:促进业务和技术团队之间的沟通和协作。

接下来我们需要分析,不同的登录方式都需要使用哪些能力。

对于用户名称密码登陆来说,简单的业务逻辑如下:

  1. 校验参数
  2. 根据用户名和密码查询用户信息
  3. 校验用户状态、密码错误次数等
  4. 生成Token
  5. 写入登陆日志
  6. 返回用户信息

对于手机号验证码登陆来说,简单的业务逻辑如下:

  1. 校验参数
  2. 校验验证码是否正确
  3. 根据手机号查询用户信息
  4. 校验用户状态等
  5. 生成Token
  6. 写入登陆日志
  7. 返回用户信息

从这里,我们可以分析出共同的业务逻辑,比如:

  • 校验参数
  • 查询用户信息
  • 校验用户是否能登陆
  • 生成Token
  • 写入登陆日志
  • 返回用户信息

再看这里面需要用到的一些能力,比如:

  • 生成Token
  • 写入登陆日志
  • 查询用户信息
  • 发送验证码
  • 校验验证码
  • 风控

因此,我们可以梳理出下面的业务架构图

登陆业务架构图

当梳理出业务架构图以后,我们明确了本次业务中,需要使用哪些能力,还可以在业务架构图中通过不同的颜色标识出哪些业务能力是已有的,哪些业务能力是本次建设的。还有哪些能力可能是以后建设的。

这样,我们就明确了本次需要做哪些事情。

系统架构

  1. 侧重点
  • 技术组件:展示系统的技术组件和它们之间的交互,例如数据库、服务层、消息队列等。
  • 系统模块:定义系统的模块化结构,例如用户服务、订单服务、支付服务等。
  • 技术栈:描述技术栈和工具,例如编程语言、框架、数据库技术等。
  1. 目标
  • 技术实现:指导开发团队进行技术实现和系统构建。
  • 性能优化:帮助识别系统性能瓶颈和优化机会。
  • 系统维护:支持系统的维护和扩展,确保技术架构的可持续性。

我们明确了业务以后,接下来要将业务落地,如何实现业务也是比较重要的,这个时候我们就可以做出系统架构图。

系统架构图描述了登陆的具体技术组件,用哪些技术来实现登陆业务。

登陆系统架构图

详细设计

经过上面的架构设计以后,我们明确了下面几点:

  1. 明确了本次需要实现哪些业务能力
  2. 明确了这些业务能力的共同点和差异点
  3. 明确了这些业务能力使用哪些技术实现

接下来,我们就需要明确如何实现了。这也就是详细设计需要做的内容。

比如我们可以通过用例图来理解需求。

登陆系统用例图

  1. 核心用例
  • 登录系统:主用例,用户的主要入口
  • 使用 <> 关系连接三种具体登录方式
  1. 登录方式
  • 用户名密码登录:传统登录方式
  • 手机验证码登录:需要包含”发送验证码”子用例
  • 微信OpenID登录:第三方社交登录
  1. 扩展性设计
  • 其他登录方式:通过 <> 关系预留扩展点
  • 可以轻松添加如:
  • 人脸识别登录
  • 指纹登录
  • 支付宝登录
  • QQ登录等
  1. 辅助功能
  • 注册账号:新用户注册
  • 找回密码:密码重置功能
  • 发送验证码:被手机登录包含
  • 验证身份:多种场景下的身份验证

可以通过UML类图来指导代码结构。

登陆系统UML类图

这些类使用了以下设计模式和原则:

  • 策略模式:将不同的认证方式抽象为策略,便于扩展新的登录方式
  • 工厂模式:通过工厂统一管理和创建认证策略
  • 门面模式:提供统一的认证入口,隐藏内部复杂性
  • 依赖倒置原则:高层模块依赖抽象而非具体实现
  • 开闭原则:对扩展开放,对修改关闭

架构优势:

  • 易于扩展新的登录方式
  • 各认证策略相互独立,互不影响
  • 统一的认证接口,使用简单
  • 符合DDD领域设计思想

通过这些UML类图可以指导我们如何进行开发。

开发

当设计完成以后就可以进入开发阶段了。这里给出一些开发伪代码。

接口层:入口的Controller:

  • AuthController
    • login: 登陆方法,所有登陆都走这个方法。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class AuthController{
@Resource
private AuthenticationFacade facade;

public AuthResponse login(AuthRequest req) {
//1. 参数校验
validate(req);
// 2. 调用登陆门面进行登陆
AuthResponse res = facade.authenticate(req);
// 3. 返回用户信息
return res;
}
}

应用服务层:编排登陆领域服务实现登陆逻辑

  • AuthenticationFacade: 登陆门面,屏蔽登陆细节。
    • authenticate: 登陆方法,执行登陆操作
  • AuthStrategyFactory: 登陆策略工厂,用来创建不同的登陆策略。
    • getStrategy:获取对应的登陆策略
    • register: 注册登陆策略
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
public class AuthenticationFacade{

@Resource
private AuthStrategyFactory factory;

public AuthResponse authenticate(AuthRequest req) {
// 1. 通过工厂获取对应的策略
AuthStrategy strategy = factory.getStrategy(req.getType);
// 2. 构建请求参数
AuthContext ctx = new AuthContext();
// 3. 执行对应的登陆策略
AuthResult result = strategy.authenticate(ctx);
// 4. 返回结果
AuthResponse res = convert(result);
return res;
}
}

public class AuthStrategyFactory{
private Map<AuthType, AuthStrategy> loginStrategyMap;

@Resource
private List<AuthStrategy> loginStrategies;

@PostConstruct
public void init() {
// 初始化的时候注册登陆策略
loginStrategies.forEach(loginStrategy -> register(loginStrategy.supports(), loginStrategy));
}

public AuthStrategy getStrategy(AuthType type) {
// 返回策略
// 这里还需要判断如果没有实现这个策略怎么处理
return loginStrategyMap.get(type);
}

public void register(AuthType type, AuthStrategy strategy) {
loginStrategyMap.put(type, strategy);
}

}

领域服务层:实现登陆逻辑

  • AuthContext:登陆上下文信息,是一个值对象
  • AuthType:登陆枚举
  • AuthStrategy:登陆策略接口,所有的登陆策略都要实现这个接口
    • supports:登陆策略支持的登陆枚举
    • authenticate: 登陆策略的具体实现逻辑
  • UsernamePasswordStrategy:用户名密码登陆策略,实现对应逻辑
  • SmsCodeStrategy:手机号验证码登陆策略
  • WechatOpenIdStrategy:微信OpenId登陆策略
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
public interface AuthStrategy{
AuthType supports();
AuthResult authenticate(AuthContext ctx);
}

// 实现用户名密码登陆策略
public class UsernamePasswordStrategy implements AuthStrategy{
public AuthType supports() {
return AuthType.USERNAME_PASSWORD;
}

public AuthResult authenticate(AuthContext ctx) {
// 处理逻辑
}
}

// 实现手机号验证码登陆逻辑
public class SmsCodeStrategy implements AuthStrategy{
public AuthType supports() {
return AuthType.SMS;
}

public AuthResult authenticate(AuthContext ctx) {
// 处理逻辑
}
}

// 实现微信OpenId登陆逻辑
public class WechatOpenIdStrategy implements AuthStrategy{
public AuthType supports() {
return AuthType.WECHAT_OPENID;
}

public AuthResult authenticate(AuthContext ctx) {
// 处理逻辑
}
}

总结

我们从0-1,完整的实现了一个可扩展的登陆功能的设计,包括系统架构、业务架构、流程图、用例图、UML类图、以及最后开发落地。

整个设计思路如上所述。其他的系统我们同样可以根据这个思路进行设计。

文末福利

关注我发送“MySQL知识图谱”领取完整的MySQL学习路线。
发送“电子书”即可领取价值上千的电子书资源。
发送“大厂内推”即可获取京东、美团等大厂内推信息,祝你获得高薪职位。
发送“AI”即可领取AI学习资料。
部分电子书如图所示。

概念学习

概念学习

概念学习

概念学习

大家好,我是大头,职高毕业,现在大厂资深开发,前上市公司架构师,管理过10人团队!
我将持续分享成体系的知识以及我自身的转码经验、面试经验、架构技术分享、AI技术分享等!
愿景是带领更多人完成破局、打破信息差!我自身知道走到现在是如何艰难,因此让以后的人少走弯路!
无论你是统本CS专业出身、专科出身、还是我和一样职高毕业等。都可以跟着我学习,一起成长!一起涨工资挣钱!
关注我一起挣大钱!文末有惊喜哦!

关注我发送“MySQL知识图谱”领取完整的MySQL学习路线。
发送“电子书”即可领取价值上千的电子书资源。
发送“大厂内推”即可获取京东、美团等大厂内推信息,祝你获得高薪职位。
发送“AI”即可领取AI学习资料。

MySQL零基础教程

本教程为零基础教程,零基础小白也可以直接学习,有基础的可以跳到后面的原理篇学习。
基础概念和SQL已经更新完成。

接下来是应用篇,应用篇的内容大致如下图所示。

应用学习

MySQL零基础教程导入导出

数据库的角色一般是用来做数据持久化的。既然我们把数据持久化到了磁盘中,那么当我们需要将数据迁移、转移、备份的时候,我们就需要将数据导出出来,并导入到其他的持久化地方。

导入的可能是另一个MySQL数据库,也可能是其他的数据库。

因此我们要学习如何对MySQL进行导入导出操作。

导出

MySQL提供了多种数据导出方式,每种方式都有其优缺点和适用场景。以下是常见的MySQL数据导出方式及其示例:

  1. 使用 mysqldump 工具

优点:

  • 灵活性高:支持导出整个数据库、单个表或部分数据。
  • 格式支持:可以导出为SQL文件,便于备份和迁移。
  • 广泛使用:是MySQL官方提供的工具,支持多种选项和参数。

缺点:

  • 速度较慢:对于大型数据库,导出速度可能较慢。
  • 资源消耗:导出过程中可能会占用较多的系统资源。

示例:

1
2
3
4
5
6
7
8
9
# 导出整个数据库
mysqldump -u username -p database_name > backup.sql

# 导出单个表
mysqldump -u username -p database_name table_name > table_backup.sql

# 导出数据和结构
mysqldump -u username -p --no-data database_name > structure_backup.sql
mysqldump -u username -p --no-create-info database_name > data_backup.sql

使用场景:

小李今天接到领导要求:我们要进行数据库版本升级,从5.7升级到8.0。现在需要将数据从老数据库迁移到新数据库。因此需要导出整个数据库的数据到一个SQL文件里面,并导入到新数据库中。

小王接到领导要求:我们要进行数据库备份,每天备份一次,因此,需要导出数据库信息并留存。

  1. 使用 SQL 查询导出数据

优点:

  • 简单直接:可以使用常规的SQL查询导出数据。
  • 灵活性:可以导出特定的数据集,支持复杂查询条件。

缺点

  • 格式有限:通常导出为文本格式,需后续处理。
  • 适用性:不适合导出整个数据库或复杂结构。

示例:

1
2
3
4
5
# 导出到CSV文件
SELECT * INTO OUTFILE '/path/to/file.csv'
FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '"'
LINES TERMINATED BY '\n'
FROM table_name;

使用场景:

小靳今天接到了一个任务,有一个运营需要导出一批用户行为数据做观察,因此使用这个命令直接导出为CSV文件,并交给运营。

  1. 使用 MySQL Workbench 导出

出来MySQL Workbench以外,还可以使用其他的图形化工具,比如Navicat等。

MySQL workbench是免费开源的,但是Navicat则需要花钱,因此我们使用MySQL workbench作为示例。

优点:

  • 用户友好:图形化界面,易于操作。
  • 功能丰富:支持导出为SQL文件、CSV等多种格式。

缺点

  • 依赖图形界面:需要安装MySQL Workbench,适合小型数据集。
  • 灵活性较低:对于复杂需求,可能需要手动调整。

示例
在MySQL Workbench中,选择数据库或表,右键点击选择“导出数据”。

mysql workbench导出

选择要导出的列,点击Next

mysql workbench导出

选择导出格式和路径,点击确认即可完成导出。

mysql workbench导出

选择建议

根据具体需求选择合适的导出方式:

mysqldump:适合备份和迁移整个数据库或表。
SQL查询导出:适合导出特定数据集或简单数据。
MySQL Workbench:适合小型数据集的用户友好导出。
选择时需考虑数据规模、格式要求、系统资源和权限限制等因素。

导入

MySQL提供了多种数据导入方式,跟上面的导出对应,也有几种方式,每种方式都有其优缺点和适用场景。以下是常见的MySQL数据导入方式及其示例:

  1. 使用 mysqlimport 工具

优点:

  • 简单易用:命令行工具,适合批量导入。
  • 格式支持:支持CSV、TSV等文本格式。

缺点:

  • 权限要求:需要文件系统权限。
  • 依赖命令行:需要在服务器上运行命令行工具。
1
mysqlimport --local -u username -p database_name /path/to/file.csv
  1. 使用 LOAD DATA INFILE

优点:

  • 速度快:可以批量导入大量数据,性能较高。
  • 灵活性:支持多种文件格式和字段选项。

缺点:

  • 权限要求:需要文件系统权限和secure-file-priv设置。
  • 格式限制:主要适用于文本文件(如CSV)。
1
2
3
4
LOAD DATA INFILE '/path/to/file.csv'
INTO TABLE table_name
FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '"'
LINES TERMINATED BY '\n';
  1. 使用 MySQL Workbench 导入

优点

  • 用户友好:图形化界面,易于操作。
  • 功能丰富:支持导入CSV、SQL等多种格式。

缺点

  • 依赖图形界面:需要安装MySQL Workbench,适合小型数据集。
  • 灵活性较低:对于复杂需求,可能需要手动调整。

在MySQL Workbench中,选择数据库或表,右键点击选择“导入数据”。

mysql workbench导入

选择要导入的文件,点击Next

mysql workbench导入

选择现有的数据表,还是创建新的数据表

mysql workbench导入

选择要导入的列信息

mysql workbench导入

确认信息,点击Next即可执行导入

mysql workbench导入

总结

本次讲解了MySQL的导入导出,讲解了几种方式,以及各自的优缺点、使用场景。并且通过MySQL Workbench做了示例。

文末福利

关注我发送“MySQL知识图谱”领取完整的MySQL学习路线。
发送“电子书”即可领取价值上千的电子书资源。
发送“大厂内推”即可获取京东、美团等大厂内推信息,祝你获得高薪职位。
发送“AI”即可领取AI学习资料。
部分电子书如图所示。

概念学习

概念学习

概念学习

概念学习

大家好,我是大头,职高毕业,现在大厂资深开发,前上市公司架构师,管理过10人团队!
我将持续分享成体系的知识以及我自身的转码经验、面试经验、架构技术分享、AI技术分享等!
愿景是带领更多人完成破局、打破信息差!我自身知道走到现在是如何艰难,因此让以后的人少走弯路!
无论你是统本CS专业出身、专科出身、还是我和一样职高毕业等。都可以跟着我学习,一起成长!一起涨工资挣钱!
关注我一起挣大钱!文末有惊喜哦!

关注我发送“MySQL知识图谱”领取完整的MySQL学习路线。
发送“电子书”即可领取价值上千的电子书资源。
发送“大厂内推”即可获取京东、美团等大厂内推信息,祝你获得高薪职位。
发送“AI”即可领取AI学习资料。

DDD记账软件实战二

经过一开始的战略设计和战术设计以后,我们有了一个初步的服务划分

  • 用户服务:管理用户相关的信息
  • 记账服务:管理账本和收支相关的信息

并且,我们已经知道了我们要创建一个什么样的目录结构,每个服务的目录如下:

  • starter: 接口层,包括HTTP接口、队列的消费者、DTO、启动类
  • api: 接口层,提供RPC接口,包括外部RPC接口需要使用的DTO、枚举等
  • application:应用服务层,放应用服务,负责编排领域服务、聚合根等。
  • domain:领域服务层,放领域相关的一切信息,领域服务负责编排聚合根,聚合根负责完成自身的业务逻辑。
  • infrastructure: 基础设施层,放配置、仓储、工厂、对外部的请求、发送MQ消息等。
  • common: 放一些公共信息。

我们这个整体架构图如下:

架构图

除了我们一开始划分好的两个服务以外,还有一些支撑服务,属于不管干啥都需要用到的。

  1. 网关

首先,需要通过网关来进行流量转发、权限校验、负载均衡等。

  • 网关使用Spring Cloud GateWay来实现,网关不需要采用DDD来设计。
  1. 缓存

缓存也是必不可少的一环,缓存使用Redis来实现。

  1. 唯一ID生成

每个服务都需要使用到唯一ID,因此可以使用分布式唯一ID发号器。我们的案例里面使用的是SnowFlake算法进行生成,这个方案有一个缺点就是强依赖系统时钟。对于我们来说已经够用了。

如果需要更加强大的方案可以使用美团开源的Leaf来进行唯一ID生成,Leaf支持号段模式SnowFlake模式,解决了SnowFlake强依赖系统时钟的问题。

  1. 日志收集

日志收集也是必不可少的一环,我们日常都需要将日志统一收集到一起,并提供查询功能。我们直接使用ELK来实现。

  1. 链路追踪

链路追踪也一样,毕竟多个微服务之间需要通过一个唯一请求ID来实现链路追踪,查询出一次请求的微服务以及对应的信息。

  • 我们本次使用SkyWalking来实现链路追踪的能力。
  1. 消息触达服务

不管做什么业务,其实都需要发送一些消息,包括短信消息、邮件消息、微信消息等等。

因此我们同样抽象出一个消息触达服务来执行消息发送功能。

创建用户服务

首先,我们要创建一个spring项目。可以直接使用start.spring.io来创建。

我们选择的是

  • java
  • maven
  • 3.5.4版本
  • java21
  • Jar

接下来输入一些基础信息就可以了。

创建用户服务

点击下面的generate就可以把项目代码下载下来了。

下载下来以后,我们需要调整maven文件和目录结构。

最外层的maven文件内容如下,我们主要引入了下面的一些依赖

  • spring cloud
  • nacos: 配置管理和服务注册发现
  • mysql
  • mybatis plus
  • jwt
  • lombok
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
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.3</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.zt.bookkeeping.user</groupId>
<artifactId>user</artifactId>
<version>0.0.1-SNAPSHOT</version>
<packaging>pom</packaging>
<name>user</name>
<description>记账软件用户服务</description>
<url/>
<licenses>
<license/>
</licenses>
<developers>
<developer/>
</developers>
<scm>
<connection/>
<developerConnection/>
<tag/>
<url/>
</scm>
<properties>
<java.version>21</java.version>
<spring-cloud.version>2025.0.0</spring-cloud.version>
<spring-cloud-alibaba.version>2023.0.3.2</spring-cloud-alibaba.version>
<project.version>0.0.1-SNAPSHOT</project.version>
</properties>

<modules>
<module>user-domain</module>
<module>user-application</module>
<module>user-infrastructure</module>
<module>user-starter</module>
<module>user-common</module>
</modules>

<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>

<!-- 添加Spring Cloud Alibaba依赖管理 -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
<version>${spring-cloud-alibaba.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>

<!-- 内部模块依赖管理 -->
<dependency>
<groupId>com.zt.bookkeeping.user</groupId>
<artifactId>user-domain</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.zt.bookkeeping.user</groupId>
<artifactId>user-application</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.zt.bookkeeping.user</groupId>
<artifactId>user-infrastructure</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies>
</dependencyManagement>

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<!-- 添加Nacos服务发现依赖 -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>

<!-- 添加Nacos配置管理依赖 -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>

<!-- 添加bootstrap配置支持 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-bootstrap</artifactId>
</dependency>

<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>

<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>3.5.6</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.30</version>
</dependency>

<!-- 引入jwt-->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.2</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.2</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.2</version>
<scope>runtime</scope>
</dependency>
</dependencies>

</project>

调整了目录结构,整体目录如下图

目录图

我们首先复制一份代码放到目录下面并改名为user-starter。然后修改maven文件如下:

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
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.zt.bookkeeping.user</groupId>
<artifactId>user</artifactId>
<version>0.0.1-SNAPSHOT</version>
</parent>

<artifactId>user-starter</artifactId>
<packaging>jar</packaging>
<name>user-starter</name>
<description>用户服务启动模块</description>

<dependencies>
<!-- 依赖应用层 -->
<dependency>
<groupId>com.zt.bookkeeping.user</groupId>
<artifactId>user-application</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.12</version>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>

</project>

接下来用同样的方法复制出应用服务层。改名user-application.修改maven文件如下:

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
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.zt.bookkeeping.user</groupId>
<artifactId>user</artifactId>
<version>0.0.1-SNAPSHOT</version>
</parent>

<artifactId>user-application</artifactId>
<packaging>jar</packaging>
<name>user-application</name>
<description>用户服务应用层</description>

<dependencies>
<!-- 依赖领域层 -->
<dependency>
<groupId>com.zt.bookkeeping.user</groupId>
<artifactId>user-domain</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>jakarta.validation</groupId>
<artifactId>jakarta.validation-api</artifactId>
</dependency>
</dependencies>

</project>

在复制出领域层,改名user-domain。maven文件如下:

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
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.zt.bookkeeping.user</groupId>
<artifactId>user</artifactId>
<version>0.0.1-SNAPSHOT</version>
</parent>

<artifactId>user-domain</artifactId>
<packaging>jar</packaging>
<name>user-domain</name>
<description>用户服务领域层</description>

<dependencies>
<!-- 依赖common -->
<dependency>
<groupId>com.zt.bookkeeping.user</groupId>
<artifactId>user-common</artifactId>
<version>0.0.1-SNAPSHOT</version>
<scope>compile</scope>
</dependency>
<!-- 领域层只需要基础的Java依赖,不需要Spring Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
</dependencies>

</project>

继续复制出基础设施层,改名user-infrastructure。maven文件如下:

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
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.zt.bookkeeping.user</groupId>
<artifactId>user</artifactId>
<version>0.0.1-SNAPSHOT</version>
</parent>

<artifactId>user-infrastructure</artifactId>
<packaging>jar</packaging>
<name>user-infrastructure</name>
<description>用户服务基础设施层</description>

<dependencies>
<!-- 依赖领域层 -->
<dependency>
<groupId>com.zt.bookkeeping.user</groupId>
<artifactId>user-domain</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>

<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>3.5.6</version>
</dependency>

<!-- 引入jwt-->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.2</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.2</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.2</version>
<scope>runtime</scope>
</dependency>
</dependencies>

</project>

最后,复制出common层,即可。maven文件如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.zt.bookkeeping.user</groupId>
<artifactId>user</artifactId>
<version>0.0.1-SNAPSHOT</version>
</parent>

<artifactId>user-common</artifactId>
<packaging>jar</packaging>
<name>user-common</name>
<description>用户服务公共层</description>

<dependencies>
</dependencies>

</project>

设置启动类

我们将启动类放到user-starter下面。内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package com.zt.bookkeeping.user;

import lombok.extern.slf4j.Slf4j;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;

@SpringBootApplication
@EnableAsync
public class UserApplication {

private static final Logger logger = LoggerFactory.getLogger(UserApplication.class);

public static void main(String[] args) {
SpringApplication.run(UserApplication.class, args);
logger.info("用户服务启动成功");
}

}

目录如下图:

启动用户服务

接下来启动服务,启动成功,打印出用户服务启动成功。我们就成功创建了DDD结构的用户微服务了。

总结

本次我们梳理了整体的系统架构图,为接下来的工作做了准备,并且带大家从0-1创建了用户微服务。并且改造成了DDD项目的目录结构。

至于剩下的记账微服务。用同样的方式创建即可。遇到问题可以随时在评论区留言~

文末福利

关注我发送“MySQL知识图谱”领取完整的MySQL学习路线。
发送“电子书”即可领取价值上千的电子书资源。
发送“大厂内推”即可获取京东、美团等大厂内推信息,祝你获得高薪职位。
发送“AI”即可领取AI学习资料。
部分电子书如图所示。

概念学习

概念学习

概念学习

概念学习

大家好,我是大头,职高毕业,现在大厂资深开发,前上市公司架构师,管理过10人团队!
我将持续分享成体系的知识以及我自身的转码经验、面试经验、架构技术分享、AI技术分享等!
愿景是带领更多人完成破局、打破信息差!我自身知道走到现在是如何艰难,因此让以后的人少走弯路!
无论你是统本CS专业出身、专科出身、还是我和一样职高毕业等。都可以跟着我学习,一起成长!一起涨工资挣钱!
关注我一起挣大钱!文末有惊喜哦!

关注我发送“MySQL知识图谱”领取完整的MySQL学习路线。
发送“电子书”即可领取价值上千的电子书资源。
发送“大厂内推”即可获取京东、美团等大厂内推信息,祝你获得高薪职位。
发送“AI”即可领取AI学习资料。

DDD战术设计代码结构

之前讲过了如何进行领域建模,也就是将真实世界映射到业务领域中。

接下来,看一下如何将业务领域落地到代码实现里面。

只有战略设计战术设计结合到一起,才能做好DDD,不能只注重领域建模而忽视了代码实现,也不能只注重代码实现而忽视了领域建模。

层次结构

贫血三层架构

一般情况下,我们使用的是贫血三层架构。也就是

  • controller:负责接收参数,参数校验,调用service处理业务逻辑。
  • service:处理业务逻辑
  • Model:和数据库交互,可能是Mybatics。

贫血三层架构

DDD四层架构

而对于DDD来说,使用的则是经典四层架构。

  • 用户接口层:作为程序的入口,controller就属于这一层。
  • 应用服务层:负责领域编排,不负责具体的业务逻辑处理。通过调用领域服务层来完成业务逻辑处理。
  • 领域服务层:负责具体的业务逻辑处理。可以将service的业务逻辑下沉到这一层,而业务逻辑编排则放到应用服务层。
  • 基础设施层:提供一些基础设施,包括数据库持久化,缓存,RPC调用等等。Model就属于这一层。

DDD四层架构

如果是重构项目的话,可以将三层架构拆解成四层架构,映射关系如下:

映射关系

用户接口层

用户接口层的命名可以是starter或者是interface等等。

用户接口层的职责就是整个程序的入口,对外部提供接口,包括HTTP接口RPC接口

这一层的结构很简单:

  • controller: 就是传统的controller
  • dto: dto里面包括了请求参数和返回参数
  • converter:负责将请求参数转换成应用服务的入参,将应用服务的出参转换成返回参数
  • consumer:消费者,负责消费MQ的消息

我们还可以将这一层进一步划分为两个目录,一个是HTTP接口的starter目录,一个是RPC接口的api目录。

因为一般来说rpc会对外部提供一个包。因此可以将这两个分开。

而且对于RPC接口的包来说,consumer这个目录是没有用的,因此,只需要三个目录就可以了。

对于HTTP接口来说,因此请求和返回都是JSON格式的对象,因此,可以省略dto和converter这两个步骤。

用户接口层

应用服务层

应用服务的职责边界很清晰,就是调用领域服务、聚合等完成业务操作。这一层只做服务编排,而不处理具体的业务逻辑。

  1. 代码结构

因此,应用服务层的代码结构也很简单,可以放到目录application里面。

  • service: 应用服务,应用服务不需要和领域服务一对一的关系,也不需要和controller一对一的关系。根据业务来创建即可。
  • dto: 这个里面存放应用服务层的入参和出参。可以根据CQRS规则来设置,创建、修改、删除的入参是Command。查询的入参是Query
  • event:应用服务层的事件发布。

应用服务层

  1. 代码示例:

这里,用注册用户来做一个简单的示例。看一下应用服务层是如何做服务编排的。

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
@Service
@Slf4j
public class MobileRegisterApplicationService {

@Resource
private UserAggService userAggService;

@Resource
private ApplicationEventPublisher eventPublisher;

@Resource
private SnowFlakeGenerator snowFlakeGenerator;

public Long register(MobileRegisterRequest command) {
// 1. 校验验证码是否正确
checkVerifyCode(command.getMobile(), command.getCode());

// 2. 调用用户领域服务 判断能否注册用户
userAggService.canRegister(command.getMobile());

// 3. 验证码正确 用户可以注册 生成用户聚合
UserAgg userAgg = UserAgg.init(command.getMobile(), snowFlakeGenerator.nextId("user"));

// 4. 调用用户领域服务 注册用户
userAggService.save(userAgg);

// 5. 发送注册成功领域事件
eventPublisher.publishEvent(new UserRegisteredEvent(userAgg.getId(), userAgg.getUsername(), null, LocalDateTime.now()));
return userAgg.getId();
}

private void checkVerifyCode(String mobile, String verifyCode) {
// 调用RPC接口来校验验证码

}
}

领域服务层

领域服务层是DDD的核心,所有的业务逻辑都以充血模型的形式放到聚合根、实体、值对象里面。

领域服务层的入参和出参都应该是领域模型。

  1. 代码结构

领域服务层的代码结构相对复杂。领域服务层的代码目录首先按照聚合来区分。其次,每个聚合下面有自己的领域模型和领域服务。可以放到目录domain里面。

  • 聚合
    • service: 存储领域服务,领域服务的职责是编排聚合。调用聚合来完成业务逻辑。
    • entity:这个目录存储所有的聚合根、实体和值对象。
    • event:存放领域事件
    • repository:存放仓储的接口,仓储的具体实现放在基础设施层。
    • factory:存放工厂的接口,工厂的具体实现放在基础设施层。
    • enums:存放一些领域层使用的枚举
    • gateway:存放一些RPC请求的接口,具体实现放在基础设施层。

领域服务层

  1. 代码示例

可以看到领域服务里面调用了Repository来重建聚合,然后判断聚合是否存在来完成校验。

1
2
3
4
5
6
7
8
9
public void canRegister(String mobile) {
// 根据要注册的手机号获取用户信息
UserAgg userAgg = userRepository.getUserByMobile(mobile);
// 校验用户是否存在
if (userAgg != null) {
log.warn("用户已存在");
throw new DomainException(ResultCode.USER_ALREADY_EXISTS);
}
}

再来看一下判断能否登陆的方法。这里调用的是聚合的方法来校验用户状态,而不是通过领域服务来校验。这就是编排聚合。

1
2
3
4
5
6
7
8
9
10
11
12
13
public void canLogin(UserAgg userAgg) throws UserLoginException {
// 0. 校验用户存在
if (userAgg == null) {
log.warn("用户不存在");
throw new UserLoginException(ResultCode.USER_NOT_FOUND);
}

// 2. 校验用户状态
if (!userAgg.validateStatus()) {
log.warn("用户状态错误:{}", userAgg.getUserStatus());
throw new UserLoginException(ResultCode.USER_STATUS_ERROR);
}
}

基础设施层

基础设施层为所有层提供基础的技术支持,包括但不限于数据库、缓存、队列、RPC请求、HTTP请求等等。

基础设施层有两个重要的概念

  • Repository:仓储,为领域模型提供持久化的功能,入参是领域模型,将领域模型转换成数据模型,在进行持久化。至于是缓存还是数据库,是MySQL还是PostgreSQL,上层并不关心。同时,也负责从持久化中恢复领域模型。
  • Factory:工厂,和工厂模式的工厂不同,这里的工厂是负责创建领域模型的。有些领域模型的创建较为复杂,可以封装到工厂里面。
  1. 基础设施层的目录

基础设施层的代码结构可以按照职能区分。其中Repository和聚合是一对一的关系,一个聚合有一个Repository负责持久化和恢复。可以放到目录infrastructure里面。

  • config: 放置项目的配置文件。
  • repository: 放仓储的具体实现类,一个仓储对应一个聚合,负责持久化聚合里面的多个表数据。
  • factory: 工厂的具体实现类,负责创建领域模型。
  • gateway:放对外部的请求,包括HTTP请求和RPC请求。
  • util:放一些工具类
  • listener: 放一些领域事件的监听者,有一些领域事件是放到队列中其他服务监听,也有一些领域事件是自己服务监听的。
  • producer: 生产者,负责向队列中发送消息

基础设施层

  1. 代码示例

以用户聚合对应的用户仓储来看,需要创建用户的时候传入用户聚合。然后转换成数据模型,当插入数据库以后会更新用户聚合的ID。

1
2
3
4
5
6
7
@Override
public void insert(UserAgg userAgg) {
UserPO userPO = this.toPO(userAgg);
userMapper.insert(userPO);
// 回填id
userAgg.setId(userPO.getId());
}

当需要查询数据的时候,传入查询条件,返回用户聚合。

1
2
3
4
5
6
7
8
9
10
11
12
13
@Override
public UserAgg getUserByMobile(String mobile) {
// 1. 查询用户信息
LambdaQueryWrapper<UserPO> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(UserPO::getMobile, mobile);
UserPO userPO = userMapper.selectOne(queryWrapper);
if (userPO == null) {
return null;
}

// 2. 转化成聚合返回
return this.toEntity(userPO);
}

四层架构之间的依赖关系

四层架构的目录结构已经出来了

  • starter: 放置启动类和HTTP请求以及队列的消费者。
  • api: 放置RPC接口以及外部需要使用的一些DTO。
  • application:应用服务层的代码
  • domain: 领域服务层的代码
  • infrastructure: 基础设施层的代码
  • common: 可以放置一些公共的代码,比如工具类、exception、枚举等。

总的目录结构如下,外层的user代表服务名称,这个是用户服务,也就是用户领域。

目录

用户接口层

  • 依赖应用服务层:调用应用服务实现功能。
1
2
3
4
<dependency>
<groupId>com.zt.bookkeeping.user</groupId>
<artifactId>user-domain</artifactId>
</dependency>

应用服务层

  • 依赖领域服务层:应用服务的职责就是编排领域服务和领域模型。
1
2
3
4
5
<!-- 依赖领域层 -->
<dependency>
<groupId>com.zt.bookkeeping.user</groupId>
<artifactId>user-domain</artifactId>
</dependency>

领域服务层

  • 什么也不依赖,领域服务层作为最底层的服务,什么也不依赖,如果需要使用基础设施的话,接口定义在领域层,实现放在基础设施层。

基础设施层

  • 依赖领域服务层:因为接口要放在领域服务层,实现放在基础设施层
1
2
3
4
5
<!-- 依赖领域层 -->
<dependency>
<groupId>com.zt.bookkeeping.user</groupId>
<artifactId>user-domain</artifactId>
</dependency>

整体依赖关系如下:

整体依赖关系

如何确定代码放在哪一层?

写代码的时候如何确定这个代码该放到哪一层呢?

如果这个代码是负责和外部交互的,那么就放到基础设施层。

如果这个代码包含业务逻辑,那么就放到领域层,将业务逻辑拆分到聚合、实体、值对象中,如果逻辑复杂,就通过领域服务进行编排。

应用层代码只编排领域服务和领域模型。

接口层代码只暴露接口给外部。

文末福利

关注我发送“MySQL知识图谱”领取完整的MySQL学习路线。
发送“电子书”即可领取价值上千的电子书资源。
发送“大厂内推”即可获取京东、美团等大厂内推信息,祝你获得高薪职位。
发送“AI”即可领取AI学习资料。
部分电子书如图所示。

概念学习

概念学习

概念学习

概念学习

大家好,我是大头,职高毕业,现在大厂资深开发,前上市公司架构师,管理过10人团队!
我将持续分享成体系的知识以及我自身的转码经验、面试经验、架构技术分享、AI技术分享等!
愿景是带领更多人完成破局、打破信息差!我自身知道走到现在是如何艰难,因此让以后的人少走弯路!
无论你是统本CS专业出身、专科出身、还是我和一样职高毕业等。都可以跟着我学习,一起成长!一起涨工资挣钱!
关注我一起挣大钱!文末有惊喜哦!

关注我发送“MySQL知识图谱”领取完整的MySQL学习路线。
发送“电子书”即可领取价值上千的电子书资源。
发送“大厂内推”即可获取京东、美团等大厂内推信息,祝你获得高薪职位。
发送“AI”即可领取AI学习资料。

用 @TypeHandler 做自动转换进阶:MyBatis + 自定义 TypeHandler

之前讲过通过枚举+值对象的方式来构建用户状态和用户类型。

但是每次查询的时候我们都需要手动的将数据库中的1,2,3来映射成值对象。

我们可以使用TypeHandler来实现自动转换,就不需要每次都手动执行了。

场景目标

已经使用了 UserStatus 值对象 + UserStatusEnum 枚举来表达用户状态(如 ACTIVE、FROZEN、DELETED),现在希望:

  • 数据库存储 Integer 类型(如:1、2、3)
  • MyBatis 能自动将 Integer → UserStatus
  • 不再每次手动 UserStatus.of(statusInt) 转换

最佳方案:MyBatis 自定义 @TypeHandler

✅ 1. 项目结构

1
2
3
4
5
6
7
8
9
10
11
domain.model.user
└── UserStatus.java(值对象)
└── UserStatusEnum.java(枚举)

infrastructure.mybatis.typehandler
└── UserStatusTypeHandler.java(自定义 TypeHandler)

infrastructure.repository.mybatis
└── UserMapper.java
└── UserPO.java
└── UserMapper.xml

✅ 2. 代码实现

✅ 值对象:UserStatus(简化版)

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
public class UserStatus {
private final UserStatusEnum status;

public static final UserStatus ACTIVE = new UserStatus(UserStatusEnum.ACTIVE);
public static final UserStatus FROZEN = new UserStatus(UserStatusEnum.FROZEN);
public static final UserStatus DELETED = new UserStatus(UserStatusEnum.DELETED);

private UserStatus(UserStatusEnum status) {
this.status = status;
}

public static UserStatus of(int code) {
return new UserStatus(UserStatusEnum.fromCode(code));
}

public int getCode() {
return status.getCode();
}

public UserStatusEnum getStatusEnum() {
return status;
}

@Override
public boolean equals(Object o) {
return (o instanceof UserStatus that) && this.status == that.status;
}

@Override
public int hashCode() {
return status.hashCode();
}
}

✅ 枚举类:UserStatusEnum

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public enum UserStatusEnum {
ACTIVE(1, "启用"),
FROZEN(2, "冻结"),
DELETED(3, "注销");

private final int code;
private final String label;

UserStatusEnum(int code, String label) {
this.code = code;
this.label = label;
}

public int getCode() { return code; }

public static UserStatusEnum fromCode(int code) {
for (UserStatusEnum e : values()) {
if (e.code == code) return e;
}
throw new IllegalArgumentException("未知状态码: " + code);
}
}

✅ 自定义 TypeHandler:UserStatusTypeHandler

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
@MappedJdbcTypes(JdbcType.INTEGER)
@MappedTypes(UserStatus.class)
public class UserStatusTypeHandler extends BaseTypeHandler<UserStatus> {

@Override
public void setNonNullParameter(PreparedStatement ps, int i, UserStatus parameter, JdbcType jdbcType) throws SQLException {
ps.setInt(i, parameter.getCode());
}

@Override
public UserStatus getNullableResult(ResultSet rs, String columnName) throws SQLException {
int code = rs.getInt(columnName);
return UserStatus.of(code);
}

@Override
public UserStatus getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
int code = rs.getInt(columnIndex);
return UserStatus.of(code);
}

@Override
public UserStatus getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
int code = cs.getInt(columnIndex);
return UserStatus.of(code);
}
}

✅ 3. PO 层配置 TypeHandler

1
2
3
4
5
6
7
8
9
10
11
public class UserPO {
private Long id;
private String username;
private String passwordHash;

@TableField(typeHandler = UserStatusTypeHandler.class)
private UserStatus status;

// getter/setter
}

如果你用 XML 而不是注解,也可以在 XML 中配置:

1
<result column="status" property="status" typeHandler="com.example.infrastructure.mybatis.typehandler.UserStatusTypeHandler"/>

✅ 4. 注册 TypeHandler(推荐方式)
Spring Boot + MyBatis:
确保你在配置类里添加扫描:

1
2
3
4
5
6
7
8
9
10
11
@MapperScan("com.example.infrastructure.repository.mybatis")
@Configuration
public class MyBatisConfig {
@Bean
public ConfigurationCustomizer configurationCustomizer() {
return configuration -> {
configuration.getTypeHandlerRegistry().register(UserStatusTypeHandler.class);
};
}
}

或者:使用 Spring Boot 自动注册(只要包结构正确 + 被 Spring 管理)

✅ 5. 使用效果

现在,MyBatis 自动完成:

  • SELECT 时:从 int 字段转成 UserStatus 值对象
  • INSERT/UPDATE 时:从 UserStatus 转为 int

你无需手动 .of() 转换,仓储代码更简洁:

1
2
3
4
5
6
7
8
UserPO userPO = mapper.selectByUsername("jack");
User user = new User(
userPO.getId(),
userPO.getUsername(),
userPO.getPasswordHash(),
userPO.getStatus() // ✅ 直接是 UserStatus
);

总结

问题 建议
如何让 MyBatis 自动转换值对象? ✅ 自定义 @TypeHandler 并在字段或 XML 中指定
推荐封装格式? ✅ 使用 enum + ValueObject 组合建模
是否适用于其他值对象? ✅ 适用于所有 “数据库是整数/字符串” 的值对象场景

文末福利

关注我发送“MySQL知识图谱”领取完整的MySQL学习路线。
发送“电子书”即可领取价值上千的电子书资源。
发送“大厂内推”即可获取京东、美团等大厂内推信息,祝你获得高薪职位。
发送“AI”即可领取AI学习资料。
部分电子书如图所示。

概念学习

概念学习

概念学习

概念学习

大家好,我是大头,职高毕业,现在大厂资深开发,前上市公司架构师,管理过10人团队!
我将持续分享成体系的知识以及我自身的转码经验、面试经验、架构技术分享、AI技术分享等!
愿景是带领更多人完成破局、打破信息差!我自身知道走到现在是如何艰难,因此让以后的人少走弯路!
无论你是统本CS专业出身、专科出身、还是我和一样职高毕业等。都可以跟着我学习,一起成长!一起涨工资挣钱!
关注我一起挣大钱!文末有惊喜哦!

关注我发送“MySQL知识图谱”领取完整的MySQL学习路线。
发送“电子书”即可领取价值上千的电子书资源。
发送“大厂内推”即可获取京东、美团等大厂内推信息,祝你获得高薪职位。
发送“AI”即可领取AI学习资料。

用户状态(UserStatus) 和 用户类型(UserType) 应该如何在领域模型中表达?

这是一个很有代表性的建模问题:用户状态(UserStatus) 和 用户类型(UserType) 应该如何在领域模型中表达?

✅ 最佳实践结论(DDD 建模角度):

✅ 建议建模为单独的值对象(Value Object),而不是直接使用 Integer 类型。

接下来分析一下为什么

✅ 原因分析:为什么不用普通的 Integer?

问题 使用 Integer 的弊端
可读性差 status == 1 到底代表啥?新手难以理解
安全性差 任何地方都可以传入不合法值,如 status = 999
违反封装 User 实体暴露了状态内部的具体编码实现
业务语义不明确 Integer 不能表达“启用/冻结/注销”等领域含义

✅ 为什么用值对象更合适?

在 DDD 中,值对象(Value Object)是具有领域语义但无身份标识的对象,适合表达用户的“状态”、“类型”、“级别”等固定枚举值的场景。

它能提供:

✅ 明确的领域语言(如:UserStatus.ACTIVE、UserType.ADMIN)
✅ 不变性(状态是不可变的值对象)
✅ 合法性控制(通过私有构造 + 工厂控制范围)
✅ 方便扩展(可以加业务方法,如 canLogin())

将用户状态设置为值对象以后,我们能明确的表达状态的含义,是正常冻结还是封禁等等。且避免了非法值。并且如果要变更的话,是变更整个值对象。

在领域建模中,你可以一下子明白这个实体中这个值的含义。

在DDD中,不要使用传统的数据建模思想,而是要使用领域建模思想。

传统的数据建模思想的话,用户状态存储在数据库中通常都是1,2,3,4这样的Int值。

所以实体中一般也直接使用Int类型的值作为存储。

领域建模思想则强调将真实世界中的业务映射成领域模型。数据库仅仅是领域模型持久化的一个地方而已。

✅ 示例:建模用户状态为值对象

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
package com.example.accountbook.domain.model.user;

import java.util.Objects;

public class UserStatus {

private final int code;

public static final UserStatus ACTIVE = new UserStatus(1);
public static final UserStatus FROZEN = new UserStatus(2);
public static final UserStatus DELETED = new UserStatus(3);

private UserStatus(int code) {
this.code = code;
}

public static UserStatus of(int code) {
return switch (code) {
case 1 -> ACTIVE;
case 2 -> FROZEN;
case 3 -> DELETED;
default -> throw new IllegalArgumentException("非法用户状态: " + code);
};
}

public boolean isActive() {
return this == ACTIVE;
}

public int getCode() {
return code;
}

// equals / hashCode for value object identity

@Override
public boolean equals(Object o) {
return (o instanceof UserStatus that) && this.code == that.code;
}

@Override
public int hashCode() {
return Objects.hash(code);
}
}

聚合中使用值对象

1
2
3
4
5
6
7
8
9
10
public class User {
private Long id;
private String username;
private String passwordHash;

private UserStatus status; // ✅ 状态是值对象
private UserType type; // ✅ 类型是值对象

}

用 enum 枚举 + 值对象混合的高级方案

一般来说,用户状态和用户类型,我们都会创建一个枚举类。因此我们可以使用枚举+值对象的方式来重新构建一下用户状态。

我们现在深入 DDD 实践:用 Enum 枚举 + 值对象(Value Object)混合建模方式 表达如用户状态 / 用户类型,并展示如何在 MyBatis 中映射为字段、在 Repository 中自动转换为领域值对象。

✅ 为什么用「Enum + 值对象」组合建模?

对象类型 作用
enum 表达离散枚举值(常量集合)
Value Object 表达领域语义 + 方法(如 isActive()

🌟 组合起来,既有类型安全、又有语义表达、也便于持久化。

✅ 枚举类:UserStatusEnum

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public enum UserStatusEnum {
ACTIVE(1, "启用"),
FROZEN(2, "冻结"),
DELETED(3, "注销");

private final int code;
private final String label;

UserStatusEnum(int code, String label) {
this.code = code;
this.label = label;
}

public int getCode() {
return code;
}

public static UserStatusEnum fromCode(int code) {
for (UserStatusEnum status : values()) {
if (status.code == code) return status;
}
throw new IllegalArgumentException("未知状态: " + code);
}
}

✅ 值对象:UserStatus

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
public class UserStatus {

private final UserStatusEnum status;

private UserStatus(UserStatusEnum status) {
this.status = status;
}

public static UserStatus of(UserStatusEnum status) {
return new UserStatus(status);
}

public static UserStatus of(int code) {
return new UserStatus(UserStatusEnum.fromCode(code));
}

public boolean isActive() {
return this.status == UserStatusEnum.ACTIVE;
}

public int getCode() {
return this.status.getCode();
}

public String getLabel() {
return this.status.name();
}

public UserStatusEnum getStatusEnum() {
return this.status;
}

@Override
public boolean equals(Object o) {
return (o instanceof UserStatus that) && this.status == that.status;
}

@Override
public int hashCode() {
return status.hashCode();
}
}

✅ MyBatis 持久化对象(PO):UserPO

1
2
3
4
5
6
7
8
public class UserPO {
private Long id;
private String username;
private String passwordHash;
private Integer status; // 状态值字段
// ... 省略 getter/setter
}

✅ Mapper XML 映射

1
2
3
4
5
6
7
<mapper namespace="com.example.repository.mybatis.UserMapper">
<select id="selectByUsername" resultType="com.example.repository.mybatis.UserPO">
SELECT id, username, password_hash, status
FROM users
WHERE username = #{username}
</select>
</mapper>

✅ 5. 仓储实现:UserRepositoryImpl → 转换 PO → 聚合

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
@Repository
public class UserRepositoryImpl implements UserRepository {

private final UserMapper mapper;

public UserRepositoryImpl(UserMapper mapper) {
this.mapper = mapper;
}

@Override
public Optional<User> findByUsername(String username) {
UserPO userPO = mapper.selectByUsername(username);
return Optional.ofNullable(userPO).map(this::toEntity);
}

private User toEntity(UserPO doObj) {
return new User(
doObj.getId(),
doObj.getUsername(),
doObj.getPasswordHash(),
UserStatus.of(doObj.getStatus()) // ← 关键转换点
);
}
}

✅ 领域聚合实体: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
public class User {
private Long id;
private String username;
private String passwordHash;
private UserStatus status; // 值对象

public User(Long id, String username, String passwordHash, UserStatus status) {
this.id = id;
this.username = username;
this.passwordHash = passwordHash;
this.status = status;
}

public boolean canLogin() {
return status.isActive();
}

public int getStatusCode() {
return status.getCode();
}

public String getStatusLabel() {
return status.getLabel();
}

// 省略其他 getter...
}

✅ 保存数据时:实体 → PO

1
2
3
4
5
6
UserPO userPO = new UserPO();
userPO.setId(user.getId());
userPO.setUsername(user.getUsername());
userPO.setPasswordHash(user.getPasswordHash());
userPO.setStatus(user.getStatus().getCode()); // ← 值对象 → DB 字段

✅ 总结对比

Integer enum Value Object + enum ✅ 推荐
类型安全
语义表达力 ⚠️ 限
是否可添加行为 ⚠️(有限) ✅(如 canLogin()
可维护性 一般

文末福利

关注我发送“MySQL知识图谱”领取完整的MySQL学习路线。
发送“电子书”即可领取价值上千的电子书资源。
发送“大厂内推”即可获取京东、美团等大厂内推信息,祝你获得高薪职位。
发送“AI”即可领取AI学习资料。
部分电子书如图所示。

概念学习

概念学习

概念学习

概念学习

大家好,我是大头,职高毕业,现在大厂资深开发,前上市公司架构师,管理过10人团队!
我将持续分享成体系的知识以及我自身的转码经验、面试经验、架构技术分享、AI技术分享等!
愿景是带领更多人完成破局、打破信息差!我自身知道走到现在是如何艰难,因此让以后的人少走弯路!
无论你是统本CS专业出身、专科出身、还是我和一样职高毕业等。都可以跟着我学习,一起成长!一起涨工资挣钱!
关注我一起挣大钱!文末有惊喜哦!

关注我发送“MySQL知识图谱”领取完整的MySQL学习路线。
发送“电子书”即可领取价值上千的电子书资源。
发送“大厂内推”即可获取京东、美团等大厂内推信息,祝你获得高薪职位。
发送“AI”即可领取AI学习资料。

DDD领域驱动设计记账软件实战教程–划分领域和聚合

本文章为实战教程,主要内容包括:

  • 从零使用DDD构建一个记账软件

你是否有以下疑问?

  • 你是一个DDD软件开发人员,但是只是按照现有的项目去写,如果自己搭建一个新项目就无从下手了?
  • 你是一个传统软件开发人员,想使用DDD重构软件,却不知道如何入手?
  • 你看过很多DDD的文章,了解DDD概念,却没有一个从头到尾的完整教程,无法把碎片化的知识串起来?

本文章带你从头构建一个完整的DDD项目。由点到面的串联起来所有的知识。达成一个完整的知识图谱。

事件风暴

首先需要进行事件风暴。整理出来所有的命令业务流事件

  • 命令:可以简单理解为一些行为。这些命令可以映射成代码模型中的一个个方法。
  • 业务流:可以按照场景进行分析业务流,比如对于记账软件来说:记账场景、查账场景就是两个不同的场景,可能有不同的业务流。业务流可以映射成代码模型中的接口。
  • 事件:可以简单理解成上面的命令产生的一些事件。会有一些监听者异步监听这些事件并实现一些逻辑。

我们来分析一些记账场景会有哪些业务流。

首先用户需要注册登陆。因此我们可以识别出注册业务流登陆业务流。继而识别出注册命令登陆命令

对于登陆业务流中来说,除了登陆命令,还可以有查询用户、校验用户信息、生成token等命令。这些命令共同完成了一次登陆业务流。

此外,还会产生对应的事件。比如用户已注册事件已登陆事件等。

对于已注册事件,我们可以发送注册成功的消息提醒。还可以参与注册活动等。

对于已登陆事件,我们可以写入登陆成功日志,发送登陆成功消息提醒等。

登陆成功以后,首先需要创建一个账本。接下来可以更新账本信息。更新预算。设置角色信息。然后就可以添加收支记录在账本上了。

DDD领域其实是现实世界的映射。这里我们要思考现实世界的记账是怎么样的。
现实世界需要一个账本、需要记录收支信息、账本的最上方可能会写上本月的预算。记录收支信息的时候会写上分类、角色、金额等信息。

场景分析

在这里我仅仅分析了记账场景的业务流。其实还有查账场景的业务流。此外还有一些其他的通用业务流。比如监控,数据统计等。

大家可以自己分析一下将结果发到评论区。我会一一回复的。

纸上得来终觉浅,绝知此事要躬行

领域建模

当我们分析完所有的命令事件以后。我们就可以根据上面分析出来的这些进行领域建模了。通过这些我们可以分析出领域对象。

比如,登陆注册行为是围绕用户领域对象来完成的行为。写入登陆日志、登陆日志事件是围绕用户日志领域对象的行为。

此外,我们还可以从上面的分析中找到账本预算角色收支记录这几个领域对象。

如下图:

领域建模

领域对象也分三种类型

  • 聚合根:聚合的入口,操作聚合下所有的实体和值对象都需要通过聚合根来操作
  • 实体:具有唯一ID的实体,使用充血模型实现,也就是要包含属性和方法。所有对于实体的操作都应该放到这个实体中。可以映射为数据库的一个表。
  • 值对象:值对象分为简单值对象和复杂值对象。值对象和实体最大的区别在于可变性,值对象不能单独存在,只能作为实体的一部分存在。同样的,值对象不支持变更,如果需要变更则是整个值对象删除再创建一个新的。
    • 简单值对象:基本类型的属性可以视为简单值对象。
    • 复杂值对象:引用其他类(自定义的类)作为属性的值,可以视为复杂值对象。

接下来我们就可以分析出上面的领域对象哪些是实体,哪些是值对象了。

实体:

  • 用户实体:用户实体包括用户信息和用户的一些操作。这里可以将最新的一个登陆日志作为值对象放到用户实体中。而不是将所有的登陆日志都作为值对象放到用户实体中,因为那样的话太庞大了。
  • 用户登陆日志实体:存储登陆日志的信息,虽然从用户的角度来看,登陆日志不能单独存在。但是如果从数据分析的角度看,登陆日志则可以单独存在了。因此可以作为一个实体存在。
  • 账本实体:账本作为一个现实世界记账必须的物品,自然也要作为一个实体存在,有账本ID保证唯一性。并且可以修改账本的部分信息。
  • 收支记录实体:每一个收支记录都是账本上的一条信息,都是可以独立修改的个体。比如修改某一个记录的金额等。所以收支记录也作为一个实体存在。

值对象:

  • 预算值对象:预算信息是依赖于账本的一个信息,修改预算就是修改预算这个整体。因此预算作为账本的一个值对象存在。
  • 角色值对象:多个人记账,每个人记账的时候的角色都是不一样的,修改角色也是修改角色这个整体,角色离开账本以后就没有存在的意义了,因此角色也作为值对象存在。

聚合建模

经过上面的步骤,我们已经找到了我们需要的实体值对象。接下来我们可以根据他们之间的关系,来划分成一个个聚合了。

如果把实体理解成一个个的人,那么聚合就相当于部落家族等等。相近的一些人聚集在一起,就变成了一个聚合。

变成聚合以后,当对外沟通的时候,需要一个话事人,也就是聚合根

首先我们要有用户聚合,用户聚合来管理用户信息。用户实体就是用户聚合的聚合根。

其次,用户登陆日志也作为一个单独的聚合存在。可以思考一下。

✅ 1. 领域含义:登录日志是用户行为的“历史轨迹”,而非用户本身的组成部分

  • 用户(User)是一个业务主语,它的职责是认证、权限、信息管理
  • 登录日志是用户行为产生的事实事件,它与用户的生命周期边界无关

✅ 2. 技术设计上:日志数据量大、写入频繁、读取模式完全不同

特性 用户聚合(User) 登录日志(LoginLog)
写入频率 中低(修改信息、改密码) 高(每次登录都写)
数据量 小(一个用户一条) 非常大(每用户数百/数千条)
删除时是否联动 删除用户可保留日志 有审计/合规要求时不能删除
查询模式 通过用户 ID 查询 分页查询 / 最近10条等

这说明 LoginLog 应该用独立表、独立聚合、独立读写模型管理。

✅ 3. 聚合的职责原则(Evans 原则)

一个聚合应尽可能小,保持一致性边界清晰,仅包含保护不变式所必须的部分。

登录日志并不影响用户不变式(用户名、密码唯一性、身份验证),因此它不应该进聚合。

所以,我们把登陆日志放到一个单独的聚合中。登陆日志就是聚合根。

接下来,账本实体、预算角色值对象一起构成了账本聚合。账本作为聚合根存在。

剩下的收支记录作为一个聚合。

领域建模

有了4个聚合。我们还需要划分领域。将相近的聚合放到一个领域中。

领域可以简单理解为粗粒度的聚合。聚合中是实体和值对象。而领域中则是聚合。

我们将4个聚合划分成两个领域,分别是用户领域记账领域

总结

至此,我们完成了整个领域建模的工作,下一步就是将领域建模转换成代码建模了。

下一遍文章会将我们这次的领域建模转换成代码建模,并用spring框架来实现整个代码。

大家可以先自己将领域建模转换成代码建模,并将结果发到评论区一起讨论~

文末福利

关注我发送“MySQL知识图谱”领取完整的MySQL学习路线。
发送“电子书”即可领取价值上千的电子书资源。
发送“大厂内推”即可获取京东、美团等大厂内推信息,祝你获得高薪职位。
发送“AI”即可领取AI学习资料。
部分电子书如图所示。

概念学习

概念学习

概念学习

概念学习